diff --git a/appcues/src/main/java/com/appcues/AnalyticsPublisher.kt b/appcues/src/main/java/com/appcues/AnalyticsPublisher.kt index 702f67553..469f416ed 100644 --- a/appcues/src/main/java/com/appcues/AnalyticsPublisher.kt +++ b/appcues/src/main/java/com/appcues/AnalyticsPublisher.kt @@ -6,17 +6,16 @@ import com.appcues.AnalyticType.IDENTIFY import com.appcues.AnalyticType.SCREEN import com.appcues.analytics.ActivityRequestBuilder import com.appcues.analytics.TrackingData -import com.appcues.data.model.ExperienceStepFormState import com.appcues.data.remote.appcues.request.EventRequest -import java.util.Date -import java.util.UUID +import com.appcues.util.DataSanitizer internal class AnalyticsPublisher( - private val storage: Storage + private val storage: Storage, + private val dataSanitizer: DataSanitizer, ) { - fun publish(listener: AnalyticsListener?, data: TrackingData) { - if (listener == null) return + fun publish(listener: AnalyticsListener?, data: TrackingData) = with(dataSanitizer) { + if (listener == null) return@with when (data.type) { EVENT -> data.request.events?.forEach { @@ -32,46 +31,4 @@ internal class AnalyticsPublisher( private fun EventRequest.screenTitle(): String? = attributes[ActivityRequestBuilder.SCREEN_TITLE_ATTRIBUTE] as? String - - private fun Map<*, *>.sanitize(): MutableMap { - val sanitizedMap = mutableMapOf() - - forEach { - val key = it.key - val value = it.value - if (key is String && value != null) { - sanitizedMap[key] = when (value) { - is ExperienceStepFormState -> value.toHashMap().sanitize() - // convert Date types to Double value - is Date -> value.time.toDouble() - // convert UUID to string value - is UUID -> value.toString() - is Map<*, *> -> value.sanitize() - is List<*> -> value.sanitize() - else -> value - } - } - } - return sanitizedMap - } - - private fun List<*>.sanitize(): List<*> { - val sanitizedList = mutableListOf() - - filterNotNull().forEach { - sanitizedList.add( - when (it) { - is ExperienceStepFormState -> it.toHashMap().sanitize() - // convert Date types to Double value - is Date -> it.time.toDouble() - // convert UUID to string value - is UUID -> it.toString() - is Map<*, *> -> it.sanitize() - is List<*> -> it.sanitize() - else -> it - } - ) - } - return sanitizedList - } } diff --git a/appcues/src/main/java/com/appcues/MainModule.kt b/appcues/src/main/java/com/appcues/MainModule.kt index d232c6d14..453834680 100644 --- a/appcues/src/main/java/com/appcues/MainModule.kt +++ b/appcues/src/main/java/com/appcues/MainModule.kt @@ -18,6 +18,7 @@ import com.appcues.ui.ExperienceRenderer import com.appcues.ui.StateMachineDirectory import com.appcues.ui.utils.ImageLoaderWrapper import com.appcues.util.AppcuesViewTreeOwner +import com.appcues.util.DataSanitizer import com.appcues.util.LinkOpener import kotlinx.coroutines.CoroutineScope @@ -51,7 +52,8 @@ internal object MainModule : AppcuesModule { } scoped { PushRepository(get(), get()) } scoped { LinkOpener(get()) } - scoped { AnalyticsPublisher(storage = get()) } + scoped { DataSanitizer() } + scoped { AnalyticsPublisher(get(), get()) } factory { StateMachine(actionProcessor = get(), lifecycleTracker = get(), onEndedExperience = it.next()) } diff --git a/appcues/src/main/java/com/appcues/util/DataSanitizer.kt b/appcues/src/main/java/com/appcues/util/DataSanitizer.kt new file mode 100644 index 000000000..e600229ec --- /dev/null +++ b/appcues/src/main/java/com/appcues/util/DataSanitizer.kt @@ -0,0 +1,53 @@ +package com.appcues.util + +import com.appcues.data.model.ExperienceStepFormState +import org.jetbrains.annotations.VisibleForTesting +import java.util.Date +import java.util.UUID + +internal class DataSanitizer { + + fun Map<*, *>.sanitize(): Map { + val sanitizedMap = mutableMapOf() + + forEach { + val key = it.key + val value = it.value + if (key is String && value != null) { + sanitizedMap[key] = when (value) { + is ExperienceStepFormState -> value.toHashMap().sanitize() + // convert Date types to Double value + is Date -> value.time.toDouble() + // convert UUID to string value + is UUID -> value.toString() + is Map<*, *> -> value.sanitize() + is List<*> -> value.sanitize() + else -> value + } + } + } + + return sanitizedMap + } + + @VisibleForTesting + fun List<*>.sanitize(): List { + val sanitizedList = mutableListOf() + + filterNotNull().forEach { item -> + sanitizedList.add( + when (item) { + is ExperienceStepFormState -> item.toHashMap().sanitize() + // convert Date types to Double value + is Date -> item.time.toDouble() + // convert UUID to string value + is UUID -> item.toString() + is Map<*, *> -> item.sanitize() + is List<*> -> item.sanitize() + else -> item + } + ) + } + return sanitizedList + } +} diff --git a/appcues/src/test/java/com/appcues/AnalyticsPublisherTest.kt b/appcues/src/test/java/com/appcues/AnalyticsPublisherTest.kt index 385cad9cb..23e3efa65 100644 --- a/appcues/src/test/java/com/appcues/AnalyticsPublisherTest.kt +++ b/appcues/src/test/java/com/appcues/AnalyticsPublisherTest.kt @@ -12,13 +12,15 @@ import com.appcues.data.remote.appcues.request.EventRequest import com.appcues.di.component.get import com.appcues.rules.MainDispatcherRule import com.appcues.rules.TestScopeRule +import com.appcues.util.DataSanitizer import com.google.common.truth.Truth.assertThat +import io.mockk.called import io.mockk.mockk import io.mockk.verify import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test -import java.util.Date import java.util.UUID internal class AnalyticsPublisherTest : AppcuesScopeTest { @@ -48,47 +50,84 @@ internal class AnalyticsPublisherTest : AppcuesScopeTest { @Before fun setUp() { - analyticsPublisher = AnalyticsPublisher(get()) + analyticsPublisher = AnalyticsPublisher(get(), get()) } @Test - fun `analyticsPublisher SHOULD sanitize Date objects to Double in properties map`() { - // GIVEN - val dateLong = 1666102372942 - val attributes = hashMapOf( - "date" to Date(dateLong), - "map" to hashMapOf( - "date" to Date(dateLong) - ), - "list" to listOf(Date(dateLong)) - ) + fun `publish SHOULD do nothing when listener is null`() { + // Given + val trackingData = mockk() + // When + analyticsPublisher.publish(null, trackingData) + // Then + verify { trackingData wasNot called } + } + + @Test + fun `publish SHOULD not call trackedAnalytic WHEN data type is event AND event list is empty`() { + // Given val activity = ActivityRequest( accountId = "123", appId = "appId", userId = "userId", sessionId = UUID.randomUUID(), - events = listOf(EventRequest("event1", attributes = attributes)) + events = listOf() ) val data = TrackingData(EVENT, false, activity) - - // WHEN + // When analyticsPublisher.publish(testListener, data) + // Then + assertThat(eventList).hasSize(0) + } - // THEN + @Test + fun `publish SHOULD call trackedAnalytic of type EVENT once WHEN data type is event AND event list is one`() { + // Given + val activity = ActivityRequest( + accountId = "123", + appId = "appId", + userId = "userId", + sessionId = UUID.randomUUID(), + events = listOf(EventRequest("event1")) + ) + val data = TrackingData(EVENT, false, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then assertThat(eventList).hasSize(1) + assertThat(eventList[0].type).isEqualTo(EVENT) + assertThat(eventList[0].value).isEqualTo("event1") + assertThat(eventList[0].properties).isEmpty() + assertThat(eventList[0].isInternal).isFalse() + } - with(eventList[0]) { - assertThat(properties).hasSize(3) - assertThat(properties).containsEntry("date", dateLong.toDouble()) - assertThat(properties!!["map"] as Map<*, *>).containsEntry("date", dateLong.toDouble()) - assertThat(properties["list"] as List<*>).containsExactly(dateLong.toDouble()) - } + @Test + fun `publish SHOULD call trackedAnalytic of type EVENT twice WHEN data type is event AND event list is two`() { + // Given + val activity = ActivityRequest( + accountId = "123", + appId = "appId", + userId = "userId", + sessionId = UUID.randomUUID(), + events = listOf(EventRequest("event1"), EventRequest("event2")) + ) + val data = TrackingData(EVENT, true, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then + assertThat(eventList).hasSize(2) + assertThat(eventList[0].type).isEqualTo(EVENT) + assertThat(eventList[0].value).isEqualTo("event1") + assertThat(eventList[0].isInternal).isTrue() + assertThat(eventList[1].type).isEqualTo(EVENT) + assertThat(eventList[1].value).isEqualTo("event2") + assertThat(eventList[1].isInternal).isTrue() } @Test - fun `analyticsListener SHOULD track event WHEN event TrackingData is published`() { - // GIVEN - val attributes = hashMapOf("prop" to 42) + fun `publish SHOULD sanitize attributes for for type EVENT`() { + // Given + val attributes = mutableMapOf("prop" to 42) val activity = ActivityRequest( accountId = "123", appId = "appId", @@ -97,101 +136,196 @@ internal class AnalyticsPublisherTest : AppcuesScopeTest { events = listOf(EventRequest("event1", attributes = attributes)) ) val data = TrackingData(EVENT, false, activity) - val listener = mockk(relaxed = true) - - // WHEN - analyticsPublisher.publish(listener, data) - - // THEN - verify { listener.trackedAnalytic(EVENT, "event1", attributes, false) } + // When + analyticsPublisher.publish(testListener, data) + // Then + verify(exactly = 1) { with(get()) { attributes.sanitize() } } } @Test - fun `analyticsListener SHOULD track screen WHEN screen TrackingData is published`() { - // GIVEN - val attributes = hashMapOf(ActivityRequestBuilder.SCREEN_TITLE_ATTRIBUTE to "screen1") + @Ignore("Currently getting userId from storage.userId which depends on external factors to be right.") + fun `publish SHOULD call trackedAnalytic of type IDENTIFY WHEN data type is identify`() { + // Given val activity = ActivityRequest( accountId = "123", appId = "appId", userId = "userId", sessionId = UUID.randomUUID(), - events = listOf(EventRequest(ScreenView.eventName, attributes = attributes)) ) - val data = TrackingData(SCREEN, false, activity) - val listener = mockk(relaxed = true) - - // WHEN - analyticsPublisher.publish(listener, data) - - // THEN - verify { listener.trackedAnalytic(SCREEN, "screen1", attributes, false) } + val data = TrackingData(IDENTIFY, false, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then + assertThat(eventList).hasSize(1) + assertThat(eventList[0].type).isEqualTo(IDENTIFY) + assertThat(eventList[0].value).isEqualTo("userId") + assertThat(eventList[0].properties).isNull() + assertThat(eventList[0].isInternal).isFalse() + // Validate sanitize was not called when profileUpdate is null + verify { with(get()) { any>().sanitize() } wasNot called } } @Test - fun `analyticsListener SHOULD track identify WHEN identify TrackingData is published`() { - // GIVEN - val storage: Storage = get() - storage.userId = "userId" - val attributes = hashMapOf("prop" to 42) + fun `publish SHOULD sanitize profileUpdate for for type IDENTIFY WHEN profileUpdate is not null`() { + // Given + val profileUpdate = mutableMapOf("prop" to 42) val activity = ActivityRequest( accountId = "123", appId = "appId", + userId = "userId", + profileUpdate = profileUpdate, sessionId = UUID.randomUUID(), - userId = storage.userId, - profileUpdate = attributes ) val data = TrackingData(IDENTIFY, false, activity) - val listener = mockk(relaxed = true) - - // WHEN - analyticsPublisher.publish(listener, data) + // When + analyticsPublisher.publish(testListener, data) + // Then + verify(exactly = 1) { with(get()) { profileUpdate.sanitize() } } + } - // THEN - verify { listener.trackedAnalytic(IDENTIFY, "userId", attributes, false) } + @Test + @Ignore("Currently getting groupId from storage.group which depends on external factors to be right.") + fun `publish SHOULD call trackedAnalytic of type GROUP WHEN data type is group`() { + // Given + val activity = ActivityRequest( + accountId = "123", + appId = "appId", + userId = "userId", + groupId = "groupId", + sessionId = UUID.randomUUID(), + ) + val data = TrackingData(GROUP, true, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then + assertThat(eventList).hasSize(1) + assertThat(eventList[0].type).isEqualTo(GROUP) + assertThat(eventList[0].value).isEqualTo("groupId") + assertThat(eventList[0].properties).isNull() + assertThat(eventList[0].isInternal).isTrue() + // Validate sanitize was not called when groupUpdate is null + verify { with(get()) { any>().sanitize() } wasNot called } } @Test - fun `analyticsListener SHOULD track group WHEN group TrackingData is published`() { - // GIVEN - val storage: Storage = get() - storage.groupId = "groupId" - val attributes = hashMapOf("prop" to 42) + fun `publish SHOULD sanitize groupUpdate for for type GROUP WHEN groupUpdate is not null`() { + // Given + val groupUpdate = mutableMapOf("prop" to 42) val activity = ActivityRequest( accountId = "123", appId = "appId", userId = "userId", + groupUpdate = groupUpdate, sessionId = UUID.randomUUID(), - groupId = storage.groupId, - groupUpdate = attributes ) val data = TrackingData(GROUP, false, activity) - val listener = mockk(relaxed = true) + // When + analyticsPublisher.publish(testListener, data) + // Then + verify(exactly = 1) { with(get()) { groupUpdate.sanitize() } } + } - // WHEN - analyticsPublisher.publish(listener, data) + @Test + fun `publish SHOULD not call trackedAnalytic of type SCREEN WHEN data type is screen AND event list is empty`() { + // Given + val activity = ActivityRequest( + accountId = "123", + appId = "appId", + userId = "userId", + sessionId = UUID.randomUUID(), + events = listOf() + ) + val data = TrackingData(SCREEN, false, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then + assertThat(eventList).hasSize(0) + } - // THEN - verify { listener.trackedAnalytic(GROUP, "groupId", attributes, false) } + @Test + fun `publish SHOULD call trackedAnalytic of type SCREEN once WHEN data type is screen AND event list is one`() { + // Given + val attributes = hashMapOf(ActivityRequestBuilder.SCREEN_TITLE_ATTRIBUTE to "home") + val activity = ActivityRequest( + accountId = "123", + appId = "appId", + userId = "userId", + sessionId = UUID.randomUUID(), + events = listOf(EventRequest(ScreenView.eventName, attributes = attributes)) + ) + val data = TrackingData(SCREEN, false, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then + assertThat(eventList).hasSize(1) + assertThat(eventList[0].type).isEqualTo(SCREEN) + assertThat(eventList[0].value).isEqualTo("home") + assertThat(eventList[0].properties).isEmpty() + assertThat(eventList[0].isInternal).isFalse() } @Test - fun `analyticsListener SHOULD track internal event WHEN event TrackingData is published AND isInternal equals true`() { - // GIVEN - val attributes = hashMapOf("prop" to 42) + fun `publish SHOULD call trackedAnalytic of type SCREEN twice WHEN data type is screen AND event list is two`() { + // Given + val attributes = hashMapOf(ActivityRequestBuilder.SCREEN_TITLE_ATTRIBUTE to "home") val activity = ActivityRequest( accountId = "123", appId = "appId", userId = "userId", sessionId = UUID.randomUUID(), - events = listOf(EventRequest("event1", attributes = attributes)) + events = listOf(EventRequest(ScreenView.eventName, attributes = attributes), EventRequest("event2")) ) - val data = TrackingData(EVENT, true, activity) - val listener = mockk(relaxed = true) + val data = TrackingData(SCREEN, true, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then + assertThat(eventList).hasSize(2) + assertThat(eventList[0].type).isEqualTo(SCREEN) + assertThat(eventList[0].value).isEqualTo("home") + assertThat(eventList[0].isInternal).isTrue() + /** + * Should not be allowed since we are reporting it as a SCREEN event when its not a "appcues:screen_view" event + * + * @see AnalyticsPublisher + */ + assertThat(eventList[1].type).isEqualTo(SCREEN) + assertThat(eventList[1].value).isEqualTo(null) + assertThat(eventList[1].isInternal).isTrue() + } - // WHEN - analyticsPublisher.publish(listener, data) + @Test + fun `publish SHOULD sanitize attributes for for type SCREEN`() { + // Given + val attributes = hashMapOf(ActivityRequestBuilder.SCREEN_TITLE_ATTRIBUTE to "home", "prop" to 42) + val activity = ActivityRequest( + accountId = "123", + appId = "appId", + userId = "userId", + sessionId = UUID.randomUUID(), + events = listOf(EventRequest(ScreenView.eventName, attributes = attributes)) + ) + val data = TrackingData(SCREEN, false, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then + verify(exactly = 1) { with(get()) { attributes.sanitize() } } + } - // THEN - verify { listener.trackedAnalytic(EVENT, "event1", attributes, true) } + @Test + @Ignore("We should filter events that are appcues:screen_view when publishing for type SCREEN") + fun `publish SHOULD should not call trackedAnalytic of type SCREEN WHEN EventRequest is not appcues screen_view`() { + // Given + val activity = ActivityRequest( + accountId = "123", + appId = "appId", + userId = "userId", + sessionId = UUID.randomUUID(), + events = listOf(EventRequest("event2")) + ) + val data = TrackingData(SCREEN, true, activity) + // When + analyticsPublisher.publish(testListener, data) + // Then + assertThat(eventList).hasSize(0) } } diff --git a/appcues/src/test/java/com/appcues/rules/TestScopeRule.kt b/appcues/src/test/java/com/appcues/rules/TestScopeRule.kt index d7738871d..b17f92872 100644 --- a/appcues/src/test/java/com/appcues/rules/TestScopeRule.kt +++ b/appcues/src/test/java/com/appcues/rules/TestScopeRule.kt @@ -24,6 +24,7 @@ import com.appcues.statemachine.StateMachine import com.appcues.trait.TraitRegistry import com.appcues.ui.ExperienceRenderer import com.appcues.util.ContextWrapper +import com.appcues.util.DataSanitizer import com.appcues.util.LinkOpener import io.mockk.mockk import kotlinx.coroutines.CoroutineScope @@ -59,6 +60,7 @@ internal class TestScopeRule : TestWatcher() { scoped { storageMockk() } scoped { mockk(relaxed = true) } scoped { mockk(relaxed = true) } + scoped { mockk(relaxed = true) } factory { mockk(relaxed = true) } } } diff --git a/appcues/src/test/java/com/appcues/util/DataSanitizerTest.kt b/appcues/src/test/java/com/appcues/util/DataSanitizerTest.kt new file mode 100644 index 000000000..bb105396c --- /dev/null +++ b/appcues/src/test/java/com/appcues/util/DataSanitizerTest.kt @@ -0,0 +1,168 @@ +package com.appcues.util + +import com.appcues.data.model.ExperienceStepFormState +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import java.util.Date +import java.util.UUID + +internal class DataSanitizerTest { + + private val sanitizer = DataSanitizer() + + @Test + fun `map sanitize SHOULD remove null value`() = with(sanitizer) { + // Given + val map = hashMapOf("prop1" to null) + // When + val sanitizedMap = map.sanitize() + // Then + assertThat(sanitizedMap).hasSize(0) + } + + @Test + fun `map sanitize SHOULD keep primitive value`() = with(sanitizer) { + // Given + val map = hashMapOf("prop1" to 1, "prop2" to "value") + // When + val sanitizedMap = map.sanitize() + // Then + assertThat(sanitizedMap).hasSize(2) + assertThat(sanitizedMap).containsEntry("prop1", 1) + assertThat(sanitizedMap).containsEntry("prop2", "value") + } + + @Test + fun `map sanitize SHOULD handle ExperienceStepFormState`() = with(sanitizer) { + // Given + val experienceStepFormState = mockk().apply { + every { toHashMap() } returns hashMapOf("form_prop" to 1) + } + val map = hashMapOf("prop1" to experienceStepFormState) + // When + val sanitizedMap = map.sanitize() + // Then + assertThat(sanitizedMap).hasSize(1) + assertThat(sanitizedMap).containsEntry("prop1", hashMapOf("form_prop" to 1)) + } + + @Test + fun `map sanitize SHOULD map Date to Double`() = with(sanitizer) { + // Given + val map = hashMapOf("prop1" to Date(1666102372942)) + // When + val sanitizedMap = map.sanitize() + // Then + assertThat(sanitizedMap).hasSize(1) + assertThat(sanitizedMap).containsEntry("prop1", 1666102372942.0) + } + + @Test + fun `map sanitize SHOULD map UUID to String`() = with(sanitizer) { + // Given + val uuid = UUID.randomUUID() + val map = hashMapOf("prop1" to uuid) + // When + val sanitizedMap = map.sanitize() + // Then + assertThat(sanitizedMap).hasSize(1) + assertThat(sanitizedMap).containsEntry("prop1", uuid.toString()) + } + + @Test + fun `map sanitize SHOULD flat inner maps`() = with(sanitizer) { + // Given + val map = hashMapOf("prop1" to hashMapOf("prop2" to 1)) + // When + val sanitizedMap = map.sanitize() + // Then + assertThat(sanitizedMap).hasSize(1) + assertThat(sanitizedMap).containsEntry("prop1", hashMapOf("prop2" to 1)) + } + + @Test + fun `map sanitize SHOULD items from a list`() = with(sanitizer) { + // Given + val map = hashMapOf("prop1" to listOf(Date(1666102372942), 2, 3, 4, 5)) + // When + val sanitizedMap = map.sanitize() + // Then + assertThat(sanitizedMap).hasSize(1) + assertThat(sanitizedMap).containsEntry("prop1", listOf(1666102372942.0, 2, 3, 4, 5)) + } + + @Test + fun `list sanitize SHOULD filter null items`() = with(sanitizer) { + // Given + val list = listOf(1, null, 2) + // When + val sanitizedList = list.sanitize() + // Then + assertThat(sanitizedList).hasSize(2) + } + + @Test + fun `list sanitize SHOULD handle ExperienceStepFormState`() = with(sanitizer) { + // Given + val experienceStepFormState = mockk().apply { + every { toHashMap() } returns hashMapOf("form_prop" to 1) + } + // When + val sanitizedList = listOf(experienceStepFormState).sanitize() + // Then + assertThat(sanitizedList).hasSize(1) + assertThat(sanitizedList).containsExactly(hashMapOf("form_prop" to 1)) + return@with + } + + @Test + fun `list sanitize SHOULD map Date to Double`() = with(sanitizer) { + // Given + val list = listOf(Date(1666102372942)) + // When + val sanitizedList = list.sanitize() + // Then + assertThat(sanitizedList).hasSize(1) + assertThat(sanitizedList).containsExactly(1666102372942.0) + return@with + } + + @Test + fun `list sanitize SHOULD map UUID to String`() = with(sanitizer) { + // Given + val uuid = UUID.randomUUID() + val list = listOf(uuid) + // When + val sanitizedList = list.sanitize() + // Then + assertThat(sanitizedList).hasSize(1) + assertThat(sanitizedList).containsExactly(uuid.toString()) + return@with + } + + @Test + fun `list sanitize SHOULD handle maps`() = with(sanitizer) { + // Given + val list = listOf(hashMapOf("prop2" to Date(1666102372942))) + // When + val sanitizedList = list.sanitize() + // Then + assertThat(sanitizedList).hasSize(1) + assertThat(sanitizedList).containsExactly(hashMapOf("prop2" to 1666102372942.0)) + return@with + } + + @Test + fun `list sanitize SHOULD handle lists`() = with(sanitizer) { + // Given + val list = listOf(listOf(Date(1666102372942))) + // When + val sanitizedList = list.sanitize() + // Then + assertThat(sanitizedList).hasSize(1) + assertThat(sanitizedList).containsExactly(listOf(1666102372942.0)) + return@with + } +} diff --git a/appcues/src/test/java/com/appcues/util/NotificationBuilderExtTest.kt b/appcues/src/test/java/com/appcues/util/NotificationBuilderExtTest.kt new file mode 100644 index 000000000..c1d0c46a3 --- /dev/null +++ b/appcues/src/test/java/com/appcues/util/NotificationBuilderExtTest.kt @@ -0,0 +1,137 @@ +package com.appcues.util + +import android.app.PendingIntent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.appcues.AppcuesFirebaseMessagingService.AppcuesMessagingData +import com.appcues.DeepLinkHandler +import com.appcues.push.PushDeeplinkHandler +import com.google.firebase.messaging.RemoteMessage +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.Test + +internal class NotificationBuilderExtTest { + + private val notificationBuilder = mockk(relaxed = true) + + @Test + fun `setupContent SHOULD set title and content`() { + // Given + val data = mockk().apply { + every { title } returns "title1" + every { body } returns "content body" + } + // When + notificationBuilder.setContent(data) + // Then + verify { notificationBuilder.setContentTitle("title1") } + verify { notificationBuilder.setContentText("content body") } + } + + @Test + fun `setupNotification SHOULD set autoCancel and priority`() { + // Given + val data = mockk(relaxed = true).apply { + every { priority } returns 5 + } + // When + notificationBuilder.setupNotification(data) + // Then + verify { notificationBuilder.setAutoCancel(true) } + verify { notificationBuilder.setPriority(5) } + } + + @Test + fun `setupNotification SHOULD set visibility and channel id WHEN notification is present`() { + // Given + val data = mockk(relaxed = true).apply { + every { priority } returns 5 + every { notification } returns mockk { + every { visibility } returns 3 + every { channelId } returns "channelId" + } + } + // When + notificationBuilder.setupNotification(data) + // Then + verify { notificationBuilder.setVisibility(3) } + verify { notificationBuilder.setChannelId("channelId") } + } + + @Test + fun `setupNotification SHOULD not set visibility or channel WHEN notification is null`() { + // Given + val data = mockk(relaxed = true).apply { + every { priority } returns 5 + every { notification } returns null + } + // When + notificationBuilder.setupNotification(data) + // Then + verify(exactly = 0) { notificationBuilder.setVisibility(any()) } + verify(exactly = 0) { notificationBuilder.setChannelId(any()) } + } + + @Test + fun `setIntent SHOULD setContentIntent using DeeplinkHandler WHEN isCheckPush is true`() { + // Given + val data = mockk(relaxed = true) + mockkStatic(PendingIntent::class) + every { PendingIntent.getActivity(any(), any(), any(), any()) } returns mockk(relaxed = true) + mockkObject(DeepLinkHandler.Companion) + every { DeepLinkHandler.getDebuggerValidationIntent(any(), any()) } returns mockk(relaxed = true) + // When + notificationBuilder.setIntent(mockk(relaxed = true), data, true) + // Then + verify { notificationBuilder.setContentIntent(any()) } + unmockkAll() + } + + @Test + fun `setIntent SHOULD setContentIntent using PushDeeplinkHandler WHEN isCheckPush is false`() { + // Given + val data = mockk(relaxed = true) + mockkStatic(PendingIntent::class) + every { PendingIntent.getActivity(any(), any(), any(), any()) } returns mockk(relaxed = true) + mockkObject(PushDeeplinkHandler.Companion) + every { PushDeeplinkHandler.getNotificationIntent(any(), any()) } returns mockk(relaxed = true) + // When + notificationBuilder.setIntent(mockk(relaxed = true), data, false) + // Then + verify { notificationBuilder.setContentIntent(any()) } + unmockkAll() + } + + @Test + fun `notify SHOULD send notification using 1_100_100 WHEN isCheckPush is true`() { + // Given + val mockkNotificationManager = mockk(relaxed = true) + mockkStatic(NotificationManagerCompat::class) + every { NotificationManagerCompat.from(any()) } returns mockkNotificationManager + // When + notificationBuilder.notify(mockk(relaxed = true), true) + // Then + verify { mockkNotificationManager.notify(1_100_100, notificationBuilder.build()) } + unmockkAll() + } + + @Test + fun `notify SHOULD send notification using incremental value starting from 1_000_000 WHEN isCheckPush is false`() { + // Given + val mockkNotificationManager = mockk(relaxed = true) + mockkStatic(NotificationManagerCompat::class) + every { NotificationManagerCompat.from(any()) } returns mockkNotificationManager + // When + notificationBuilder.notify(mockk(relaxed = true), false) + notificationBuilder.notify(mockk(relaxed = true), false) + // Then + verify { mockkNotificationManager.notify(1_000_000, notificationBuilder.build()) } + verify { mockkNotificationManager.notify(1_000_001, notificationBuilder.build()) } + unmockkAll() + } +}