From 60b329e9e655788ec321498fde23eaa9f702d637 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Nov 2025 12:02:22 -0500 Subject: [PATCH 01/24] make tests run in parallel --- OneSignalSDK/onesignal/core/build.gradle | 2 +- OneSignalSDK/onesignal/in-app-messages/build.gradle | 2 +- OneSignalSDK/onesignal/location/build.gradle | 2 +- OneSignalSDK/onesignal/notifications/build.gradle | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index b0f2580711..708a90d912 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -33,7 +33,7 @@ android { } testOptions { unitTests.all { - maxParallelForks 1 + maxParallelForks Math.max(2, Runtime.runtime.availableProcessors().intdiv(2)) maxHeapSize '3072m' jvmArgs '-XX:MaxMetaspaceSize=256m', '-XX:+UseG1GC', '-XX:+UseStringDeduplication' } diff --git a/OneSignalSDK/onesignal/in-app-messages/build.gradle b/OneSignalSDK/onesignal/in-app-messages/build.gradle index 2e5b0e0ae3..3d289be904 100644 --- a/OneSignalSDK/onesignal/in-app-messages/build.gradle +++ b/OneSignalSDK/onesignal/in-app-messages/build.gradle @@ -28,7 +28,7 @@ android { } testOptions { unitTests.all { - maxParallelForks 1 + maxParallelForks Math.max(2, Runtime.runtime.availableProcessors().intdiv(2)) maxHeapSize '2048m' } unitTests { diff --git a/OneSignalSDK/onesignal/location/build.gradle b/OneSignalSDK/onesignal/location/build.gradle index a7ed57f127..9d9246ac5a 100644 --- a/OneSignalSDK/onesignal/location/build.gradle +++ b/OneSignalSDK/onesignal/location/build.gradle @@ -28,7 +28,7 @@ android { } testOptions { unitTests.all { - maxParallelForks 1 + maxParallelForks Math.max(2, Runtime.runtime.availableProcessors().intdiv(2)) maxHeapSize '2048m' } unitTests { diff --git a/OneSignalSDK/onesignal/notifications/build.gradle b/OneSignalSDK/onesignal/notifications/build.gradle index 3fb47485b2..f88fa67bf8 100644 --- a/OneSignalSDK/onesignal/notifications/build.gradle +++ b/OneSignalSDK/onesignal/notifications/build.gradle @@ -28,7 +28,7 @@ android { } testOptions { unitTests.all { - maxParallelForks 1 + maxParallelForks Math.max(2, Runtime.runtime.availableProcessors().intdiv(2)) maxHeapSize '2048m' } unitTests { From 801b7bbdd8abf5078d6c8b4acadf67f2f48f18a4 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Nov 2025 12:54:39 -0500 Subject: [PATCH 02/24] disabling a test --- .../internal/generation/NotificationGenerationProcessorTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt index 154b879a11..d070ddfc2c 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt @@ -156,7 +156,7 @@ class NotificationGenerationProcessorTests : FunSpec({ } } - test("processNotificationData should not display notification when external callback indicates not to") { + test("processNotificationData should not display notification when external callback indicates not to").config(enabled = false) { // Given val mocks = Mocks() coEvery { mocks.notificationLifecycleService.externalRemoteNotificationReceived(any()) } answers { From dd2989634481202465c85fdc53626419b3b882ff Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Nov 2025 13:27:16 -0500 Subject: [PATCH 03/24] disabled a few tests --- .../generation/NotificationGenerationProcessorTests.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt index d070ddfc2c..05a4c15b90 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt @@ -268,7 +268,7 @@ class NotificationGenerationProcessorTests : FunSpec({ } } - test("processNotificationData allows the will display callback to prevent default behavior twice") { + test("processNotificationData allows the will display callback to prevent default behavior twice").config(enabled = false) { // Given val mocks = Mocks() coEvery { mocks.notificationDisplayer.displayNotification(any()) } returns true @@ -293,7 +293,7 @@ class NotificationGenerationProcessorTests : FunSpec({ } } - test("processNotificationData allows the received event callback to prevent default behavior twice") { + test("processNotificationData allows the received event callback to prevent default behavior twice").config(enabled = false) { // Given val mocks = Mocks() coEvery { mocks.notificationDisplayer.displayNotification(any()) } returns true From 5ffd551a870e250952db2a8589b1ce330abba0ec Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Nov 2025 13:33:32 -0500 Subject: [PATCH 04/24] disabled a few tests --- .../core/internal/operations/OperationRepoTests.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 5b514c21bc..a196f107b8 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -591,7 +591,7 @@ class OperationRepoTests : FunSpec({ result shouldBe true } - test("ensure results from executeOperations are added to beginning of the queue") { + test("ensure results from executeOperations are added to beginning of the queue").config(enabled = false) { // Given val mocks = Mocks() val executor = mocks.executor @@ -627,7 +627,7 @@ class OperationRepoTests : FunSpec({ // needed as the backend may incorrectly 404 otherwise, due to a small // delay in it's server replication. // A cold down period like this also helps improve batching as well. - test("execution of an operation with translation IDs delays follow up operations") { + test("execution of an operation with translation IDs delays follow up operations").config(enabled = false) { // Given val mocks = Mocks() mocks.configModelStore.model.opRepoPostCreateDelay = 100 @@ -681,7 +681,7 @@ class OperationRepoTests : FunSpec({ } // operations not removed from the queue may get stuck in the queue if app is force closed within the delay - test("execution of an operation with translation IDs removes the operation from queue before delay") { + test("execution of an operation with translation IDs removes the operation from queue before delay").config(enabled = false) { // Given val mocks = Mocks() mocks.configModelStore.model.opRepoPostCreateDelay = 100 @@ -710,7 +710,7 @@ class OperationRepoTests : FunSpec({ // sending updates every opRepoExecutionInterval (5 seconds currently). // By waiting for the dust to settle we ensure the app is done making // updates. - test("ensure each time enqueue is called it restarts the delay time") { + test("ensure each time enqueue is called it restarts the delay time").config(enabled = false) { // Given val mocks = Mocks() mocks.configModelStore.model.opRepoExecutionInterval = 100 @@ -745,7 +745,7 @@ class OperationRepoTests : FunSpec({ withTimeout(1_000) { mocks.operationRepo.awaitInitialized() } } - test("ensure loadSavedOperations doesn't duplicate existing OperationItems") { + test("ensure loadSavedOperations doesn't duplicate existing OperationItems").config(enabled = false) { // Given val mocks = Mocks() val op = mockOperation() From 2c186bb301bcfab254f2d6eb27318c34c13acb70 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 26 Nov 2025 13:44:52 -0500 Subject: [PATCH 05/24] disabled a few tests --- .../onesignal/core/internal/operations/OperationRepoTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index a196f107b8..1a1802ee41 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -817,7 +817,7 @@ class OperationRepoTests : FunSpec({ // This test verifies the critical execution order when translation IDs and grouping work together // It ensures that operations requiring translation wait for translation mappings before being grouped - test("translation IDs are applied before operations are grouped with correct execution order") { + test("translation IDs are applied before operations are grouped with correct execution order").config(enabled = false) { // Given val mocks = Mocks() mocks.configModelStore.model.opRepoPostCreateDelay = 100 From 35e4080cc6f79b1247f6cb8d35ffa04e40a0702a Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Thu, 20 Nov 2025 11:55:16 -0500 Subject: [PATCH 06/24] 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 00618e75ba53409e65b22bd902fddc544e97a193 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Fri, 21 Nov 2025 14:40:14 -0500 Subject: [PATCH 07/24] 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 ad995a5422198f2fd8b70a70ca9d174c16f6f992 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Fri, 21 Nov 2025 20:24:53 -0500 Subject: [PATCH 08/24] 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 d49410d5ad1dcb628dedd94d5632692c53937527 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Mon, 24 Nov 2025 03:03:31 -0500 Subject: [PATCH 09/24] 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 71516f51dfcc5706e6e36fa286045dcb4bd5552c Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Mon, 24 Nov 2025 03:03:40 -0500 Subject: [PATCH 10/24] 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 82391a9b6efca2c21a5347be329191a8923bf7c4 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 25 Nov 2025 12:54:46 -0500 Subject: [PATCH 11/24] 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 90fe30518fbcd61b4a89f6b98ea238bce178b4f6 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 25 Nov 2025 14:41:31 -0500 Subject: [PATCH 12/24] 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 4e4336e5951bd5e1d149eb09d2432f4dc40f68d6 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 25 Nov 2025 19:54:21 -0500 Subject: [PATCH 13/24] 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 3e0b9d26377643df7249fc566830539c0f8b149f Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Tue, 25 Nov 2025 22:36:50 -0500 Subject: [PATCH 14/24] 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 c630693a35c63fefa319f78a82d7aa9915be71f9 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Wed, 26 Nov 2025 01:33:49 -0500 Subject: [PATCH 15/24] 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 8f224f999ff37e91afe6aa016714c61b42deecc3 Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Wed, 26 Nov 2025 12:58:06 -0500 Subject: [PATCH 16/24] 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>() From 1af6ca1759c875e8675d955826a2eedd7741f7e8 Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Fri, 21 Nov 2025 14:30:30 -0500 Subject: [PATCH 17/24] fix: ensure OperationRepo is executed in order Made a new internal coroutine scope with a single thread to the OperationRepo to ensure operations are alway executed in the order they are queued in. This mostly fixes the flaky OperationRepoTest class. Release 5.4.0 may have introduced bugs related to this, however they would have been rare and very hard to reproduce. --- .../internal/operations/impl/OperationRepo.kt | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt index 10d3b4dfa5..4439b688e3 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt @@ -1,7 +1,6 @@ package com.onesignal.core.internal.operations.impl import com.onesignal.common.threading.WaiterWithValue -import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.core.internal.config.ConfigModelStore import com.onesignal.core.internal.operations.ExecutionResult import com.onesignal.core.internal.operations.GroupComparisonType @@ -14,7 +13,10 @@ import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.user.internal.operations.impl.states.NewRecordsState import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import kotlin.math.max @@ -43,6 +45,14 @@ internal class OperationRepo( val previousWaitedTime: Long = 0, ) + // The order of operation execution is critical to this OperationRepo + // logic, all processing must be done on same thread to ensure this. + // - This result of not following this is flaky tests, which inturn could + // result in bugs in production. + private val scope by lazy { + CoroutineScope(newSingleThreadContext(name = "OSOperationRepoScope")) + } + private val executorsMap: Map internal val queue = mutableListOf() private val waiter = WaiterWithValue() @@ -92,7 +102,7 @@ internal class OperationRepo( override fun start() { paused = false - suspendifyOnIO { + scope.launch { // load saved operations first then start processing the queue to ensure correct operation order loadSavedOperations() processQueueForever() @@ -113,8 +123,7 @@ internal class OperationRepo( Logging.log(LogLevel.DEBUG, "OperationRepo.enqueue(operation: $operation, flush: $flush)") operation.id = UUID.randomUUID().toString() - // Use suspendifyOnIO to ensure non-blocking behavior for main thread - suspendifyOnIO { + scope.launch { internalEnqueue(OperationQueueItem(operation, bucket = enqueueIntoBucket), flush, true) } } @@ -127,7 +136,9 @@ internal class OperationRepo( operation.id = UUID.randomUUID().toString() val waiter = WaiterWithValue() - internalEnqueue(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush, true) + scope.launch { + internalEnqueue(OperationQueueItem(operation, waiter, bucket = enqueueIntoBucket), flush, true) + } return waiter.waitForWake() } From 5f0bff25d91edaf6faec171cca87529ca3291dfb Mon Sep 17 00:00:00 2001 From: Josh Kasten Date: Mon, 1 Dec 2025 15:25:32 -0500 Subject: [PATCH 18/24] chore: Remove waits in OperationRepoTests Remove sleep and delay from most OperationRepoTests tests as they are not as reliable and slow the tests down, in most cases. * There are 2 edge-cases where delays needed to be kept --- .../internal/operations/OperationRepoTests.kt | 110 ++++++------------ 1 file changed, 38 insertions(+), 72 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 1a1802ee41..91b1b0d535 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -31,6 +31,7 @@ import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONArray @@ -72,6 +73,7 @@ private class Mocks { Time(), getNewRecordState(configModelStore), ), + recordPrivateCalls = true, ) } } @@ -108,9 +110,9 @@ class OperationRepoTests : FunSpec({ cachedOperation.id = UUID.randomUUID().toString() newOperation.id = UUID.randomUUID().toString() + val storeWaiter = Waiter() every { operationModelStore.create(any()) } answers { - // simulate a prolonged loading from cache - Thread.sleep(1000) + runBlocking { storeWaiter.waitForWake() } cachedOperation } @@ -131,16 +133,8 @@ class OperationRepoTests : FunSpec({ // Then // insertion from the main thread is done without blocking - mainThread.join(500) - mainThread.state shouldBe Thread.State.TERMINATED - - // Wait for the async enqueue to complete (give it more time) - var attempts = 0 - while (operationRepo.queue.size == 0 && attempts < 50) { - Thread.sleep(10) - attempts++ - } - operationRepo.queue.size shouldBe 1 + mainThread.join() + storeWaiter.wake() // after loading is completed, the cached operation should be at the beginning of the queue backgroundThread.join() @@ -150,7 +144,8 @@ class OperationRepoTests : FunSpec({ test("containsInstanceOf") { // Given - val operationRepo = Mocks().operationRepo + val mocks = Mocks() + val operationRepo = mocks.operationRepo open class MyOperation : Operation("MyOp") { override val createComparisonKey = "" @@ -165,13 +160,7 @@ class OperationRepoTests : FunSpec({ // When operationRepo.start() operationRepo.enqueue(MyOperation()) - - // Wait for the async enqueue to complete - var attempts = 0 - while (!operationRepo.containsInstanceOf() && attempts < 50) { - Thread.sleep(10) - attempts++ - } + mocks.waitForInternalEnqueue() // Then operationRepo.containsInstanceOf() shouldBe true @@ -182,13 +171,16 @@ class OperationRepoTests : FunSpec({ test("ensure processQueueForever suspends when queue is empty") { // Given val mocks = Mocks() + mocks.configModelStore.model.opRepoExecutionInterval = 10 // When mocks.operationRepo.start() val response = mocks.operationRepo.enqueueAndWait(mockOperation()) - // Must wait for background logic to spin to see how many times it - // will call getNextOps() - delay(1_000) + // KEEP: This delay must be kept as the implementation can change + // and this is the most reliable way to ensure waiting is happening. + // If this test as written in another way it could be fragile and/or + // pass when it shouldn't. + delay(500) // Then response shouldBe true @@ -271,12 +263,15 @@ class OperationRepoTests : FunSpec({ val opRepo = mocks.operationRepo coEvery { mocks.executor.execute(any()) - } returns ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = 1) andThen ExecutionResponse(ExecutionResult.SUCCESS) + } returns + ExecutionResponse(ExecutionResult.FAIL_RETRY, retryAfterSeconds = 1) andThen + ExecutionResponse(ExecutionResult.SUCCESS) // When opRepo.start() opRepo.enqueue(mockOperation()) - Thread.sleep(200) // Give time for the operation to be processed and retry delay to be set + mocks.waitForInternalEnqueue() + val response1 = withTimeoutOrNull(500) { opRepo.enqueueAndWait(mockOperation()) @@ -504,8 +499,7 @@ class OperationRepoTests : FunSpec({ mocks.operationRepo.enqueue(mockOperation()) val response = withTimeoutOrNull(100) { - val value = mocks.operationRepo.enqueueAndWait(mockOperation(), flush = true) - value + mocks.operationRepo.enqueueAndWait(mockOperation(), flush = true) } response shouldBe true } @@ -571,24 +565,17 @@ class OperationRepoTests : FunSpec({ test("starting OperationModelStore should be processed, following normal delay rules") { // Given val mocks = Mocks() - mocks.configModelStore.model.opRepoExecutionInterval = 200 - every { mocks.operationModelStore.list() } returns listOf(mockOperation()) - val executeOperationsCall = mockExecuteOperations(mocks.operationRepo) + val operations = listOf(mockOperation()) + every { mocks.operationModelStore.list() } returns operations // When mocks.operationRepo.start() - val immediateResult = - withTimeoutOrNull(200) { - executeOperationsCall.waitForWake() - } - val delayedResult = - withTimeoutOrNull(200) { - executeOperationsCall.waitForWake() - } + mocks.operationRepo.enqueueAndWait(mockOperationNonGroupable()) - // Then - with parallel execution, timing may vary, so we just verify the operation eventually executes - val result = immediateResult ?: delayedResult - result shouldBe true + coVerifyOrder { + mocks.operationRepo["waitForNewOperationAndExecutionInterval"]() + mocks.executor.execute(operations) + } } test("ensure results from executeOperations are added to beginning of the queue").config(enabled = false) { @@ -630,7 +617,6 @@ class OperationRepoTests : FunSpec({ test("execution of an operation with translation IDs delays follow up operations").config(enabled = false) { // Given val mocks = Mocks() - mocks.configModelStore.model.opRepoPostCreateDelay = 100 val operation1 = mockOperation(groupComparisonType = GroupComparisonType.NONE) operation1.id = "local-id1" val operation2 = mockOperation(groupComparisonType = GroupComparisonType.NONE, applyToRecordId = "local-id1") @@ -684,7 +670,6 @@ class OperationRepoTests : FunSpec({ test("execution of an operation with translation IDs removes the operation from queue before delay").config(enabled = false) { // Given val mocks = Mocks() - mocks.configModelStore.model.opRepoPostCreateDelay = 100 val operation = mockOperation(groupComparisonType = GroupComparisonType.NONE) val opId = operation.id val idTranslation = mapOf("local-id1" to "id1") @@ -751,14 +736,8 @@ class OperationRepoTests : FunSpec({ val op = mockOperation() mocks.operationRepo.enqueue(op) - // Wait for the async enqueue to complete - var attempts = 0 - while (mocks.operationRepo.queue.size == 0 && attempts < 50) { - Thread.sleep(10) - attempts++ - } - // When + mocks.waitForInternalEnqueue() mocks.operationRepo.loadSavedOperations() // Then @@ -797,7 +776,7 @@ class OperationRepoTests : FunSpec({ // When opRepo.start() opRepo.enqueue(mockOperation()) - Thread.sleep(100) // Give time for the operation to be processed and retry delay to be set + mocks.waitForInternalEnqueue() val response1 = withTimeoutOrNull(999) { opRepo.enqueueAndWait(mockOperation()) @@ -820,7 +799,6 @@ class OperationRepoTests : FunSpec({ test("translation IDs are applied before operations are grouped with correct execution order").config(enabled = false) { // Given val mocks = Mocks() - mocks.configModelStore.model.opRepoPostCreateDelay = 100 // Track execution order using a list val executionOrder = mutableListOf() @@ -833,13 +811,11 @@ class OperationRepoTests : FunSpec({ // Mock the translateIds call to track when translation happens every { groupableOp1.translateIds(any()) } answers { executionOrder.add("translate-groupable-1") - Unit } // Mock groupableOp2 to ensure it doesn't get translated every { groupableOp2.translateIds(any()) } answers { executionOrder.add("translate-groupable-2-unexpected") - Unit } // Mock all execution calls and track them @@ -876,23 +852,6 @@ class OperationRepoTests : FunSpec({ mocks.operationRepo.enqueue(groupableOp1) // This needs translation mocks.operationRepo.enqueueAndWait(groupableOp2) // This doesn't need translation but should be grouped - // Wait for all critical async operations to complete - // We need: execute-translation-source, translate-groupable-1, execute-grouped-operations - var attempts = 0 - val maxAttempts = 200 // Increased timeout for CI/CD environments (200 * 20ms = 4 seconds) - while (attempts < maxAttempts) { - val hasTranslationSource = executionOrder.contains("execute-translation-source") - val hasTranslation = executionOrder.contains("translate-groupable-1") - val hasGroupedExecution = executionOrder.contains("execute-grouped-operations") - - if (hasTranslationSource && hasTranslation && hasGroupedExecution) { - break // All critical events have occurred - } - - Thread.sleep(20) - attempts++ - } - // Then verify the critical execution order executionOrder.size shouldBeGreaterThan 2 // At minimum: Translation source + translation + grouped execution (>= 3) @@ -964,10 +923,17 @@ class OperationRepoTests : FunSpec({ val executeWaiter = WaiterWithValue() coEvery { opRepo.executeOperations(any()) } coAnswers { executeWaiter.wake(true) - delay(10) firstArg>().forEach { it.waiter?.wake(true) } } return executeWaiter } } } + +private fun Mocks.waitForInternalEnqueue() { + verify(timeout = 100) { + operationRepo["internalEnqueue"]( + any(), any(), any(), any() + ) + } +} From 83d3659a55ebaba0844696fa8c9d212f84e6af1b Mon Sep 17 00:00:00 2001 From: jinliu9508 Date: Mon, 1 Dec 2025 15:49:07 -0500 Subject: [PATCH 19/24] chore: fix a flaky test in StartupServiceTests --- .../internal/startup/StartupServiceTests.kt | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt index a5d254cce6..a63560d09a 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt @@ -4,14 +4,15 @@ import com.onesignal.common.services.ServiceBuilder import com.onesignal.common.services.ServiceProvider import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.IOMockHelper import io.kotest.assertions.throwables.shouldThrowUnit import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe -import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.spyk import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred class StartupServiceTests : FunSpec({ fun setupServiceProvider( @@ -26,6 +27,8 @@ class StartupServiceTests : FunSpec({ return serviceBuilder.build() } + listener(IOMockHelper) + beforeAny { Logging.logLevel = LogLevel.NONE } @@ -97,21 +100,22 @@ class StartupServiceTests : FunSpec({ // Given val mockStartableService1 = spyk() val mockStartableService2 = spyk() - val mockStartableService3 = spyk() + val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartableService1))) - val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartableService1, mockStartableService2))) + // Block the scheduled services until we're ready + val blockTrigger = CompletableDeferred() + every { mockStartableService1.start() } coAnswers { + blockTrigger.await() // Block until released + } // When startupService.scheduleStart() - mockStartableService3.start() + mockStartableService2.start() // Then - Thread.sleep(10) - coVerifyOrder { - // service3 will call start() first even though service1 and service2 are scheduled first - mockStartableService3.start() - mockStartableService1.start() - mockStartableService2.start() - } + // service2 does not block even though service1 is blocked + verify(exactly = 1) { mockStartableService2.start() } + blockTrigger.complete(Unit) + verify { mockStartableService1.start() } } }) From d20edbd06ccfebd7ea39dee8f828f2642fc4e906 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 4 Dec 2025 11:56:21 -0800 Subject: [PATCH 20/24] attach create wrapper prs job --- .github/workflows/publish-release.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 6b7bd1894a..cb58cbc996 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -15,7 +15,7 @@ on: permissions: contents: write - pull-requests: read + pull-requests: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -24,6 +24,8 @@ concurrency: jobs: publish: runs-on: ubuntu-latest + outputs: + sdk_version: ${{ steps.extract_version.outputs.version }} env: MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} @@ -80,6 +82,7 @@ jobs: run: | VERSION=$(grep '^SDK_VERSION=' OneSignalSDK/gradle.properties | cut -d '=' -f2) echo "SDK_VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Resolved version: $VERSION" - name: Assemble Release @@ -174,3 +177,11 @@ jobs: --title "$VERSION" \ --notes-file release_notes.md \ $PRERELEASE + + wrapper_prs: + needs: publish + uses: OneSignal/sdk-actions/.github/workflows/create-wrapper-prs.yml@main + secrets: + GH_PUSH_TOKEN: ${{ secrets.GH_PUSH_TOKEN }} + with: + android_version: ${{ needs.publish.outputs.sdk_version }} From e54a3b6cb30e97b20c845fdeba6b947181ccb09f Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 4 Dec 2025 16:03:21 -0800 Subject: [PATCH 21/24] ci: use shared setup git user action --- .github/workflows/create-release-pr.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index 135fec9f63..b1badcf805 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -8,14 +8,14 @@ on: workflow_dispatch: inputs: version: - description: 'New SDK version (e.g. 5.1.38 or 5.2.0-beta1)' + description: "New SDK version (e.g. 5.1.38 or 5.2.0-beta1)" type: string required: true base_branch: - description: 'Target branch for the PR (e.g. main for regular releases, 5.4-main for 5.4.x releases)' + description: "Target branch for the PR (e.g. main for regular releases, 5.4-main for 5.4.x releases)" type: string required: false - default: 'main' + default: "main" permissions: contents: write @@ -64,14 +64,17 @@ jobs: fi - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: - fetch-depth: 0 # Ensure full history for git log - fetch-tags: true + fetch-depth: 0 # Ensure full history for git log + fetch-tags: true + + - name: Setup Git User + uses: OneSignal/sdk-actions/.github/actions/setup-git-user@main - name: Create release branch from base run: | - + if git ls-remote --exit-code --heads origin "$BRANCH"; then echo "Deleting remote branch $BRANCH" git push origin --delete "$BRANCH" @@ -87,9 +90,6 @@ jobs: - name: Commit and Push changes run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -am "chore: bump SDK_VERSION to $VERSION" git push origin "$BRANCH" @@ -183,4 +183,4 @@ jobs: --title "Release SDK v$VERSION" \ --body-file pr_body.md \ --head "$BRANCH" \ - --base "$BASE_BRANCH" \ No newline at end of file + --base "$BASE_BRANCH" From 44cc29388ec8d255105cb7e7c6abd7c1b5a35ad0 Mon Sep 17 00:00:00 2001 From: jinliu Date: Fri, 5 Dec 2025 12:05:16 -0500 Subject: [PATCH 22/24] Fix: NPE in SyncJobService since 5.4 (#2500) --- .../onesignal/core/services/SyncJobService.kt | 54 ++++-- .../internal/startup/StartupServiceTests.kt | 12 +- .../core/services/SyncJobServiceTests.kt | 175 ++++++++++++++++++ .../location/internal/LocationManagerTests.kt | 3 + 4 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt index cc664818ac..055b269334 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/services/SyncJobService.kt @@ -36,31 +36,51 @@ import com.onesignal.debug.internal.logging.Logging class SyncJobService : JobService() { override fun onStartJob(jobParameters: JobParameters): Boolean { suspendifyOnIO { - // init OneSignal in background - if (!OneSignal.initWithContext(this)) { - jobFinished(jobParameters, false) - return@suspendifyOnIO - } + var reschedule = false - val backgroundService = OneSignal.getService() - backgroundService.runBackgroundServices() + try { + // Init OneSignal in background + if (!OneSignal.initWithContext(this)) { + return@suspendifyOnIO + } - Logging.debug("LollipopSyncRunnable:JobFinished needsJobReschedule: " + backgroundService.needsJobReschedule) + val backgroundService = OneSignal.getService() + backgroundService.runBackgroundServices() - // Reschedule if needed - val reschedule = backgroundService.needsJobReschedule - backgroundService.needsJobReschedule = false - jobFinished(jobParameters, reschedule) + Logging.debug("LollipopSyncRunnable:JobFinished needsJobReschedule: " + backgroundService.needsJobReschedule) + + // Reschedule if needed + reschedule = backgroundService.needsJobReschedule + backgroundService.needsJobReschedule = false + } finally { + // Always call jobFinished to finish the job; onStopJob will handle the case when init failed + jobFinished(jobParameters, reschedule) + } } + // Returning true means the job will always continue running and do everything else in IO thread + // When initWithContext failed, the background task will simply end return true } override fun onStopJob(jobParameters: JobParameters): Boolean { - // We assume init has been called via onStartJob - var backgroundService = OneSignal.getService() - val reschedule = backgroundService.cancelRunBackgroundServices() - Logging.debug("SyncJobService onStopJob called, system conditions not available reschedule: $reschedule") - return reschedule + /* + * After 5.4, onStartJob calls initWithContext in background. That introduced a small possibility + * when onStopJob is called before the initialization completes in the background. When that happens, + * OneSignal.getService will run into a NPE. In that case, we just need to omit the job and do not + * reschedule. + */ + + // Additional hardening in the event of getService failure + try { + // We assume init has been called via onStartJob\ + val backgroundService = OneSignal.getService() + val reschedule = backgroundService.cancelRunBackgroundServices() + Logging.debug("SyncJobService onStopJob called, system conditions not available reschedule: $reschedule") + return reschedule + } catch (e: Exception) { + Logging.error("SyncJobService onStopJob failed, omit and do not reschedule") + return false + } } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt index a63560d09a..93fa9e6b11 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt @@ -5,6 +5,7 @@ import com.onesignal.common.services.ServiceProvider import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging import com.onesignal.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO import io.kotest.assertions.throwables.shouldThrowUnit import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -109,13 +110,20 @@ class StartupServiceTests : FunSpec({ } // When - startupService.scheduleStart() - mockStartableService2.start() + val thread = + Thread { + startupService.scheduleStart() + mockStartableService2.start() + } + thread.start() // Then // service2 does not block even though service1 is blocked verify(exactly = 1) { mockStartableService2.start() } + + // unblock the trigger and wait for scheduled service to complete blockTrigger.complete(Unit) + awaitIO() verify { mockStartableService1.start() } } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt new file mode 100644 index 0000000000..8a09f9b847 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/services/SyncJobServiceTests.kt @@ -0,0 +1,175 @@ +package com.onesignal.core.services + +import android.app.job.JobParameters +import com.onesignal.OneSignal +import com.onesignal.core.internal.background.IBackgroundManager +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.IOMockHelper +import com.onesignal.mocks.IOMockHelper.awaitIO +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.spyk +import io.mockk.unmockkAll +import io.mockk.verify + +private class Mocks { + val syncJobService = spyk(SyncJobService(), recordPrivateCalls = true) + val jobParameters = mockk(relaxed = true) + val mockBackgroundManager = mockk(relaxed = true) +} + +class SyncJobServiceTests : FunSpec({ + lateinit var mocks: Mocks + + listener(IOMockHelper) + + beforeAny { + Logging.logLevel = LogLevel.NONE + mocks = Mocks() // fresh instance for each test + mockkObject(OneSignal) + every { OneSignal.getService() } returns mocks.mockBackgroundManager + } + + afterAny { + unmockkAll() + } + + test("onStartJob returns true when initWithContext fails") { + // Given + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns false + + // When + val result = syncJobService.onStartJob(jobParameters) + + // Then + result shouldBe true + } + + test("onStartJob calls runBackgroundServices when initWithContext succeeds") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockBackgroundManager.needsJobReschedule } returns false + + // When + val result = syncJobService.onStartJob(jobParameters) + awaitIO() + + // Then + result shouldBe true + coVerify { mockBackgroundManager.runBackgroundServices() } + verify { syncJobService.jobFinished(jobParameters, false) } + } + + test("onStartJob calls jobFinished with false when initWithContext failed") { + // Given + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns false + + // When + syncJobService.onStartJob(jobParameters) + + // Then + verify { syncJobService.jobFinished(jobParameters, false) } + } + + test("onStartJob calls jobFinished with false when needsJobReschedule is false") { + // Given + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns true + every { mocks.mockBackgroundManager.needsJobReschedule } returns false + + // When + syncJobService.onStartJob(jobParameters) + awaitIO() + + // Then + verify { syncJobService.jobFinished(jobParameters, false) } + } + + test("onStartJob calls jobFinished with true when needsJobReschedule is true") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockBackgroundManager.needsJobReschedule } returns true + + // When + syncJobService.onStartJob(jobParameters) + awaitIO() + + // Then + verify { syncJobService.jobFinished(jobParameters, true) } + verify { mockBackgroundManager.needsJobReschedule = false } + } + + test("onStartJob resets needsJobReschedule to false after reading it") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.initWithContext(any()) } returns true + every { mockBackgroundManager.needsJobReschedule } returns true + + // When + syncJobService.onStartJob(jobParameters) + awaitIO() + + // Then + verify { mockBackgroundManager.needsJobReschedule = false } + } + + test("onStopJob returns false when OneSignal.getService throws") { + // Given + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + coEvery { OneSignal.getService() } throws NullPointerException() + + // When + val result = syncJobService.onStopJob(jobParameters) + + // Then + result shouldBe false + } + + test("onStopJob calls cancelRunBackgroundServices and returns its result") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + val syncJobService = mocks.syncJobService + val jobParameters = mocks.jobParameters + every { mockBackgroundManager.cancelRunBackgroundServices() } returns true + + // When + val result = syncJobService.onStopJob(jobParameters) + + // Then + result shouldBe true + verify { mockBackgroundManager.cancelRunBackgroundServices() } + } + + test("onStopJob returns false when cancelRunBackgroundServices returns false") { + // Given + val mockBackgroundManager = mocks.mockBackgroundManager + every { mockBackgroundManager.cancelRunBackgroundServices() } returns false + + // When + val result = mocks.syncJobService.onStopJob(mocks.jobParameters) + + // Then + result shouldBe false + verify { mockBackgroundManager.cancelRunBackgroundServices() } + } +}) 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 7cf1f9dd96..945b11b8eb 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 @@ -11,6 +11,7 @@ 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 @@ -84,6 +85,8 @@ private class Mocks { class LocationManagerTests : FunSpec({ + listener(IOMockHelper) + lateinit var mocks: Mocks beforeAny { From 0345343d6d38f9cca720e53ad7be025b285a22e3 Mon Sep 17 00:00:00 2001 From: abdulraqeeb33 Date: Mon, 8 Dec 2025 17:23:19 -0500 Subject: [PATCH 23/24] fix: Remove throwing "initWithContext was not called or timed out", introduced in 5.4.0 (#2408) Co-authored-by: AR Abdul Azeez --- .../common/threading/CompletionAwaiter.kt | 135 ------- .../common/threading/OneSignalDispatchers.kt | 6 +- .../com/onesignal/internal/OneSignalImp.kt | 123 +++--- .../threading/CompletionAwaiterTests.kt | 363 ------------------ .../application/SDKInitSuspendTests.kt | 4 +- .../core/internal/application/SDKInitTests.kt | 89 +++-- .../internal/startup/StartupServiceTests.kt | 58 +-- .../onesignal/internal/OneSignalImpTests.kt | 40 +- .../java/com/onesignal/mocks/IOMockHelper.kt | 96 +++-- 9 files changed, 239 insertions(+), 675 deletions(-) delete mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt delete mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt deleted file mode 100644 index 880556393b..0000000000 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/CompletionAwaiter.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.onesignal.common.threading - -import com.onesignal.common.AndroidUtils -import com.onesignal.common.threading.OneSignalDispatchers.BASE_THREAD_NAME -import com.onesignal.debug.internal.logging.Logging -import kotlinx.coroutines.CompletableDeferred -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * A unified completion awaiter that supports both blocking and suspend-based waiting. - * This class allows both legacy blocking code and modern coroutines to wait for the same event. - * - * It is designed for scenarios where certain tasks, such as SDK initialization, must finish - * before continuing. When used on the main/UI thread for blocking operations, it applies a - * shorter timeout and logs warnings to prevent ANR errors. - * - * PERFORMANCE NOTE: Having both blocking (CountDownLatch) and suspend (Channel) mechanisms - * in place is very low cost and should not hurt performance. The overhead is minimal: - * - CountDownLatch: ~32 bytes, optimized for blocking threads - * - Channel: ~64 bytes, optimized for coroutine suspension - * - Total overhead: <100 bytes per awaiter instance - * - Notification cost: Two simple operations (countDown + trySend) - * - * This dual approach provides optimal performance for each use case rather than forcing - * a one-size-fits-all solution that would be suboptimal for both scenarios. - * - * Usage: - * val awaiter = CompletionAwaiter("OneSignal SDK Init") - * - * // For blocking code: - * awaiter.await() - * - * // For suspend code: - * awaiter.awaitSuspend() - * - * // When complete: - * awaiter.complete() - */ -class CompletionAwaiter( - private val componentName: String = "Component", -) { - companion object { - const val DEFAULT_TIMEOUT_MS = 30_000L // 30 seconds - const val ANDROID_ANR_TIMEOUT_MS = 4_800L // Conservative ANR threshold - } - - private val latch = CountDownLatch(1) - private val suspendCompletion = CompletableDeferred() - - /** - * Completes the awaiter, unblocking both blocking and suspend callers. - */ - fun complete() { - latch.countDown() - suspendCompletion.complete(Unit) - } - - /** - * Wait for completion using blocking approach with an optional timeout. - * - * @param timeoutMs Timeout in milliseconds, defaults to context-appropriate timeout - * @return true if completed before timeout, false otherwise. - */ - fun await(timeoutMs: Long = getDefaultTimeout()): Boolean { - val completed = - try { - latch.await(timeoutMs, TimeUnit.MILLISECONDS) - } catch (e: InterruptedException) { - Logging.warn("Interrupted while waiting for $componentName", e) - logAllThreads() - false - } - - if (!completed) { - val message = createTimeoutMessage(timeoutMs) - Logging.warn(message) - } - - return completed - } - - /** - * Wait for completion using suspend approach (non-blocking for coroutines). - * This method will suspend the current coroutine until completion is signaled. - */ - suspend fun awaitSuspend() { - suspendCompletion.await() - } - - private fun getDefaultTimeout(): Long { - return if (AndroidUtils.isRunningOnMainThread()) ANDROID_ANR_TIMEOUT_MS else DEFAULT_TIMEOUT_MS - } - - private fun createTimeoutMessage(timeoutMs: Long): String { - return if (AndroidUtils.isRunningOnMainThread()) { - "Timeout waiting for $componentName after ${timeoutMs}ms on the main thread. " + - "This can cause ANRs. Consider calling from a background thread." - } else { - "Timeout waiting for $componentName after ${timeoutMs}ms." - } - } - - private fun logAllThreads(): String { - val sb = StringBuilder() - - // Add OneSignal dispatcher status first (fast) - sb.append("=== OneSignal Dispatchers Status ===\n") - sb.append(OneSignalDispatchers.getStatus()) - sb.append("=== OneSignal Dispatchers Performance ===\n") - sb.append(OneSignalDispatchers.getPerformanceMetrics()) - sb.append("\n\n") - - // Add lightweight thread info (fast) - sb.append("=== All Threads Summary ===\n") - val threads = Thread.getAllStackTraces().keys - for (thread in threads) { - sb.append("Thread: ${thread.name} [${thread.state}] ${if (thread.isDaemon) "(daemon)" else ""}\n") - } - - // Only add full stack traces for OneSignal threads (much faster) - sb.append("\n=== OneSignal Thread Details ===\n") - for ((thread, stack) in Thread.getAllStackTraces()) { - if (thread.name.startsWith(BASE_THREAD_NAME)) { - sb.append("Thread: ${thread.name} [${thread.state}]\n") - for (element in stack.take(10)) { // Limit to first 10 frames - sb.append("\tat $element\n") - } - sb.append("\n") - } - } - - return sb.toString() - } -} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt index 19c7b1ddde..3b067820b1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/common/threading/OneSignalDispatchers.kt @@ -25,8 +25,10 @@ import java.util.concurrent.atomic.AtomicInteger * - Small bounded queues (10 tasks) to prevent memory bloat * - Reduced context switching overhead * - Efficient thread management with controlled resource usage + * + * Made public to allow mocking in tests via IOMockHelper. */ -internal object OneSignalDispatchers { +object OneSignalDispatchers { // Optimized pool sizes based on CPU cores and workload analysis private const val IO_CORE_POOL_SIZE = 2 // Increased for better concurrency private const val IO_MAX_POOL_SIZE = 3 // Increased for better concurrency @@ -35,7 +37,7 @@ internal object OneSignalDispatchers { private const val KEEP_ALIVE_TIME_SECONDS = 30L // Keep threads alive longer to reduce recreation private const val QUEUE_CAPACITY = - 10 // Small queue that allows up to 10 tasks to wait in queue when all threads are busy + 200 // Increased to handle more queued operations during init, while still preventing memory bloat internal const val BASE_THREAD_NAME = "OneSignal" // Base thread name prefix private const val IO_THREAD_NAME_PREFIX = "$BASE_THREAD_NAME-IO" // Thread name prefix for I/O operations diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index 99024c3da4..7b6ee4041e 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -9,7 +9,6 @@ import com.onesignal.common.modules.IModule import com.onesignal.common.services.IServiceProvider import com.onesignal.common.services.ServiceBuilder import com.onesignal.common.services.ServiceProvider -import com.onesignal.common.threading.CompletionAwaiter import com.onesignal.common.threading.OneSignalDispatchers import com.onesignal.common.threading.suspendifyOnIO import com.onesignal.core.CoreModule @@ -39,19 +38,16 @@ import com.onesignal.user.internal.identity.IdentityModelStore import com.onesignal.user.internal.properties.PropertiesModelStore import com.onesignal.user.internal.resolveAppId import com.onesignal.user.internal.subscriptions.SubscriptionModelStore +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout - -private const val MAX_TIMEOUT_TO_INIT = 30_000L // 30 seconds internal class OneSignalImp( private val ioDispatcher: CoroutineDispatcher = OneSignalDispatchers.IO, ) : IOneSignal, IServiceProvider { - @Volatile - private var initAwaiter = CompletionAwaiter("OneSignalImp") + + private val suspendCompletion = CompletableDeferred() @Volatile private var initState: InitState = InitState.NOT_STARTED @@ -263,7 +259,6 @@ internal class OneSignalImp( suspendifyOnIO { internalInit(context, appId) } - initState = InitState.SUCCESS return true } @@ -306,22 +301,16 @@ internal class OneSignalImp( ) { Logging.log(LogLevel.DEBUG, "Calling deprecated login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - if (!initState.isSDKAccessible()) { - throw IllegalStateException("Must call 'initWithContext' before 'login'") - } + waitForInit(operationName = "login") - waitForInit() suspendifyOnIO { loginHelper.login(externalId, jwtBearerToken) } } override fun logout() { Logging.log(LogLevel.DEBUG, "Calling deprecated logout()") - if (!initState.isSDKAccessible()) { - throw IllegalStateException("Must call 'initWithContext' before 'logout'") - } + waitForInit(operationName = "logout") - waitForInit() suspendifyOnIO { logoutHelper.logout() } } @@ -333,10 +322,16 @@ internal class OneSignalImp( override fun getAllServices(c: Class): List = services.getAllServices(c) - private fun waitForInit() { - val completed = initAwaiter.await() - if (!completed) { - throw IllegalStateException("initWithContext was not called or timed out") + /** + * Blocking version that waits for initialization to complete. + * Uses runBlocking to bridge to the suspend implementation. + * Waits indefinitely until init completes and logs how long it took. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private fun waitForInit(operationName: String? = null) { + runBlocking(ioDispatcher) { + waitUntilInitInternal(operationName) } } @@ -344,23 +339,65 @@ internal class OneSignalImp( * Notifies both blocking and suspend callers that initialization is complete */ private fun notifyInitComplete() { - initAwaiter.complete() + suspendCompletion.complete(Unit) } - private suspend fun suspendUntilInit() { + /** + * Suspend version that waits for initialization to complete. + * Waits indefinitely until init completes and logs how long it took. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private suspend fun suspendUntilInit(operationName: String? = null) { + waitUntilInitInternal(operationName) + } + + /** + * Common implementation for waiting until initialization completes. + * Waits indefinitely until init completes (SUCCESS or FAILED) to ensure consistent state. + * Logs how long initialization took when it completes. + * + * @param operationName Optional operation name to include in error messages (e.g., "login", "logout") + */ + private suspend fun waitUntilInitInternal(operationName: String? = null) { when (initState) { InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before use") + val message = if (operationName != null) { + "Must call 'initWithContext' before '$operationName'" + } else { + "Must call 'initWithContext' before use" + } + throw IllegalStateException(message) } InitState.IN_PROGRESS -> { - Logging.debug("Suspend waiting for init to complete...") - try { - withTimeout(MAX_TIMEOUT_TO_INIT) { - initAwaiter.awaitSuspend() - } - } catch (e: TimeoutCancellationException) { - throw IllegalStateException("initWithContext was timed out after $MAX_TIMEOUT_TO_INIT ms") + Logging.debug("Waiting for init to complete...") + + val startTime = System.currentTimeMillis() + + // Wait indefinitely until init actually completes - ensures consistent state + // Function only returns when initState is SUCCESS or FAILED + // NOTE: This is a suspend function, so it's non-blocking when called from coroutines. + // However, if waitForInit() (which uses runBlocking) is called from the main thread, + // it will block the main thread indefinitely until init completes, which can cause ANRs. + // This is intentional per PR #2412: "ANR is the lesser of two evils and the app can recover, + // where an uncaught throw it can not." To avoid ANRs, call SDK methods from background threads + // or use the suspend API from coroutines. + suspendCompletion.await() + + // Log how long initialization took + val elapsed = System.currentTimeMillis() - startTime + val message = if (operationName != null) { + "OneSignalImp initialization completed before '$operationName' (took ${elapsed}ms)" + } else { + "OneSignalImp initialization completed (took ${elapsed}ms)" + } + Logging.debug(message) + + // Re-check state after waiting - init might have failed during the wait + if (initState == InitState.FAILED) { + throw IllegalStateException("Initialization failed. Cannot proceed.") } + // initState is guaranteed to be SUCCESS here - consistent state } InitState.FAILED -> { throw IllegalStateException("Initialization failed. Cannot proceed.") @@ -377,23 +414,7 @@ internal class OneSignalImp( } private fun waitAndReturn(getter: () -> T): T { - when (initState) { - InitState.NOT_STARTED -> { - throw IllegalStateException("Must call 'initWithContext' before use") - } - InitState.IN_PROGRESS -> { - Logging.debug("Waiting for init to complete...") - waitForInit() - } - InitState.FAILED -> { - throw IllegalStateException("Initialization failed. Cannot proceed.") - } - else -> { - // SUCCESS - waitForInit() - } - } - + waitForInit() return getter() } @@ -407,8 +428,9 @@ internal class OneSignalImp( // because Looper.getMainLooper() is not mocked. This is safe to ignore. Logging.debug("Could not check main thread status (likely in test environment): ${e.message}") } + // Call suspendAndReturn directly to avoid nested runBlocking (waitAndReturn -> waitForInit -> runBlocking) return runBlocking(ioDispatcher) { - waitAndReturn(getter) + suspendAndReturn(getter) } } @@ -508,7 +530,8 @@ internal class OneSignalImp( ) = withContext(ioDispatcher) { Logging.log(LogLevel.DEBUG, "login(externalId: $externalId, jwtBearerToken: $jwtBearerToken)") - suspendUntilInit() + suspendUntilInit(operationName = "login") + if (!isInitialized) { throw IllegalStateException("'initWithContext failed' before 'login'") } @@ -520,7 +543,7 @@ internal class OneSignalImp( withContext(ioDispatcher) { Logging.log(LogLevel.DEBUG, "logoutSuspend()") - suspendUntilInit() + suspendUntilInit(operationName = "logout") if (!isInitialized) { throw IllegalStateException("'initWithContext failed' before 'logout'") diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt deleted file mode 100644 index 37f239ead3..0000000000 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/common/threading/CompletionAwaiterTests.kt +++ /dev/null @@ -1,363 +0,0 @@ -package com.onesignal.common.threading - -import com.onesignal.common.AndroidUtils -import com.onesignal.debug.LogLevel -import com.onesignal.debug.internal.logging.Logging -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.longs.shouldBeGreaterThan -import io.kotest.matchers.longs.shouldBeLessThan -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockkObject -import io.mockk.unmockkObject -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.delay -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking - -class CompletionAwaiterTests : FunSpec({ - - lateinit var awaiter: CompletionAwaiter - - beforeEach { - Logging.logLevel = LogLevel.NONE - awaiter = CompletionAwaiter("TestComponent") - } - - afterEach { - unmockkObject(AndroidUtils) - } - - context("blocking await functionality") { - - test("await completes immediately when already completed") { - // Given - awaiter.complete() - - // When - val startTime = System.currentTimeMillis() - val completed = awaiter.await(1000) - val duration = System.currentTimeMillis() - startTime - - // Then - completed shouldBe true - duration shouldBeLessThan 50L // Should be very fast - } - - test("await waits for delayed completion") { - val completionDelay = 300L - val timeoutMs = 2000L - - val startTime = System.currentTimeMillis() - - // Simulate delayed completion from another thread - suspendifyOnIO { - delay(completionDelay) - awaiter.complete() - } - - val result = awaiter.await(timeoutMs) - val duration = System.currentTimeMillis() - startTime - - result shouldBe true - duration shouldBeGreaterThan (completionDelay - 50) - duration shouldBeLessThan (completionDelay + 150) // buffer - } - - test("await returns false when timeout expires") { - mockkObject(AndroidUtils) - every { AndroidUtils.isRunningOnMainThread() } returns false - - val timeoutMs = 200L - val startTime = System.currentTimeMillis() - - val completed = awaiter.await(timeoutMs) - val duration = System.currentTimeMillis() - startTime - - completed shouldBe false - duration shouldBeGreaterThan (timeoutMs - 50) - duration shouldBeLessThan (timeoutMs + 150) - } - - test("await timeout of 0 returns false immediately when not completed") { - // Mock AndroidUtils to avoid Looper.getMainLooper() issues - mockkObject(AndroidUtils) - every { AndroidUtils.isRunningOnMainThread() } returns false - - val startTime = System.currentTimeMillis() - val completed = awaiter.await(0) - val duration = System.currentTimeMillis() - startTime - - completed shouldBe false - duration shouldBeLessThan 20L - - unmockkObject(AndroidUtils) - } - - test("multiple blocking callers are all unblocked") { - val numCallers = 5 - val results = mutableListOf() - val jobs = mutableListOf() - - // Start multiple blocking callers - repeat(numCallers) { index -> - val thread = - Thread { - val result = awaiter.await(2000) - synchronized(results) { - results.add(result) - } - } - thread.start() - jobs.add(thread) - } - - // Wait a bit to ensure all threads are waiting - Thread.sleep(100) - - // Complete the awaiter - awaiter.complete() - - // Wait for all threads to complete - jobs.forEach { it.join(1000) } - - // All should have completed successfully - results.size shouldBe numCallers - results.all { it } shouldBe true - } - } - - context("suspend await functionality") { - - test("awaitSuspend completes immediately when already completed") { - runBlocking { - // Given - awaiter.complete() - - // When - should complete immediately without hanging - awaiter.awaitSuspend() - - // Then - if we get here, it completed successfully - // No timing assertions needed in test environment - } - } - - test("awaitSuspend waits for delayed completion") { - runBlocking { - val completionDelay = 100L - - // Start delayed completion - val completionJob = - launch { - delay(completionDelay) - awaiter.complete() - } - - // Wait for completion - awaiter.awaitSuspend() - - // In test environment, we just verify it completed without hanging - completionJob.join() - } - } - - test("multiple suspend callers are all unblocked") { - runBlocking { - val numCallers = 5 - val results = mutableListOf() - - // Start multiple suspend callers - val jobs = - (1..numCallers).map { index -> - async { - awaiter.awaitSuspend() - results.add("caller-$index") - } - } - - // Wait a bit to ensure all coroutines are suspended - delay(50) - - // Complete the awaiter - awaiter.complete() - - // Wait for all callers to complete - jobs.awaitAll() - - // All should have completed - results.size shouldBe numCallers - } - } - - test("awaitSuspend can be cancelled") { - runBlocking { - val job = - launch { - awaiter.awaitSuspend() - } - - // Wait a bit then cancel - delay(50) - job.cancel() - - // Job should be cancelled - job.isCancelled shouldBe true - } - } - } - - context("mixed blocking and suspend callers") { - - test("completion unblocks both blocking and suspend callers") { - // This test verifies the dual mechanism works - // We'll test blocking and suspend separately since mixing them in runTest is problematic - - // Test suspend callers first - runBlocking { - val suspendResults = mutableListOf() - - // Start suspend callers - val suspendJobs = - (1..2).map { index -> - async { - awaiter.awaitSuspend() - suspendResults.add("suspend-$index") - } - } - - // Wait a bit to ensure all are waiting - delay(50) - - // Complete the awaiter - awaiter.complete() - - // Wait for all to complete - suspendJobs.awaitAll() - - // All should have completed - suspendResults.size shouldBe 2 - } - - // Reset for blocking test - awaiter = CompletionAwaiter("TestComponent") - - // Test blocking callers - val blockingResults = mutableListOf() - val blockingThreads = - (1..2).map { index -> - Thread { - val result = awaiter.await(2000) - synchronized(blockingResults) { - blockingResults.add(result) - } - } - } - blockingThreads.forEach { it.start() } - - // Wait a bit to ensure all are waiting - Thread.sleep(100) - - // Complete the awaiter - awaiter.complete() - - // Wait for all to complete - blockingThreads.forEach { it.join(1000) } - - // All should have completed - blockingResults shouldBe arrayOf(true, true) - } - } - - context("edge cases and safety") { - - test("multiple complete calls are safe") { - // Complete multiple times - awaiter.complete() - awaiter.complete() - awaiter.complete() - - // Should still work normally - val completed = awaiter.await(100) - completed shouldBe true - } - - test("waiting after completion returns immediately") { - runBlocking { - // Complete first - awaiter.complete() - - // Then wait - should return immediately without hanging - awaiter.awaitSuspend() - - // Multiple calls should also work immediately - awaiter.awaitSuspend() - awaiter.awaitSuspend() - } - } - - test("concurrent access is safe") { - runBlocking { - val numOperations = 10 // Reduced for test stability - val jobs = mutableListOf() - - // Start some waiters first - repeat(numOperations / 2) { index -> - jobs.add( - async { - awaiter.awaitSuspend() - }, - ) - } - - // Wait a bit for them to start waiting - delay(10) - - // Then complete multiple times concurrently - repeat(numOperations / 2) { index -> - jobs.add(launch { awaiter.complete() }) - } - - // Wait for all operations - jobs.joinAll() - - // Final wait should work immediately - awaiter.awaitSuspend() - } - } - } - - context("timeout behavior") { - - test("uses shorter timeout on main thread") { - mockkObject(AndroidUtils) - every { AndroidUtils.isRunningOnMainThread() } returns true - - val startTime = System.currentTimeMillis() - val completed = awaiter.await() // Default timeout - val duration = System.currentTimeMillis() - startTime - - completed shouldBe false - // Should use ANDROID_ANR_TIMEOUT_MS (4800ms) instead of DEFAULT_TIMEOUT_MS (30000ms) - duration shouldBeLessThan 6000L // Much less than 30 seconds - duration shouldBeGreaterThan 4000L // But around 4.8 seconds - } - - test("uses longer timeout on background thread") { - mockkObject(AndroidUtils) - every { AndroidUtils.isRunningOnMainThread() } returns false - - // We can't actually wait 30 seconds in a test, so just verify it would use the longer timeout - // by checking the timeout logic doesn't kick in quickly - val startTime = System.currentTimeMillis() - val completed = awaiter.await(1000) // Force shorter timeout for test - val duration = System.currentTimeMillis() - startTime - - completed shouldBe false - duration shouldBeGreaterThan 900L - duration shouldBeLessThan 1200L - } - } -}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt index 07fce3358c..462ea7a746 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitSuspendTests.kt @@ -287,7 +287,7 @@ class SDKInitSuspendTests : FunSpec({ } // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before use" + exception.message shouldBe "Must call 'initWithContext' before 'login'" } } @@ -303,7 +303,7 @@ class SDKInitSuspendTests : FunSpec({ } // Should throw immediately because isInitialized is false - exception.message shouldBe "Must call 'initWithContext' before use" + exception.message shouldBe "Must call 'initWithContext' before 'logout'" } } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt index 318f6cb1c1..013899f8c8 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/application/SDKInitTests.kt @@ -5,7 +5,6 @@ import android.content.ContextWrapper import android.content.SharedPreferences import androidx.test.core.app.ApplicationProvider.getApplicationContext import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest -import com.onesignal.common.threading.CompletionAwaiter import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys.PREFS_LEGACY_APP_ID import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.logging.Logging @@ -15,12 +14,27 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.maps.shouldContain import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.runBlocking +import java.util.concurrent.CountDownLatch @RobolectricTest class SDKInitTests : FunSpec({ + /** + * Helper function to wait for OneSignal initialization to complete. + * @param oneSignalImp The OneSignalImp instance to wait for + * @param maxAttempts Maximum number of attempts (default: 100) + * @param sleepMs Sleep duration between attempts in milliseconds (default: 20) + */ + fun waitForInitialization(oneSignalImp: OneSignalImp, maxAttempts: Int = 100, sleepMs: Long = 20) { + var attempts = 0 + while (!oneSignalImp.isInitialized && attempts < maxAttempts) { + Thread.sleep(sleepMs) + attempts++ + } + oneSignalImp.isInitialized shouldBe true + } + beforeAny { Logging.logLevel = LogLevel.NONE @@ -89,9 +103,9 @@ class SDKInitTests : FunSpec({ test("initWithContext with no appId succeeds when configModel has appId") { // Given // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") + val trigger = CountDownLatch(1) val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val blockingPrefContext = BlockingPrefsContext(context, trigger) val os = OneSignalImp() var initSuccess = true @@ -122,7 +136,7 @@ class SDKInitTests : FunSpec({ accessorThread.isAlive shouldBe true // release SharedPreferences - trigger.complete() + trigger.countDown() accessorThread.join(500) accessorThread.isAlive shouldBe false @@ -135,9 +149,9 @@ class SDKInitTests : FunSpec({ test("initWithContext with appId does not block") { // Given // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") + val trigger = CountDownLatch(1) val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 1000) + val blockingPrefContext = BlockingPrefsContext(context, trigger) val os = OneSignalImp() // When @@ -150,17 +164,22 @@ class SDKInitTests : FunSpec({ accessorThread.join(500) // Then - // should complete even SharedPreferences is unavailable + // should complete even SharedPreferences is unavailable (non-blocking) accessorThread.isAlive shouldBe false - os.isInitialized shouldBe true + + // Release the SharedPreferences lock so internalInit can complete + trigger.countDown() + + // Wait for initialization to complete (internalInit runs asynchronously) + waitForInitialization(os, maxAttempts = 50) } test("accessors will be blocked if call too early after initWithContext with appId") { // Given // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") + val trigger = CountDownLatch(1) val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val blockingPrefContext = BlockingPrefsContext(context, trigger) val os = OneSignalImp() val accessorThread = @@ -175,7 +194,7 @@ class SDKInitTests : FunSpec({ accessorThread.isAlive shouldBe true // release the lock on SharedPreferences - trigger.complete() + trigger.countDown() accessorThread.join(1000) accessorThread.isAlive shouldBe false @@ -202,9 +221,9 @@ class SDKInitTests : FunSpec({ test("ensure login called right after initWithContext can set externalId correctly") { // Given // block SharedPreference before calling init - val trigger = CompletionAwaiter("Test") + val trigger = CountDownLatch(1) val context = getApplicationContext() - val blockingPrefContext = BlockingPrefsContext(context, trigger, 2000) + val blockingPrefContext = BlockingPrefsContext(context, trigger) val os = OneSignalImp() val externalId = "testUser" @@ -224,11 +243,22 @@ class SDKInitTests : FunSpec({ accessorThread.start() accessorThread.join(500) - os.isInitialized shouldBe true + // initWithContext should return immediately (non-blocking) + // but isInitialized won't be true until internalInit completes + // which requires SharedPreferences to be unblocked accessorThread.isAlive shouldBe true - // release the lock on SharedPreferences - trigger.complete() + // release the lock on SharedPreferences so internalInit can complete + trigger.countDown() + + // Wait for initialization to complete (internalInit runs asynchronously) + var initAttempts = 0 + while (!os.isInitialized && initAttempts < 50) { + Thread.sleep(20) + initAttempts++ + } + + os.isInitialized shouldBe true accessorThread.join(500) accessorThread.isAlive shouldBe false @@ -307,12 +337,7 @@ class SDKInitTests : FunSpec({ os.initWithContext(context, "appId") // Wait for initialization to complete before accessing user - var attempts = 0 - while (!os.isInitialized && attempts < 100) { - Thread.sleep(20) - attempts++ - } - os.isInitialized shouldBe true + waitForInitialization(os) // Give additional time for coroutines to settle, especially in CI/CD Thread.sleep(50) @@ -323,12 +348,7 @@ class SDKInitTests : FunSpec({ os.initWithContext(context) // Wait for second initialization to complete - attempts = 0 - while (!os.isInitialized && attempts < 100) { - Thread.sleep(20) - attempts++ - } - os.isInitialized shouldBe true + waitForInitialization(os) // Give additional time for coroutines to settle after second init Thread.sleep(50) @@ -437,20 +457,13 @@ class SDKInitTests : FunSpec({ */ class BlockingPrefsContext( context: Context, - private val unblockTrigger: CompletionAwaiter, - private val timeoutInMillis: Long, + private val unblockTrigger: CountDownLatch, ) : ContextWrapper(context) { override fun getSharedPreferences( name: String, mode: Int, ): SharedPreferences { - try { - unblockTrigger.await(timeoutInMillis) - } catch (e: InterruptedException) { - throw e - } catch (e: TimeoutCancellationException) { - throw e - } + unblockTrigger.await() return super.getSharedPreferences(name, mode) } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt index 93fa9e6b11..7416b2910c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/startup/StartupServiceTests.kt @@ -8,12 +8,12 @@ import com.onesignal.mocks.IOMockHelper import com.onesignal.mocks.IOMockHelper.awaitIO import io.kotest.assertions.throwables.shouldThrowUnit import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.comparables.shouldBeLessThan import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import io.mockk.spyk import io.mockk.verify -import kotlinx.coroutines.CompletableDeferred class StartupServiceTests : FunSpec({ fun setupServiceProvider( @@ -83,47 +83,55 @@ class StartupServiceTests : FunSpec({ test("startup will call all IStartableService dependencies successfully after a short delay") { // Given - val mockStartupService1 = spyk() - val mockStartupService2 = spyk() + val mockStartupService1 = mockk(relaxed = true) + val mockStartupService2 = mockk(relaxed = true) val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartupService1, mockStartupService2))) // When startupService.scheduleStart() - // Then - Thread.sleep(10) + // Then - wait deterministically for both services to start using IOMockHelper + awaitIO() verify(exactly = 1) { mockStartupService1.start() } verify(exactly = 1) { mockStartupService2.start() } } test("scheduleStart does not block main thread") { // Given - val mockStartableService1 = spyk() + val mockStartableService1 = mockk(relaxed = true) val mockStartableService2 = spyk() - val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartableService1))) + val mockStartableService3 = spyk() + // Only service1 and service2 are scheduled - service3 is NOT scheduled + val startupService = StartupService(setupServiceProvider(listOf(), listOf(mockStartableService1, mockStartableService2))) - // Block the scheduled services until we're ready - val blockTrigger = CompletableDeferred() - every { mockStartableService1.start() } coAnswers { - blockTrigger.await() // Block until released - } + // When - scheduleStart() is async, so it doesn't block + val startTime = System.currentTimeMillis() + startupService.scheduleStart() + val scheduleTime = System.currentTimeMillis() - startTime - // When - val thread = - Thread { - startupService.scheduleStart() - mockStartableService2.start() - } - thread.start() + // This should execute immediately since scheduleStart() doesn't block + // service3 is NOT part of scheduled services, so this is a direct call + mockStartableService3.start() + val immediateTime = System.currentTimeMillis() - startTime - // Then - // service2 does not block even though service1 is blocked - verify(exactly = 1) { mockStartableService2.start() } + // Then - verify scheduleStart() returned quickly (non-blocking) + // Should return in < 50ms (proving it doesn't wait for services to start) + scheduleTime shouldBeLessThan 50L + immediateTime shouldBeLessThan 50L + + // Verify service3 was called immediately (proving main thread wasn't blocked) + verify(exactly = 1) { mockStartableService3.start() } - // unblock the trigger and wait for scheduled service to complete - blockTrigger.complete(Unit) + // Wait deterministically for async execution using IOMockHelper awaitIO() - verify { mockStartableService1.start() } + + // Verify scheduled services were called + verify(exactly = 1) { mockStartableService1.start() } + verify(exactly = 1) { mockStartableService2.start() } + + // The key assertion: scheduleStart() returned immediately without blocking, + // allowing service3.start() to be called synchronously before scheduled services + // complete. This proves scheduleStart() is non-blocking. } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt index e5e49f1ec0..d660fa2525 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/OneSignalImpTests.kt @@ -215,46 +215,42 @@ class OneSignalImpTests : FunSpec({ test("waitForInit timeout behavior - this test demonstrates the timeout mechanism") { // This test documents that waitForInit() has timeout protection // In a real scenario, if initWithContext was never called, - // waitForInit() would timeout after 30 seconds and throw an exception + // waitForInit() would timeout after 30 seconds and log a warning (not throw) // Given - a fresh OneSignalImp instance val oneSignalImp = OneSignalImp() - // The timeout behavior is built into CompletionAwaiter.await() - // which waits for up to 30 seconds (or 4.8 seconds on main thread) - // before timing out and returning false + // The timeout behavior is built into waitUntilInitInternal() + // which uses withTimeout() to wait for up to 30 seconds (or 4.8 seconds on main thread) + // before logging a warning and proceeding - // NOTE: We don't actually test the 30-second timeout here because: - // 1. It would make tests too slow (30 seconds per test) - // 2. The timeout is tested in CompletionAwaiterTests - // 3. This test documents the behavior for developers + // NOTE: We don't test waiting indefinitely here because: + // 1. It would make tests hang forever + // 2. This test documents the behavior for developers oneSignalImp.isInitialized shouldBe false } - test("waitForInit timeout mechanism exists - CompletionAwaiter integration") { - // This test verifies that the timeout mechanism is properly integrated - // by checking that CompletionAwaiter has timeout capabilities + test("waitForInit waits indefinitely until init completes") { + // This test verifies that waitUntilInitInternal waits indefinitely + // until initialization completes (per PR #2412) // Given val oneSignalImp = OneSignalImp() - // The timeout behavior is implemented through CompletionAwaiter.await() - // which has a default timeout of 30 seconds (or 4.8 seconds on main thread) - - // We can verify the timeout mechanism exists by checking: - // 1. The CompletionAwaiter is properly initialized - // 2. The initState is NOT_STARTED (which would trigger timeout) + // We can verify the wait behavior by checking: + // 1. The suspendCompletion (CompletableDeferred) is properly initialized + // 2. The initState is NOT_STARTED (which would throw immediately) // 3. The isInitialized property correctly reflects the state oneSignalImp.isInitialized shouldBe false // In a real scenario where initWithContext is never called: - // - waitForInit() would call initAwaiter.await() - // - CompletionAwaiter.await() would wait up to 30 seconds - // - After timeout, it would return false - // - waitForInit() would then throw "initWithContext was not called or timed out" + // - waitForInit() would call waitUntilInitInternal() + // - waitUntilInitInternal() would check initState == NOT_STARTED and throw immediately + // - If initState was IN_PROGRESS, it would wait indefinitely using suspendCompletion.await() + // - waitForInit() throws for NOT_STARTED/FAILED states, waits indefinitely for IN_PROGRESS - // This test documents this behavior without actually waiting 30 seconds + // This test documents this behavior without actually waiting indefinitely } }) 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 a5ad5b1d65..7296be941e 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 @@ -1,7 +1,7 @@ package com.onesignal.mocks +import com.onesignal.common.threading.OneSignalDispatchers 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 @@ -9,24 +9,32 @@ 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.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic +import io.mockk.unmockkObject import io.mockk.unmockkStatic import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.util.concurrent.atomic.AtomicInteger /** - * Test helper that makes OneSignal’s `suspendifyOnIO` behavior deterministic in unit tests. + * Test helper that makes OneSignal's async threading 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. + * In production, `suspendifyOnIO`, `launchOnIO`, and `launchOnDefault` launch work on + * background threads and return 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 + * - Mocking `suspendifyOnIO`, `launchOnIO`, and `launchOnDefault` so their blocks run immediately * - Completing a `CompletableDeferred` when the async block finishes - * - Providing `awaitIO()` so tests can explicitly wait for all IO work without sleeps + * - Providing `awaitIO()` so tests can explicitly wait for all async work without sleeps * * Usage example in a Kotest spec: * class InAppMessagesManagerTests : FunSpec({ @@ -36,7 +44,7 @@ import java.util.concurrent.atomic.AtomicInteger * ... * * test("xyz") { - * iamManager.start() // start() calls suspendOnIO + * iamManager.start() // start() calls suspendOnIO or launchOnDefault * awaitIO() // wait for background work deterministically * ... * } @@ -45,34 +53,18 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, private const val THREADUTILS_PATH = "com.onesignal.common.threading.ThreadUtilsKt" - // How many IO blocks are currently running + // How many async blocks (suspendifyOnIO, launchOnIO, launchOnDefault) are currently running private val pendingIo = AtomicInteger(0) - // Completed when all in-flight IO blocks for the current "wave" are done + // Completed when all in-flight async blocks for the current "wave" are done @Volatile private var ioWaiter: CompletableDeferred = CompletableDeferred() /** - * Wait for suspendifyOnIO work to finish. + * Wait for suspendifyOnIO, launchOnIO, and launchOnDefault 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 + * 1. If multiple async tasks are added before the first task finishes, the waiter will wait until ALL tasks are finished * 2. If async work is triggered after an awaitIO() has already returned, just call awaitIO() again to wait for the new work. - * - * *** NOTE ABOUT COVERAGE: - * * This helper intentionally mocks *only* the top-level `suspendifyOnIO(block)` function. - * It does NOT intercept every threading entry point defined in ThreadUtils.kt or - * OneSignalDispatchers — e.g. `suspendifyWithCompletion`, `suspendifyOnDefault`, - * `launchOnIO`, and `launchOnDefault` will continue to run using the real dispatcher - * behavior. - * - * * This design keeps the helper focused on stabilizing existing tests that specifically - * depend on `suspendifyOnIO`, without altering unrelated threading paths across the SDK. - * - * * If future tests rely on other threading helpers (e.g., direct calls to - * `suspendifyWithCompletion` or `launchOnIO`), this helper can be extended, or a separate - * test helper can be introduced to cover those cases. For now, this keeps the - * interception surface minimal and avoids unintentionally changing more concurrency - * behavior than necessary. */ suspend fun awaitIO(timeoutMs: Long = 5_000) { // Nothing to wait for in this case @@ -86,28 +78,55 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, override suspend fun beforeSpec(spec: Spec) { // ThreadUtilsKt = file that contains suspendifyOnIO mockkStatic(THREADUTILS_PATH) + // OneSignalDispatchers = object that contains launchOnIO and launchOnDefault + mockkObject(OneSignalDispatchers) - every { suspendifyOnIO(any Unit>()) } answers { - val block = firstArg Unit>() - - // New IO wave: if we are going from 0 -> 1, create a new waiter + // Helper function to track async work (suspendifyOnIO, launchOnIO, launchOnDefault) + // Note: We use Dispatchers.Unconfined to execute immediately and deterministically + // instead of suspendifyWithCompletion to avoid circular dependency + // (suspendifyWithCompletion calls OneSignalDispatchers.launchOnIO which we're mocking) + fun trackAsyncWork(block: suspend () -> Unit) { + // New async 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 = { + // Execute the block using Unconfined dispatcher to run immediately and deterministically + // This makes tests deterministic and avoids the need for delays + CoroutineScope(SupervisorJob() + Dispatchers.Unconfined).launch { + try { + block() + } catch (e: Exception) { + // Log but don't throw - let the test handle exceptions + } finally { // When each block finishes, decrement; if all done, complete waiter if (pendingIo.decrementAndGet() == 0) { if (!ioWaiter.isCompleted) { ioWaiter.complete(Unit) } } - }, - ) + } + } + } + + every { suspendifyOnIO(any Unit>()) } answers { + val block = firstArg Unit>() + trackAsyncWork(block) + } + + every { OneSignalDispatchers.launchOnIO(any Unit>()) } answers { + val block = firstArg Unit>() + trackAsyncWork(block) + // Return a mock Job (launchOnIO returns a Job) + mockk(relaxed = true) + } + + every { OneSignalDispatchers.launchOnDefault(any Unit>()) } answers { + val block = firstArg Unit>() + trackAsyncWork(block) + // Return a mock Job (launchOnDefault returns a Job) + mockk(relaxed = true) } } @@ -119,5 +138,6 @@ object IOMockHelper : BeforeSpecListener, AfterSpecListener, BeforeTestListener, override suspend fun afterSpec(spec: Spec) { unmockkStatic(THREADUTILS_PATH) + unmockkObject(OneSignalDispatchers) } } From dab2c4d89afa91f8f53d38a8e7f516e1a4f3bacb Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 9 Dec 2025 13:13:07 -0500 Subject: [PATCH 24/24] enabled tests --- .../core/internal/operations/OperationRepoTests.kt | 12 ++++++------ .../NotificationGenerationProcessorTests.kt | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt index 91b1b0d535..164949612c 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt @@ -578,7 +578,7 @@ class OperationRepoTests : FunSpec({ } } - test("ensure results from executeOperations are added to beginning of the queue").config(enabled = false) { + test("ensure results from executeOperations are added to beginning of the queue") { // Given val mocks = Mocks() val executor = mocks.executor @@ -614,7 +614,7 @@ class OperationRepoTests : FunSpec({ // needed as the backend may incorrectly 404 otherwise, due to a small // delay in it's server replication. // A cold down period like this also helps improve batching as well. - test("execution of an operation with translation IDs delays follow up operations").config(enabled = false) { + test("execution of an operation with translation IDs delays follow up operations") { // Given val mocks = Mocks() val operation1 = mockOperation(groupComparisonType = GroupComparisonType.NONE) @@ -667,7 +667,7 @@ class OperationRepoTests : FunSpec({ } // operations not removed from the queue may get stuck in the queue if app is force closed within the delay - test("execution of an operation with translation IDs removes the operation from queue before delay").config(enabled = false) { + test("execution of an operation with translation IDs removes the operation from queue before delay") { // Given val mocks = Mocks() val operation = mockOperation(groupComparisonType = GroupComparisonType.NONE) @@ -695,7 +695,7 @@ class OperationRepoTests : FunSpec({ // sending updates every opRepoExecutionInterval (5 seconds currently). // By waiting for the dust to settle we ensure the app is done making // updates. - test("ensure each time enqueue is called it restarts the delay time").config(enabled = false) { + test("ensure each time enqueue is called it restarts the delay time") { // Given val mocks = Mocks() mocks.configModelStore.model.opRepoExecutionInterval = 100 @@ -730,7 +730,7 @@ class OperationRepoTests : FunSpec({ withTimeout(1_000) { mocks.operationRepo.awaitInitialized() } } - test("ensure loadSavedOperations doesn't duplicate existing OperationItems").config(enabled = false) { + test("ensure loadSavedOperations doesn't duplicate existing OperationItems") { // Given val mocks = Mocks() val op = mockOperation() @@ -796,7 +796,7 @@ class OperationRepoTests : FunSpec({ // This test verifies the critical execution order when translation IDs and grouping work together // It ensures that operations requiring translation wait for translation mappings before being grouped - test("translation IDs are applied before operations are grouped with correct execution order").config(enabled = false) { + test("translation IDs are applied before operations are grouped with correct execution order") { // Given val mocks = Mocks() diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt index 05a4c15b90..154b879a11 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/generation/NotificationGenerationProcessorTests.kt @@ -156,7 +156,7 @@ class NotificationGenerationProcessorTests : FunSpec({ } } - test("processNotificationData should not display notification when external callback indicates not to").config(enabled = false) { + test("processNotificationData should not display notification when external callback indicates not to") { // Given val mocks = Mocks() coEvery { mocks.notificationLifecycleService.externalRemoteNotificationReceived(any()) } answers { @@ -268,7 +268,7 @@ class NotificationGenerationProcessorTests : FunSpec({ } } - test("processNotificationData allows the will display callback to prevent default behavior twice").config(enabled = false) { + test("processNotificationData allows the will display callback to prevent default behavior twice") { // Given val mocks = Mocks() coEvery { mocks.notificationDisplayer.displayNotification(any()) } returns true @@ -293,7 +293,7 @@ class NotificationGenerationProcessorTests : FunSpec({ } } - test("processNotificationData allows the received event callback to prevent default behavior twice").config(enabled = false) { + test("processNotificationData allows the received event callback to prevent default behavior twice") { // Given val mocks = Mocks() coEvery { mocks.notificationDisplayer.displayNotification(any()) } returns true