diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt index b8a49e9551..fe846f503a 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/AndroidUtils.kt @@ -68,6 +68,9 @@ object AndroidUtils { return appVersion?.toString() } + // return Build.VERSION.SDK_INT; can be mocked to test specific functionalities under different SDK levels + val androidSDKInt: Int = Build.VERSION.SDK_INT + fun getManifestMeta( context: Context, metaName: String?, diff --git a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt index 16dce8e5a4..397ea43152 100644 --- a/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt +++ b/OneSignalSDK/onesignal/in-app-messages/src/test/java/com/onesignal/inAppMessages/internal/InAppMessagesManagerTests.kt @@ -1,80 +1,1190 @@ package com.onesignal.inAppMessages.internal +import android.content.Context +import com.onesignal.common.AndroidUtils +import com.onesignal.common.consistency.IamFetchReadyCondition +import com.onesignal.common.consistency.RywData import com.onesignal.common.consistency.models.IConsistencyManager -import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.common.exceptions.BackendException +import com.onesignal.common.modeling.ModelChangedArgs +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.inAppMessages.IInAppMessageClickListener +import com.onesignal.inAppMessages.IInAppMessageLifecycleListener +import com.onesignal.inAppMessages.InAppMessageActionUrlType import com.onesignal.inAppMessages.internal.backend.IInAppBackendService +import com.onesignal.inAppMessages.internal.common.OneSignalChromeTab import com.onesignal.inAppMessages.internal.display.IInAppDisplayer import com.onesignal.inAppMessages.internal.lifecycle.IInAppLifecycleService import com.onesignal.inAppMessages.internal.preferences.IInAppPreferencesController +import com.onesignal.inAppMessages.internal.prompt.impl.InAppMessagePrompt import com.onesignal.inAppMessages.internal.repositories.IInAppRepository import com.onesignal.inAppMessages.internal.state.InAppStateService import com.onesignal.inAppMessages.internal.triggers.ITriggerController +import com.onesignal.inAppMessages.internal.triggers.TriggerModel +import com.onesignal.inAppMessages.internal.triggers.TriggerModelStore +import com.onesignal.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO import com.onesignal.mocks.MockHelper import com.onesignal.session.internal.influence.IInfluenceManager import com.onesignal.session.internal.outcomes.IOutcomeEventsController import com.onesignal.session.internal.session.ISessionService import com.onesignal.user.IUserManager import com.onesignal.user.internal.subscriptions.ISubscriptionManager +import com.onesignal.user.internal.subscriptions.SubscriptionModel +import com.onesignal.user.subscriptions.IPushSubscription +import com.onesignal.user.subscriptions.ISubscription import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.runs +import io.mockk.spyk +import io.mockk.unmockkObject import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.json.JSONArray +import org.json.JSONObject + +private class Mocks { + // mock default services needed for InAppMessagesManager + val applicationService = MockHelper.applicationService() + val sessionService = mockk(relaxed = true) + val influenceManager = mockk(relaxed = true) + val configModelStore = MockHelper.configModelStore() + val userManager = mockk(relaxed = true) + val identityModelStore = MockHelper.identityModelStore() + val pushSubscription = mockk(relaxed = true) + val outcomeEventsController = mockk(relaxed = true) + val inAppStateService = mockk(relaxed = true) + val inAppPreferencesController = mockk(relaxed = true) + val repository = mockk(relaxed = true) + val backend = mockk(relaxed = true) + val triggerController = mockk(relaxed = true) + val triggerModelStore = mockk(relaxed = true) + val inAppDisplayer = mockk(relaxed = true) + val inAppLifecycleService = mockk(relaxed = true) + val languageContext = MockHelper.languageContext() + val time = MockHelper.time(1000) + val inAppMessageLifecycleListener = spyk() + val inAppMessageClickListener = spyk() + val rywData = RywData("token", 100L) + + val rywDeferred = mockk> { + coEvery { await() } returns rywData + } + + val consistencyManager = mockk(relaxed = true) { + coEvery { getRywDataFromAwaitableCondition(any()) } returns rywDeferred + } + + val subscriptionManager = mockk(relaxed = true) { + every { subscriptions } returns mockk { + every { push } returns pushSubscription + } + } + + val testOutcome = + run { + val outcome = mockk(relaxed = true) + every { outcome.name } returns "outcome-name" + outcome + } + + val inAppMessageClickResult = + run { + val result = mockk(relaxed = true) + every { result.prompts } returns mutableListOf() + every { result.outcomes } returns mutableListOf(testOutcome) + every { result.tags } returns null + every { result.url } returns null + every { result.clickId } returns "click-id" + result + } + + // factory-style so every access returns a new message: + val testInAppMessage: InAppMessage + get() { + val json = JSONObject() + json.put("id", "test-message-id") + val variantsJson = JSONObject() + val allVariantJson = JSONObject() + allVariantJson.put("en", "variant-id-123") + variantsJson.put("all", allVariantJson) + json.put("variants", variantsJson) + json.put("triggers", JSONArray()) + return InAppMessage(json, time) + } + + // factory-style so every access returns a new message: + val testInAppMessagePreview: InAppMessage + get() = InAppMessage(true, time) + + // Helper function to create InAppMessagesManager with all dependencies + val inAppMessagesManager = InAppMessagesManager( + applicationService, + sessionService, + influenceManager, + configModelStore, + userManager, + identityModelStore, + subscriptionManager, + outcomeEventsController, + inAppStateService, + inAppPreferencesController, + repository, + backend, + triggerController, + triggerModelStore, + inAppDisplayer, + inAppLifecycleService, + languageContext, + time, + consistencyManager, + ) +} class InAppMessagesManagerTests : FunSpec({ - test("triggers are backed by the trigger model store") { - // Given - val mockTriggerModelStore = mockk() - val triggerModelSlots = mutableListOf() - every { mockTriggerModelStore.get(any()) } returns null - every { mockTriggerModelStore.add(capture(triggerModelSlots)) } answers {} - every { mockTriggerModelStore.remove(any()) } just runs - every { mockTriggerModelStore.clear() } just runs - - val iamManager = - InAppMessagesManager( - MockHelper.applicationService(), - mockk(), - mockk(), - mockk(), - mockk(), - MockHelper.identityModelStore(), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(), - mockk(), - mockTriggerModelStore, - mockk(), - mockk(), - MockHelper.languageContext(), - MockHelper.time(1000), - mockk(), + lateinit var mocks: Mocks + + // register to access awaitIO() + listener(IOMockHelper) + + beforeAny { + Logging.logLevel = LogLevel.NONE + mocks = Mocks() // fresh instance for each test + } + + beforeSpec { + // required when testing functions that internally call suspendifyOnMain + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + afterSpec { + Dispatchers.resetMain() + } + + context("Trigger Management") { + test("triggers are backed by the trigger model store") { + // Given + val mockTriggerModelStore = mocks.triggerModelStore + val triggerModelSlots = mutableListOf() + val iamManager = mocks.inAppMessagesManager + every { mockTriggerModelStore.get(any()) } returns null + every { mockTriggerModelStore.add(capture(triggerModelSlots)) } answers {} + every { mockTriggerModelStore.remove(any()) } just runs + every { mockTriggerModelStore.clear() } just runs + + // When + iamManager.addTrigger("trigger-key1", "trigger-value1") + iamManager.addTriggers(mapOf("trigger-key2" to "trigger-value2", "trigger-key3" to "trigger-value3")) + iamManager.removeTrigger("trigger-key4") + iamManager.removeTriggers(listOf("trigger-key5", "trigger-key6")) + iamManager.clearTriggers() + + // Then + triggerModelSlots.map { it.key to it.value } shouldBe listOf( + "trigger-key1" to "trigger-value1", + "trigger-key2" to "trigger-value2", + "trigger-key3" to "trigger-value3", + ) + verify(exactly = 1) { + mockTriggerModelStore.remove("trigger-key4") + mockTriggerModelStore.remove("trigger-key5") + mockTriggerModelStore.remove("trigger-key6") + mockTriggerModelStore.clear() + } + } + + test("addTrigger updates existing trigger model when trigger already exists") { + // Given + val mockTriggerModelStore = mocks.triggerModelStore + val existingTrigger = TriggerModel().apply { + id = "existing-key" + key = "existing-key" + value = "old-value" + } + every { mockTriggerModelStore.get("existing-key") } returns existingTrigger + every { mockTriggerModelStore.add(any()) } just runs + + // When + mocks.inAppMessagesManager.addTrigger("existing-key", "new-value") + + // Then + existingTrigger.value shouldBe "new-value" + verify(exactly = 0) { mockTriggerModelStore.add(any()) } + } + + test("addTrigger creates new trigger model when trigger does not exist") { + // Given + val mockTriggerModelStore = mocks.triggerModelStore + val triggerModelSlots = mutableListOf() + every { mockTriggerModelStore.get("new-key") } returns null + every { mockTriggerModelStore.add(capture(triggerModelSlots)) } answers {} + + // When + mocks.inAppMessagesManager.addTrigger("new-key", "new-value") + + // Then + triggerModelSlots.size shouldBe 1 + with(triggerModelSlots[0]) { key to value } shouldBe ("new-key" to "new-value") + } + } + + context("Initialization and Start") { + test("start loads dismissed messages from preferences") { + // Given + val mockPrefs = mocks.inAppPreferencesController + val dismissedSet = setOf("dismissed-1", "dismissed-2") + val mockRepository = mocks.repository + every { mockPrefs.dismissedMessagesId } returns dismissedSet + every { mockPrefs.lastTimeInAppDismissed } returns null + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + + // When + mocks.inAppMessagesManager.start() + + // Then + verify { mockPrefs.dismissedMessagesId } + coVerify { mockRepository.cleanCachedInAppMessages() } + } + + test("start loads last dismissal time from preferences") { + // Given + val mockPrefs = mocks.inAppPreferencesController + val mockState = mocks.inAppStateService + val lastDismissalTime = 5000L + every { mockPrefs.dismissedMessagesId } returns null + every { mockPrefs.lastTimeInAppDismissed } returns lastDismissalTime + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + + // When + mocks.inAppMessagesManager.start() + + // Then + verify { mockState.lastTimeInAppDismissed = lastDismissalTime } + } + + test("start loads redisplayed messages from repository and resets display flag") { + // Given + val message1 = mocks.testInAppMessage + val message2 = mocks.testInAppMessage + message1.isDisplayedInSession = true + message2.isDisplayedInSession = true + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns listOf(message1, message2) + + // When + mocks.inAppMessagesManager.start() + awaitIO() + + // Then + message1.isDisplayedInSession shouldBe false + message2.isDisplayedInSession shouldBe false + } + + test("start subscribes to all required services") { + // Given + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + + // When + mocks.inAppMessagesManager.start() + awaitIO() + + // Then + verify { mocks.subscriptionManager.subscribe(any()) } + verify { mocks.inAppLifecycleService.subscribe(any()) } + verify { mocks.triggerController.subscribe(any()) } + verify { mocks.sessionService.subscribe(any()) } + verify { mocks.applicationService.addApplicationLifecycleHandler(any()) } + } + } + + context("Paused Property") { + test("paused getter returns state paused value") { + // Given + every { mocks.inAppStateService.paused } returns true + + // When + val result = mocks.inAppMessagesManager.paused + + // Then + result shouldBe true + } + + test("setting paused to true does nothing when no message showing") { + // Given + val mockState = mocks.inAppStateService + val mockDisplayer = mocks.inAppDisplayer + val iamManager = mocks.inAppMessagesManager + every { mockState.paused } returns false + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + + // When + iamManager.paused = true + + // Then + verify { mockState.paused = true } + coVerify(exactly = 0) { mockDisplayer.dismissCurrentInAppMessage() } + } + } + + context("Lifecycle Listeners") { + test("addLifecycleListener subscribes listener") { + // Given + val mockListener = mocks.inAppMessageLifecycleListener + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addLifecycleListener(mockListener) + iamManager.onMessageWillDisplay(mocks.testInAppMessage) + + // Then + // Verify listener callback was called + verify { mockListener.onWillDisplay(any()) } + } + + test("removeLifecycleListener unsubscribes listener") { + // Given + val mockListener = mocks.inAppMessageLifecycleListener + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addLifecycleListener(mockListener) + iamManager.removeLifecycleListener(mockListener) + iamManager.onMessageWillDisplay(mocks.testInAppMessage) + + // Then + // Listener should not be called after removal + verify(exactly = 0) { mockListener.onWillDisplay(any()) } + } + + test("addClickListener subscribes listener") { + // Given + val mockListener = mocks.inAppMessageClickListener + val message = mocks.testInAppMessage + val mockClickResult = mocks.inAppMessageClickResult + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addClickListener(mockListener) + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + awaitIO() + + // Then + // Verify listener callback was called + verify { mockListener.onClick(any()) } + } + + test("removeClickListener unsubscribes listener") { + // Given + val mockListener = mockk(relaxed = true) + val message = mocks.testInAppMessage + val mockClickResult = mocks.inAppMessageClickResult + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addClickListener(mockListener) + iamManager.removeClickListener(mockListener) + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + awaitIO() + + // Then + // Listener should not be called after removal + verify(exactly = 0) { mockListener.onClick(any()) } + } + } + + context("Config Model Changes") { + test("onModelUpdated fetches messages when appId property changes") { + // Given + val mockDeferred = mocks.rywDeferred + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + val args = ModelChangedArgs( + ConfigModel(), + ConfigModel::appId.name, + ConfigModel::appId.name, + "old-value", + "new-value", ) - // When - iamManager.addTrigger("trigger-key1", "trigger-value1") - iamManager.addTriggers(mapOf("trigger-key2" to "trigger-value2", "trigger-key3" to "trigger-value3")) - iamManager.removeTrigger("trigger-key4") - iamManager.removeTriggers(listOf("trigger-key5", "trigger-key6")) - iamManager.clearTriggers() - - // Then - triggerModelSlots[0].key shouldBe "trigger-key1" - triggerModelSlots[0].value shouldBe "trigger-value1" - triggerModelSlots[1].key shouldBe "trigger-key2" - triggerModelSlots[1].value shouldBe "trigger-value2" - triggerModelSlots[2].key shouldBe "trigger-key3" - triggerModelSlots[2].value shouldBe "trigger-value3" - - verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key4") } - verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key5") } - verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key6") } - verify(exactly = 1) { mockTriggerModelStore.clear() } + // When + mocks.inAppMessagesManager.onModelUpdated(args, "tag") + awaitIO() + + // Then + // Should trigger fetchMessagesWhenConditionIsMet + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onModelUpdated does nothing when non-appId property changes") { + // Given + val args = ModelChangedArgs( + ConfigModel(), + "other-property", + "other-property", + "old-value", + "new-value", + ) + + // When + mocks.inAppMessagesManager.onModelUpdated(args, "tag") + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onModelReplaced fetches messages") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + // When + mocks.inAppMessagesManager.onModelReplaced(ConfigModel(), "tag") + awaitIO() + + // Then + coVerify { + mocks.backend.listInAppMessages(any(), any(), any(), any()) + } + } + } + + context("Subscription Changes") { + test("onSubscriptionChanged fetches messages when push subscription id changes") { + // Given + val mockDeferred = mocks.rywDeferred + val subscriptionModel = SubscriptionModel() + val args = ModelChangedArgs( + subscriptionModel, + SubscriptionModel::id.name, + SubscriptionModel::id.name, + "old-id", + "new-id", + ) + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + // When + mocks.inAppMessagesManager.onSubscriptionChanged(mocks.pushSubscription, args) + awaitIO() + + // Then + coVerify { + mocks.backend.listInAppMessages(any(), any(), any(), any()) + } + } + + test("onSubscriptionChanged does nothing for non-push subscription") { + // Given + val iamManager = mocks.inAppMessagesManager + val mockSubscription = mockk() + val subscriptionModel = SubscriptionModel() + val args = ModelChangedArgs( + subscriptionModel, + SubscriptionModel::id.name, + SubscriptionModel::id.name, + "old-id", + "new-id", + ) + + // When + iamManager.onSubscriptionChanged(mockSubscription, args) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSubscriptionChanged does nothing when id path does not match") { + // Given + val iamManager = mocks.inAppMessagesManager + val subscriptionModel = SubscriptionModel() + val args = ModelChangedArgs( + subscriptionModel, + "other-path", + "other-path", + "old-value", + "new-value", + ) + + // When + iamManager.onSubscriptionChanged(mocks.pushSubscription, args) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSubscriptionAdded does not fetch") { + // Given + val iamManager = mocks.inAppMessagesManager + val mockSubscription = mockk() + + // When + iamManager.onSubscriptionAdded(mockSubscription) + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSubscriptionRemoved does not fetch") { + // Given + val iamManager = mocks.inAppMessagesManager + val mockSubscription = mockk() + + // When + iamManager.onSubscriptionRemoved(mockSubscription) + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + } + + context("Session Lifecycle") { + test("onSessionStarted resets redisplayed messages and fetches messages") { + // Given + val message1 = mocks.testInAppMessage + val message2 = mocks.testInAppMessage + val mockRywData = mocks.rywData + val mockDeferred = mocks.rywDeferred + val mockRepository = mocks.repository + + message1.isDisplayedInSession = true + message2.isDisplayedInSession = true + coEvery { mockRepository.listInAppMessages() } returns listOf(message1, message2) + every { mocks.userManager.onesignalId } returns "onesignal-id" + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + // When + mocks.inAppMessagesManager.start() + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + // Verify messages were reset and backend was called + message1.isDisplayedInSession shouldBe false + message2.isDisplayedInSession shouldBe false + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSessionActive does nothing") { + // Given + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onSessionActive() + + // Verified by no exception being thrown + } + + test("onSessionEnded does nothing") { + // Given + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onSessionEnded(10L) + + // Verified by no exception being thrown + } + } + + context("Message Lifecycle Callbacks") { + test("onMessageWillDisplay fires lifecycle callback when subscribers exist") { + // Given + mocks.inAppMessagesManager.addLifecycleListener(mocks.inAppMessageLifecycleListener) + + // When + mocks.inAppMessagesManager.onMessageWillDisplay(mocks.testInAppMessage) + awaitIO() + + // Then + // Verify callback was fired + verify { mocks.inAppMessageLifecycleListener.onWillDisplay(any()) } + } + + test("onMessageWillDisplay does nothing when no subscribers") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onMessageWillDisplay(mocks.testInAppMessage) + + // Verified by no exception being thrown when no listeners are subscribed + } + + test("onMessageWasDisplayed sends impression for non-preview message") { + // Given + + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs + + // When + mocks.inAppMessagesManager.onMessageWasDisplayed(mocks.testInAppMessage) + awaitIO() + + // Then + coVerify { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWasDisplayed does not send impression for preview message") { + // Given + + // When + mocks.inAppMessagesManager.onMessageWasDisplayed(mocks.testInAppMessagePreview) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWasDisplayed does not send duplicate impressions") { + // Given + val message = mocks.testInAppMessage + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs + + // When - send impression twice + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() + + // Then - should only send once + coVerify(exactly = 1) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWillDismiss fires lifecycle callback when subscribers exist") { + // Given + mocks.inAppMessagesManager.addLifecycleListener(mocks.inAppMessageLifecycleListener) + + // When + mocks.inAppMessagesManager.onMessageWillDismiss(mocks.testInAppMessage) + awaitIO() + + // Then + // Verify callback was fired + verify { mocks.inAppMessageLifecycleListener.onWillDismiss(any()) } + } + + test("onMessageWillDismiss does nothing when no subscribers") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onMessageWillDismiss(mocks.testInAppMessage) + + // Verified by no exception being thrown when no listeners are subscribed + } + + test("onMessageWasDismissed calls messageWasDismissed") { + // Given + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + + // When + mocks.inAppMessagesManager.onMessageWasDismissed(mocks.testInAppMessage) + awaitIO() + + // Then + verify { mocks.influenceManager.onInAppMessageDismissed() } + } + } + + context("Trigger Callbacks") { + test("onTriggerCompleted does nothing") { + // Given + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onTriggerCompleted("trigger-id") + + // Verified by no exception being thrown (method is a no-op) + } + + test("onTriggerConditionChanged makes redisplay messages available and re-evaluates") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + + // Fetch messages first + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // When + mocks.inAppMessagesManager.onTriggerConditionChanged("trigger-id") + + // Then + // Should trigger re-evaluation + coVerify { mocks.triggerController.evaluateMessageTriggers(any()) } + } + + test("onTriggerChanged makes redisplay messages available and re-evaluates") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + + // Fetch messages first + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // When + mocks.inAppMessagesManager.onTriggerChanged("trigger-key") + + // Then + // Should trigger re-evaluation + verify { mocks.triggerController.evaluateMessageTriggers(any()) } + } + } + + context("Application Lifecycle") { + test("onFocus does nothing") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onFocus(false) + mocks.inAppMessagesManager.onFocus(true) + } + test("onUnfocused does nothing") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onUnfocused() + + // Verified by no exception being thrown + } + } + + context("Message Action Handling") { + test("onMessageActionOccurredOnPreview processes preview actions") { + // Given + val mockClickListener = mockk(relaxed = true) + val mockPrompt = mockk(relaxed = true) + every { mockPrompt.hasPrompted() } returns false + coEvery { mockPrompt.handlePrompt() } returns InAppMessagePrompt.PromptActionResult.PERMISSION_GRANTED + every { mocks.inAppStateService.currentPrompt } returns null + mocks.inAppMessagesManager.addClickListener(mockClickListener) + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnPreview(mocks.testInAppMessagePreview, mocks.inAppMessageClickResult) + awaitIO() + + // Then + verify { mocks.inAppMessageClickResult.isFirstClick = any() } + } + + test("onMessagePageChanged sends page impression for non-preview message") { + // Given + val mockPage = mockk(relaxed = true) + every { mocks.pushSubscription.id } returns "subscription-id" + every { mockPage.pageId } returns "page-id" + coEvery { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } just runs + + // When + mocks.inAppMessagesManager.onMessagePageChanged(mocks.testInAppMessage, mockPage) + awaitIO() + + // Then + coVerify { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } + } + + test("onMessagePageChanged does nothing for preview message") { + // Given + val mockPage = mockk(relaxed = true) + + // When + mocks.inAppMessagesManager.onMessagePageChanged(mocks.testInAppMessagePreview, mockPage) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } + } + } + + context("Error Handling") { + test("onMessageWasDisplayed removes impression from set on backend failure") { + // Given + val message = mocks.testInAppMessage + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { + mocks.backend.sendIAMImpression(any(), any(), any(), any()) + } throws BackendException(500, "Server error") + + // When + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() + + // Try again - should retry since impression was removed + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() + + // Then - should attempt twice since first failed + coVerify(exactly = 2) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessagePageChanged removes page impression on backend failure") { + // Given + val message = mocks.testInAppMessage + val mockPage = mockk(relaxed = true) + every { mocks.pushSubscription.id } returns "subscription-id" + every { mockPage.pageId } returns "page-id" + coEvery { + mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) + } throws BackendException(500, "Server error") + + // When + mocks.inAppMessagesManager.onMessagePageChanged(message, mockPage) + awaitIO() + + // Try again - should retry since page impression was removed + mocks.inAppMessagesManager.onMessagePageChanged(message, mockPage) + awaitIO() + + // Then - should attempt twice since first failed + coVerify(exactly = 2) { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } + } + + test("onMessageActionOccurredOnMessage removes click on backend failure") { + // Given + val message = mocks.testInAppMessage + coEvery { + mocks.backend.sendIAMClick(any(), any(), any(), any(), any(), any()) + } throws BackendException(500, "Server error") + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(message, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify { mocks.backend.sendIAMClick(any(), any(), any(), any(), any(), any()) } + // Click should be removed from message on failure + message.isClickAvailable("click-id") shouldBe true + } + } + + context("Message Fetching") { + test("fetchMessagesWhenConditionIsMet returns early when app is not in foreground") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns false + + // When - trigger fetch via onSessionStarted + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is empty") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "" + + // When + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is local ID") { + // Given + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "local-123" + + // When + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet evaluates messages when new messages are returned") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + + // When + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + verify { mocks.triggerController.evaluateMessageTriggers(any()) } + } + } + + context("Message Queue and Display") { + test("messages are not queued when paused") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(message) } returns true + every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false + every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + every { mocks.inAppStateService.paused } returns true + + // When - fetch messages while paused + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // Then - should not display + coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(any()) } + } + } + + context("Message Evaluation") { + test("messages are evaluated and queued when paused is set to false") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(message) } returns true + every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false + every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + every { mocks.inAppStateService.paused } returns true + coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true + + // Fetch messages first + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() + + // When - set paused to false, which triggers evaluateInAppMessages + mocks.inAppMessagesManager.paused = false + + // Then + verify { mocks.triggerController.evaluateMessageTriggers(message) } + } + + test("dismissed messages are not queued for display") { + // Given + val message = mocks.testInAppMessage + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(message) } returns true + every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false + every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false + every { mocks.inAppStateService.paused } returns false + + // Fetch messages + mocks.inAppMessagesManager.onSessionStarted() + + // Dismiss the message + mocks.inAppMessagesManager.onMessageWasDismissed(message) + awaitIO() + + // When - trigger evaluation + mocks.inAppMessagesManager.paused = false + + // Then - should not display dismissed message + coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(message) } + } + } + + context("Message Actions - Outcomes and Tags") { + test("onMessageActionOccurredOnMessage fires outcomes") { + // Given + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then - wait for async operations + coVerify { mocks.outcomeEventsController.sendOutcomeEvent("outcome-name") } + } + + test("onMessageActionOccurredOnMessage fires outcomes with weight") { + // Given + val weight = 5.0f + every { mocks.testOutcome.weight } returns weight + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then - wait for async operations + coVerify { mocks.outcomeEventsController.sendOutcomeEventWithValue("outcome-name", weight) } + } + + test("onMessageActionOccurredOnMessage adds tags") { + // Given + val mockTags = mockk(relaxed = true) + val tagsToAdd = JSONObject() + tagsToAdd.put("key1", "value1") + every { mockTags.tagsToAdd } returns tagsToAdd + every { mockTags.tagsToRemove } returns null + every { mocks.inAppMessageClickResult.tags } returns mockTags + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then - wait for async operations + verify { mocks.userManager.addTags(any()) } + } + + test("onMessageActionOccurredOnMessage removes tags") { + // Given + val mockTags = mockk(relaxed = true) + val tagsToRemove = JSONArray() + tagsToRemove.put("key1") + every { mockTags.tagsToAdd } returns null + every { mockTags.tagsToRemove } returns tagsToRemove + every { mocks.inAppMessageClickResult.tags } returns mockTags + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then - wait for async operations + coVerify { mocks.userManager.removeTags(any()) } + } + + test("onMessageActionOccurredOnMessage opens URL in browser") { + // Given + val url = "https://example.com" + val mockContext = mockk(relaxed = true) + every { mocks.applicationService.appContext } returns mockContext + every { mocks.inAppMessageClickResult.url } returns url + every { mocks.inAppMessageClickResult.urlTarget } returns InAppMessageActionUrlType.BROWSER + mockkObject(AndroidUtils) + every { AndroidUtils.openURLInBrowser(any(), any()) } just runs + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify { AndroidUtils.openURLInBrowser(any(), url) } + + unmockkObject(AndroidUtils) + } + + test("onMessageActionOccurredOnMessage opens URL in webview") { + // Given + val mockContext = mockk(relaxed = true) + every { mocks.applicationService.appContext } returns mockContext + every { mocks.inAppMessageClickResult.url } returns "https://example.com" + every { mocks.inAppMessageClickResult.urlTarget } returns InAppMessageActionUrlType.IN_APP_WEBVIEW + mockkObject(OneSignalChromeTab) + every { OneSignalChromeTab.open(any(), any(), any()) } returns true + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify { OneSignalChromeTab.open("https://example.com", true, any()) } + + unmockkObject(OneSignalChromeTab) + } + + test("onMessageActionOccurredOnMessage does nothing when URL is empty") { + // Given + + // When/Then - should not throw + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + } + } + context("Prompt Processing") { + test("onMessageActionOccurredOnMessage processes prompts") { + // Given + val mockPrompt = mockk(relaxed = true) + every { mocks.inAppMessageClickResult.prompts } returns mutableListOf(mockPrompt) + every { mockPrompt.hasPrompted() } returns false + every { mockPrompt.setPrompted(any()) } just runs + // currentPrompt starts as null, then gets set to the prompt during processing + var currentPrompt: InAppMessagePrompt? = null + every { mocks.inAppStateService.currentPrompt } answers { currentPrompt } + every { mocks.inAppStateService.currentPrompt = any() } answers { currentPrompt = firstArg() } + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify { mocks.inAppDisplayer.dismissCurrentInAppMessage() } + coVerify { mockPrompt.setPrompted(any()) } + } + + test("onMessageActionOccurredOnMessage does nothing when prompts list is empty") { + // Given + + // When + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.inAppDisplayer.dismissCurrentInAppMessage() } + } + } + + context("Message Persistence") { + test("onMessageWasDismissed persists message to repository") { + // Given + val message = mocks.testInAppMessage + coEvery { mocks.repository.saveInAppMessage(any()) } just runs + every { mocks.inAppStateService.lastTimeInAppDismissed } returns 500L + every { mocks.inAppStateService.currentPrompt } returns null + + // When + mocks.inAppMessagesManager.onMessageWasDismissed(message) + awaitIO() + + // Then + coVerify { mocks.repository.saveInAppMessage(message) } + message.isDisplayedInSession shouldBe true + message.isTriggerChanged shouldBe false + } } }) diff --git a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt index fe82884e57..d6bff44e70 100644 --- a/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt +++ b/OneSignalSDK/onesignal/location/src/main/java/com/onesignal/location/internal/LocationManager.kt @@ -95,11 +95,12 @@ internal class LocationManager( _capturer.locationCoarse = true } - if (Build.VERSION.SDK_INT >= 29) { + val androidSDKInt = AndroidUtils.androidSDKInt + if (androidSDKInt >= 29) { hasBackgroundPermissionGranted = AndroidUtils.hasPermission(LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, true, _applicationService) } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + if (androidSDKInt < Build.VERSION_CODES.M) { if (!hasFinePermissionGranted && !hasCoarsePermissionGranted) { // Permission missing on manifest Logging.error("Location permissions not added on AndroidManifest file < M") @@ -130,7 +131,7 @@ internal class LocationManager( // ACCESS_COARSE_LOCATION permission defined on Manifest, prompt for permission // If permission already given prompt will return positive, otherwise will prompt again or show settings requestPermission = LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING - } else if (Build.VERSION.SDK_INT >= 29 && permissionList.contains(LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING)) { + } else if (androidSDKInt >= 29 && permissionList.contains(LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING)) { // ACCESS_BACKGROUND_LOCATION permission defined on Manifest, prompt for permission requestPermission = LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING } @@ -151,7 +152,7 @@ internal class LocationManager( } else { hasCoarsePermissionGranted } - } else if (Build.VERSION.SDK_INT >= 29 && !hasBackgroundPermissionGranted) { + } else if (androidSDKInt >= 29 && !hasBackgroundPermissionGranted) { result = backgroundLocationPermissionLogic(true) } else { result = true diff --git a/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/LocationManagerTests.kt b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/LocationManagerTests.kt new file mode 100644 index 0000000000..7cf1f9dd96 --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/LocationManagerTests.kt @@ -0,0 +1,523 @@ +package com.onesignal.location.internal + +import com.onesignal.common.AndroidUtils +import com.onesignal.core.internal.preferences.IPreferencesService +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.location.internal.capture.ILocationCapturer +import com.onesignal.location.internal.common.LocationConstants +import com.onesignal.location.internal.common.LocationUtils +import com.onesignal.location.internal.controller.ILocationController +import com.onesignal.location.internal.permissions.LocationPermissionController +import com.onesignal.mocks.IOMockHelper.awaitIO +import com.onesignal.mocks.MockHelper +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +private class Mocks { + val locationCapture = mockk(relaxed = true) + val locationController = mockk(relaxed = true) + val permissionController = mockk(relaxed = true) + val mockAppService = MockHelper.applicationService() + + val mockPrefs = + run { + val pref = mockk(relaxed = true) + every { + pref.getBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, false) + } returns true + pref + } + + val mockContext = + run { + val context = mockk(relaxed = true) + every { mockAppService.appContext } returns context + context + } + + val locationManager = LocationManager( + mockAppService, + locationCapture, + locationController, + permissionController, + mockPrefs, + ) + + fun setAndroidSDKInt(sdkInt: Int) { + every { AndroidUtils.androidSDKInt } returns sdkInt + } + + fun setFineLocationPermission(granted: Boolean) { + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING, + true, + mockAppService, + ) + } returns granted + } + + fun setCoarseLocationPermission(granted: Boolean) { + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING, + true, + mockAppService, + ) + } returns granted + } +} + +class LocationManagerTests : FunSpec({ + + lateinit var mocks: Mocks + + beforeAny { + Logging.logLevel = LogLevel.NONE + mocks = Mocks() // fresh instance for each test + } + + beforeSpec { + // required when testing functions that internally call suspendifyOnMain + Dispatchers.setMain(UnconfinedTestDispatcher()) + mockkObject(LocationUtils) + mockkObject(AndroidUtils) + every { LocationUtils.hasLocationPermission(any()) } returns false + every { AndroidUtils.hasPermission(any(), any(), any()) } returns false + every { AndroidUtils.filterManifestPermissions(any(), any()) } returns emptyList() + } + + afterSpec { + Dispatchers.resetMain() + unmockkObject(LocationUtils) + unmockkObject(AndroidUtils) + } + + test("isShared getter returns value from preferences") { + // Given + val mockPrefs = mocks.mockPrefs + val locationManager = mocks.locationManager + + // When + val result = locationManager.isShared + + // Then + result shouldBe true + verify { + mockPrefs.getBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, false) + } + } + + test("isShared setter saves value to preferences and triggers permission change") { + // Given + val mockPrefs = mocks.mockPrefs + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true + every { LocationUtils.hasLocationPermission(any()) } returns true + val locationManager = mocks.locationManager + + // When + locationManager.isShared = true + + // Then + verify { + mockPrefs.saveBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, true) + } + locationManager.isShared shouldBe true + } + + test("isShared setter to false does not start location when permission changed") { + // Given + val mockLocationController = mocks.locationController + val locationManager = mocks.locationManager + + // When + locationManager.isShared = false + + // Then + locationManager.isShared shouldBe false + coVerify(exactly = 0) { mockLocationController.start() } + } + + test("start subscribes to location permission controller") { + // Given + every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns false + val locationManager = mocks.locationManager + + // When + locationManager.start() + + // Then + verify(exactly = 1) { mocks.permissionController.subscribe(locationManager) } + } + + test("start calls startGetLocation when location permission is granted") { + // Given + every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns true + coEvery { mocks.locationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + locationManager.start() + awaitIO() + + // Then + coVerify { mocks.locationController.start() } + } + + test("start does not call startGetLocation when location permission is not granted") { + // Given + val mockLocationController = mockk(relaxed = true) + every { LocationUtils.hasLocationPermission(mocks.mockContext) } returns false + + val locationManager = mocks.locationManager + + // When + locationManager.start() + + // Then + coVerify(exactly = 0) { mockLocationController.start() } + } + + test("onLocationPermissionChanged calls startGetLocation when enabled is true") { + // Given + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + locationManager.onLocationPermissionChanged(true) + awaitIO() + + // Then + coVerify { mockLocationController.start() } + } + + test("onLocationPermissionChanged does not call startGetLocation when enabled is false") { + // Given + val locationManager = mocks.locationManager + + // When + locationManager.onLocationPermissionChanged(false) + + // Then + coVerify(exactly = 0) { mocks.locationController.start() } + } + + test("onLocationPermissionChanged does not call startGetLocation when isShared is false") { + // Given + every { + mocks.mockPrefs.getBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, false) + } returns false + // Create a new LocationManager with isShared = false + val locationManager = LocationManager( + mocks.mockAppService, + mocks.locationCapture, + mocks.locationController, + mocks.permissionController, + mocks.mockPrefs, + ) + + // When + locationManager.onLocationPermissionChanged(true) + awaitIO() + + // Then + coVerify(exactly = 0) { mocks.locationController.start() } + } + + test("requestPermission returns true when fine permission granted on API < 23") { + // Given + mocks.setFineLocationPermission(true) + mocks.setAndroidSDKInt(22) + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + } + + test("requestPermission returns true when coarse permission granted on API < 23") { + // Given + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(true) + mocks.setAndroidSDKInt(22) + coEvery { mocks.locationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission returns false when no permissions in manifest on API < 23") { + // Given + val mockApplicationService = mocks.mockAppService + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(false) + mocks.setAndroidSDKInt(22) + // Ensure filterManifestPermissions returns empty list (no permissions in manifest) + every { + AndroidUtils.filterManifestPermissions(any(), mockApplicationService) + } returns emptyList() + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe false + } + + test("requestPermission returns true when fine permission already granted") { + // Given + mocks.setFineLocationPermission(true) + mocks.setAndroidSDKInt(23) + coEvery { mocks.locationController.start() } returns true + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission prompts for fine permission when not granted and in manifest") { + // Given + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mocks.permissionController + mocks.setFineLocationPermission(false) + mocks.setAndroidSDKInt(23) + every { + AndroidUtils.filterManifestPermissions( + any(), + mockApplicationService, + ) + } returns listOf(LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING) + coEvery { + mockPermissionController.prompt(true, LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING) + } returns true + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { + mockPermissionController.prompt(true, LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING) + } + } + + test("requestPermission prompts for coarse permission when fine not in manifest") { + // Given + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mocks.permissionController + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(true) + mocks.setAndroidSDKInt(23) + every { + AndroidUtils.filterManifestPermissions( + any(), + mockApplicationService, + ) + } returns listOf(LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING, + true, + mocks.mockAppService, + ) + } returns false + val locationManager = mocks.locationManager + + // When + locationManager.requestPermission() + + // Then + coVerify { + mockPermissionController.prompt(true, LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) + } + } + + test("requestPermission returns false when permissions not in manifest") { + // Given + mocks.setFineLocationPermission(false) + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe false + } + + test("requestPermission returns true when coarse permission already granted") { + // Given + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(true) + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + } + + test("requestPermission prompts for background permission when fine granted but background not") { + // Given + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mocks.permissionController + mocks.setFineLocationPermission(true) + mocks.setAndroidSDKInt(29) + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, + true, + mockApplicationService, + ) + } returns false + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, + false, + mockApplicationService, + ) + } returns true + coEvery { + mockPermissionController.prompt(true, LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING) + } returns true + coEvery { mocks.locationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { + mockPermissionController.prompt(true, LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING) + } + } + + test("requestPermission starts location when all permissions granted") { + // Given + val mockApplicationService = mocks.mockAppService + mocks.setFineLocationPermission(true) + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, + true, + mockApplicationService, + ) + } returns true + coEvery { mocks.locationController.start() } returns true + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission warns when isShared is false") { + // Given + mocks.setFineLocationPermission(true) + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + // Warning should be logged (tested indirectly through no exception) + } + + test("requestPermission handles location controller start failure gracefully") { + // Given + mocks.setFineLocationPermission(true) + coEvery { mocks.locationController.start() } returns false + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + } + + test("requestPermission handles location controller exception gracefully") { + // Given + val mockLocationController = mocks.permissionController + mocks.setFineLocationPermission(true) + coEvery { mockLocationController.start() } throws RuntimeException("Location error") + + val locationManager = mocks.locationManager + + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + // Exception should be caught and logged (tested indirectly through no crash) + } + + test("startGetLocation does nothing when isShared is false") { + // Given + val mockLocationController = mocks.locationController + val locationManager = mocks.locationManager + locationManager.isShared = false + + // When - trigger startGetLocation indirectly via onLocationPermissionChanged + locationManager.onLocationPermissionChanged(true) + awaitIO() + + // Then + coVerify(exactly = 0) { mockLocationController.start() } + } + + test("startGetLocation calls location controller start when isShared is true") { + // Given + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true + val locationManager = mocks.locationManager + + // When - trigger startGetLocation indirectly via onLocationPermissionChanged + locationManager.onLocationPermissionChanged(true) + awaitIO() + + // Then + coVerify { mockLocationController.start() } + } +}) diff --git a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt new file mode 100644 index 0000000000..a5ad5b1d65 --- /dev/null +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -0,0 +1,123 @@ +package com.onesignal.mocks + +import com.onesignal.common.threading.suspendifyOnIO +import com.onesignal.common.threading.suspendifyWithCompletion +import io.kotest.core.listeners.AfterSpecListener +import io.kotest.core.listeners.BeforeSpecListener +import io.kotest.core.listeners.BeforeTestListener +import io.kotest.core.listeners.TestListener +import io.kotest.core.spec.Spec +import io.kotest.core.test.TestCase +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicInteger + +/** + * Test helper that makes OneSignal’s `suspendifyOnIO` behavior deterministic in unit tests. + * Can be helpful to speed up unit tests by replacing all delay(x) or Thread.sleep(x). + * + * In production, `suspendifyOnIO` launches work on background threads and returns immediately. + * This causes tests to require arbitrary delays (e.g., delay(50)) to wait for async work to finish. + * + * This helper avoids that by: + * - Replacing Dispatchers.Main with a test dispatcher + * - Mocking `suspendifyOnIO` so its block runs immediately + * - Completing a `CompletableDeferred` when the async block finishes + * - Providing `awaitIO()` so tests can explicitly wait for all IO work without sleeps + * + * Usage example in a Kotest spec: + * class InAppMessagesManagerTests : FunSpec({ + * + * // register to access awaitIO() + * listener(IOMockHelper) + * ... + * + * test("xyz") { + * iamManager.start() // start() calls suspendOnIO + * awaitIO() // wait for background work deterministically + * ... + * } + */ +object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, TestListener { + + private const val THREADUTILS_PATH = "com.onesignal.common.threading.ThreadUtilsKt" + + // How many IO blocks are currently running + private val pendingIo = AtomicInteger(0) + + // Completed when all in-flight IO blocks for the current "wave" are done + @Volatile + private var ioWaiter: CompletableDeferred = CompletableDeferred() + + /** + * Wait for suspendifyOnIO work to finish. + * Can be called multiple times in a test. + * 1. If multiple IO tasks are added before the first task finishes, the waiter will wait until ALL tasks are finished + * 2. If async work is triggered after an awaitIO() has already returned, just call awaitIO() again to wait for the new work. + * + * *** NOTE ABOUT COVERAGE: + * * This helper intentionally mocks *only* the top-level `suspendifyOnIO(block)` function. + * It does NOT intercept every threading entry point defined in ThreadUtils.kt or + * OneSignalDispatchers — e.g. `suspendifyWithCompletion`, `suspendifyOnDefault`, + * `launchOnIO`, and `launchOnDefault` will continue to run using the real dispatcher + * behavior. + * + * * This design keeps the helper focused on stabilizing existing tests that specifically + * depend on `suspendifyOnIO`, without altering unrelated threading paths across the SDK. + * + * * If future tests rely on other threading helpers (e.g., direct calls to + * `suspendifyWithCompletion` or `launchOnIO`), this helper can be extended, or a separate + * test helper can be introduced to cover those cases. For now, this keeps the + * interception surface minimal and avoids unintentionally changing more concurrency + * behavior than necessary. + */ + suspend fun awaitIO(timeoutMs: Long = 5_000) { + // Nothing to wait for in this case + if (pendingIo.get() == 0) return + + withTimeout(timeoutMs) { + ioWaiter.await() + } + } + + override suspend fun beforeSpec(spec: Spec) { + // ThreadUtilsKt = file that contains suspendifyOnIO + mockkStatic(THREADUTILS_PATH) + + every { suspendifyOnIO(any Unit>()) } answers { + val block = firstArg Unit>() + + // New IO wave: if we are going from 0 -> 1, create a new waiter + val previous = pendingIo.getAndIncrement() + if (previous == 0) { + ioWaiter = CompletableDeferred() + } + + suspendifyWithCompletion( + useIO = true, + block = block, + onComplete = { + // When each block finishes, decrement; if all done, complete waiter + if (pendingIo.decrementAndGet() == 0) { + if (!ioWaiter.isCompleted) { + ioWaiter.complete(Unit) + } + } + }, + ) + } + } + + override suspend fun beforeTest(testCase: TestCase) { + // Fresh waiter for each test + pendingIo.set(0) + ioWaiter = CompletableDeferred() + } + + override suspend fun afterSpec(spec: Spec) { + unmockkStatic(THREADUTILS_PATH) + } +}