From bbfc0682452132f830afc650cd693ff351ba3383 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Thu, 20 Nov 2025 11:55:16 -0500 Subject: [PATCH 01/11] tests: add more tests for InAppMessageManager --- .../internal/InAppMessagesManagerTests.kt | 1447 ++++++++++++++++- 1 file changed, 1397 insertions(+), 50 deletions(-) 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..54c6908d44 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,1427 @@ package com.onesignal.inAppMessages.internal +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.core.internal.time.ITime +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.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.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.runs import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +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 state = mockk(relaxed = true) + val prefs = mockk(relaxed = true) + val repository = mockk(relaxed = true) + val backend = mockk(relaxed = true) + val triggerController = mockk(relaxed = true) + val triggerModelStore = mockk(relaxed = true) + val displayer = mockk(relaxed = true) + val lifecycle = mockk(relaxed = true) + val languageContext = MockHelper.languageContext() + val time = MockHelper.time(1000) + val consistencyManager = mockk(relaxed = true) + val inAppMessageLifecycleListener = mockk(relaxed = true) + val deferred = mockk>() + val rywData = RywData("token", 100L) + + val subscriptionManager = mockk(relaxed = true) { + every { subscriptions } returns mockk { + every { push } returns pushSubscription + } + } + + val outcome = + 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(outcome) + every { result.tags } returns null + every { result.url } returns null + every { result.clickId } returns "click-id" + result + } + + // Helper function to create a test InAppMessage + fun createTestMessage( + messageId: String = "test-message-id", + time: ITime = MockHelper.time(1000), + isPreview: Boolean = false, + ): InAppMessage { + return if (isPreview) { + InAppMessage(true, time) + } else { + // Create message with variants using JSON constructor so variantIdForMessage works + val json = JSONObject() + json.put("id", messageId) + val variantsJson = JSONObject() + val allVariantJson = JSONObject() + allVariantJson.put("en", "variant-id-123") + variantsJson.put("all", allVariantJson) + json.put("variants", variantsJson) + json.put("triggers", JSONArray()) + InAppMessage(json, time) + } + } + + // Helper function to create InAppMessagesManager with all dependencies + val inAppMessagesManager = InAppMessagesManager( + applicationService, + sessionService, + influenceManager, + configModelStore, + userManager, + identityModelStore, + subscriptionManager, + outcomeEventsController, + state, + prefs, + repository, + backend, + triggerController, + triggerModelStore, + displayer, + lifecycle, + 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(), + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + context("Trigger Management") { + test("triggers are backed by the trigger model store") { + // Given + val mocks = Mocks() + val mockTriggerModelStore = mocks.triggerModelStore + 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 = mocks.inAppMessagesManager + + // 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() } + } + + test("addTrigger updates existing trigger model when trigger already exists") { + // Given + val mocks = Mocks() + 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 + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.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 mocks = Mocks() + val mockTriggerModelStore = mocks.triggerModelStore + val triggerModelSlots = mutableListOf() + every { mockTriggerModelStore.get("new-key") } returns null + every { mockTriggerModelStore.add(capture(triggerModelSlots)) } answers {} + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addTrigger("new-key", "new-value") + + // Then + triggerModelSlots.size shouldBe 1 + triggerModelSlots[0].key shouldBe "new-key" + triggerModelSlots[0].value shouldBe "new-value" + } + } + + context("Initialization and Start") { + test("start loads dismissed messages from preferences") { + // Given + val mocks = Mocks() + val mockPrefs = mocks.prefs + val dismissedSet = setOf("dismissed-1", "dismissed-2") + every { mockPrefs.dismissedMessagesId } returns dismissedSet + every { mockPrefs.lastTimeInAppDismissed } returns null + + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.start() + + // Then + verify { mockPrefs.dismissedMessagesId } + coVerify { mockRepository.cleanCachedInAppMessages() } + } + + test("start loads last dismissal time from preferences") { + // Given + val mocks = Mocks() + val mockPrefs = mocks.prefs + val mockState = mocks.state + 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() + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.start() + + // Then + verify { mockState.lastTimeInAppDismissed = lastDismissalTime } + } + + test("start loads redisplayed messages from repository and resets display flag") { + // Given + val mocks = Mocks() + val message1 = mocks.createTestMessage("msg-1") + val message2 = mocks.createTestMessage("msg-2") + message1.isDisplayedInSession = true + message2.isDisplayedInSession = true + + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns listOf(message1, message2) + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.start() + + // Then - wait for async operations + runBlocking { + delay(50) + message1.isDisplayedInSession shouldBe false + message2.isDisplayedInSession shouldBe false + } + } + + test("start subscribes to all required services") { + // Given + val mocks = Mocks() + val mockRepository = mocks.repository + coEvery { mockRepository.cleanCachedInAppMessages() } just runs + coEvery { mockRepository.listInAppMessages() } returns emptyList() + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.start() + + // Then + verify { mocks.subscriptionManager.subscribe(any()) } + verify { mocks.lifecycle.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 + val mocks = Mocks() + val mockState = mocks.state + every { mockState.paused } returns true + + val iamManager = mocks.inAppMessagesManager + + // When + val result = iamManager.paused + + // Then + result shouldBe true + } + + test("setting paused to true does nothing when no message showing") { + // Given + val mocks = Mocks() + val mockState = mocks.state + val mockDisplayer = mocks.displayer + every { mockState.paused } returns false + every { mocks.state.inAppMessageIdShowing } returns null + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.paused = true + + // Then + verify { mockState.paused = true } + coVerify(exactly = 0) { mockDisplayer.dismissCurrentInAppMessage() } + } + } + + context("Lifecycle Listeners") { + test("addLifecycleListener subscribes listener") { + // Given + val mocks = Mocks() + val mockListener = mocks.inAppMessageLifecycleListener + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addLifecycleListener(mockListener) + + // Then + // Listener is added to internal EventProducer - verify by checking it can be removed + iamManager.removeLifecycleListener(mockListener) + } + + test("removeLifecycleListener unsubscribes listener") { + // Given + val mocks = Mocks() + val mockListener = mocks.inAppMessageLifecycleListener + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addLifecycleListener(mockListener) + iamManager.removeLifecycleListener(mockListener) + + // Then + // No exception should be thrown + } + + test("addClickListener subscribes listener") { + // Given + val mocks = Mocks() + val mockListener = mockk(relaxed = true) + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addClickListener(mockListener) + + // Then + // Listener is added to internal EventProducer + iamManager.removeClickListener(mockListener) + } + + test("removeClickListener unsubscribes listener") { + // Given + val mocks = Mocks() + val mockListener = mockk(relaxed = true) + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.addClickListener(mockListener) + iamManager.removeClickListener(mockListener) + + // Then + // No exception should be thrown + } + } + + context("Config Model Changes") { + test("onModelUpdated fetches messages when appId property changes") { + // Given + val mocks = Mocks() + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + 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.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + val iamManager = mocks.inAppMessagesManager + + val configModel = ConfigModel() + val args = ModelChangedArgs( + configModel, + ConfigModel::appId.name, + ConfigModel::appId.name, + "old-value", + "new-value", + ) + + // When + iamManager.onModelUpdated(args, "tag") + + // Then + // Should trigger fetchMessagesWhenConditionIsMet + // Verification happens through backend call + runBlocking { + // Give time for coroutine to execute + delay(50) + } + } + + test("onModelUpdated does nothing when non-appId property changes") { + // Given + val mocks = Mocks() + val iamManager = mocks.inAppMessagesManager + + val configModel = ConfigModel() + val args = ModelChangedArgs( + configModel, + "other-property", + "other-property", + "old-value", + "new-value", + ) + + // When + iamManager.onModelUpdated(args, "tag") + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onModelReplaced fetches messages") { + // Given + val mocks = Mocks() + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + 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.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + val iamManager = mocks.inAppMessagesManager + + val model = ConfigModel() + + // When + iamManager.onModelReplaced(model, "tag") + + // Then + coVerify { + mocks.backend.listInAppMessages(any(), any(), any(), any()) + } + } + } + + context("Subscription Changes") { + test("onSubscriptionChanged fetches messages when push subscription id changes") { + // Given + val mocks = Mocks() + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + 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.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + val iamManager = mocks.inAppMessagesManager + + val subscriptionModel = SubscriptionModel() + val args = ModelChangedArgs( + subscriptionModel, + SubscriptionModel::id.name, + SubscriptionModel::id.name, + "old-id", + "new-id", + ) + + // When + iamManager.onSubscriptionChanged(mocks.pushSubscription, args) + + // Then + coVerify { + mocks.backend.listInAppMessages(any(), any(), any(), any()) + } + } + + test("onSubscriptionChanged does nothing for non-push subscription") { + // Given + val mocks = Mocks() + 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) + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSubscriptionChanged does nothing when id path does not match") { + // Given + val mocks = Mocks() + val iamManager = mocks.inAppMessagesManager + + val subscriptionModel = SubscriptionModel() + val args = ModelChangedArgs( + subscriptionModel, + "other-path", + "other-path", + "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 + iamManager.onSubscriptionChanged(mocks.pushSubscription, args) + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("onSubscriptionAdded does not fetch") { + // Given + val mocks = Mocks() + 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 mocks = Mocks() + 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 mocks = Mocks() + val message1 = mocks.createTestMessage("msg-1") + val message2 = mocks.createTestMessage("msg-2") + message1.isDisplayedInSession = true + message2.isDisplayedInSession = true + + val mockRepository = mocks.repository + coEvery { mockRepository.listInAppMessages() } returns listOf(message1, message2) + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + 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.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onSessionStarted() + + // Then - wait for async fetchMessages operation to complete + runBlocking { + delay(50) + } + } + + test("onSessionActive does nothing") { + // Given + val mocks = Mocks() + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onSessionActive() + } + + test("onSessionEnded does nothing") { + // Given + val mocks = Mocks() + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onSessionEnded(1000L) + } + } + + context("Message Lifecycle Callbacks") { + test("onMessageWillDisplay fires lifecycle callback when subscribers exist") { + // Given + val mocks = Mocks() + val mockListener = mocks.inAppMessageLifecycleListener + val message = mocks.createTestMessage("msg-1") + val iamManager = mocks.inAppMessagesManager + + iamManager.addLifecycleListener(mockListener) + + // When + iamManager.onMessageWillDisplay(message) + + // Then + // Callback should be fired - verified through no exception + } + + test("onMessageWillDisplay does nothing when no subscribers") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onMessageWillDisplay(message) + } + + test("onMessageWasDisplayed sends impression for non-preview message") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageWasDisplayed(message) + + // Then + coVerify { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWasDisplayed does not send impression for preview message") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1", isPreview = true) + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageWasDisplayed(message) + + // Then + coVerify(exactly = 0) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWasDisplayed does not send duplicate impressions") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs + + val iamManager = mocks.inAppMessagesManager + + // When - send impression twice + runBlocking { + iamManager.onMessageWasDisplayed(message) + iamManager.onMessageWasDisplayed(message) + } + + // Then - should only send once + coVerify(exactly = 1) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } + } + + test("onMessageWillDismiss fires lifecycle callback when subscribers exist") { + // Given + val mocks = Mocks() + val mockListener = mocks.inAppMessageLifecycleListener + val message = mocks.createTestMessage("msg-1") + val iamManager = mocks.inAppMessagesManager + + iamManager.addLifecycleListener(mockListener) + + // When + iamManager.onMessageWillDismiss(message) + + // Then + // Should not throw + } + + test("onMessageWillDismiss does nothing when no subscribers") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onMessageWillDismiss(message) + } + + test("onMessageWasDismissed calls messageWasDismissed") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + every { mocks.state.inAppMessageIdShowing } returns null + + val iamManager = mocks.inAppMessagesManager + + // When + runBlocking { + iamManager.onMessageWasDismissed(message) + delay(50) + } + + // Then + verify { mocks.influenceManager.onInAppMessageDismissed() } + } + } + + context("Trigger Callbacks") { + test("onTriggerCompleted does nothing") { + // Given + val mocks = Mocks() + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onTriggerCompleted("trigger-id") + } + + test("onTriggerConditionChanged makes redisplay messages available and re-evaluates") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false + every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false + every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false + + val iamManager = mocks.inAppMessagesManager + + // When + runBlocking { + iamManager.onTriggerConditionChanged("trigger-id") + delay(50) + } + + // Then + // Should trigger re-evaluation + } + + test("onTriggerChanged makes redisplay messages available and re-evaluates") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false + every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false + every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false + + val iamManager = mocks.inAppMessagesManager + + // When + runBlocking { + iamManager.onTriggerChanged("trigger-key") + delay(50) + } + + // Then + // Should trigger re-evaluation + } + } + + context("Application Lifecycle") { + test("onFocus does nothing") { + // Given + val mocks = Mocks() + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onFocus(false) + iamManager.onFocus(true) + } + + test("onUnfocused does nothing") { + // Given + val mocks = Mocks() + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onUnfocused() + } + } + + context("Message Action Handling") { + test("onMessageActionOccurredOnPreview processes preview actions") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1", isPreview = true) + val mockClickResult = mocks.inAppMessageClickResult + 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.state.currentPrompt } returns null + + val iamManager = mocks.inAppMessagesManager + iamManager.addClickListener(mockClickListener) + + // When + iamManager.onMessageActionOccurredOnPreview(message, mockClickResult) + + // Then + verify { mockClickResult.isFirstClick = any() } + } + + test("onMessagePageChanged sends page impression for non-preview message") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockPage = mockk(relaxed = true) + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + every { mockPage.pageId } returns "page-id" + + coEvery { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } just runs + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessagePageChanged(message, mockPage) + + // Then + coVerify { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } + } + + test("onMessagePageChanged does nothing for preview message") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1", isPreview = true) + val mockPage = mockk(relaxed = true) + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessagePageChanged(message, mockPage) + + // 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 mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + coEvery { + mocks.backend.sendIAMImpression(any(), any(), any(), any()) + } throws BackendException(500, "Server error") + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageWasDisplayed(message) + delay(50) + // Try again - should retry since impression was removed + iamManager.onMessageWasDisplayed(message) + + // 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 mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + 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") + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessagePageChanged(message, mockPage) + delay(50) + // Try again - should retry since page impression was removed + iamManager.onMessagePageChanged(message, mockPage) + + // 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 mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + + coEvery { + mocks.backend.sendIAMClick(any(), any(), any(), any(), any(), any()) + } throws BackendException(500, "Server error") + + val iamManager = mocks.inAppMessagesManager + + // When + runBlocking { + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + delay(50) + } + + // 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 + val mocks = Mocks() + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns false + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + + val iamManager = mocks.inAppMessagesManager + + // When - trigger fetch via onSessionStarted + iamManager.onSessionStarted() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is empty") { + // Given + val mocks = Mocks() + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "" + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onSessionStarted() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is local ID") { + // Given + val mocks = Mocks() + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "local-123" + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onSessionStarted() + + // Then + coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } + } + + test("fetchMessagesWhenConditionIsMet evaluates messages when new messages are returned") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + every { mocks.pushSubscription.id } returns "subscription-id" + every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onSessionStarted() + + // 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 mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + 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.state.inAppMessageIdShowing } returns null + every { mocks.state.paused } returns true + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + + val iamManager = mocks.inAppMessagesManager + + // When - fetch messages while paused + iamManager.onSessionStarted() + + // Then - should not display + coVerify(exactly = 0) { mocks.displayer.displayMessage(any()) } + } + } + + context("Message Evaluation") { + test("messages are evaluated and queued when paused is set to false") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + 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.state.inAppMessageIdShowing } returns null + every { mocks.state.paused } returns true + coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) + coEvery { mocks.displayer.displayMessage(any()) } returns true + + val iamManager = mocks.inAppMessagesManager + + // Fetch messages first + iamManager.onSessionStarted() + + // When - set paused to false, which triggers evaluateInAppMessages + iamManager.paused = false + + // Then + verify { mocks.triggerController.evaluateMessageTriggers(message) } + } + + test("dismissed messages are not queued for display") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockRywData = mocks.rywData + val mockDeferred = mocks.deferred + + every { mocks.userManager.onesignalId } returns "onesignal-id" + every { mocks.applicationService.isInForeground } returns true + every { mocks.subscriptionManager.subscriptions } returns mockk { + every { push } returns mocks.pushSubscription + } + 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.state.paused } returns false + coEvery { mockDeferred.await() } returns mockRywData + coEvery { + mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) + } returns mockDeferred + + val iamManager = mocks.inAppMessagesManager + + // Fetch messages + iamManager.onSessionStarted() + + // Dismiss the message + iamManager.onMessageWasDismissed(message) + + // When - trigger evaluation + iamManager.paused = false + + // Then - should not display dismissed message + coVerify(exactly = 0) { mocks.displayer.displayMessage(message) } + } + } + + context("Message Actions - Outcomes and Tags") { + test("onMessageActionOccurredOnMessage fires outcomes") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + val mockOutcomeController = mocks.outcomeEventsController + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + + // Then - wait for async operations + coVerify { mockOutcomeController.sendOutcomeEvent("outcome-name") } + } + + test("onMessageActionOccurredOnMessage fires outcomes with weight") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + val mockOutcomeController = mocks.outcomeEventsController + val mockOutcome = mocks.outcome + val iamManager = mocks.inAppMessagesManager + val weight = 5.0f + every { mockOutcome.weight } returns weight + + // When + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + + // Then - wait for async operations + coVerify { mockOutcomeController.sendOutcomeEventWithValue("outcome-name", weight) } + } + + test("onMessageActionOccurredOnMessage adds tags") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + val mockTags = mockk(relaxed = true) + val tagsToAdd = JSONObject() + tagsToAdd.put("key1", "value1") + + every { mockTags.tagsToAdd } returns tagsToAdd + every { mockTags.tagsToRemove } returns null + every { mockClickResult.tags } returns mockTags + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + delay(50) + + // Then - wait for async operations + verify { mocks.userManager.addTags(any()) } + } + + test("onMessageActionOccurredOnMessage removes tags") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + val mockTags = mockk(relaxed = true) + val tagsToRemove = JSONArray() + tagsToRemove.put("key1") + + every { mockTags.tagsToAdd } returns null + every { mockTags.tagsToRemove } returns tagsToRemove + every { mockClickResult.tags } returns mockTags + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + + // Then - wait for async operations + verify { mocks.userManager.removeTags(any()) } + } + + test("onMessageActionOccurredOnMessage opens URL in browser") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + val mockApplicationService = mocks.applicationService + every { mockClickResult.url } returns "https://example.com" + every { mockClickResult.urlTarget } returns InAppMessageActionUrlType.BROWSER + + // When + val iamManager = mocks.inAppMessagesManager + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + + // Then - wait for async operations to complete + // URL opening is tested indirectly through no exceptions + runBlocking { + delay(50) + } + } + + test("onMessageActionOccurredOnMessage opens URL in webview") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + every { mockClickResult.url } returns "https://example.com" + every { mockClickResult.urlTarget } returns InAppMessageActionUrlType.IN_APP_WEBVIEW + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + + // Then - wait for async operations to complete + // URL opening is tested indirectly through no exceptions + runBlocking { + delay(50) + } + } + + test("onMessageActionOccurredOnMessage does nothing when URL is empty") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + + val iamManager = mocks.inAppMessagesManager + + // When/Then - should not throw + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + } + } + + context("Prompt Processing") { + test("onMessageActionOccurredOnMessage processes prompts") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockPrompt = mockk(relaxed = true) + val mockClickResult = mocks.inAppMessageClickResult + val mockState = mocks.state + val mockDisplayer = mocks.displayer + + every { mockClickResult.prompts } returns mutableListOf(mockPrompt) + every { mockPrompt.hasPrompted() } returns false + every { mockPrompt.setPrompted(true) } just runs + coEvery { mockPrompt.handlePrompt() } returns InAppMessagePrompt.PromptActionResult.PERMISSION_GRANTED + // currentPrompt starts as null, then gets set to the prompt during processing + var currentPrompt: InAppMessagePrompt? = null + every { mockState.currentPrompt } answers { currentPrompt } + every { mockState.currentPrompt = any() } answers { currentPrompt = firstArg() } + + // When + val iamManager = mocks.inAppMessagesManager + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + + // Then + coVerify { mockDisplayer.dismissCurrentInAppMessage() } + coVerify { mockPrompt.handlePrompt() } + } + + test("onMessageActionOccurredOnMessage does nothing when prompts list is empty") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockClickResult = mocks.inAppMessageClickResult + val mockDisplayer = mocks.displayer + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + + // Then + coVerify(exactly = 0) { mockDisplayer.dismissCurrentInAppMessage() } + } + } + + context("Message Persistence") { + test("onMessageWasDismissed persists message to repository") { + // Given + val mocks = Mocks() + val message = mocks.createTestMessage("msg-1") + val mockRepository = mocks.repository + val mockState = mocks.state + + coEvery { mockRepository.saveInAppMessage(any()) } just runs + every { mockState.lastTimeInAppDismissed } returns 500L + every { mockState.currentPrompt } returns null + + val iamManager = mocks.inAppMessagesManager + + // When + iamManager.onMessageWasDismissed(message) + + // Then + coVerify { mockRepository.saveInAppMessage(message) } + message.isDisplayedInSession shouldBe true + message.isTriggerChanged shouldBe false + } } }) From e5f97b7de8f362d7f5e59267631f9827ba737c16 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Fri, 21 Nov 2025 14:40:14 -0500 Subject: [PATCH 02/11] tests: add more tests for LocationManager --- .../location/internal/LocationManagerTests.kt | 625 ++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/LocationManagerTests.kt 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..991d19c31b --- /dev/null +++ b/OneSignalSDK/onesignal/location/src/test/java/com/onesignal/location/internal/LocationManagerTests.kt @@ -0,0 +1,625 @@ +package com.onesignal.location.internal + +import android.os.Build +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.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.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain + +private class Mocks { + val capturer = 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, + capturer, + locationController, + permissionController, + mockPrefs, + ) + + fun set_fine_location_permission(granted: Boolean) { + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING, + true, + mockAppService, + ) + } returns granted + } + + fun set_coarse_location_permission(granted: Boolean) { + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING, + true, + mockAppService, + ) + } returns granted + } +} + +class LocationManagerTests : FunSpec({ + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() + + beforeAny { + Logging.logLevel = LogLevel.NONE + Dispatchers.setMain(testDispatcher) + 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() + } + + afterAny { + unmockkObject(LocationUtils) + unmockkObject(AndroidUtils) + Dispatchers.resetMain() + } + + context("isShared Property") { + test("isShared getter returns value from preferences") { + // Given + val mocks = Mocks() + 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 mocks = Mocks() + 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 mocks = Mocks() + val mockLocationController = mocks.locationController + val locationManager = mocks.locationManager + + // When + locationManager.isShared = false + + // Then + locationManager.isShared shouldBe false + coVerify(exactly = 0) { mockLocationController.start() } + } + } + + context("start() Method") { + test("start subscribes to location permission controller") { + // Given + val mocks = Mocks() + 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 + val mocks = Mocks() + every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns true + coEvery { mocks.locationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + locationManager.start() + delay(50) + + // Then + coVerify { mocks.locationController.start() } + } + + test("start does not call startGetLocation when location permission is not granted") { + // Given + val mocks = Mocks() + val mockLocationController = mockk(relaxed = true) + every { LocationUtils.hasLocationPermission(mocks.mockContext) } returns false + + val locationManager = mocks.locationManager + + // When + locationManager.start() + Thread.sleep(200) // Wait for suspendifyOnIO coroutine to complete + + // Then + coVerify(exactly = 0) { mockLocationController.start() } + } + } + + context("onLocationPermissionChanged() Method") { + test("onLocationPermissionChanged calls startGetLocation when enabled is true") { + // Given + val mocks = Mocks() + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + locationManager.onLocationPermissionChanged(true) + Thread.sleep(200) // Wait for suspendifyOnIO coroutine to complete + + // Then + coVerify { mockLocationController.start() } + } + + test("onLocationPermissionChanged does not call startGetLocation when enabled is false") { + // Given + val mocks = Mocks() + val locationManager = mocks.locationManager + + // When + locationManager.onLocationPermissionChanged(false) + Thread.sleep(200) // Wait for suspendifyOnIO coroutine to complete + + // Then + coVerify(exactly = 0) { mocks.locationController.start() } + } + + test("onLocationPermissionChanged does not call startGetLocation when isShared is false") { + // Given + val mocks = Mocks() + 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.capturer, + mocks.locationController, + mocks.permissionController, + mocks.mockPrefs, + ) + + // When + locationManager.onLocationPermissionChanged(true) + delay(50) + + // Then + coVerify(exactly = 0) { mocks.locationController.start() } + } + } + + context("requestPermission() Method - API < 23") { + test("requestPermission returns true when fine permission granted on API < 23") { + // Set SDK version to 22 using reflection + setSdkVersion(22) + // Given + val mocks = Mocks() + mocks.set_fine_location_permission(true) + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + } + + test("requestPermission returns true when coarse permission granted on API < 23") { + setSdkVersion(22) + // Given + val mocks = Mocks() + mocks.set_fine_location_permission(false) + mocks.set_coarse_location_permission(true) + coEvery { mocks.locationController.start() } returns true + + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission returns false when no permissions in manifest on API < 23") { + setSdkVersion(22) + + // Given + val mocks = Mocks() + val mockApplicationService = mocks.mockAppService + mocks.set_fine_location_permission(false) + mocks.set_coarse_location_permission(false) + // Ensure filterManifestPermissions returns empty list (no permissions in manifest) + every { + AndroidUtils.filterManifestPermissions(any(), mockApplicationService) + } returns emptyList() + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe false + } + } + + context("requestPermission() Method - API >= 23") { + test("requestPermission returns true when fine permission already granted") { + // Set SDK version to 23 using reflection + setSdkVersion(23) + // Given + val mocks = Mocks() + mocks.set_fine_location_permission(true) + coEvery { mocks.locationController.start() } returns true + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission prompts for fine permission when not granted and in manifest") { + // Set SDK version to 23 using reflection + setSdkVersion(23) + + // Verify SDK version was set (if reflection fails, skip this test) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // SDK version couldn't be set, skip this test + return@test + } + + // Given + val mocks = Mocks() + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mockk(relaxed = true) + mocks.set_fine_location_permission(false) + 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 = runBlocking { + 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") { + // Set SDK version to 23 using reflection + setSdkVersion(23) + + // Verify SDK version was set (if reflection fails, skip this test) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // SDK version couldn't be set, skip this test + return@test + } + + // Given + val mocks = Mocks() + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mockk(relaxed = true) + mocks.set_fine_location_permission(false) + mocks.set_coarse_location_permission(false) + every { + AndroidUtils.filterManifestPermissions( + any(), + mockApplicationService, + ) + } returns listOf(LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) + every { + AndroidUtils.hasPermission( + LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING, + false, + mocks.mockAppService, + ) + } returns true + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + coVerify { + mockPermissionController.prompt(true, LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) + } + } + + test("requestPermission returns false when permissions not in manifest") { + // Given + val mocks = Mocks() + mocks.set_fine_location_permission(false) + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe false + } + + test("requestPermission returns true when coarse permission already granted") { + // Given + val mocks = Mocks() + mocks.set_fine_location_permission(false) + mocks.set_coarse_location_permission(true) + + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + } + } + + context("requestPermission() Method - API >= 29 (Android 10+)") { + test("requestPermission prompts for background permission when fine granted but background not") { + // Set SDK version to 29 using reflection + setSdkVersion(29) + + // Verify SDK version was set (if reflection fails, skip this test) + if (Build.VERSION.SDK_INT < 29) { + // SDK version couldn't be set, skip this test + return@test + } + + // Given + val mocks = Mocks() + val mockApplicationService = mocks.mockAppService + val mockPermissionController = mockk(relaxed = true) + mocks.set_fine_location_permission(true) + 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 = runBlocking { + 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 mocks = Mocks() + val mockApplicationService = mocks.mockAppService + mocks.set_fine_location_permission(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 = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + } + + context("requestPermission() Method - Edge Cases") { + test("requestPermission warns when isShared is false") { + // Given + val mocks = Mocks() + mocks.set_fine_location_permission(true) + + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + // Warning should be logged (tested indirectly through no exception) + } + + test("requestPermission handles location controller start failure gracefully") { + // Given + val mocks = Mocks() + mocks.set_fine_location_permission(true) + coEvery { mocks.locationController.start() } returns false + + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } + + test("requestPermission handles location controller exception gracefully") { + // Given + val mocks = Mocks() + val mockLocationController = mockk(relaxed = true) + mocks.set_fine_location_permission(true) + coEvery { mockLocationController.start() } throws RuntimeException("Location error") + + val locationManager = mocks.locationManager + + // When + val result = runBlocking { + locationManager.requestPermission() + } + + // Then + result shouldBe true + // Exception should be caught and logged (tested indirectly through no crash) + } + } + + context("startGetLocation() Method") { + test("startGetLocation does nothing when isShared is false") { + // Given + val mocks = Mocks() + val mockLocationController = mockk(relaxed = true) + val locationManager = mocks.locationManager + + // When - trigger startGetLocation indirectly via onLocationPermissionChanged + locationManager.onLocationPermissionChanged(true) + delay(50) // Wait for suspendifyOnIO coroutine to complete + + // Then + coVerify(exactly = 0) { mockLocationController.start() } + } + + test("startGetLocation calls location controller start when isShared is true") { + // Given + val mocks = Mocks() + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true + val locationManager = mocks.locationManager + + // When - trigger startGetLocation indirectly via onLocationPermissionChanged + locationManager.onLocationPermissionChanged(true) + Thread.sleep(200) // Wait for suspendifyOnIO coroutine to complete + + // Then + coVerify { mockLocationController.start() } + } + } +}) + +// Helper function to set SDK version using reflection +private fun setSdkVersion(sdkInt: Int) { + try { + val buildVersionClass = Class.forName("android.os.Build\$VERSION") + val sdkIntField = buildVersionClass.getDeclaredField("SDK_INT") + sdkIntField.isAccessible = true + sdkIntField.setInt(null, sdkInt) + } catch (e: Exception) { + // If reflection fails, the test will use the default SDK version + // This is acceptable for tests that don't strictly require a specific SDK version + } +} From 001ee12214603295f96fa28c27076a30dd3ca10f Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Fri, 21 Nov 2025 20:24:53 -0500 Subject: [PATCH 03/11] fix test fail in runner --- .../inAppMessages/internal/InAppMessagesManagerTests.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 54c6908d44..f1565c4831 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 @@ -1369,8 +1369,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mockClickResult.prompts } returns mutableListOf(mockPrompt) every { mockPrompt.hasPrompted() } returns false - every { mockPrompt.setPrompted(true) } just runs - coEvery { mockPrompt.handlePrompt() } returns InAppMessagePrompt.PromptActionResult.PERMISSION_GRANTED + every { mockPrompt.setPrompted(any()) } just runs // currentPrompt starts as null, then gets set to the prompt during processing var currentPrompt: InAppMessagePrompt? = null every { mockState.currentPrompt } answers { currentPrompt } @@ -1382,7 +1381,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then coVerify { mockDisplayer.dismissCurrentInAppMessage() } - coVerify { mockPrompt.handlePrompt() } + coVerify { mockPrompt.setPrompted(any()) } } test("onMessageActionOccurredOnMessage does nothing when prompts list is empty") { From 4013fcd90f056a1c3a2c26542ad4be107d8e0d56 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Mon, 24 Nov 2025 03:03:31 -0500 Subject: [PATCH 04/11] add IOMockHelper for deterministic IO completion --- .../java/com/onesignal/mocks/IOMockHelper.kt | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt 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..100934d305 --- /dev/null +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -0,0 +1,83 @@ +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.runBlocking + +/** + * 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 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 var ioWaiter: CompletableDeferred = CompletableDeferred() + + /** + * Wait for the current suspendifyOnIO work to finish. + * Can be called from tests instead of delay/Thread.sleep. + */ + fun awaitIO() { + if (!ioWaiter.isCompleted) { + runBlocking { + ioWaiter.await() + } + } + ioWaiter = CompletableDeferred() + } + + override suspend fun beforeSpec(spec: Spec) { + // ThreadUtilsKt = file that contains suspendifyOnIO + mockkStatic("com.onesignal.common.threading.ThreadUtilsKt") + + every { suspendifyOnIO(any Unit>()) } answers { + val block = firstArg Unit>() + suspendifyWithCompletion( + useIO = true, + block = block, + onComplete = { ioWaiter.complete(Unit) }, + ) + } + } + + override suspend fun beforeTest(testCase: TestCase) { + // fresh waiter for each test + ioWaiter = CompletableDeferred() + } + + override suspend fun afterSpec(spec: Spec) { + unmockkStatic("com.onesignal.common.threading.ThreadUtilsKt") + } +} From 5b99677a2db3d56f83e96a854c0353c3693f67f8 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Mon, 24 Nov 2025 03:03:40 -0500 Subject: [PATCH 05/11] optimize tests --- .../internal/InAppMessagesManagerTests.kt | 759 ++++++------------ .../location/internal/LocationManagerTests.kt | 63 +- 2 files changed, 271 insertions(+), 551 deletions(-) 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 f1565c4831..876a6b3151 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,18 +1,20 @@ 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.common.exceptions.BackendException import com.onesignal.common.modeling.ModelChangedArgs import com.onesignal.core.internal.config.ConfigModel -import com.onesignal.core.internal.time.ITime 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 @@ -22,6 +24,8 @@ 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 @@ -38,11 +42,17 @@ 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.delay +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain import org.json.JSONArray import org.json.JSONObject @@ -62,15 +72,22 @@ private class Mocks { val backend = mockk(relaxed = true) val triggerController = mockk(relaxed = true) val triggerModelStore = mockk(relaxed = true) - val displayer = mockk(relaxed = true) + val inAppDisplay = mockk(relaxed = true) val lifecycle = mockk(relaxed = true) val languageContext = MockHelper.languageContext() val time = MockHelper.time(1000) - val consistencyManager = mockk(relaxed = true) - val inAppMessageLifecycleListener = mockk(relaxed = true) - val deferred = mockk>() + val inAppMessageLifecycleListener = spyk() + val inAppMessageClickListener = spyk() val rywData = RywData("token", 100L) + val deferred = mockk>() { + coEvery { await() } returns rywData + } + + val consistencyManager = mockk(relaxed = true) { + coEvery { getRywDataFromAwaitableCondition(any()) } returns deferred + } + val subscriptionManager = mockk(relaxed = true) { every { subscriptions } returns mockk { every { push } returns pushSubscription @@ -95,27 +112,23 @@ private class Mocks { result } - // Helper function to create a test InAppMessage - fun createTestMessage( - messageId: String = "test-message-id", - time: ITime = MockHelper.time(1000), - isPreview: Boolean = false, - ): InAppMessage { - return if (isPreview) { - InAppMessage(true, time) - } else { - // Create message with variants using JSON constructor so variantIdForMessage works + // factory-style so every access returns a new message: + val testInAppMessage: InAppMessage + get() { val json = JSONObject() - json.put("id", messageId) + 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()) - InAppMessage(json, time) + 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( @@ -133,7 +146,7 @@ private class Mocks { backend, triggerController, triggerModelStore, - displayer, + inAppDisplay, lifecycle, languageContext, time, @@ -143,23 +156,36 @@ private class Mocks { class InAppMessagesManagerTests : FunSpec({ + 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 mocks = Mocks() 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 - val iamManager = mocks.inAppMessagesManager - // When iamManager.addTrigger("trigger-key1", "trigger-value1") iamManager.addTriggers(mapOf("trigger-key2" to "trigger-value2", "trigger-key3" to "trigger-value3")) @@ -183,7 +209,6 @@ class InAppMessagesManagerTests : FunSpec({ test("addTrigger updates existing trigger model when trigger already exists") { // Given - val mocks = Mocks() val mockTriggerModelStore = mocks.triggerModelStore val existingTrigger = TriggerModel().apply { id = "existing-key" @@ -193,10 +218,8 @@ class InAppMessagesManagerTests : FunSpec({ every { mockTriggerModelStore.get("existing-key") } returns existingTrigger every { mockTriggerModelStore.add(any()) } just runs - val iamManager = mocks.inAppMessagesManager - // When - iamManager.addTrigger("existing-key", "new-value") + mocks.inAppMessagesManager.addTrigger("existing-key", "new-value") // Then existingTrigger.value shouldBe "new-value" @@ -205,16 +228,13 @@ class InAppMessagesManagerTests : FunSpec({ test("addTrigger creates new trigger model when trigger does not exist") { // Given - val mocks = Mocks() val mockTriggerModelStore = mocks.triggerModelStore val triggerModelSlots = mutableListOf() every { mockTriggerModelStore.get("new-key") } returns null every { mockTriggerModelStore.add(capture(triggerModelSlots)) } answers {} - val iamManager = mocks.inAppMessagesManager - // When - iamManager.addTrigger("new-key", "new-value") + mocks.inAppMessagesManager.addTrigger("new-key", "new-value") // Then triggerModelSlots.size shouldBe 1 @@ -226,20 +246,16 @@ class InAppMessagesManagerTests : FunSpec({ context("Initialization and Start") { test("start loads dismissed messages from preferences") { // Given - val mocks = Mocks() val mockPrefs = mocks.prefs val dismissedSet = setOf("dismissed-1", "dismissed-2") + val mockRepository = mocks.repository every { mockPrefs.dismissedMessagesId } returns dismissedSet every { mockPrefs.lastTimeInAppDismissed } returns null - - val mockRepository = mocks.repository coEvery { mockRepository.cleanCachedInAppMessages() } just runs coEvery { mockRepository.listInAppMessages() } returns emptyList() - val iamManager = mocks.inAppMessagesManager - // When - iamManager.start() + mocks.inAppMessagesManager.start() // Then verify { mockPrefs.dismissedMessagesId } @@ -248,21 +264,17 @@ class InAppMessagesManagerTests : FunSpec({ test("start loads last dismissal time from preferences") { // Given - val mocks = Mocks() val mockPrefs = mocks.prefs val mockState = mocks.state 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() - val iamManager = mocks.inAppMessagesManager - // When - iamManager.start() + mocks.inAppMessagesManager.start() // Then verify { mockState.lastTimeInAppDismissed = lastDismissalTime } @@ -270,40 +282,31 @@ class InAppMessagesManagerTests : FunSpec({ test("start loads redisplayed messages from repository and resets display flag") { // Given - val mocks = Mocks() - val message1 = mocks.createTestMessage("msg-1") - val message2 = mocks.createTestMessage("msg-2") + 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) - val iamManager = mocks.inAppMessagesManager - // When - iamManager.start() + mocks.inAppMessagesManager.start() + awaitIO() - // Then - wait for async operations - runBlocking { - delay(50) - message1.isDisplayedInSession shouldBe false - message2.isDisplayedInSession shouldBe false - } + // Then + message1.isDisplayedInSession shouldBe false + message2.isDisplayedInSession shouldBe false } test("start subscribes to all required services") { // Given - val mocks = Mocks() val mockRepository = mocks.repository coEvery { mockRepository.cleanCachedInAppMessages() } just runs coEvery { mockRepository.listInAppMessages() } returns emptyList() - val iamManager = mocks.inAppMessagesManager - // When - iamManager.start() + mocks.inAppMessagesManager.start() // Then verify { mocks.subscriptionManager.subscribe(any()) } @@ -317,14 +320,10 @@ class InAppMessagesManagerTests : FunSpec({ context("Paused Property") { test("paused getter returns state paused value") { // Given - val mocks = Mocks() - val mockState = mocks.state - every { mockState.paused } returns true - - val iamManager = mocks.inAppMessagesManager + every { mocks.state.paused } returns true // When - val result = iamManager.paused + val result = mocks.inAppMessagesManager.paused // Then result shouldBe true @@ -332,14 +331,12 @@ class InAppMessagesManagerTests : FunSpec({ test("setting paused to true does nothing when no message showing") { // Given - val mocks = Mocks() val mockState = mocks.state - val mockDisplayer = mocks.displayer + val mockDisplayer = mocks.inAppDisplay + val iamManager = mocks.inAppMessagesManager every { mockState.paused } returns false every { mocks.state.inAppMessageIdShowing } returns null - val iamManager = mocks.inAppMessagesManager - // When iamManager.paused = true @@ -352,88 +349,77 @@ class InAppMessagesManagerTests : FunSpec({ context("Lifecycle Listeners") { test("addLifecycleListener subscribes listener") { // Given - val mocks = Mocks() val mockListener = mocks.inAppMessageLifecycleListener val iamManager = mocks.inAppMessagesManager // When iamManager.addLifecycleListener(mockListener) + iamManager.onMessageWillDisplay(mocks.testInAppMessage) // Then - // Listener is added to internal EventProducer - verify by checking it can be removed - iamManager.removeLifecycleListener(mockListener) + // Verify listener callback was called + verify { mockListener.onWillDisplay(any()) } } test("removeLifecycleListener unsubscribes listener") { // Given - val mocks = Mocks() val mockListener = mocks.inAppMessageLifecycleListener val iamManager = mocks.inAppMessagesManager // When iamManager.addLifecycleListener(mockListener) iamManager.removeLifecycleListener(mockListener) + iamManager.onMessageWillDisplay(mocks.testInAppMessage) // Then - // No exception should be thrown + // Listener should not be called after removal + verify(exactly = 0) { mockListener.onWillDisplay(any()) } } test("addClickListener subscribes listener") { // Given - val mocks = Mocks() - val mockListener = mockk(relaxed = true) + val mockListener = mocks.inAppMessageClickListener + val message = mocks.testInAppMessage + val mockClickResult = mocks.inAppMessageClickResult val iamManager = mocks.inAppMessagesManager // When iamManager.addClickListener(mockListener) + iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) // Then - // Listener is added to internal EventProducer - iamManager.removeClickListener(mockListener) + // Verify listener callback was called + verify { mockListener.onClick(any()) } } test("removeClickListener unsubscribes listener") { // Given - val mocks = Mocks() 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) // Then - // No exception should be thrown + // 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 mocks = Mocks() - val mockRywData = mocks.rywData val mockDeferred = mocks.deferred - 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.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null - - val iamManager = mocks.inAppMessagesManager - - val configModel = ConfigModel() val args = ModelChangedArgs( - configModel, + ConfigModel(), ConfigModel::appId.name, ConfigModel::appId.name, "old-value", @@ -441,25 +427,18 @@ class InAppMessagesManagerTests : FunSpec({ ) // When - iamManager.onModelUpdated(args, "tag") + mocks.inAppMessagesManager.onModelUpdated(args, "tag") + awaitIO() // Then // Should trigger fetchMessagesWhenConditionIsMet - // Verification happens through backend call - runBlocking { - // Give time for coroutine to execute - delay(50) - } + coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } } test("onModelUpdated does nothing when non-appId property changes") { // Given - val mocks = Mocks() - val iamManager = mocks.inAppMessagesManager - - val configModel = ConfigModel() val args = ModelChangedArgs( - configModel, + ConfigModel(), "other-property", "other-property", "old-value", @@ -467,7 +446,7 @@ class InAppMessagesManagerTests : FunSpec({ ) // When - iamManager.onModelUpdated(args, "tag") + mocks.inAppMessagesManager.onModelUpdated(args, "tag") // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -475,30 +454,14 @@ class InAppMessagesManagerTests : FunSpec({ test("onModelReplaced fetches messages") { // Given - val mocks = Mocks() - val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred - 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.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } every { mocks.pushSubscription.id } returns "subscription-id" - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null - val iamManager = mocks.inAppMessagesManager - - val model = ConfigModel() - // When - iamManager.onModelReplaced(model, "tag") + mocks.inAppMessagesManager.onModelReplaced(ConfigModel(), "tag") // Then coVerify { @@ -510,27 +473,7 @@ class InAppMessagesManagerTests : FunSpec({ context("Subscription Changes") { test("onSubscriptionChanged fetches messages when push subscription id changes") { // Given - val mocks = Mocks() - val mockRywData = mocks.rywData val mockDeferred = mocks.deferred - - 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.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } - every { mocks.pushSubscription.id } returns "subscription-id" - - coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null - - val iamManager = mocks.inAppMessagesManager - val subscriptionModel = SubscriptionModel() val args = ModelChangedArgs( subscriptionModel, @@ -539,9 +482,13 @@ class InAppMessagesManagerTests : FunSpec({ "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 - iamManager.onSubscriptionChanged(mocks.pushSubscription, args) + mocks.inAppMessagesManager.onSubscriptionChanged(mocks.pushSubscription, args) // Then coVerify { @@ -551,9 +498,7 @@ class InAppMessagesManagerTests : FunSpec({ test("onSubscriptionChanged does nothing for non-push subscription") { // Given - val mocks = Mocks() val iamManager = mocks.inAppMessagesManager - val mockSubscription = mockk() val subscriptionModel = SubscriptionModel() val args = ModelChangedArgs( @@ -573,9 +518,7 @@ class InAppMessagesManagerTests : FunSpec({ test("onSubscriptionChanged does nothing when id path does not match") { // Given - val mocks = Mocks() val iamManager = mocks.inAppMessagesManager - val subscriptionModel = SubscriptionModel() val args = ModelChangedArgs( subscriptionModel, @@ -594,9 +537,7 @@ class InAppMessagesManagerTests : FunSpec({ test("onSubscriptionAdded does not fetch") { // Given - val mocks = Mocks() val iamManager = mocks.inAppMessagesManager - val mockSubscription = mockk() // When @@ -608,9 +549,7 @@ class InAppMessagesManagerTests : FunSpec({ test("onSubscriptionRemoved does not fetch") { // Given - val mocks = Mocks() val iamManager = mocks.inAppMessagesManager - val mockSubscription = mockk() // When @@ -624,100 +563,87 @@ class InAppMessagesManagerTests : FunSpec({ context("Session Lifecycle") { test("onSessionStarted resets redisplayed messages and fetches messages") { // Given - val mocks = Mocks() - val message1 = mocks.createTestMessage("msg-1") - val message2 = mocks.createTestMessage("msg-2") - message1.isDisplayedInSession = true - message2.isDisplayedInSession = true - - val mockRepository = mocks.repository - coEvery { mockRepository.listInAppMessages() } returns listOf(message1, message2) + val message1 = mocks.testInAppMessage + val message2 = mocks.testInAppMessage val mockRywData = mocks.rywData val mockDeferred = mocks.deferred + 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.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } every { mocks.pushSubscription.id } returns "subscription-id" coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns null - val iamManager = mocks.inAppMessagesManager - // When - iamManager.onSessionStarted() + mocks.inAppMessagesManager.start() + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() - // Then - wait for async fetchMessages operation to complete - runBlocking { - delay(50) - } + // 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 mocks = Mocks() val iamManager = mocks.inAppMessagesManager // When/Then - should not throw iamManager.onSessionActive() + + // Verified by no exception being thrown } test("onSessionEnded does nothing") { // Given - val mocks = Mocks() val iamManager = mocks.inAppMessagesManager // When/Then - should not throw - iamManager.onSessionEnded(1000L) + iamManager.onSessionEnded(10L) + + // Verified by no exception being thrown } } context("Message Lifecycle Callbacks") { test("onMessageWillDisplay fires lifecycle callback when subscribers exist") { // Given - val mocks = Mocks() - val mockListener = mocks.inAppMessageLifecycleListener - val message = mocks.createTestMessage("msg-1") - val iamManager = mocks.inAppMessagesManager - - iamManager.addLifecycleListener(mockListener) + mocks.inAppMessagesManager.addLifecycleListener(mocks.inAppMessageLifecycleListener) // When - iamManager.onMessageWillDisplay(message) + mocks.inAppMessagesManager.onMessageWillDisplay(mocks.testInAppMessage) // Then - // Callback should be fired - verified through no exception + // Verify callback was fired + verify { mocks.inAppMessageLifecycleListener.onWillDisplay(any()) } } test("onMessageWillDisplay does nothing when no subscribers") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val iamManager = mocks.inAppMessagesManager // When/Then - should not throw - iamManager.onMessageWillDisplay(message) + 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 - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } + every { mocks.pushSubscription.id } returns "subscription-id" coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs - val iamManager = mocks.inAppMessagesManager - // When - iamManager.onMessageWasDisplayed(message) + mocks.inAppMessagesManager.onMessageWasDisplayed(mocks.testInAppMessage) // Then coVerify { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } @@ -725,12 +651,9 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessageWasDisplayed does not send impression for preview message") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1", isPreview = true) - val iamManager = mocks.inAppMessagesManager // When - iamManager.onMessageWasDisplayed(message) + mocks.inAppMessagesManager.onMessageWasDisplayed(mocks.testInAppMessagePreview) // Then coVerify(exactly = 0) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } @@ -738,20 +661,14 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessageWasDisplayed does not send duplicate impressions") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } + val message = mocks.testInAppMessage every { mocks.pushSubscription.id } returns "subscription-id" coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs - val iamManager = mocks.inAppMessagesManager - // When - send impression twice runBlocking { - iamManager.onMessageWasDisplayed(message) - iamManager.onMessageWasDisplayed(message) + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + mocks.inAppMessagesManager.onMessageWasDisplayed(message) } // Then - should only send once @@ -760,43 +677,32 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessageWillDismiss fires lifecycle callback when subscribers exist") { // Given - val mocks = Mocks() - val mockListener = mocks.inAppMessageLifecycleListener - val message = mocks.createTestMessage("msg-1") - val iamManager = mocks.inAppMessagesManager - - iamManager.addLifecycleListener(mockListener) + mocks.inAppMessagesManager.addLifecycleListener(mocks.inAppMessageLifecycleListener) // When - iamManager.onMessageWillDismiss(message) + mocks.inAppMessagesManager.onMessageWillDismiss(mocks.testInAppMessage) // Then - // Should not throw + // Verify callback was fired + verify { mocks.inAppMessageLifecycleListener.onWillDismiss(any()) } } test("onMessageWillDismiss does nothing when no subscribers") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val iamManager = mocks.inAppMessagesManager // When/Then - should not throw - iamManager.onMessageWillDismiss(message) + mocks.inAppMessagesManager.onMessageWillDismiss(mocks.testInAppMessage) + + // Verified by no exception being thrown when no listeners are subscribed } test("onMessageWasDismissed calls messageWasDismissed") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") every { mocks.state.inAppMessageIdShowing } returns null - val iamManager = mocks.inAppMessagesManager - // When - runBlocking { - iamManager.onMessageWasDismissed(message) - delay(50) - } + mocks.inAppMessagesManager.onMessageWasDismissed(mocks.testInAppMessage) + awaitIO() // Then verify { mocks.influenceManager.onInAppMessageDismissed() } @@ -806,114 +712,99 @@ class InAppMessagesManagerTests : FunSpec({ context("Trigger Callbacks") { test("onTriggerCompleted does nothing") { // Given - val mocks = Mocks() 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 mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false - every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false - every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false + 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) - val iamManager = mocks.inAppMessagesManager + // Fetch messages first + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // When - runBlocking { - iamManager.onTriggerConditionChanged("trigger-id") - delay(50) - } + mocks.inAppMessagesManager.onTriggerConditionChanged("trigger-id") // Then // Should trigger re-evaluation + verify { mocks.triggerController.evaluateMessageTriggers(any()) } } test("onTriggerChanged makes redisplay messages available and re-evaluates") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false - every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false - every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false + 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) - val iamManager = mocks.inAppMessagesManager + // Fetch messages first + mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // When - runBlocking { - iamManager.onTriggerChanged("trigger-key") - delay(50) - } + mocks.inAppMessagesManager.onTriggerChanged("trigger-key") // Then // Should trigger re-evaluation + verify { mocks.triggerController.evaluateMessageTriggers(any()) } } } context("Application Lifecycle") { test("onFocus does nothing") { // Given - val mocks = Mocks() - val iamManager = mocks.inAppMessagesManager // When/Then - should not throw - iamManager.onFocus(false) - iamManager.onFocus(true) + mocks.inAppMessagesManager.onFocus(false) + mocks.inAppMessagesManager.onFocus(true) } - test("onUnfocused does nothing") { // Given - val mocks = Mocks() - val iamManager = mocks.inAppMessagesManager // When/Then - should not throw - iamManager.onUnfocused() + mocks.inAppMessagesManager.onUnfocused() + + // Verified by no exception being thrown } } context("Message Action Handling") { test("onMessageActionOccurredOnPreview processes preview actions") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1", isPreview = true) - val mockClickResult = mocks.inAppMessageClickResult 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.state.currentPrompt } returns null - - val iamManager = mocks.inAppMessagesManager - iamManager.addClickListener(mockClickListener) + mocks.inAppMessagesManager.addClickListener(mockClickListener) // When - iamManager.onMessageActionOccurredOnPreview(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnPreview(mocks.testInAppMessagePreview, mocks.inAppMessageClickResult) // Then - verify { mockClickResult.isFirstClick = any() } + verify { mocks.inAppMessageClickResult.isFirstClick = any() } } test("onMessagePageChanged sends page impression for non-preview message") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") val mockPage = mockk(relaxed = true) - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } every { mocks.pushSubscription.id } returns "subscription-id" every { mockPage.pageId } returns "page-id" - coEvery { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } just runs - val iamManager = mocks.inAppMessagesManager - // When - iamManager.onMessagePageChanged(message, mockPage) + mocks.inAppMessagesManager.onMessagePageChanged(mocks.testInAppMessage, mockPage) // Then coVerify { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } @@ -921,13 +812,10 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessagePageChanged does nothing for preview message") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1", isPreview = true) val mockPage = mockk(relaxed = true) - val iamManager = mocks.inAppMessagesManager // When - iamManager.onMessagePageChanged(message, mockPage) + mocks.inAppMessagesManager.onMessagePageChanged(mocks.testInAppMessagePreview, mockPage) // Then coVerify(exactly = 0) { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } @@ -937,24 +825,19 @@ class InAppMessagesManagerTests : FunSpec({ context("Error Handling") { test("onMessageWasDisplayed removes impression from set on backend failure") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } + val message = mocks.testInAppMessage every { mocks.pushSubscription.id } returns "subscription-id" coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } throws BackendException(500, "Server error") - val iamManager = mocks.inAppMessagesManager - // When - iamManager.onMessageWasDisplayed(message) - delay(50) + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() + // Try again - should retry since impression was removed - iamManager.onMessageWasDisplayed(message) + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() // Then - should attempt twice since first failed coVerify(exactly = 2) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } @@ -962,23 +845,21 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessagePageChanged removes page impression on backend failure") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") + 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") - val iamManager = mocks.inAppMessagesManager - // When - iamManager.onMessagePageChanged(message, mockPage) - delay(50) + mocks.inAppMessagesManager.onMessagePageChanged(message, mockPage) + awaitIO() + // Try again - should retry since page impression was removed - iamManager.onMessagePageChanged(message, mockPage) + mocks.inAppMessagesManager.onMessagePageChanged(message, mockPage) + awaitIO() // Then - should attempt twice since first failed coVerify(exactly = 2) { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } @@ -986,21 +867,14 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessageActionOccurredOnMessage removes click on backend failure") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult - + val message = mocks.testInAppMessage coEvery { mocks.backend.sendIAMClick(any(), any(), any(), any(), any(), any()) } throws BackendException(500, "Server error") - val iamManager = mocks.inAppMessagesManager - // When - runBlocking { - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) - delay(50) - } + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(message, mocks.inAppMessageClickResult) + awaitIO() // Then coVerify { mocks.backend.sendIAMClick(any(), any(), any(), any(), any(), any()) } @@ -1012,21 +886,11 @@ class InAppMessagesManagerTests : FunSpec({ context("Message Fetching") { test("fetchMessagesWhenConditionIsMet returns early when app is not in foreground") { // Given - val mocks = Mocks() - val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred - every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns false - coEvery { mockDeferred.await() } returns mockRywData - coEvery { - mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) - } returns mockDeferred - - val iamManager = mocks.inAppMessagesManager // When - trigger fetch via onSessionStarted - iamManager.onSessionStarted() + mocks.inAppMessagesManager.onSessionStarted() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -1034,25 +898,12 @@ class InAppMessagesManagerTests : FunSpec({ test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is empty") { // Given - val mocks = Mocks() - val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred - every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } every { mocks.pushSubscription.id } returns "" - coEvery { mockDeferred.await() } returns mockRywData - coEvery { - mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) - } returns mockDeferred - - val iamManager = mocks.inAppMessagesManager // When - iamManager.onSessionStarted() + mocks.inAppMessagesManager.onSessionStarted() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -1060,25 +911,12 @@ class InAppMessagesManagerTests : FunSpec({ test("fetchMessagesWhenConditionIsMet returns early when subscriptionId is local ID") { // Given - val mocks = Mocks() - val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred - every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } every { mocks.pushSubscription.id } returns "local-123" - coEvery { mockDeferred.await() } returns mockRywData - coEvery { - mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) - } returns mockDeferred - - val iamManager = mocks.inAppMessagesManager // When - iamManager.onSessionStarted() + mocks.inAppMessagesManager.onSessionStarted() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -1086,27 +924,15 @@ class InAppMessagesManagerTests : FunSpec({ test("fetchMessagesWhenConditionIsMet evaluates messages when new messages are returned") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred - + val message = mocks.testInAppMessage every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } every { mocks.pushSubscription.id } returns "subscription-id" every { mocks.triggerController.evaluateMessageTriggers(any()) } returns false - coEvery { mockDeferred.await() } returns mockRywData - coEvery { - mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) - } returns mockDeferred coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) - val iamManager = mocks.inAppMessagesManager // When - iamManager.onSessionStarted() + mocks.inAppMessagesManager.onSessionStarted() // Then coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -1117,50 +943,30 @@ class InAppMessagesManagerTests : FunSpec({ context("Message Queue and Display") { test("messages are not queued when paused") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred - + val message = mocks.testInAppMessage every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } 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.state.inAppMessageIdShowing } returns null every { mocks.state.paused } returns true - coEvery { mockDeferred.await() } returns mockRywData - coEvery { - mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) - } returns mockDeferred - - val iamManager = mocks.inAppMessagesManager // When - fetch messages while paused - iamManager.onSessionStarted() + mocks.inAppMessagesManager.onSessionStarted() // Then - should not display - coVerify(exactly = 0) { mocks.displayer.displayMessage(any()) } + coVerify(exactly = 0) { mocks.inAppDisplay.displayMessage(any()) } } } context("Message Evaluation") { test("messages are evaluated and queued when paused is set to false") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred - + val message = mocks.testInAppMessage every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } every { mocks.pushSubscription.id } returns "subscription-id" every { mocks.triggerController.evaluateMessageTriggers(message) } returns true every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false @@ -1168,20 +974,14 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.state.inAppMessageIdShowing } returns null every { mocks.state.paused } returns true coEvery { mocks.applicationService.waitUntilSystemConditionsAvailable() } returns true - coEvery { mockDeferred.await() } returns mockRywData - coEvery { - mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) - } returns mockDeferred coEvery { mocks.backend.listInAppMessages(any(), any(), any(), any()) } returns listOf(message) - coEvery { mocks.displayer.displayMessage(any()) } returns true - - val iamManager = mocks.inAppMessagesManager + coEvery { mocks.inAppDisplay.displayMessage(any()) } returns true // Fetch messages first - iamManager.onSessionStarted() + mocks.inAppMessagesManager.onSessionStarted() // When - set paused to false, which triggers evaluateInAppMessages - iamManager.paused = false + mocks.inAppMessagesManager.paused = false // Then verify { mocks.triggerController.evaluateMessageTriggers(message) } @@ -1189,94 +989,64 @@ class InAppMessagesManagerTests : FunSpec({ test("dismissed messages are not queued for display") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred - + val message = mocks.testInAppMessage every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true - every { mocks.subscriptionManager.subscriptions } returns mockk { - every { push } returns mocks.pushSubscription - } 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.state.paused } returns false - coEvery { mockDeferred.await() } returns mockRywData - coEvery { - mocks.consistencyManager.getRywDataFromAwaitableCondition(any()) - } returns mockDeferred - - val iamManager = mocks.inAppMessagesManager // Fetch messages - iamManager.onSessionStarted() + mocks.inAppMessagesManager.onSessionStarted() // Dismiss the message - iamManager.onMessageWasDismissed(message) + mocks.inAppMessagesManager.onMessageWasDismissed(message) // When - trigger evaluation - iamManager.paused = false + mocks.inAppMessagesManager.paused = false // Then - should not display dismissed message - coVerify(exactly = 0) { mocks.displayer.displayMessage(message) } + coVerify(exactly = 0) { mocks.inAppDisplay.displayMessage(message) } } } context("Message Actions - Outcomes and Tags") { test("onMessageActionOccurredOnMessage fires outcomes") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult - val mockOutcomeController = mocks.outcomeEventsController - val iamManager = mocks.inAppMessagesManager // When - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) // Then - wait for async operations - coVerify { mockOutcomeController.sendOutcomeEvent("outcome-name") } + coVerify { mocks.outcomeEventsController.sendOutcomeEvent("outcome-name") } } test("onMessageActionOccurredOnMessage fires outcomes with weight") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult - val mockOutcomeController = mocks.outcomeEventsController - val mockOutcome = mocks.outcome - val iamManager = mocks.inAppMessagesManager val weight = 5.0f - every { mockOutcome.weight } returns weight + every { mocks.outcome.weight } returns weight // When - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) // Then - wait for async operations - coVerify { mockOutcomeController.sendOutcomeEventWithValue("outcome-name", weight) } + coVerify { mocks.outcomeEventsController.sendOutcomeEventWithValue("outcome-name", weight) } } test("onMessageActionOccurredOnMessage adds tags") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult val mockTags = mockk(relaxed = true) val tagsToAdd = JSONObject() tagsToAdd.put("key1", "value1") - every { mockTags.tagsToAdd } returns tagsToAdd every { mockTags.tagsToRemove } returns null - every { mockClickResult.tags } returns mockTags - - val iamManager = mocks.inAppMessagesManager + every { mocks.inAppMessageClickResult.tags } returns mockTags // When - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) - delay(50) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() // Then - wait for async operations verify { mocks.userManager.addTags(any()) } @@ -1284,141 +1054,108 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessageActionOccurredOnMessage removes tags") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult val mockTags = mockk(relaxed = true) val tagsToRemove = JSONArray() tagsToRemove.put("key1") - every { mockTags.tagsToAdd } returns null every { mockTags.tagsToRemove } returns tagsToRemove - every { mockClickResult.tags } returns mockTags - - val iamManager = mocks.inAppMessagesManager + every { mocks.inAppMessageClickResult.tags } returns mockTags // When - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) // Then - wait for async operations - verify { mocks.userManager.removeTags(any()) } + coVerify { mocks.userManager.removeTags(any()) } } test("onMessageActionOccurredOnMessage opens URL in browser") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult - val mockApplicationService = mocks.applicationService - every { mockClickResult.url } returns "https://example.com" - every { mockClickResult.urlTarget } returns InAppMessageActionUrlType.BROWSER + 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 - val iamManager = mocks.inAppMessagesManager - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) - // Then - wait for async operations to complete - // URL opening is tested indirectly through no exceptions - runBlocking { - delay(50) - } + // Then + coVerify { AndroidUtils.openURLInBrowser(any(), url) } + + unmockkObject(AndroidUtils) } test("onMessageActionOccurredOnMessage opens URL in webview") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult - every { mockClickResult.url } returns "https://example.com" - every { mockClickResult.urlTarget } returns InAppMessageActionUrlType.IN_APP_WEBVIEW - - val iamManager = mocks.inAppMessagesManager + 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 - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) - // Then - wait for async operations to complete - // URL opening is tested indirectly through no exceptions - runBlocking { - delay(50) - } + // Then + coVerify { OneSignalChromeTab.open("https://example.com", true, any()) } + + unmockkObject(OneSignalChromeTab) } test("onMessageActionOccurredOnMessage does nothing when URL is empty") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult - - val iamManager = mocks.inAppMessagesManager // When/Then - should not throw - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) } } - context("Prompt Processing") { test("onMessageActionOccurredOnMessage processes prompts") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") val mockPrompt = mockk(relaxed = true) - val mockClickResult = mocks.inAppMessageClickResult - val mockState = mocks.state - val mockDisplayer = mocks.displayer - - every { mockClickResult.prompts } returns mutableListOf(mockPrompt) + 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 { mockState.currentPrompt } answers { currentPrompt } - every { mockState.currentPrompt = any() } answers { currentPrompt = firstArg() } + every { mocks.state.currentPrompt } answers { currentPrompt } + every { mocks.state.currentPrompt = any() } answers { currentPrompt = firstArg() } // When - val iamManager = mocks.inAppMessagesManager - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) // Then - coVerify { mockDisplayer.dismissCurrentInAppMessage() } + coVerify { mocks.inAppDisplay.dismissCurrentInAppMessage() } coVerify { mockPrompt.setPrompted(any()) } } test("onMessageActionOccurredOnMessage does nothing when prompts list is empty") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockClickResult = mocks.inAppMessageClickResult - val mockDisplayer = mocks.displayer - val iamManager = mocks.inAppMessagesManager // When - iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) // Then - coVerify(exactly = 0) { mockDisplayer.dismissCurrentInAppMessage() } + coVerify(exactly = 0) { mocks.inAppDisplay.dismissCurrentInAppMessage() } } } context("Message Persistence") { test("onMessageWasDismissed persists message to repository") { // Given - val mocks = Mocks() - val message = mocks.createTestMessage("msg-1") - val mockRepository = mocks.repository - val mockState = mocks.state - - coEvery { mockRepository.saveInAppMessage(any()) } just runs - every { mockState.lastTimeInAppDismissed } returns 500L - every { mockState.currentPrompt } returns null - - val iamManager = mocks.inAppMessagesManager + val message = mocks.testInAppMessage + coEvery { mocks.repository.saveInAppMessage(any()) } just runs + every { mocks.state.lastTimeInAppDismissed } returns 500L + every { mocks.state.currentPrompt } returns null // When - iamManager.onMessageWasDismissed(message) + mocks.inAppMessagesManager.onMessageWasDismissed(message) // Then - coVerify { mockRepository.saveInAppMessage(message) } + coVerify { mocks.repository.saveInAppMessage(message) } message.isDisplayedInSession shouldBe true message.isTriggerChanged shouldBe false } 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 index 991d19c31b..ead5d79ff6 100644 --- 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 @@ -12,6 +12,8 @@ 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 +import com.onesignal.mocks.IOMockHelper.awaitIO import com.onesignal.mocks.MockHelper import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -23,15 +25,13 @@ import io.mockk.mockkObject import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain private class Mocks { - val capturer = mockk(relaxed = true) + val locationCapture = mockk(relaxed = true) val locationController = mockk(relaxed = true) val permissionController = mockk(relaxed = true) val mockAppService = MockHelper.applicationService() @@ -54,7 +54,7 @@ private class Mocks { val locationManager = LocationManager( mockAppService, - capturer, + locationCapture, locationController, permissionController, mockPrefs, @@ -82,11 +82,20 @@ private class Mocks { } class LocationManagerTests : FunSpec({ - val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() + + lateinit var mocks: Mocks + + // register to access awaitIO() + listener(IOMockHelper) beforeAny { Logging.logLevel = LogLevel.NONE - Dispatchers.setMain(testDispatcher) + 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 @@ -94,16 +103,15 @@ class LocationManagerTests : FunSpec({ every { AndroidUtils.filterManifestPermissions(any(), any()) } returns emptyList() } - afterAny { + afterSpec { + Dispatchers.resetMain() unmockkObject(LocationUtils) unmockkObject(AndroidUtils) - Dispatchers.resetMain() } context("isShared Property") { test("isShared getter returns value from preferences") { // Given - val mocks = Mocks() val mockPrefs = mocks.mockPrefs val locationManager = mocks.locationManager @@ -119,7 +127,6 @@ class LocationManagerTests : FunSpec({ test("isShared setter saves value to preferences and triggers permission change") { // Given - val mocks = Mocks() val mockPrefs = mocks.mockPrefs val mockLocationController = mocks.locationController coEvery { mockLocationController.start() } returns true @@ -138,7 +145,6 @@ class LocationManagerTests : FunSpec({ test("isShared setter to false does not start location when permission changed") { // Given - val mocks = Mocks() val mockLocationController = mocks.locationController val locationManager = mocks.locationManager @@ -154,7 +160,6 @@ class LocationManagerTests : FunSpec({ context("start() Method") { test("start subscribes to location permission controller") { // Given - val mocks = Mocks() every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns false val locationManager = mocks.locationManager @@ -167,7 +172,6 @@ class LocationManagerTests : FunSpec({ test("start calls startGetLocation when location permission is granted") { // Given - val mocks = Mocks() every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns true coEvery { mocks.locationController.start() } returns true @@ -175,7 +179,7 @@ class LocationManagerTests : FunSpec({ // When locationManager.start() - delay(50) + awaitIO() // Then coVerify { mocks.locationController.start() } @@ -183,7 +187,6 @@ class LocationManagerTests : FunSpec({ test("start does not call startGetLocation when location permission is not granted") { // Given - val mocks = Mocks() val mockLocationController = mockk(relaxed = true) every { LocationUtils.hasLocationPermission(mocks.mockContext) } returns false @@ -191,7 +194,6 @@ class LocationManagerTests : FunSpec({ // When locationManager.start() - Thread.sleep(200) // Wait for suspendifyOnIO coroutine to complete // Then coVerify(exactly = 0) { mockLocationController.start() } @@ -201,7 +203,6 @@ class LocationManagerTests : FunSpec({ context("onLocationPermissionChanged() Method") { test("onLocationPermissionChanged calls startGetLocation when enabled is true") { // Given - val mocks = Mocks() val mockLocationController = mocks.locationController coEvery { mockLocationController.start() } returns true @@ -209,7 +210,7 @@ class LocationManagerTests : FunSpec({ // When locationManager.onLocationPermissionChanged(true) - Thread.sleep(200) // Wait for suspendifyOnIO coroutine to complete + awaitIO() // Then coVerify { mockLocationController.start() } @@ -217,12 +218,10 @@ class LocationManagerTests : FunSpec({ test("onLocationPermissionChanged does not call startGetLocation when enabled is false") { // Given - val mocks = Mocks() val locationManager = mocks.locationManager // When locationManager.onLocationPermissionChanged(false) - Thread.sleep(200) // Wait for suspendifyOnIO coroutine to complete // Then coVerify(exactly = 0) { mocks.locationController.start() } @@ -230,14 +229,13 @@ class LocationManagerTests : FunSpec({ test("onLocationPermissionChanged does not call startGetLocation when isShared is false") { // Given - val mocks = Mocks() 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.capturer, + mocks.locationCapture, mocks.locationController, mocks.permissionController, mocks.mockPrefs, @@ -245,7 +243,7 @@ class LocationManagerTests : FunSpec({ // When locationManager.onLocationPermissionChanged(true) - delay(50) + awaitIO() // Then coVerify(exactly = 0) { mocks.locationController.start() } @@ -257,7 +255,6 @@ class LocationManagerTests : FunSpec({ // Set SDK version to 22 using reflection setSdkVersion(22) // Given - val mocks = Mocks() mocks.set_fine_location_permission(true) val locationManager = mocks.locationManager @@ -273,7 +270,6 @@ class LocationManagerTests : FunSpec({ test("requestPermission returns true when coarse permission granted on API < 23") { setSdkVersion(22) // Given - val mocks = Mocks() mocks.set_fine_location_permission(false) mocks.set_coarse_location_permission(true) coEvery { mocks.locationController.start() } returns true @@ -294,7 +290,6 @@ class LocationManagerTests : FunSpec({ setSdkVersion(22) // Given - val mocks = Mocks() val mockApplicationService = mocks.mockAppService mocks.set_fine_location_permission(false) mocks.set_coarse_location_permission(false) @@ -319,7 +314,6 @@ class LocationManagerTests : FunSpec({ // Set SDK version to 23 using reflection setSdkVersion(23) // Given - val mocks = Mocks() mocks.set_fine_location_permission(true) coEvery { mocks.locationController.start() } returns true val locationManager = mocks.locationManager @@ -345,7 +339,6 @@ class LocationManagerTests : FunSpec({ } // Given - val mocks = Mocks() val mockApplicationService = mocks.mockAppService val mockPermissionController = mockk(relaxed = true) mocks.set_fine_location_permission(false) @@ -383,7 +376,6 @@ class LocationManagerTests : FunSpec({ } // Given - val mocks = Mocks() val mockApplicationService = mocks.mockAppService val mockPermissionController = mockk(relaxed = true) mocks.set_fine_location_permission(false) @@ -417,7 +409,6 @@ class LocationManagerTests : FunSpec({ test("requestPermission returns false when permissions not in manifest") { // Given - val mocks = Mocks() mocks.set_fine_location_permission(false) val locationManager = mocks.locationManager @@ -432,7 +423,6 @@ class LocationManagerTests : FunSpec({ test("requestPermission returns true when coarse permission already granted") { // Given - val mocks = Mocks() mocks.set_fine_location_permission(false) mocks.set_coarse_location_permission(true) @@ -460,7 +450,6 @@ class LocationManagerTests : FunSpec({ } // Given - val mocks = Mocks() val mockApplicationService = mocks.mockAppService val mockPermissionController = mockk(relaxed = true) mocks.set_fine_location_permission(true) @@ -499,7 +488,6 @@ class LocationManagerTests : FunSpec({ test("requestPermission starts location when all permissions granted") { // Given - val mocks = Mocks() val mockApplicationService = mocks.mockAppService mocks.set_fine_location_permission(true) every { @@ -526,7 +514,6 @@ class LocationManagerTests : FunSpec({ context("requestPermission() Method - Edge Cases") { test("requestPermission warns when isShared is false") { // Given - val mocks = Mocks() mocks.set_fine_location_permission(true) val locationManager = mocks.locationManager @@ -543,7 +530,6 @@ class LocationManagerTests : FunSpec({ test("requestPermission handles location controller start failure gracefully") { // Given - val mocks = Mocks() mocks.set_fine_location_permission(true) coEvery { mocks.locationController.start() } returns false @@ -561,7 +547,6 @@ class LocationManagerTests : FunSpec({ test("requestPermission handles location controller exception gracefully") { // Given - val mocks = Mocks() val mockLocationController = mockk(relaxed = true) mocks.set_fine_location_permission(true) coEvery { mockLocationController.start() } throws RuntimeException("Location error") @@ -582,13 +567,12 @@ class LocationManagerTests : FunSpec({ context("startGetLocation() Method") { test("startGetLocation does nothing when isShared is false") { // Given - val mocks = Mocks() val mockLocationController = mockk(relaxed = true) val locationManager = mocks.locationManager // When - trigger startGetLocation indirectly via onLocationPermissionChanged locationManager.onLocationPermissionChanged(true) - delay(50) // Wait for suspendifyOnIO coroutine to complete + awaitIO() // Then coVerify(exactly = 0) { mockLocationController.start() } @@ -596,14 +580,13 @@ class LocationManagerTests : FunSpec({ test("startGetLocation calls location controller start when isShared is true") { // Given - val mocks = Mocks() val mockLocationController = mocks.locationController coEvery { mockLocationController.start() } returns true val locationManager = mocks.locationManager // When - trigger startGetLocation indirectly via onLocationPermissionChanged locationManager.onLocationPermissionChanged(true) - Thread.sleep(200) // Wait for suspendifyOnIO coroutine to complete + awaitIO() // Then coVerify { mockLocationController.start() } From 127f39c7268ef2f66e238e12430f421757996e4e Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 25 Nov 2025 12:54:46 -0500 Subject: [PATCH 06/11] address review comments --- .../java/com/onesignal/common/AndroidUtils.kt | 3 + .../internal/InAppMessagesManagerTests.kt | 95 ++- .../location/internal/LocationManager.kt | 9 +- .../location/internal/LocationManagerTests.kt | 753 ++++++++---------- .../java/com/onesignal/mocks/IOMockHelper.kt | 58 +- 5 files changed, 432 insertions(+), 486 deletions(-) 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 876a6b3151..a7c89027f0 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 @@ -66,26 +66,26 @@ private class Mocks { val identityModelStore = MockHelper.identityModelStore() val pushSubscription = mockk(relaxed = true) val outcomeEventsController = mockk(relaxed = true) - val state = mockk(relaxed = true) - val prefs = 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 inAppDisplay = mockk(relaxed = true) - val lifecycle = 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 deferred = mockk>() { + val rywDeferred = mockk> { coEvery { await() } returns rywData } val consistencyManager = mockk(relaxed = true) { - coEvery { getRywDataFromAwaitableCondition(any()) } returns deferred + coEvery { getRywDataFromAwaitableCondition(any()) } returns rywDeferred } val subscriptionManager = mockk(relaxed = true) { @@ -94,7 +94,7 @@ private class Mocks { } } - val outcome = + val testOutcome = run { val outcome = mockk(relaxed = true) every { outcome.name } returns "outcome-name" @@ -105,7 +105,7 @@ private class Mocks { run { val result = mockk(relaxed = true) every { result.prompts } returns mutableListOf() - every { result.outcomes } returns mutableListOf(outcome) + every { result.outcomes } returns mutableListOf(testOutcome) every { result.tags } returns null every { result.url } returns null every { result.clickId } returns "click-id" @@ -140,14 +140,14 @@ private class Mocks { identityModelStore, subscriptionManager, outcomeEventsController, - state, - prefs, + inAppStateService, + inAppPreferencesController, repository, backend, triggerController, triggerModelStore, - inAppDisplay, - lifecycle, + inAppDisplayer, + inAppLifecycleService, languageContext, time, consistencyManager, @@ -194,13 +194,9 @@ class InAppMessagesManagerTests : FunSpec({ 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" - + with(triggerModelSlots[0]) { key to value } shouldBe ("trigger-key1" to "trigger-value1") + with(triggerModelSlots[1]) { key to value } shouldBe ("trigger-key2" to "trigger-value2") + with(triggerModelSlots[2]) { key to value } shouldBe ("trigger-key3" to "trigger-value3") verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key4") } verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key5") } verify(exactly = 1) { mockTriggerModelStore.remove("trigger-key6") } @@ -238,15 +234,14 @@ class InAppMessagesManagerTests : FunSpec({ // Then triggerModelSlots.size shouldBe 1 - triggerModelSlots[0].key shouldBe "new-key" - triggerModelSlots[0].value shouldBe "new-value" + 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.prefs + val mockPrefs = mocks.inAppPreferencesController val dismissedSet = setOf("dismissed-1", "dismissed-2") val mockRepository = mocks.repository every { mockPrefs.dismissedMessagesId } returns dismissedSet @@ -264,8 +259,8 @@ class InAppMessagesManagerTests : FunSpec({ test("start loads last dismissal time from preferences") { // Given - val mockPrefs = mocks.prefs - val mockState = mocks.state + val mockPrefs = mocks.inAppPreferencesController + val mockState = mocks.inAppStateService val lastDismissalTime = 5000L every { mockPrefs.dismissedMessagesId } returns null every { mockPrefs.lastTimeInAppDismissed } returns lastDismissalTime @@ -310,7 +305,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then verify { mocks.subscriptionManager.subscribe(any()) } - verify { mocks.lifecycle.subscribe(any()) } + verify { mocks.inAppLifecycleService.subscribe(any()) } verify { mocks.triggerController.subscribe(any()) } verify { mocks.sessionService.subscribe(any()) } verify { mocks.applicationService.addApplicationLifecycleHandler(any()) } @@ -320,7 +315,7 @@ class InAppMessagesManagerTests : FunSpec({ context("Paused Property") { test("paused getter returns state paused value") { // Given - every { mocks.state.paused } returns true + every { mocks.inAppStateService.paused } returns true // When val result = mocks.inAppMessagesManager.paused @@ -331,11 +326,11 @@ class InAppMessagesManagerTests : FunSpec({ test("setting paused to true does nothing when no message showing") { // Given - val mockState = mocks.state - val mockDisplayer = mocks.inAppDisplay + val mockState = mocks.inAppStateService + val mockDisplayer = mocks.inAppDisplayer val iamManager = mocks.inAppMessagesManager every { mockState.paused } returns false - every { mocks.state.inAppMessageIdShowing } returns null + every { mocks.inAppStateService.inAppMessageIdShowing } returns null // When iamManager.paused = true @@ -413,7 +408,7 @@ class InAppMessagesManagerTests : FunSpec({ context("Config Model Changes") { test("onModelUpdated fetches messages when appId property changes") { // Given - val mockDeferred = mocks.deferred + val mockDeferred = mocks.rywDeferred every { mocks.userManager.onesignalId } returns "onesignal-id" every { mocks.applicationService.isInForeground } returns true every { mocks.pushSubscription.id } returns "subscription-id" @@ -462,6 +457,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onModelReplaced(ConfigModel(), "tag") + awaitIO() // Then coVerify { @@ -473,7 +469,7 @@ class InAppMessagesManagerTests : FunSpec({ context("Subscription Changes") { test("onSubscriptionChanged fetches messages when push subscription id changes") { // Given - val mockDeferred = mocks.deferred + val mockDeferred = mocks.rywDeferred val subscriptionModel = SubscriptionModel() val args = ModelChangedArgs( subscriptionModel, @@ -566,7 +562,7 @@ class InAppMessagesManagerTests : FunSpec({ val message1 = mocks.testInAppMessage val message2 = mocks.testInAppMessage val mockRywData = mocks.rywData - val mockDeferred = mocks.deferred + val mockDeferred = mocks.rywDeferred val mockRepository = mocks.repository message1.isDisplayedInSession = true @@ -698,7 +694,7 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessageWasDismissed calls messageWasDismissed") { // Given - every { mocks.state.inAppMessageIdShowing } returns null + every { mocks.inAppStateService.inAppMessageIdShowing } returns null // When mocks.inAppMessagesManager.onMessageWasDismissed(mocks.testInAppMessage) @@ -786,7 +782,7 @@ class InAppMessagesManagerTests : FunSpec({ val mockPrompt = mockk(relaxed = true) every { mockPrompt.hasPrompted() } returns false coEvery { mockPrompt.handlePrompt() } returns InAppMessagePrompt.PromptActionResult.PERMISSION_GRANTED - every { mocks.state.currentPrompt } returns null + every { mocks.inAppStateService.currentPrompt } returns null mocks.inAppMessagesManager.addClickListener(mockClickListener) // When @@ -950,14 +946,14 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.triggerController.evaluateMessageTriggers(message) } returns true every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false - every { mocks.state.inAppMessageIdShowing } returns null - every { mocks.state.paused } returns true + every { mocks.inAppStateService.inAppMessageIdShowing } returns null + every { mocks.inAppStateService.paused } returns true // When - fetch messages while paused mocks.inAppMessagesManager.onSessionStarted() // Then - should not display - coVerify(exactly = 0) { mocks.inAppDisplay.displayMessage(any()) } + coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(any()) } } } @@ -971,11 +967,11 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.triggerController.evaluateMessageTriggers(message) } returns true every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false - every { mocks.state.inAppMessageIdShowing } returns null - every { mocks.state.paused } returns true + 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.inAppDisplay.displayMessage(any()) } returns true + coEvery { mocks.inAppDisplayer.displayMessage(any()) } returns true // Fetch messages first mocks.inAppMessagesManager.onSessionStarted() @@ -996,7 +992,7 @@ class InAppMessagesManagerTests : FunSpec({ every { mocks.triggerController.evaluateMessageTriggers(message) } returns true every { mocks.triggerController.isTriggerOnMessage(any(), any()) } returns false every { mocks.triggerController.messageHasOnlyDynamicTriggers(any()) } returns false - every { mocks.state.paused } returns false + every { mocks.inAppStateService.paused } returns false // Fetch messages mocks.inAppMessagesManager.onSessionStarted() @@ -1008,7 +1004,7 @@ class InAppMessagesManagerTests : FunSpec({ mocks.inAppMessagesManager.paused = false // Then - should not display dismissed message - coVerify(exactly = 0) { mocks.inAppDisplay.displayMessage(message) } + coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(message) } } } @@ -1026,7 +1022,7 @@ class InAppMessagesManagerTests : FunSpec({ test("onMessageActionOccurredOnMessage fires outcomes with weight") { // Given val weight = 5.0f - every { mocks.outcome.weight } returns weight + every { mocks.testOutcome.weight } returns weight // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) @@ -1063,6 +1059,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() // Then - wait for async operations coVerify { mocks.userManager.removeTags(any()) } @@ -1121,14 +1118,14 @@ class InAppMessagesManagerTests : FunSpec({ every { mockPrompt.setPrompted(any()) } just runs // currentPrompt starts as null, then gets set to the prompt during processing var currentPrompt: InAppMessagePrompt? = null - every { mocks.state.currentPrompt } answers { currentPrompt } - every { mocks.state.currentPrompt = any() } answers { currentPrompt = firstArg() } + every { mocks.inAppStateService.currentPrompt } answers { currentPrompt } + every { mocks.inAppStateService.currentPrompt = any() } answers { currentPrompt = firstArg() } // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) // Then - coVerify { mocks.inAppDisplay.dismissCurrentInAppMessage() } + coVerify { mocks.inAppDisplayer.dismissCurrentInAppMessage() } coVerify { mockPrompt.setPrompted(any()) } } @@ -1139,7 +1136,7 @@ class InAppMessagesManagerTests : FunSpec({ mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) // Then - coVerify(exactly = 0) { mocks.inAppDisplay.dismissCurrentInAppMessage() } + coVerify(exactly = 0) { mocks.inAppDisplayer.dismissCurrentInAppMessage() } } } @@ -1148,8 +1145,8 @@ class InAppMessagesManagerTests : FunSpec({ // Given val message = mocks.testInAppMessage coEvery { mocks.repository.saveInAppMessage(any()) } just runs - every { mocks.state.lastTimeInAppDismissed } returns 500L - every { mocks.state.currentPrompt } returns null + every { mocks.inAppStateService.lastTimeInAppDismissed } returns 500L + every { mocks.inAppStateService.currentPrompt } returns null // When mocks.inAppMessagesManager.onMessageWasDismissed(message) 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 index ead5d79ff6..7cf1f9dd96 100644 --- 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 @@ -1,6 +1,5 @@ package com.onesignal.location.internal -import android.os.Build import com.onesignal.common.AndroidUtils import com.onesignal.core.internal.preferences.IPreferencesService import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys @@ -12,7 +11,6 @@ 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 import com.onesignal.mocks.IOMockHelper.awaitIO import com.onesignal.mocks.MockHelper import io.kotest.core.spec.style.FunSpec @@ -25,7 +23,6 @@ import io.mockk.mockkObject import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -60,7 +57,11 @@ private class Mocks { mockPrefs, ) - fun set_fine_location_permission(granted: Boolean) { + fun setAndroidSDKInt(sdkInt: Int) { + every { AndroidUtils.androidSDKInt } returns sdkInt + } + + fun setFineLocationPermission(granted: Boolean) { every { AndroidUtils.hasPermission( LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING, @@ -70,7 +71,7 @@ private class Mocks { } returns granted } - fun set_coarse_location_permission(granted: Boolean) { + fun setCoarseLocationPermission(granted: Boolean) { every { AndroidUtils.hasPermission( LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING, @@ -85,9 +86,6 @@ class LocationManagerTests : FunSpec({ lateinit var mocks: Mocks - // register to access awaitIO() - listener(IOMockHelper) - beforeAny { Logging.logLevel = LogLevel.NONE mocks = Mocks() // fresh instance for each test @@ -109,500 +107,417 @@ class LocationManagerTests : FunSpec({ unmockkObject(AndroidUtils) } - context("isShared Property") { - test("isShared getter returns value from preferences") { - // Given - val mockPrefs = mocks.mockPrefs - val locationManager = mocks.locationManager + test("isShared getter returns value from preferences") { + // Given + val mockPrefs = mocks.mockPrefs + val locationManager = mocks.locationManager - // When - val result = locationManager.isShared + // When + val result = locationManager.isShared - // Then - result shouldBe true - verify { - mockPrefs.getBool(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.PREFS_OS_LOCATION_SHARED, false) - } + // 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 - 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 + // 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 + 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 + // When + locationManager.isShared = false - // Then - locationManager.isShared shouldBe false - coVerify(exactly = 0) { mockLocationController.start() } - } + // Then + locationManager.isShared shouldBe false + coVerify(exactly = 0) { mockLocationController.start() } } - context("start() Method") { - test("start subscribes to location permission controller") { - // Given - every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns false - val locationManager = mocks.locationManager + test("start subscribes to location permission controller") { + // Given + every { LocationUtils.hasLocationPermission(mocks.mockAppService.appContext) } returns false + val locationManager = mocks.locationManager - // When - locationManager.start() + // When + locationManager.start() - // Then - verify(exactly = 1) { mocks.permissionController.subscribe(locationManager) } - } + // 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 + 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 + val locationManager = mocks.locationManager - // When - locationManager.start() - awaitIO() + // When + locationManager.start() + awaitIO() - // Then - coVerify { mocks.locationController.start() } - } + // 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 + 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 + val locationManager = mocks.locationManager - // When - locationManager.start() + // When + locationManager.start() - // Then - coVerify(exactly = 0) { mockLocationController.start() } - } + // Then + coVerify(exactly = 0) { mockLocationController.start() } } - context("onLocationPermissionChanged() Method") { - test("onLocationPermissionChanged calls startGetLocation when enabled is true") { - // Given - val mockLocationController = mocks.locationController - coEvery { mockLocationController.start() } returns true + test("onLocationPermissionChanged calls startGetLocation when enabled is true") { + // Given + val mockLocationController = mocks.locationController + coEvery { mockLocationController.start() } returns true - val locationManager = mocks.locationManager + val locationManager = mocks.locationManager - // When - locationManager.onLocationPermissionChanged(true) - awaitIO() + // When + locationManager.onLocationPermissionChanged(true) + awaitIO() - // Then - coVerify { mockLocationController.start() } - } + // Then + coVerify { mockLocationController.start() } + } - test("onLocationPermissionChanged does not call startGetLocation when enabled is false") { - // Given - val locationManager = mocks.locationManager + test("onLocationPermissionChanged does not call startGetLocation when enabled is false") { + // Given + val locationManager = mocks.locationManager - // When - locationManager.onLocationPermissionChanged(false) + // When + locationManager.onLocationPermissionChanged(false) - // Then - coVerify(exactly = 0) { mocks.locationController.start() } - } + // 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, - ) + 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() } + } - // When - locationManager.onLocationPermissionChanged(true) - awaitIO() + test("requestPermission returns true when fine permission granted on API < 23") { + // Given + mocks.setFineLocationPermission(true) + mocks.setAndroidSDKInt(22) + val locationManager = mocks.locationManager - // Then - coVerify(exactly = 0) { mocks.locationController.start() } - } + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true } - context("requestPermission() Method - API < 23") { - test("requestPermission returns true when fine permission granted on API < 23") { - // Set SDK version to 22 using reflection - setSdkVersion(22) - // Given - mocks.set_fine_location_permission(true) - val locationManager = mocks.locationManager - - // When - val result = runBlocking { - 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 - test("requestPermission returns true when coarse permission granted on API < 23") { - setSdkVersion(22) - // Given - mocks.set_fine_location_permission(false) - mocks.set_coarse_location_permission(true) - coEvery { mocks.locationController.start() } returns true + val locationManager = mocks.locationManager - val locationManager = mocks.locationManager + // When + val result = locationManager.requestPermission() - // When - val result = runBlocking { - locationManager.requestPermission() - } + // Then + result shouldBe true + coVerify { mocks.locationController.start() } + } - // 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 - test("requestPermission returns false when no permissions in manifest on API < 23") { - setSdkVersion(22) + // When + val result = locationManager.requestPermission() - // Given - val mockApplicationService = mocks.mockAppService - mocks.set_fine_location_permission(false) - mocks.set_coarse_location_permission(false) - // Ensure filterManifestPermissions returns empty list (no permissions in manifest) - every { - AndroidUtils.filterManifestPermissions(any(), mockApplicationService) - } returns emptyList() - val locationManager = mocks.locationManager + // Then + result shouldBe false + } - // When - val result = runBlocking { - locationManager.requestPermission() - } + 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 - // Then - result shouldBe false - } + // When + val result = locationManager.requestPermission() + + // Then + result shouldBe true + coVerify { mocks.locationController.start() } } - context("requestPermission() Method - API >= 23") { - test("requestPermission returns true when fine permission already granted") { - // Set SDK version to 23 using reflection - setSdkVersion(23) - // Given - mocks.set_fine_location_permission(true) - coEvery { mocks.locationController.start() } returns true - val locationManager = mocks.locationManager - - // When - val result = runBlocking { - 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 fine permission when not granted and in manifest") { - // Set SDK version to 23 using reflection - setSdkVersion(23) - - // Verify SDK version was set (if reflection fails, skip this test) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // SDK version couldn't be set, skip this test - return@test - } + 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 - // Given - val mockApplicationService = mocks.mockAppService - val mockPermissionController = mockk(relaxed = true) - mocks.set_fine_location_permission(false) - 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 = runBlocking { - locationManager.requestPermission() - } - - // Then - result shouldBe true - coVerify { - mockPermissionController.prompt(true, LocationConstants.ANDROID_FINE_LOCATION_PERMISSION_STRING) - } - } + // When + locationManager.requestPermission() - test("requestPermission prompts for coarse permission when fine not in manifest") { - // Set SDK version to 23 using reflection - setSdkVersion(23) - - // Verify SDK version was set (if reflection fails, skip this test) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // SDK version couldn't be set, skip this test - return@test - } - - // Given - val mockApplicationService = mocks.mockAppService - val mockPermissionController = mockk(relaxed = true) - mocks.set_fine_location_permission(false) - mocks.set_coarse_location_permission(false) - every { - AndroidUtils.filterManifestPermissions( - any(), - mockApplicationService, - ) - } returns listOf(LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) - every { - AndroidUtils.hasPermission( - LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING, - false, - mocks.mockAppService, - ) - } returns true - val locationManager = mocks.locationManager - - // When - val result = runBlocking { - locationManager.requestPermission() - } - - // Then - result shouldBe true - coVerify { - mockPermissionController.prompt(true, LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) - } + // Then + coVerify { + mockPermissionController.prompt(true, LocationConstants.ANDROID_COARSE_LOCATION_PERMISSION_STRING) } + } - test("requestPermission returns false when permissions not in manifest") { - // Given - mocks.set_fine_location_permission(false) - val locationManager = mocks.locationManager + test("requestPermission returns false when permissions not in manifest") { + // Given + mocks.setFineLocationPermission(false) + val locationManager = mocks.locationManager - // When - val result = runBlocking { - locationManager.requestPermission() - } + // When + val result = locationManager.requestPermission() - // Then - result shouldBe false - } + // Then + result shouldBe false + } - test("requestPermission returns true when coarse permission already granted") { - // Given - mocks.set_fine_location_permission(false) - mocks.set_coarse_location_permission(true) + test("requestPermission returns true when coarse permission already granted") { + // Given + mocks.setFineLocationPermission(false) + mocks.setCoarseLocationPermission(true) - val locationManager = mocks.locationManager + val locationManager = mocks.locationManager - // When - val result = runBlocking { - locationManager.requestPermission() - } + // When + val result = locationManager.requestPermission() - // Then - result shouldBe true - } + // Then + result shouldBe true } - context("requestPermission() Method - API >= 29 (Android 10+)") { - test("requestPermission prompts for background permission when fine granted but background not") { - // Set SDK version to 29 using reflection - setSdkVersion(29) - - // Verify SDK version was set (if reflection fails, skip this test) - if (Build.VERSION.SDK_INT < 29) { - // SDK version couldn't be set, skip this test - return@test - } - - // Given - val mockApplicationService = mocks.mockAppService - val mockPermissionController = mockk(relaxed = true) - mocks.set_fine_location_permission(true) - 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 + 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 + val locationManager = mocks.locationManager - // When - val result = runBlocking { - locationManager.requestPermission() - } + // When + val result = locationManager.requestPermission() - // Then - result shouldBe true - coVerify { - mockPermissionController.prompt(true, LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING) - } + // 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.set_fine_location_permission(true) - every { - AndroidUtils.hasPermission( - LocationConstants.ANDROID_BACKGROUND_LOCATION_PERMISSION_STRING, - true, - mockApplicationService, - ) - } returns true - coEvery { mocks.locationController.start() } returns true - val locationManager = mocks.locationManager + 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 = runBlocking { - locationManager.requestPermission() - } + // When + val result = locationManager.requestPermission() - // Then - result shouldBe true - coVerify { mocks.locationController.start() } - } + // Then + result shouldBe true + coVerify { mocks.locationController.start() } } - context("requestPermission() Method - Edge Cases") { - test("requestPermission warns when isShared is false") { - // Given - mocks.set_fine_location_permission(true) + test("requestPermission warns when isShared is false") { + // Given + mocks.setFineLocationPermission(true) - val locationManager = mocks.locationManager + val locationManager = mocks.locationManager - // When - val result = runBlocking { - locationManager.requestPermission() - } + // When + val result = locationManager.requestPermission() - // Then - result shouldBe true - // Warning should be logged (tested indirectly through no exception) - } + // Then + result shouldBe true + // Warning should be logged (tested indirectly through no exception) + } - test("requestPermission handles location controller start failure gracefully") { - // Given - mocks.set_fine_location_permission(true) - coEvery { mocks.locationController.start() } returns false + test("requestPermission handles location controller start failure gracefully") { + // Given + mocks.setFineLocationPermission(true) + coEvery { mocks.locationController.start() } returns false - val locationManager = mocks.locationManager + val locationManager = mocks.locationManager - // When - val result = runBlocking { - locationManager.requestPermission() - } + // When + val result = locationManager.requestPermission() - // Then - result shouldBe true - coVerify { mocks.locationController.start() } - } + // Then + result shouldBe true + } - test("requestPermission handles location controller exception gracefully") { - // Given - val mockLocationController = mockk(relaxed = true) - mocks.set_fine_location_permission(true) - coEvery { mockLocationController.start() } throws RuntimeException("Location error") + 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 + val locationManager = mocks.locationManager - // When - val result = runBlocking { - locationManager.requestPermission() - } + // When + val result = locationManager.requestPermission() - // Then - result shouldBe true - // Exception should be caught and logged (tested indirectly through no crash) - } + // Then + result shouldBe true + // Exception should be caught and logged (tested indirectly through no crash) } - context("startGetLocation() Method") { - test("startGetLocation does nothing when isShared is false") { - // Given - val mockLocationController = mockk(relaxed = true) - val locationManager = mocks.locationManager + 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() + // When - trigger startGetLocation indirectly via onLocationPermissionChanged + locationManager.onLocationPermissionChanged(true) + awaitIO() - // Then - coVerify(exactly = 0) { mockLocationController.start() } - } + // 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 + 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() + // When - trigger startGetLocation indirectly via onLocationPermissionChanged + locationManager.onLocationPermissionChanged(true) + awaitIO() - // Then - coVerify { mockLocationController.start() } - } + // Then + coVerify { mockLocationController.start() } } }) - -// Helper function to set SDK version using reflection -private fun setSdkVersion(sdkInt: Int) { - try { - val buildVersionClass = Class.forName("android.os.Build\$VERSION") - val sdkIntField = buildVersionClass.getDeclaredField("SDK_INT") - sdkIntField.isAccessible = true - sdkIntField.setInt(null, sdkInt) - } catch (e: Exception) { - // If reflection fails, the test will use the default SDK version - // This is acceptable for tests that don't strictly require a specific SDK version - } -} 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 index 100934d305..db4f1eccf2 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -12,7 +12,7 @@ import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkStatic import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.runBlocking +import java.util.concurrent.atomic.AtomicInteger /** * Test helper that makes OneSignal’s `suspendifyOnIO` behavior deterministic in unit tests. @@ -43,41 +43,71 @@ import kotlinx.coroutines.runBlocking */ 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 the current suspendifyOnIO work to finish. - * Can be called from tests instead of delay/Thread.sleep. + * 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. */ - fun awaitIO() { - if (!ioWaiter.isCompleted) { - runBlocking { - ioWaiter.await() - } - } - ioWaiter = CompletableDeferred() + suspend fun awaitIO() { + // Nothing to wait for in this case + if (pendingIo.get() == 0) return + + ioWaiter.await() } override suspend fun beforeSpec(spec: Spec) { // ThreadUtilsKt = file that contains suspendifyOnIO - mockkStatic("com.onesignal.common.threading.ThreadUtilsKt") + mockkStatic(THREADUTILS_PATH) + + every { + suspendifyWithCompletion( + useIO = any(), + block = any Unit>(), + onComplete = any() + ) + } answers { callOriginal() } 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 = { ioWaiter.complete(Unit) }, + 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 + // Fresh waiter for each test + pendingIo.set(0) ioWaiter = CompletableDeferred() } override suspend fun afterSpec(spec: Spec) { - unmockkStatic("com.onesignal.common.threading.ThreadUtilsKt") + unmockkStatic(THREADUTILS_PATH) } } From c875f7297428bc6e6447e586be3fbe569ee1e996 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 25 Nov 2025 14:41:31 -0500 Subject: [PATCH 07/11] flaky test --- .../internal/InAppMessagesManagerTests.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) 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 a7c89027f0..86d8fd4ecf 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 @@ -49,7 +49,6 @@ import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain @@ -485,6 +484,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onSubscriptionChanged(mocks.pushSubscription, args) + awaitIO() // Then coVerify { @@ -617,6 +617,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageWillDisplay(mocks.testInAppMessage) + awaitIO() // Then // Verify callback was fired @@ -640,6 +641,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageWasDisplayed(mocks.testInAppMessage) + awaitIO() // Then coVerify { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } @@ -650,6 +652,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageWasDisplayed(mocks.testInAppMessagePreview) + awaitIO() // Then coVerify(exactly = 0) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } @@ -662,10 +665,9 @@ class InAppMessagesManagerTests : FunSpec({ coEvery { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } just runs // When - send impression twice - runBlocking { - mocks.inAppMessagesManager.onMessageWasDisplayed(message) - mocks.inAppMessagesManager.onMessageWasDisplayed(message) - } + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + mocks.inAppMessagesManager.onMessageWasDisplayed(message) + awaitIO() // Then - should only send once coVerify(exactly = 1) { mocks.backend.sendIAMImpression(any(), any(), any(), any()) } @@ -677,6 +679,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageWillDismiss(mocks.testInAppMessage) + awaitIO() // Then // Verify callback was fired @@ -787,6 +790,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageActionOccurredOnPreview(mocks.testInAppMessagePreview, mocks.inAppMessageClickResult) + awaitIO() // Then verify { mocks.inAppMessageClickResult.isFirstClick = any() } @@ -801,6 +805,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessagePageChanged(mocks.testInAppMessage, mockPage) + awaitIO() // Then coVerify { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } @@ -812,6 +817,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessagePageChanged(mocks.testInAppMessagePreview, mockPage) + awaitIO() // Then coVerify(exactly = 0) { mocks.backend.sendIAMPageImpression(any(), any(), any(), any(), any()) } @@ -887,6 +893,7 @@ class InAppMessagesManagerTests : FunSpec({ // When - trigger fetch via onSessionStarted mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -900,6 +907,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -913,6 +921,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -929,6 +938,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // Then coVerify { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -951,6 +961,7 @@ class InAppMessagesManagerTests : FunSpec({ // When - fetch messages while paused mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // Then - should not display coVerify(exactly = 0) { mocks.inAppDisplayer.displayMessage(any()) } @@ -975,6 +986,7 @@ class InAppMessagesManagerTests : FunSpec({ // Fetch messages first mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // When - set paused to false, which triggers evaluateInAppMessages mocks.inAppMessagesManager.paused = false @@ -996,6 +1008,7 @@ class InAppMessagesManagerTests : FunSpec({ // Fetch messages mocks.inAppMessagesManager.onSessionStarted() + awaitIO() // Dismiss the message mocks.inAppMessagesManager.onMessageWasDismissed(message) @@ -1026,6 +1039,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() // Then - wait for async operations coVerify { mocks.outcomeEventsController.sendOutcomeEventWithValue("outcome-name", weight) } @@ -1077,6 +1091,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() // Then coVerify { AndroidUtils.openURLInBrowser(any(), url) } @@ -1095,6 +1110,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() // Then coVerify { OneSignalChromeTab.open("https://example.com", true, any()) } @@ -1123,6 +1139,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() // Then coVerify { mocks.inAppDisplayer.dismissCurrentInAppMessage() } From 8b4334e6a913c9d77e498cef2a3030803f72a5a4 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 25 Nov 2025 19:54:21 -0500 Subject: [PATCH 08/11] address awaitIO coverage --- .../internal/InAppMessagesManagerTests.kt | 18 +++++++++++------- .../java/com/onesignal/mocks/IOMockHelper.kt | 19 +++++++++++++++++-- 2 files changed, 28 insertions(+), 9 deletions(-) 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 86d8fd4ecf..c55fa79435 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 @@ -193,13 +193,17 @@ class InAppMessagesManagerTests : FunSpec({ iamManager.clearTriggers() // Then - with(triggerModelSlots[0]) { key to value } shouldBe ("trigger-key1" to "trigger-value1") - with(triggerModelSlots[1]) { key to value } shouldBe ("trigger-key2" to "trigger-value2") - with(triggerModelSlots[2]) { key to value } shouldBe ("trigger-key3" to "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() } + 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") { 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 index db4f1eccf2..9ae1a2efed 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -27,8 +27,7 @@ import java.util.concurrent.atomic.AtomicInteger * - Completing a `CompletableDeferred` when the async block finishes * - Providing `awaitIO()` so tests can explicitly wait for all IO work without sleeps * - * Usage in a Kotest spec: - * + * Usage example in a Kotest spec: * class InAppMessagesManagerTests : FunSpec({ * * // register to access awaitIO() @@ -57,6 +56,22 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, * 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() { // Nothing to wait for in this case From 7511d23bbfff2ea12a9db72fe0def338bf133871 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 25 Nov 2025 22:36:50 -0500 Subject: [PATCH 09/11] fix failing tests --- .../internal/InAppMessagesManagerTests.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 c55fa79435..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 @@ -305,6 +305,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.start() + awaitIO() // Then verify { mocks.subscriptionManager.subscribe(any()) } @@ -384,6 +385,7 @@ class InAppMessagesManagerTests : FunSpec({ // When iamManager.addClickListener(mockListener) iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + awaitIO() // Then // Verify listener callback was called @@ -401,6 +403,7 @@ class InAppMessagesManagerTests : FunSpec({ iamManager.addClickListener(mockListener) iamManager.removeClickListener(mockListener) iamManager.onMessageActionOccurredOnMessage(message, mockClickResult) + awaitIO() // Then // Listener should not be called after removal @@ -445,6 +448,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onModelUpdated(args, "tag") + awaitIO() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -511,6 +515,7 @@ class InAppMessagesManagerTests : FunSpec({ // When iamManager.onSubscriptionChanged(mockSubscription, args) + awaitIO() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -530,6 +535,7 @@ class InAppMessagesManagerTests : FunSpec({ // When iamManager.onSubscriptionChanged(mocks.pushSubscription, args) + awaitIO() // Then coVerify(exactly = 0) { mocks.backend.listInAppMessages(any(), any(), any(), any()) } @@ -740,7 +746,7 @@ class InAppMessagesManagerTests : FunSpec({ // Then // Should trigger re-evaluation - verify { mocks.triggerController.evaluateMessageTriggers(any()) } + coVerify { mocks.triggerController.evaluateMessageTriggers(any()) } } test("onTriggerChanged makes redisplay messages available and re-evaluates") { @@ -1012,10 +1018,10 @@ class InAppMessagesManagerTests : FunSpec({ // Fetch messages mocks.inAppMessagesManager.onSessionStarted() - awaitIO() // Dismiss the message mocks.inAppMessagesManager.onMessageWasDismissed(message) + awaitIO() // When - trigger evaluation mocks.inAppMessagesManager.paused = false @@ -1031,6 +1037,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() // Then - wait for async operations coVerify { mocks.outcomeEventsController.sendOutcomeEvent("outcome-name") } @@ -1155,6 +1162,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageActionOccurredOnMessage(mocks.testInAppMessage, mocks.inAppMessageClickResult) + awaitIO() // Then coVerify(exactly = 0) { mocks.inAppDisplayer.dismissCurrentInAppMessage() } @@ -1171,6 +1179,7 @@ class InAppMessagesManagerTests : FunSpec({ // When mocks.inAppMessagesManager.onMessageWasDismissed(message) + awaitIO() // Then coVerify { mocks.repository.saveInAppMessage(message) } From f1f4f11fc4d5a786ace6b3ccc3f498d786c7e558 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Wed, 26 Nov 2025 01:33:49 -0500 Subject: [PATCH 10/11] harden awaitIO with timeout --- .../src/main/java/com/onesignal/mocks/IOMockHelper.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index 9ae1a2efed..e7d83b39d3 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -12,6 +12,7 @@ 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 /** @@ -73,11 +74,13 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, * interception surface minimal and avoids unintentionally changing more concurrency * behavior than necessary. */ - suspend fun awaitIO() { + suspend fun awaitIO(timeoutMs: Long = 5_000) { // Nothing to wait for in this case if (pendingIo.get() == 0) return - ioWaiter.await() + withTimeout(timeoutMs) { + ioWaiter.await() + } } override suspend fun beforeSpec(spec: Spec) { From 47ba05bc954a216536ab93cf07cc2afddd2f808f Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Wed, 26 Nov 2025 12:58:06 -0500 Subject: [PATCH 11/11] remove unused code --- .../src/main/java/com/onesignal/mocks/IOMockHelper.kt | 8 -------- 1 file changed, 8 deletions(-) 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 index e7d83b39d3..a5ad5b1d65 100644 --- a/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt +++ b/OneSignalSDK/onesignal/testhelpers/src/main/java/com/onesignal/mocks/IOMockHelper.kt @@ -87,14 +87,6 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, // ThreadUtilsKt = file that contains suspendifyOnIO mockkStatic(THREADUTILS_PATH) - every { - suspendifyWithCompletion( - useIO = any(), - block = any Unit>(), - onComplete = any() - ) - } answers { callOriginal() } - every { suspendifyOnIO(any Unit>()) } answers { val block = firstArg Unit>()