diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt index a6ae6ddc8..276bcfda1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/Extensions.kt @@ -302,6 +302,14 @@ internal fun List.sortByPriority(): List { return this.sortedByDescending { it.isPriority } } +/** + * Tags to attach to operations emitted by this in-app: [InApp.tags] when non-empty and the tags + * feature is enabled, otherwise `null` (so Gson omits the `tags` key downstream). The feature-flag + * decision is taken by the caller and passed in as [isTagsFeatureEnabled]. + */ +internal fun InApp.gatedTags(isTagsFeatureEnabled: Boolean): Map? = + tags?.takeIf { it.isNotEmpty() && isTagsFeatureEnabled } + internal inline fun Queue.pollIf(predicate: (T) -> Boolean): T? { return peek()?.takeIf(predicate)?.let { poll() } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt index a2cf4ba4f..367531343 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/DomainModule.kt @@ -47,7 +47,8 @@ internal fun DomainModule( inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = inAppContentFetcher, inAppRepository = inAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt index de83c9b68..f05beac23 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/di/modules/PresentationModule.kt @@ -29,7 +29,8 @@ internal fun PresentationModule( sessionStorageManager = sessionStorageManager, userVisitManager = userVisitManager, inAppMessageDelayedManager = inAppMessageDelayedManager, - timeProvider = timeProvider + timeProvider = timeProvider, + featureToggleManager = featureToggleManager ) } override val clipboardManager: ClipboardManager by lazy { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt index 3407125cb..c580c6adc 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/FeatureToggleManagerImpl.kt @@ -5,6 +5,7 @@ import cloud.mindbox.mobile_sdk.models.operation.response.InAppConfigResponse import java.util.concurrent.ConcurrentHashMap internal const val SEND_INAPP_SHOW_ERROR_FEATURE = "MobileSdkShouldSendInAppShowError" +internal const val SEND_INAPP_TAGS_FEATURE = "MobileSdkShouldSendInAppTags" internal class FeatureToggleManagerImpl : FeatureToggleManager { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt index 8b9fd6a3b..580338fd4 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImpl.kt @@ -44,7 +44,12 @@ internal class InAppFailureTrackerImpl( inAppRepository.sendInAppShowFailure(listOf(failure)) } - override fun sendFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + override fun sendFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String?, + tags: Map? + ) { val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) .convertToZonedDateTimeAtUTC() .convertToString() @@ -54,12 +59,18 @@ internal class InAppFailureTrackerImpl( inAppId = inAppId, failureReason = failureReason, errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), - dateTimeUtc = timestamp + dateTimeUtc = timestamp, + tags = tags ) ) } - override fun collectFailure(inAppId: String, failureReason: FailureReason, errorDetails: String?) { + override fun collectFailure( + inAppId: String, + failureReason: FailureReason, + errorDetails: String?, + tags: Map? + ) { val timestamp = Instant.ofEpochMilli(timeProvider.currentTimeMillis()) .convertToZonedDateTimeAtUTC() .convertToString() @@ -68,7 +79,8 @@ internal class InAppFailureTrackerImpl( inAppId = inAppId, failureReason = failureReason, errorDetails = errorDetails?.take(COUNT_OF_CHARS_IN_ERROR_DETAILS), - dateTimeUtc = timestamp + dateTimeUtc = timestamp, + tags = tags ) ) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt index d80cff2c1..675e549e1 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerImpl.kt @@ -3,9 +3,10 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.fromJsonTyped import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper -import cloud.mindbox.mobile_sdk.models.operation.request.InAppHandleRequest +import cloud.mindbox.mobile_sdk.models.operation.request.InAppClickRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import cloud.mindbox.mobile_sdk.models.operation.request.InAppTargetingRequest import cloud.mindbox.mobile_sdk.toJsonTyped import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler import cloud.mindbox.mobile_sdk.utils.loggingRunCatching @@ -30,9 +31,20 @@ internal class InAppSerializationManagerImpl(private val gson: Gson) : InAppSeri } } - override fun serializeToInAppActionString(inAppId: String): String { - return loggingRunCatching("") { - gson.toJsonTyped(InAppHandleRequest(inAppId = inAppId)) + override fun serializeToInAppTargetingString(inAppId: String, tags: Map?): String { + return loggingRunCatching(defaultValue = "") { + gson.toJsonTyped(InAppTargetingRequest(inAppId = inAppId, tags = tags)) + } + } + + override fun serializeToInAppClickActionString(inAppId: String, tags: Map?): String { + return loggingRunCatching(defaultValue = "") { + gson.toJsonTyped( + InAppClickRequest( + inAppId = inAppId, + tags = tags, + ) + ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt index 1e0ad72a3..19f31bf4a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryImpl.kt @@ -109,8 +109,8 @@ internal class InAppRepositoryImpl( } } - override fun sendInAppClicked(inAppId: String) { - inAppSerializationManager.serializeToInAppActionString(inAppId).apply { + override fun sendInAppClicked(inAppId: String, tags: Map?) { + inAppSerializationManager.serializeToInAppClickActionString(inAppId, tags).apply { if (isNotBlank()) { MindboxEventManager.inAppClicked( context, @@ -120,8 +120,8 @@ internal class InAppRepositoryImpl( } } - override fun sendUserTargeted(inAppId: String) { - inAppSerializationManager.serializeToInAppActionString(inAppId).apply { + override fun sendUserTargeted(inAppId: String, tags: Map?) { + inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags).apply { if (isNotBlank()) { MindboxEventManager.sendUserTargeted( context, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt index 758e8613b..0cb0ca776 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImpl.kt @@ -118,8 +118,8 @@ internal class InAppInteractorImpl( inAppRepository.saveInAppStateChangeTime(timeStamp.toTimestamp()) } - override fun sendInAppClicked(inAppId: String) { - inAppRepository.sendInAppClicked(inAppId) + override fun sendInAppClicked(inAppId: String, tags: Map?) { + inAppRepository.sendInAppClicked(inAppId, tags) } override suspend fun listenToTargetingEvents() { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt index 271a93f05..6294f0d11 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerImpl.kt @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.Mindbox.logI +import cloud.mindbox.mobile_sdk.gatedTags import cloud.mindbox.mobile_sdk.getErrorResponseBodyData import cloud.mindbox.mobile_sdk.getImageUrl import cloud.mindbox.mobile_sdk.inapp.domain.extensions.asVolleyError @@ -8,7 +9,9 @@ import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getProductFromTargetingD import cloud.mindbox.mobile_sdk.inapp.domain.extensions.getVolleyErrorDetails import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackImageDownloadError import cloud.mindbox.mobile_sdk.inapp.domain.extensions.shouldTrackTargetingError +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppProcessingManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository @@ -27,7 +30,8 @@ internal class InAppProcessingManagerImpl( private val inAppTargetingErrorRepository: InAppTargetingErrorRepository, private val inAppContentFetcher: InAppContentFetcher, private val inAppRepository: InAppRepository, - private val inAppFailureTracker: InAppFailureTracker + private val inAppFailureTracker: InAppFailureTracker, + private val featureToggleManager: FeatureToggleManager ) : InAppProcessingManager { companion object { @@ -35,12 +39,16 @@ internal class InAppProcessingManagerImpl( "CheckCustomerSegments requires customer" } + private fun isTagsFeatureEnabled(): Boolean = + featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) + override suspend fun chooseInAppToShow( inApps: List, triggerEvent: InAppEventType, ): InApp? { for (inApp in inApps) { val data = getTargetingData(triggerEvent) + val tags = inApp.gatedTags(isTagsFeatureEnabled()) var isTargetingErrorOccurred = false var isInAppContentFetched: Boolean? = null var targetingCheck = false @@ -105,7 +113,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.sendFailure( inAppId = inApp.id, failureReason = FailureReason.UNKNOWN_ERROR, - errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}" + errorDetails = "Unknown exception when checking target ${throwable.message}. ${throwable.cause?.getVolleyErrorDetails() ?: "volleyError=null"}", + tags = tags ) throw throwable } @@ -125,13 +134,14 @@ internal class InAppProcessingManagerImpl( } mindboxLogD("loading and targeting fetching finished") if (isTargetingErrorOccurred) return chooseInAppToShow(inApps, triggerEvent) - trackTargetingErrorIfAny(inApp, data) + trackTargetingErrorIfAny(inApp, data, tags) if (isInAppContentFetched == false && targetingCheck) { imageFetchError?.takeIf { it.shouldTrackImageDownloadError() }?.let { error -> inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, - errorDetails = (error.message ?: "Image loading error") + "\n Url is ${inApp.form.variants.first().getImageUrl()}" + errorDetails = (error.message ?: "Image loading error") + "\n Url is ${inApp.form.variants.first().getImageUrl()}", + tags = tags ) } } @@ -184,7 +194,10 @@ internal class InAppProcessingManagerImpl( if (isTargetingErrorOccurred) return sendTargetedInApp(inApp, triggerEvent) if (inApp.targeting.checkTargeting(data)) { logI("InApp with id = ${inApp.id} sends targeting by event $triggerEvent") - inAppRepository.sendUserTargeted(inAppId = inApp.id) + inAppRepository.sendUserTargeted( + inAppId = inApp.id, + tags = inApp.gatedTags(isTagsFeatureEnabled()), + ) } } @@ -210,7 +223,7 @@ internal class InAppProcessingManagerImpl( mindboxLogW("Error fetching customer segmentations", error) } - private fun trackTargetingErrorIfAny(inApp: InApp, data: TargetingData) { + private fun trackTargetingErrorIfAny(inApp: InApp, data: TargetingData, tags: Map?) { when { inApp.targeting.hasSegmentationNode() && inAppSegmentationRepository.getCustomerSegmentationFetched() == CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR -> { @@ -219,7 +232,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } return @@ -232,7 +246,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.GEO_TARGETING_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } return @@ -246,7 +261,8 @@ internal class InAppProcessingManagerImpl( inAppFailureTracker.collectFailure( inAppId = inApp.id, failureReason = FailureReason.PRODUCT_SEGMENT_REQUEST_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt index 2f15015d2..e2f5453eb 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/extensions/TrackingFailureExtension.kt @@ -82,7 +82,8 @@ private fun parseOperationBody(operationBody: String?): Pair? = internal fun InAppFailureTracker.sendPresentationFailure( inAppId: String, errorDescription: String, - throwable: Throwable? = null + throwable: Throwable? = null, + tags: Map? = null ) { val errorDetails = when { throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" @@ -92,7 +93,8 @@ internal fun InAppFailureTracker.sendPresentationFailure( sendFailure( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } @@ -100,7 +102,8 @@ internal fun InAppFailureTracker.sendFailureWithContext( inAppId: String, failureReason: FailureReason, errorDescription: String, - throwable: Throwable? = null + throwable: Throwable? = null, + tags: Map? = null ) { val errorDetails = when { throwable != null -> "$errorDescription: ${throwable.message ?: "Unknown error"}" @@ -110,7 +113,8 @@ internal fun InAppFailureTracker.sendFailureWithContext( sendFailure( inAppId = inAppId, failureReason = failureReason, - errorDetails = errorDetails + errorDetails = errorDetails, + tags = tags ) } @@ -118,6 +122,7 @@ internal inline fun InAppFailureTracker.executeWithFailureTracking( inAppId: String, failureReason: FailureReason, errorDescription: String, + tags: Map? = null, crossinline onFailure: () -> Unit = {}, block: () -> T ): Result { @@ -126,7 +131,8 @@ internal inline fun InAppFailureTracker.executeWithFailureTracking( inAppId = inAppId, failureReason = failureReason, errorDescription = errorDescription, - throwable = throwable + throwable = throwable, + tags = tags ) onFailure() } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt index 44d940eb6..c755790af 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/interactors/InAppInteractor.kt @@ -19,7 +19,7 @@ internal interface InAppInteractor { tags: Map? ) - fun sendInAppClicked(inAppId: String) + fun sendInAppClicked(inAppId: String, tags: Map?) suspend fun fetchMobileConfig() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt index 9c4f4e268..7d53d8dab 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppFailureTracker.kt @@ -7,13 +7,15 @@ internal interface InAppFailureTracker { fun sendFailure( inAppId: String, failureReason: FailureReason, - errorDetails: String? + errorDetails: String?, + tags: Map? = null ) fun collectFailure( inAppId: String, failureReason: FailureReason, - errorDetails: String? + errorDetails: String?, + tags: Map? = null ) fun sendCollectedFailures() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt index e01acce06..d10e48bcf 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppSerializationManager.kt @@ -10,7 +10,9 @@ internal interface InAppSerializationManager { fun serializeToInAppShownActionString(inAppId: String, timeToDisplay: String, tags: Map?): String - fun serializeToInAppActionString(inAppId: String): String + fun serializeToInAppTargetingString(inAppId: String, tags: Map?): String + + fun serializeToInAppClickActionString(inAppId: String, tags: Map?): String fun serializeToInAppShowFailuresString(inAppShowFailures: List): String diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt index eba668ef2..b40c0101d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/repositories/InAppRepository.kt @@ -31,9 +31,9 @@ internal interface InAppRepository { fun sendInAppShown(inAppId: String, timeToDisplay: String, tags: Map?) - fun sendInAppClicked(inAppId: String) + fun sendInAppClicked(inAppId: String, tags: Map?) - fun sendUserTargeted(inAppId: String) + fun sendUserTargeted(inAppId: String, tags: Map?) fun sendInAppShowFailure(failures: List) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt index f520c7ab9..8ad560b21 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppTypeWrapper.kt @@ -6,6 +6,7 @@ internal data class InAppTypeWrapper( val inAppType: T, val inAppActionCallbacks: InAppActionCallbacks, val onRenderStart: () -> Unit, + val tags: Map? = null, ) internal fun interface OnInAppClick { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt index f8ee03ee6..9d5698870 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerImpl.kt @@ -3,8 +3,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.app.Activity import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.Mindbox +import cloud.mindbox.mobile_sdk.gatedTags +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.inapp.domain.models.OnInAppClick @@ -34,7 +37,8 @@ internal class InAppMessageManagerImpl( private val sessionStorageManager: SessionStorageManager, private val userVisitManager: UserVisitManager, private val inAppMessageDelayedManager: InAppMessageDelayedManager, - private val timeProvider: TimeProvider + private val timeProvider: TimeProvider, + private val featureToggleManager: FeatureToggleManager, ) : InAppMessageManager { init { @@ -93,14 +97,15 @@ internal class InAppMessageManagerImpl( } var renderStartTime = Timestamp(0L) - val tags = inApp.tags?.takeIf { it.isNotEmpty() } + val tags = inApp.gatedTags(featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE)) inAppMessageViewDisplayer.tryShowInAppMessage( inAppType = inAppMessage, onRenderStart = { renderStartTime = timeProvider.currentTimestamp() }, + tags = tags, inAppActionCallbacks = object : InAppActionCallbacks { override val onInAppClick = OnInAppClick { - inAppInteractor.sendInAppClicked(inAppMessage.inAppId) + inAppInteractor.sendInAppClicked(inAppMessage.inAppId, tags) } override val onInAppShown = OnInAppShown { handleInAppShown(renderStartTime, preparedTimeMs, inAppMessage, tags) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt index 6caf48ff8..52b089e5c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayer.kt @@ -16,6 +16,7 @@ internal interface InAppMessageViewDisplayer { inAppType: InAppType, inAppActionCallbacks: InAppActionCallbacks, onRenderStart: () -> Unit = {}, + tags: Map? = null, ) fun registerCurrentActivity(activity: Activity) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt index 3a978bea4..5d61e57ec 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImpl.kt @@ -137,8 +137,9 @@ internal class InAppMessageViewDisplayerImpl( inAppType: InAppType, inAppActionCallbacks: InAppActionCallbacks, onRenderStart: () -> Unit, + tags: Map?, ) { - val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks, onRenderStart) + val wrapper = InAppTypeWrapper(inAppType, inAppActionCallbacks, onRenderStart, tags) if (isUiPresent() && currentHolder == null && pausedHolder == null) { val duration = Stopwatch.track(Stopwatch.INIT_SDK) @@ -208,6 +209,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying draw inapp", + tags = wrapper.tags, onFailure = ::closeInApp ) { currentHolder?.show(createMindboxView(root)) @@ -215,7 +217,8 @@ internal class InAppMessageViewDisplayerImpl( } ?: run { inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, - errorDescription = "currentRoot is null" + errorDescription = "currentRoot is null", + tags = wrapper.tags ) } } @@ -226,10 +229,12 @@ internal class InAppMessageViewDisplayerImpl( ?: return false currentHolder = restoredHolder pausedHolder = null + val restoredTags = restoredHolder.wrapper.tags val root: ViewGroup = currentActivity?.root ?: run { inAppFailureTracker.sendPresentationFailure( inAppId = inAppId, - errorDescription = "failed to reattach inApp: currentRoot is null" + errorDescription = "failed to reattach inApp: currentRoot is null", + tags = restoredTags ) return true } @@ -237,6 +242,7 @@ internal class InAppMessageViewDisplayerImpl( inAppId = inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying reattach InApp", + tags = restoredTags, onFailure = ::closeInApp, ) { restoredHolder.reattach(createMindboxView(root)) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt index fa5a37b8c..f4565da49 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/AbstractInAppViewHolder.kt @@ -152,14 +152,16 @@ internal abstract class AbstractInAppViewHolder( inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, errorDescription = "Failed to load in-app image with url = $url", - throwable = e + throwable = e, + tags = wrapper.tags ) inAppController.close() }.onFailure { throwable -> inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, errorDescription = "Unknown error in onLoadFailed callback for url = $url", - throwable = throwable + throwable = throwable, + tags = wrapper.tags ) } return false @@ -184,7 +186,8 @@ internal abstract class AbstractInAppViewHolder( inAppFailureTracker.sendPresentationFailure( inAppId = wrapper.inAppType.inAppId, errorDescription = "Unknown error in onResourceReady callback for url = $url", - throwable = throwable + throwable = throwable, + tags = wrapper.tags ) } return false diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt index 9542b7c9a..31cdb7145 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewInappViewHolder.kt @@ -97,7 +97,7 @@ internal class WebViewInAppViewHolder( private val mindboxNotificationManager: MindboxNotificationManager by mindboxInject { mindboxNotificationManager } private val appContext: Application by mindboxInject { appContext } private val operationExecutor: WebViewOperationExecutor by lazy { - MindboxWebViewOperationExecutor() + MindboxWebViewOperationExecutor(gson) } private val linkRouter: WebViewLinkRouter by lazy { MindboxWebViewLinkRouter(appContext) @@ -351,7 +351,7 @@ internal class WebViewInAppViewHolder( } private fun handleAsyncOperationAction(message: BridgeMessage.Request): String { - operationExecutor.executeAsyncOperation(appContext, message.payload) + operationExecutor.executeAsyncOperation(appContext, message.payload, wrapper.tags) return BridgeMessage.EMPTY_PAYLOAD } @@ -364,7 +364,7 @@ internal class WebViewInAppViewHolder( } private suspend fun handleSyncOperationAction(message: BridgeMessage.Request): String { - return operationExecutor.executeSyncOperation(message.payload) + return operationExecutor.executeSyncOperation(message.payload, wrapper.tags) } private fun handleLocalStateGetAction(message: BridgeMessage.Request): String { @@ -439,7 +439,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}" + errorDescription = "WebView error: code=${error.code}, description=${error.description}, url=${error.url}", + tags = wrapper.tags ) inAppController.close() } @@ -521,7 +522,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "evaluateJavaScript return unexpected response: $response" + errorDescription = "evaluateJavaScript return unexpected response: $response", + tags = wrapper.tags ) inAppController.close() false @@ -646,7 +648,8 @@ internal class WebViewInAppViewHolder( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_LOAD_FAILED, errorDescription = "Failed to fetch HTML content for In-App", - throwable = e + throwable = e, + tags = wrapper.tags ) controller.executeOnViewThread { inAppController.close() } } @@ -654,7 +657,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_LOAD_FAILED, - errorDescription = "WebView content URL is null" + errorDescription = "WebView content URL is null", + tags = wrapper.tags ) controller.executeOnViewThread { inAppController.close() } } @@ -666,6 +670,7 @@ internal class WebViewInAppViewHolder( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.PRESENTATION_FAILED, errorDescription = "Error when trying WebView layout", + tags = wrapper.tags, ) { val view: WebViewPlatformView = controller.view if (view.parent !== inAppLayout) { @@ -677,7 +682,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_PRESENTATION_FAILED, - errorDescription = "WebView controller is null when trying show inapp" + errorDescription = "WebView controller is null when trying show inapp", + tags = wrapper.tags ) inAppController.close() } @@ -692,7 +698,8 @@ internal class WebViewInAppViewHolder( inAppFailureTracker.sendFailureWithContext( inAppId = wrapper.inAppType.inAppId, failureReason = FailureReason.WEBVIEW_LOAD_FAILED, - errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}." + errorDescription = "WebView initialization timed out after ${Stopwatch.stop(TIMER)}.", + tags = wrapper.tags ) controller.executeOnViewThread { inAppController.close() diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt index 80812e9f1..a7f9f54f7 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutor.kt @@ -1,8 +1,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application +import cloud.mindbox.mobile_sdk.logger.mindboxLogW import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.Gson +import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser import kotlinx.coroutines.suspendCancellableCoroutine @@ -11,20 +14,23 @@ import kotlin.coroutines.resumeWithException internal interface WebViewOperationExecutor { - fun executeAsyncOperation(context: Application, payload: String?) + fun executeAsyncOperation(context: Application, payload: String?, tags: Map?) - suspend fun executeSyncOperation(payload: String?): String + suspend fun executeSyncOperation(payload: String?, tags: Map?): String } -internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { +internal class MindboxWebViewOperationExecutor( + private val gson: Gson, +) : WebViewOperationExecutor { companion object { private const val OPERATION_FIELD = "operation" private const val BODY_FIELD = "body" + private const val TAGS_FIELD = "tags" } - override fun executeAsyncOperation(context: Application, payload: String?) { - val (operation, body) = parseOperationRequest(payload) + override fun executeAsyncOperation(context: Application, payload: String?, tags: Map?) { + val (operation, body) = parseOperationRequest(payload, tags) MindboxEventManager.asyncOperation( context = context, name = operation, @@ -32,8 +38,8 @@ internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { ) } - override suspend fun executeSyncOperation(payload: String?): String { - val (operation, body) = parseOperationRequest(payload) + override suspend fun executeSyncOperation(payload: String?, tags: Map?): String { + val (operation, body) = parseOperationRequest(payload, tags) return suspendCancellableCoroutine { continuation -> MindboxEventManager.syncOperation( name = operation, @@ -54,12 +60,51 @@ internal class MindboxWebViewOperationExecutor : WebViewOperationExecutor { } } - private fun parseOperationRequest(payload: String?): Pair { - val jsonObject: JsonObject = JsonParser.parseString(payload).asJsonObject + private fun parseOperationRequest(payload: String?, tags: Map?): Pair { + payload ?: throw IllegalArgumentException("Payload is not provided") + val jsonObject: JsonObject = runCatching { JsonParser.parseString(payload).asJsonObject } + .getOrElse { throw IllegalArgumentException("Payload is not a valid JSON object", it) } val operation: String = jsonObject.getAsJsonPrimitive(OPERATION_FIELD)?.asString ?: throw IllegalArgumentException("Operation is not provided") - val body: String = jsonObject.getAsJsonObject(BODY_FIELD)?.toString() + val bodyObject: JsonObject = jsonObject.getAsJsonObject(BODY_FIELD) ?: throw IllegalArgumentException("Body is not provided") - return operation to body + return operation to buildOperationBody(bodyObject, tags) + } + + private fun buildOperationBody(bodyObject: JsonObject, tags: Map?): String { + if (!tags.isNullOrEmpty()) { + mergeTags(bodyObject, tags) + } + return bodyObject.toString() + } + + private fun mergeTags(bodyObject: JsonObject, tags: Map) { + val existingTags: JsonElement? = bodyObject.get(TAGS_FIELD) + when { + existingTags == null || existingTags.isJsonNull -> { + bodyObject.add(TAGS_FIELD, gson.toJsonTree(tags)) + } + + existingTags.isJsonObject -> { + val tagsObject: JsonObject = existingTags.asJsonObject + tags.forEach { (key: String, value: String) -> + if (tagsObject.has(key)) { + mindboxLogW( + "WebView operation body `tags` already contains key `$key`; " + + "keeping client value, skipping in-app value" + ) + } else { + tagsObject.addProperty(key, value) + } + } + } + + else -> { + mindboxLogW( + "WebView operation body `tags` is not a JSON object; " + + "keeping client value, skipping in-app tags" + ) + } + } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt deleted file mode 100644 index b8256c07a..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package cloud.mindbox.mobile_sdk.models.operation.request - -import com.google.gson.annotations.SerializedName - -internal data class InAppHandleRequest( - @SerializedName("inappId") - val inAppId: String -) - -internal data class InAppShowRequest( - @SerializedName("inappId") - val inAppId: String, - @SerializedName("timeToDisplay") - val timeToDisplay: String, - @SerializedName("tags") - val tags: Map? -) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt similarity index 65% rename from sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt rename to sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt index c3a0b4fe4..b1ea0ea8c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppShowFailure.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppOperationRequests.kt @@ -2,6 +2,29 @@ package cloud.mindbox.mobile_sdk.models.operation.request import com.google.gson.annotations.SerializedName +internal data class InAppShowRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("timeToDisplay") + val timeToDisplay: String, + @SerializedName("tags") + val tags: Map? +) + +internal data class InAppClickRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("tags") + val tags: Map? +) + +internal data class InAppTargetingRequest( + @SerializedName("inappId") + val inAppId: String, + @SerializedName("tags") + val tags: Map? = null +) + internal data class InAppShowFailure( @SerializedName("inappId") val inAppId: String, @@ -10,7 +33,9 @@ internal data class InAppShowFailure( @SerializedName("errorDetails") val errorDetails: String?, @SerializedName("dateTimeUtc") - val dateTimeUtc: String + val dateTimeUtc: String, + @SerializedName("tags") + val tags: Map? = null ) internal enum class FailureReason(val value: String) { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt index ddded4e97..63432761d 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/ExtensionsKtTest.kt @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk +import cloud.mindbox.mobile_sdk.models.InAppStub import org.junit.Assert.* import org.junit.Test @@ -45,4 +46,29 @@ class ExtensionsKtTest { assertFalse((null as String?).equalsAny("")) assertFalse((null as String?).equalsAny("null")) } + + @Test + fun `gatedTags returns tags when present and feature enabled`() { + val tags = mapOf("templateType" to "Popup") + val inApp = InAppStub.getInApp().copy(tags = tags) + assertEquals(tags, inApp.gatedTags(isTagsFeatureEnabled = true)) + } + + @Test + fun `gatedTags returns null when feature disabled`() { + val inApp = InAppStub.getInApp().copy(tags = mapOf("templateType" to "Popup")) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = false)) + } + + @Test + fun `gatedTags returns null when tags empty`() { + val inApp = InAppStub.getInApp().copy(tags = emptyMap()) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = true)) + } + + @Test + fun `gatedTags returns null when tags null`() { + val inApp = InAppStub.getInApp().copy(tags = null) + assertNull(inApp.gatedTags(isTagsFeatureEnabled = true)) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt index 3638f2e16..6a062ed24 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppFailureTrackerImplTest.kt @@ -204,6 +204,48 @@ internal class InAppFailureTrackerImplTest { verify(exactly = 0) { inAppRepository.sendInAppShowFailure(any()) } } + @Test + fun `sendFailure forwards tags to the failure`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val tags = mapOf("templateType" to "Popup") + val slot = slot>() + + inAppFailureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "error", + tags = tags + ) + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + assertEquals(tags, slot.captured[0].tags) + } + + @Test + fun `collectFailure keeps each failures own tags`() { + every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true + val slot = slot>() + + inAppFailureTracker.collectFailure( + inAppId = "inApp1", + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = null, + tags = mapOf("templateType" to "Popup") + ) + inAppFailureTracker.collectFailure( + inAppId = "inApp2", + failureReason = FailureReason.IMAGE_DOWNLOAD_FAILED, + errorDetails = null, + tags = null + ) + inAppFailureTracker.sendCollectedFailures() + + verify(exactly = 1) { inAppRepository.sendInAppShowFailure(capture(slot)) } + val captured = slot.captured + assertEquals(mapOf("templateType" to "Popup"), captured.first { it.inAppId == "inApp1" }.tags) + assertEquals(null, captured.first { it.inAppId == "inApp2" }.tags) + } + @Test fun `sendFailure with null errorDetails`() { every { featureToggleManager.isEnabled(SEND_INAPP_SHOW_ERROR_FEATURE) } returns true diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt index 71e300061..f690d7f9b 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/managers/InAppSerializationManagerTest.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.managers import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppFailuresWrapper import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason +import cloud.mindbox.mobile_sdk.models.operation.request.InAppClickRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowRequest import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure import com.google.gson.Gson @@ -71,18 +72,53 @@ internal class InAppSerializationManagerTest { } @Test - fun `serializeToInAppActionString returns JSON with inAppId only`() { + fun `serializeToInAppTargetingString returns JSON with inAppId only when tags null`() { val expectedResult = "{\"inappId\":\"${inAppId}\"}" - val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) + val actualResult = inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags = null) assertEquals(expectedResult, actualResult) } @Test - fun `serializeToInAppActionString returns empty string on error`() { + fun `serializeToInAppTargetingString returns JSON with inAppId and tags when tags present`() { + val expectedResult = "{\"inappId\":\"${inAppId}\",\"tags\":{\"templateType\":\"Popup\"}}" + val actualResult = inAppSerializationManager.serializeToInAppTargetingString( + inAppId, + tags = mapOf("templateType" to "Popup"), + ) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `serializeToInAppTargetingString returns empty string on error`() { + val gson: Gson = mockk() + every { gson.toJson(any(), any>()) } throws Error("errorMessage") + inAppSerializationManager = InAppSerializationManagerImpl(gson) + val actualResult = inAppSerializationManager.serializeToInAppTargetingString(inAppId, tags = null) + assertEquals("", actualResult) + } + + @Test + fun `serializeToInAppClickActionString returns JSON with inAppId and tags`() { + val tags = mapOf("templateType" to "Popup") + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, tags) + val parsed = gson.fromJson(actualResult, InAppClickRequest::class.java) + assertEquals(inAppId, parsed.inAppId) + assertEquals(tags, parsed.tags) + } + + @Test + fun `serializeToInAppClickActionString omits tags when null`() { + val expectedResult = "{\"inappId\":\"${inAppId}\"}" + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, null) + assertEquals(expectedResult, actualResult) + } + + @Test + fun `serializeToInAppClickActionString returns empty string on error`() { val gson: Gson = mockk() every { gson.toJson(any(), any>()) } throws Error("errorMessage") inAppSerializationManager = InAppSerializationManagerImpl(gson) - val actualResult = inAppSerializationManager.serializeToInAppActionString(inAppId) + val actualResult = inAppSerializationManager.serializeToInAppClickActionString(inAppId, null) assertEquals("", actualResult) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt index df681930d..25569b02e 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppRepositoryTest.kt @@ -147,9 +147,10 @@ class InAppRepositoryTest { @Test fun `send in app clicked success`() { val testInAppId = "testInAppId" + val tags = mapOf("templateType" to "Popup") val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppClickActionString(testInAppId, tags) } returns serializedString + inAppRepository.sendInAppClicked(testInAppId, tags) verify(exactly = 1) { MindboxEventManager.inAppClicked(context, serializedString) } @@ -159,8 +160,8 @@ class InAppRepositoryTest { fun `send in app clicked empty string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendInAppClicked(testInAppId) + every { inAppSerializationManager.serializeToInAppClickActionString(any(), any()) } returns serializedString + inAppRepository.sendInAppClicked(testInAppId, null) verify(exactly = 0) { MindboxEventManager.inAppClicked(context, serializedString) } @@ -169,9 +170,10 @@ class InAppRepositoryTest { @Test fun `send user targeted success`() { val testInAppId = "testInAppId" + val tags = mapOf("templateType" to "Popup") val serializedString = "serializedString" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendUserTargeted(testInAppId) + every { inAppSerializationManager.serializeToInAppTargetingString(testInAppId, tags) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId, tags) verify(exactly = 1) { MindboxEventManager.sendUserTargeted(context, serializedString) } @@ -181,8 +183,8 @@ class InAppRepositoryTest { fun `send user targeted string`() { val testInAppId = "testInAppId" val serializedString = "" - every { inAppSerializationManager.serializeToInAppActionString(any()) } returns serializedString - inAppRepository.sendUserTargeted(testInAppId) + every { inAppSerializationManager.serializeToInAppTargetingString(any(), any()) } returns serializedString + inAppRepository.sendUserTargeted(testInAppId, null) verify(exactly = 0) { MindboxEventManager.sendUserTargeted(context, serializedString) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt index 6f7dd52de..a78a231a7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppInteractorImplTest.kt @@ -7,6 +7,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.checkers.Checker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppEventManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFilteringManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFrequencyManager @@ -91,6 +92,9 @@ class InAppInteractorImplTest { @RelaxedMockK private lateinit var inAppFailureTracker: InAppFailureTracker + @RelaxedMockK + private lateinit var featureToggleManager: FeatureToggleManager + @Before fun setup() { interactor = InAppInteractorImpl( @@ -178,7 +182,8 @@ class InAppInteractorImplTest { inAppTargetingErrorRepository, inAppContentFetcher, inAppRepository, - inAppFailureTracker + inAppFailureTracker, + featureToggleManager ) interactor = InAppInteractorImpl( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt index 03407e358..61e00249e 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/InAppProcessingManagerTest.kt @@ -2,12 +2,14 @@ package cloud.mindbox.mobile_sdk.inapp.domain import android.content.Context import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppGeoRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppSegmentationRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.data.repositories.InAppTargetingErrorRepositoryImpl import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository @@ -44,7 +46,7 @@ internal class InAppProcessingManagerTest { private val mockInAppRepository = mockk { every { - sendUserTargeted(any()) + sendUserTargeted(any(), any()) } just runs every { saveTargetedInAppWithEvent(any(), any()) @@ -85,6 +87,7 @@ internal class InAppProcessingManagerTest { private val geoSerializationManager: GeoSerializationManager = mockk(relaxed = true) private val gatewayManager: GatewayManager = mockk(relaxed = true) private val inAppFailureTracker: InAppFailureTracker = mockk(relaxed = true) + private val featureToggleManager: FeatureToggleManager = mockk(relaxed = true) private val inAppTargetingErrorRepository: InAppTargetingErrorRepository = spyk(InAppTargetingErrorRepositoryImpl(sessionStorageManager)) @@ -137,7 +140,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) private val inAppProcessingManagerTestImpl = InAppProcessingManagerImpl( @@ -146,7 +150,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = inAppTargetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = inAppFailureTracker + inAppFailureTracker = inAppFailureTracker, + featureToggleManager = featureToggleManager ) private fun setupTestGeoRepositoryForErrorScenario() { @@ -177,7 +182,7 @@ internal class InAppProcessingManagerTest { InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") ) verify(exactly = 0) { - mockInAppRepository.sendUserTargeted(any()) + mockInAppRepository.sendUserTargeted(any(), any()) } } @@ -206,7 +211,42 @@ internal class InAppProcessingManagerTest { InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") ) verify(exactly = 2) { - mockInAppRepository.sendUserTargeted(any()) + mockInAppRepository.sendUserTargeted(any(), any()) + } + } + + @Test + fun `sendTargetedInApp passes inApp tags when tags feature enabled`() = runTest { + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + val testInApp = mockk(relaxed = true) + every { testInApp.id } returns "123" + every { testInApp.tags } returns tags + every { testInApp.targeting.checkTargeting(any()) } returns true + coEvery { testInApp.targeting.fetchTargetingInfo(any()) } just runs + inAppProcessingManager.sendTargetedInApp( + testInApp, + InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") + ) + verify(exactly = 1) { + mockInAppRepository.sendUserTargeted(inAppId = "123", tags = tags) + } + } + + @Test + fun `sendTargetedInApp passes null tags when tags feature disabled`() = runTest { + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + val testInApp = mockk(relaxed = true) + every { testInApp.id } returns "123" + every { testInApp.tags } returns mapOf("templateType" to "Popup") + every { testInApp.targeting.checkTargeting(any()) } returns true + coEvery { testInApp.targeting.fetchTargetingInfo(any()) } just runs + inAppProcessingManager.sendTargetedInApp( + testInApp, + InAppEventType.OrdinalEvent(EventType.AsyncOperation(""), "") + ) + verify(exactly = 1) { + mockInAppRepository.sendUserTargeted(inAppId = "123", tags = null) } } @@ -431,7 +471,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = mockk(relaxed = true), inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = mockk(relaxed = true) + inAppFailureTracker = mockk(relaxed = true), + featureToggleManager = featureToggleManager ) val expectedResult = InAppStub.getInApp().copy( @@ -467,7 +508,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionStorageManager.geoFetchStatus) } @@ -485,7 +526,7 @@ internal class InAppProcessingManagerTest { ) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.customerSegmentationFetchStatus) } @@ -505,7 +546,7 @@ internal class InAppProcessingManagerTest { ) ) inAppProcessingManager.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -517,7 +558,7 @@ internal class InAppProcessingManagerTest { targeting = InAppStub.getTargetingCountryNode().copy(kind = Kind.NEGATIVE) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionStorageManager.geoFetchStatus) } @@ -529,7 +570,7 @@ internal class InAppProcessingManagerTest { targeting = InAppStub.getTargetingSegmentNode().copy(kind = Kind.NEGATIVE) ) inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.customerSegmentationFetchStatus) } @@ -543,7 +584,7 @@ internal class InAppProcessingManagerTest { } ) inAppProcessingManager.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -561,7 +602,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -584,7 +625,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 1) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -604,7 +645,7 @@ internal class InAppProcessingManagerTest { inAppProcessingManagerTestImpl.sendTargetedInApp(testInApp, InAppEventType.AppStartup) - verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any()) } + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } } @Test @@ -653,7 +694,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -735,7 +777,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val result = processingManager.chooseInAppToShow(testInAppList, event) @@ -780,7 +823,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -817,6 +861,140 @@ internal class InAppProcessingManagerTest { } } + @Test + fun `collected failure carries inApp tags when tags feature enabled`() = runTest { + val errorDetails = "Customer segmentation fetch failed. statusCode=500" + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + tags = tags, + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + processingManager.chooseInAppToShow(testInAppList, event) + + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails, + tags = tags + ) + } + } + + @Test + fun `collected failure carries null tags when tags feature disabled`() = runTest { + val errorDetails = "Customer segmentation fetch failed. statusCode=500" + val tags = mapOf("templateType" to "Popup") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + val segmentationRepo = mockk { + coEvery { fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError()) + every { getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + every { setCustomerSegmentationStatus(any()) } just runs + every { getCustomerSegmentations() } returns listOf( + SegmentationCheckInAppStub.getCustomerSegmentation().copy( + segmentation = "segmentationEI", segment = "segmentEI" + ) + ) + every { getProductSegmentationFetched(any()) } returns ProductSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + } + val targetingErrorRepository = mockk { + every { getError(TargetingErrorKey.CustomerSegmentation) } returns errorDetails + every { saveError(any(), any()) } just runs + every { clearErrors() } just runs + } + setDIModule(mockkInAppGeoRepository, segmentationRepo, targetingErrorRepository) + val failureTracker = mockk(relaxed = true) + val processingManager = InAppProcessingManagerImpl( + inAppGeoRepository = mockkInAppGeoRepository, + inAppSegmentationRepository = segmentationRepo, + inAppTargetingErrorRepository = targetingErrorRepository, + inAppContentFetcher = mockkInAppContentFetcher, + inAppRepository = mockInAppRepository, + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager + ) + val testInAppList = listOf( + InAppStub.getInApp().copy( + id = "123", + tags = tags, + targeting = InAppStub.getTargetingSegmentNode().copy( + type = "", + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "123")) + ) + ), + InAppStub.getInApp().copy( + id = "validId", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy( + variants = listOf(InAppStub.getModalWindow().copy(inAppId = "validId")) + ) + ) + ) + + processingManager.chooseInAppToShow(testInAppList, event) + + verify(exactly = 1) { + failureTracker.collectFailure( + inAppId = "123", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = errorDetails, + tags = null + ) + } + } + @Test fun `trackTargetingErrorIfAny does not collect customer segmentation failure when error was not saved`() = runTest { val segmentationRepo = mockk { @@ -843,7 +1021,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -903,7 +1082,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( @@ -980,7 +1160,8 @@ internal class InAppProcessingManagerTest { inAppTargetingErrorRepository = targetingErrorRepository, inAppContentFetcher = mockkInAppContentFetcher, inAppRepository = mockInAppRepository, - inAppFailureTracker = failureTracker + inAppFailureTracker = failureTracker, + featureToggleManager = featureToggleManager ) val testInAppList = listOf( InAppStub.getInApp().copy( diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt index c0e33f37f..7ef0c8017 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageManagerTest.kt @@ -1,8 +1,11 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import android.util.Log +import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppActionCallbacks import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.InAppInteractor +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.FeatureToggleManager import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.managers.UserVisitManager @@ -59,6 +62,8 @@ internal class InAppMessageManagerTest { private val timeProvider = mockk() + private val featureToggleManager = mockk() + /** * sets a thread to be used as main dispatcher for running on JVM * **/ @@ -71,6 +76,7 @@ internal class InAppMessageManagerTest { coEvery { inAppMessageInteractor.listenToTargetingEvents() } just runs + every { featureToggleManager.isEnabled(any()) } returns true every { Log.isLoggable(any(), any()) }.answers { @@ -95,7 +101,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.fetchMobileConfig() @@ -118,7 +125,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkObject(LoggingExceptionHandler) every { MindboxPreferences.inAppConfig } returns "test" @@ -157,7 +165,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.processEventAndConfig() @@ -171,7 +180,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } - verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 1) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -188,7 +197,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -211,7 +221,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -228,7 +238,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.listenToTargetingEvents() @@ -251,7 +262,7 @@ internal class InAppMessageManagerTest { advanceUntilIdle() verify(exactly = 1) { inAppMessageDelayedManager.process(inApp, any()) } coVerify(exactly = 1) { inAppMessageInteractor.listenToTargetingEvents() } - verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any()) } + verify(exactly = 0) { inAppMessageViewDisplayer.tryShowInAppMessage(inApp.form.variants.first(), any(), any(), any()) } } @Test @@ -264,7 +275,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) coEvery { inAppMessageInteractor.processEventAndConfig() @@ -299,7 +311,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() @@ -334,7 +347,8 @@ internal class InAppMessageManagerTest { sessionStorageManager, userVisitManager, inAppMessageDelayedManager, - timeProvider + timeProvider, + featureToggleManager ) mockkConstructor(NetworkResponse::class) val networkResponse = mockk() @@ -368,4 +382,89 @@ internal class InAppMessageManagerTest { assertEquals(expectedInappList, resultInappList) } + + @Test + fun `tags feature on - tags passed to show and click`() = runTest { + val tags = mapOf("templateType" to "Popup") + val inAppToShowFlow = MutableSharedFlow>() + val inApp = InAppStub.getInApp().copy(tags = tags) + val inAppMessage = inApp.form.variants.first() + var capturedCallbacks: InAppActionCallbacks? = null + var capturedTags: Map? = null + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns true + every { inAppMessageViewDisplayer.isInAppActive() } returns false + every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true + every { inAppMessageInteractor.sendInAppClicked(any(), any()) } just runs + every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow + every { inAppMessageDelayedManager.process(inApp, any()) } coAnswers { + this@runTest.launch { inAppToShowFlow.emit(inApp to Milliseconds(0L)) } + } + every { inAppMessageViewDisplayer.tryShowInAppMessage(any(), any(), any(), any()) } answers { + capturedCallbacks = arg(1) + capturedTags = arg(3) + } + inAppMessageManager = InAppMessageManagerImpl( + inAppMessageViewDisplayer, + inAppMessageInteractor, + testDispatcher, + monitoringRepository, + sessionStorageManager, + userVisitManager, + inAppMessageDelayedManager, + timeProvider, + featureToggleManager + ) + coEvery { inAppMessageInteractor.processEventAndConfig() } answers { + flow { emit(inApp to Milliseconds(0L)) } + } + + inAppMessageManager.listenEventAndInApp() + advanceUntilIdle() + + assertEquals(tags, capturedTags) + capturedCallbacks!!.onInAppClick.onClick() + verify(exactly = 1) { inAppMessageInteractor.sendInAppClicked(inAppMessage.inAppId, tags) } + } + + @Test + fun `tags feature off - tags are null for show and click`() = runTest { + val inAppToShowFlow = MutableSharedFlow>() + val inApp = InAppStub.getInApp().copy(tags = mapOf("templateType" to "Popup")) + val inAppMessage = inApp.form.variants.first() + var capturedCallbacks: InAppActionCallbacks? = null + var capturedTags: Map? = mapOf("sentinel" to "value") + every { featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) } returns false + every { inAppMessageViewDisplayer.isInAppActive() } returns false + every { inAppMessageInteractor.areShowAndFrequencyLimitsAllowed(any()) } returns true + every { inAppMessageInteractor.sendInAppClicked(any(), any()) } just runs + every { inAppMessageDelayedManager.inAppToShowFlow } returns inAppToShowFlow + every { inAppMessageDelayedManager.process(inApp, any()) } coAnswers { + this@runTest.launch { inAppToShowFlow.emit(inApp to Milliseconds(0L)) } + } + every { inAppMessageViewDisplayer.tryShowInAppMessage(any(), any(), any(), any()) } answers { + capturedCallbacks = arg(1) + capturedTags = arg(3) + } + inAppMessageManager = InAppMessageManagerImpl( + inAppMessageViewDisplayer, + inAppMessageInteractor, + testDispatcher, + monitoringRepository, + sessionStorageManager, + userVisitManager, + inAppMessageDelayedManager, + timeProvider, + featureToggleManager + ) + coEvery { inAppMessageInteractor.processEventAndConfig() } answers { + flow { emit(inApp to Milliseconds(0L)) } + } + + inAppMessageManager.listenEventAndInApp() + advanceUntilIdle() + + assertEquals(null, capturedTags) + capturedCallbacks!!.onInAppClick.onClick() + verify(exactly = 1) { inAppMessageInteractor.sendInAppClicked(inAppMessage.inAppId, null) } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt index 6aa831908..bc294f907 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/InAppMessageViewDisplayerImplTest.kt @@ -1,12 +1,16 @@ package cloud.mindbox.mobile_sdk.inapp.presentation import cloud.mindbox.mobile_sdk.di.MindboxDI +import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.InAppFailureTracker import cloud.mindbox.mobile_sdk.inapp.presentation.callbacks.ComposableInAppCallback +import cloud.mindbox.mobile_sdk.inapp.presentation.view.InAppViewHolder +import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason import com.google.gson.Gson import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll +import io.mockk.verify import org.junit.After import org.junit.Assert.assertNotSame import org.junit.Assert.assertSame @@ -17,12 +21,15 @@ import org.junit.Test internal class InAppMessageViewDisplayerImplTest { private lateinit var displayer: InAppMessageViewDisplayerImpl + private lateinit var failureTracker: InAppFailureTracker @Before fun setUp() { mockkObject(MindboxDI) - every { MindboxDI.appModule } returns mockk { + failureTracker = mockk(relaxed = true) + every { MindboxDI.appModule } returns mockk(relaxed = true) { every { gson } returns Gson() + every { inAppFailureTracker } returns failureTracker } displayer = InAppMessageViewDisplayerImpl(mockk()) } @@ -84,10 +91,47 @@ internal class InAppMessageViewDisplayerImplTest { assertTrue(displayer.currentCallback() is ComposableInAppCallback) } + @Test + fun `reattach presentation failure propagates restored holder tags`() { + val expectedTags = mapOf("templateType" to "Popup", "campaign" to "summer") + val inAppId = "reattach-inapp-id" + + val restoredHolder = mockk>(relaxed = true) + every { restoredHolder.canReuseOnRestore(inAppId) } returns true + every { restoredHolder.wrapper.tags } returns expectedTags + + // pausedHolder present, currentActivity stays null -> root is null -> reattach failure branch + displayer.setPrivateField("pausedHolder", restoredHolder) + + val reattached = displayer.invokePrivateWithString("tryReattachRestoredInApp", inAppId) as Boolean + + assertTrue("reattach should be attempted for a reusable paused holder", reattached) + verify(exactly = 1) { + failureTracker.sendFailure( + inAppId = inAppId, + failureReason = FailureReason.PRESENTATION_FAILED, + errorDetails = "failed to reattach inApp: currentRoot is null", + tags = expectedTags, + ) + } + } + // Accesses the private inAppCallback field via reflection private fun InAppMessageViewDisplayerImpl.currentCallback(): InAppCallback { val field = InAppMessageViewDisplayerImpl::class.java.getDeclaredField("inAppCallback") field.isAccessible = true return field.get(this) as InAppCallback } + + private fun InAppMessageViewDisplayerImpl.setPrivateField(name: String, value: Any?) { + val field = InAppMessageViewDisplayerImpl::class.java.getDeclaredField(name) + field.isAccessible = true + field.set(this, value) + } + + private fun InAppMessageViewDisplayerImpl.invokePrivateWithString(name: String, arg: String): Any? { + val method = InAppMessageViewDisplayerImpl::class.java.getDeclaredMethod(name, String::class.java) + method.isAccessible = true + return method.invoke(this, arg) + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt index ee49e2892..4138f21ff 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/presentation/view/WebViewOperationExecutorTest.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.presentation.view import android.app.Application import cloud.mindbox.mobile_sdk.managers.MindboxEventManager import cloud.mindbox.mobile_sdk.models.MindboxError +import com.google.gson.Gson import io.mockk.* import kotlinx.coroutines.test.runTest import org.junit.After @@ -17,7 +18,7 @@ class WebViewOperationExecutorTest { @Before fun onTestStart() { - executor = MindboxWebViewOperationExecutor() + executor = MindboxWebViewOperationExecutor(Gson()) mockkObject(MindboxEventManager) } @@ -31,7 +32,7 @@ class WebViewOperationExecutorTest { val context: Application = mockk() val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) verify(exactly = 1) { MindboxEventManager.asyncOperation( context = context, @@ -41,12 +42,122 @@ class WebViewOperationExecutorTest { } } + @Test + fun `executeAsyncOperation adds top-level tags to body when tags present`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation does not add tags when tags empty`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = emptyMap()) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home"}""", + ) + } + } + + @Test + fun `executeAsyncOperation adds in-app tags when existing tags is json null`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":null}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation merges in-app tags into existing tags without collision`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":{"client":"own"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"client":"own","templateType":"Popup"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation keeps client value and skips in-app value on key collision`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"ClientOwn"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation merges only non-colliding keys when tags partially overlap`() { + val context: Application = mockk() + val payload: String = + """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn","keep":"x"}}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation( + context, + payload, + tags = mapOf("templateType" to "Popup", "campaign" to "summer"), + ) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":{"templateType":"ClientOwn","keep":"x","campaign":"summer"}}""", + ) + } + } + + @Test + fun `executeAsyncOperation keeps client tags untouched when existing tags is not an object`() { + val context: Application = mockk() + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home","tags":"raw"}}""" + every { MindboxEventManager.asyncOperation(any(), any(), any()) } returns Unit + executor.executeAsyncOperation(context, payload, tags = mapOf("templateType" to "Popup")) + verify(exactly = 1) { + MindboxEventManager.asyncOperation( + context = context, + name = "OpenScreen", + body = """{"screen":"home","tags":"raw"}""", + ) + } + } + @Test fun `executeAsyncOperation throws when payload misses operation`() { val context: Application = mockk() val payload: String = """{"body":{"screen":"home"}}""" try { - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Operation is not provided", exception.message) @@ -59,7 +170,7 @@ class WebViewOperationExecutorTest { val context: Application = mockk() val payload: String = """{"operation":"OpenScreen"}""" try { - executor.executeAsyncOperation(context, payload) + executor.executeAsyncOperation(context, payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Body is not provided", exception.message) @@ -68,15 +179,27 @@ class WebViewOperationExecutorTest { } @Test - fun `executeAsyncOperation throws when payload is invalid json empty or null`() { + fun `executeAsyncOperation throws IllegalArgumentException when payload is null`() { val context: Application = mockk() - val payloads: List = listOf("not-json", "", null) - payloads.forEach { payload: String? -> + try { + executor.executeAsyncOperation(context, payload = null, tags = null) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not provided", exception.message) + } + verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } + } + + @Test + fun `executeAsyncOperation throws IllegalArgumentException when payload is invalid json`() { + val context: Application = mockk() + val payloads: List = listOf("not-json", "") + payloads.forEach { payload: String -> try { - executor.executeAsyncOperation(context, payload) - fail("Expected exception for payload: $payload") - } catch (exception: Exception) { - // Expected: payload cannot be parsed to required JSON object. + executor.executeAsyncOperation(context, payload, tags = null) + fail("Expected IllegalArgumentException for payload: $payload") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not a valid JSON object", exception.message) } } verify(exactly = 0) { MindboxEventManager.asyncOperation(any(), any(), any()) } @@ -97,7 +220,7 @@ class WebViewOperationExecutorTest { val onSuccess: (String) -> Unit = arg(2) onSuccess(expectedResponse) } - val actualResponse: String = executor.executeSyncOperation(payload) + val actualResponse: String = executor.executeSyncOperation(payload, tags = null) assertEquals(expectedResponse, actualResponse) verify(exactly = 1) { MindboxEventManager.syncOperation( @@ -109,6 +232,64 @@ class WebViewOperationExecutorTest { } } + @Test + fun `executeSyncOperation adds top-level tags and still returns response`() = runTest { + val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" + val expectedResponse: String = """{"result":"ok"}""" + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onSuccess: (String) -> Unit = arg(2) + onSuccess(expectedResponse) + } + val actualResponse: String = executor.executeSyncOperation(payload, tags = mapOf("templateType" to "Popup")) + assertEquals(expectedResponse, actualResponse) + verify(exactly = 1) { + MindboxEventManager.syncOperation( + name = "OpenScreen", + bodyJson = """{"screen":"home","tags":{"templateType":"Popup"}}""", + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation merges tags keeping client value on collision and still returns response`() = runTest { + val payload: String = + """{"operation":"OpenScreen","body":{"screen":"home","tags":{"templateType":"ClientOwn"}}}""" + val expectedResponse: String = """{"result":"ok"}""" + every { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } answers { + val onSuccess: (String) -> Unit = arg(2) + onSuccess(expectedResponse) + } + val actualResponse: String = executor.executeSyncOperation( + payload, + tags = mapOf("templateType" to "Popup", "campaign" to "summer"), + ) + assertEquals(expectedResponse, actualResponse) + verify(exactly = 1) { + MindboxEventManager.syncOperation( + name = "OpenScreen", + bodyJson = """{"screen":"home","tags":{"templateType":"ClientOwn","campaign":"summer"}}""", + onSuccess = any(), + onError = any(), + ) + } + } + @Test fun `executeSyncOperation throws IllegalStateException when event manager returns error`() = runTest { val payload: String = """{"operation":"OpenScreen","body":{"screen":"home"}}""" @@ -125,7 +306,7 @@ class WebViewOperationExecutorTest { onError(expectedError) } try { - executor.executeSyncOperation(payload) + executor.executeSyncOperation(payload, tags = null) fail("Expected IllegalStateException") } catch (exception: IllegalStateException) { assertEquals(expectedError.toJson(), exception.message) @@ -136,7 +317,7 @@ class WebViewOperationExecutorTest { fun `executeSyncOperation throws when payload misses body`() = runTest { val payload: String = """{"operation":"OpenScreen"}""" try { - executor.executeSyncOperation(payload) + executor.executeSyncOperation(payload, tags = null) fail("Expected IllegalArgumentException") } catch (exception: IllegalArgumentException) { assertEquals("Body is not provided", exception.message) @@ -152,14 +333,32 @@ class WebViewOperationExecutorTest { } @Test - fun `executeSyncOperation throws when payload is invalid json empty or null`() = runTest { - val payloads: List = listOf("not-json", "", null) - payloads.forEach { payload: String? -> + fun `executeSyncOperation throws IllegalArgumentException when payload is null`() = runTest { + try { + executor.executeSyncOperation(payload = null, tags = null) + fail("Expected IllegalArgumentException") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not provided", exception.message) + } + verify(exactly = 0) { + MindboxEventManager.syncOperation( + name = any(), + bodyJson = any(), + onSuccess = any(), + onError = any(), + ) + } + } + + @Test + fun `executeSyncOperation throws IllegalArgumentException when payload is invalid json`() = runTest { + val payloads: List = listOf("not-json", "") + payloads.forEach { payload: String -> try { - executor.executeSyncOperation(payload) - fail("Expected exception for payload: $payload") - } catch (exception: Exception) { - // Expected: payload cannot be parsed to required JSON object. + executor.executeSyncOperation(payload, tags = null) + fail("Expected IllegalArgumentException for payload: $payload") + } catch (exception: IllegalArgumentException) { + assertEquals("Payload is not a valid JSON object", exception.message) } } verify(exactly = 0) { diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt deleted file mode 100644 index 0249c78ca..000000000 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/models/operation/request/InAppHandleRequestStub.kt +++ /dev/null @@ -1,7 +0,0 @@ -package cloud.mindbox.mobile_sdk.models.operation.request - -internal class InAppHandleRequestStub { - companion object { - fun get() = InAppHandleRequest(inAppId = "") - } -}