diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt index 0fe86ddc..6e76c05e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/embedded/EmbeddedBlockRevealAnimation.kt @@ -5,6 +5,7 @@ import android.animation.AnimatorListenerAdapter import android.animation.ValueAnimator import android.os.Build import android.view.View +import android.view.animation.PathInterpolator import cloud.mindbox.mobile_sdk.models.Milliseconds import cloud.mindbox.mobile_sdk.utils.Constants @@ -19,10 +20,13 @@ internal open class EmbeddedBlockRevealAnimation( val isEnabled: Boolean get() = areSystemAnimationsEnabled() + private val revealInterpolator = PathInterpolator(0.42f, 0f, 0.58f, 1f) + open fun crossfade(incoming: View, outgoing: View?, onEnd: () -> Unit): Animator = ValueAnimator.ofFloat(0f, 1f).apply { incoming.alpha = 0f this.duration = this@EmbeddedBlockRevealAnimation.duration.interval + interpolator = revealInterpolator addUpdateListener { animator -> val shown = animator.animatedValue as Float incoming.alpha = shown @@ -43,6 +47,7 @@ internal open class EmbeddedBlockRevealAnimation( open fun growHeight(view: View, targetHeight: Int, onEnd: () -> Unit): Animator = ValueAnimator.ofInt(0, targetHeight).apply { this.duration = this@EmbeddedBlockRevealAnimation.duration.interval + interpolator = revealInterpolator addUpdateListener { animator -> view.layoutParams?.let { params -> params.height = animator.animatedValue as Int diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt index 92861881..03e0610f 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryImpl.kt @@ -5,12 +5,16 @@ import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppGeoRepository +import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoError import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoFetchStatus import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoTargeting import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.repository.MindboxPreferences import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.Constants +import com.android.volley.TimeoutError +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -26,18 +30,23 @@ internal class InAppGeoRepositoryImpl( private val geoMutex = Mutex() override suspend fun fetchGeo() = geoMutex.withLock { - if (sessionStorageManager.state.geoFetchStatus == GeoFetchStatus.GEO_FETCH_SUCCESS) { + val state = sessionStorageManager.state + if (state.geoFetchStatus != GeoFetchStatus.GEO_NOT_FETCHED) { return@withLock } val configuration = DbManager.listenConfigurations().first() - val geoTargeting = inAppMapper.mapGeoTargetingDtoToGeoTargeting( - geoTargetingDto = gatewayManager.checkGeoTargeting( - configuration = configuration - ) - ) + val geoTargetingDto = try { + withTimeoutOrNull(Constants.targetingFetchWaitLimit.interval) { + gatewayManager.checkGeoTargeting(configuration = configuration) + } ?: throw GeoError(TimeoutError()) + } catch (error: GeoError) { + state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_ERROR + throw error + } + val geoTargeting = inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto = geoTargetingDto) MindboxPreferences.inAppGeo = geoSerializationManager.serializeToGeoString(geoTargeting) - sessionStorageManager.state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_SUCCESS + state.geoFetchStatus = GeoFetchStatus.GEO_FETCH_SUCCESS } override fun setGeoStatus(status: GeoFetchStatus) { diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt index e0d68abc..86009c3e 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryImpl.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.data.repositories import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository +import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationError import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationFetchStatus import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationInApp import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationFetchStatus @@ -11,6 +12,9 @@ import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.utils.LoggingExceptionHandler +import cloud.mindbox.mobile_sdk.utils.Constants +import com.android.volley.TimeoutError +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -24,18 +28,15 @@ internal class InAppSegmentationRepositoryImpl( private val customerSegmentationsMutex = Mutex() override suspend fun fetchCustomerSegmentations() = customerSegmentationsMutex.withLock { - if (sessionStorageManager.state.customerSegmentationFetchStatus == - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS - ) { + val state = sessionStorageManager.state + if (state.customerSegmentationFetchStatus != CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED) { return@withLock } - if (sessionStorageManager.state.currentSessionInApps.isEmpty()) { + if (state.currentSessionInApps.isEmpty()) { MindboxLoggerImpl.d( this, "No unshown inapps. Do not request segmentations" ) - sessionStorageManager.state.customerSegmentationFetchStatus = - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR return@withLock } MindboxLoggerImpl.d( @@ -43,16 +44,19 @@ internal class InAppSegmentationRepositoryImpl( "Request segmentations" ) val configuration = DbManager.listenConfigurations().first() - val response = gatewayManager.checkCustomerSegmentations( - configuration = configuration, - segmentationCheckRequest = inAppMapper.mapToCustomerSegmentationCheckRequest( - sessionStorageManager.state.currentSessionInApps - ) - ) - sessionStorageManager.state.inAppCustomerSegmentations = - inAppMapper.mapToSegmentationCheck(response) - sessionStorageManager.state.customerSegmentationFetchStatus = - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + val response = try { + withTimeoutOrNull(Constants.targetingFetchWaitLimit.interval) { + gatewayManager.checkCustomerSegmentations( + configuration = configuration, + segmentationCheckRequest = inAppMapper.mapToCustomerSegmentationCheckRequest(state.currentSessionInApps) + ) + } ?: throw CustomerSegmentationError(TimeoutError()) + } catch (error: CustomerSegmentationError) { + state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + throw error + } + state.inAppCustomerSegmentations = inAppMapper.mapToSegmentationCheck(response) + state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS return@withLock } 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 c72993fe..4db66038 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 @@ -21,8 +21,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppReposi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp +import cloud.mindbox.mobile_sdk.inapp.domain.models.Layer import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType -import cloud.mindbox.mobile_sdk.inapp.domain.models.TargetingDataWrapper import cloud.mindbox.mobile_sdk.logger.MindboxLog import cloud.mindbox.mobile_sdk.millisToTimeSpan import cloud.mindbox.mobile_sdk.models.Milliseconds @@ -54,9 +54,6 @@ internal class InAppInteractorImpl( private val inAppTargetingChannel = Channel(Channel.UNLIMITED) - private val placeRequestTargetingData = - TargetingDataWrapper(InAppEventType.EmbeddedPlaceRequested.EVENT_NAME) - override suspend fun processEventAndConfig(): Flow> { val inApps: List = mobileConfigRepository.getInAppsSection() .let { inApps -> @@ -113,6 +110,9 @@ internal class InAppInteractorImpl( inApp?.let { sessionStorageManager.state.inAppTriggerEvent = event } + if (inApp?.overlayHostsWebPage() == true) { + inAppProcessingManager.prefetchTargetingDependencies(inAppRepository.getCurrentSessionInApps()) + } inApp?.let { inapp -> inapp to timeProvider.elapsedSince(triggerTimeMillis) } } .onEach { pair -> @@ -125,15 +125,28 @@ internal class InAppInteractorImpl( override suspend fun selectInAppForPlace( placeSystemName: PlaceKey, triggerEvent: InAppEventType, + ): EmbeddedResolveOutcome = selectInAppForPlace(placeSystemName, triggerEvent, isRetryAfterSessionReset = false) + + private suspend fun selectInAppForPlace( + placeSystemName: PlaceKey, + triggerEvent: InAppEventType, + isRetryAfterSessionReset: Boolean, ): EmbeddedResolveOutcome { val inApps = mobileConfigRepository.getInAppsSectionIfAvailable() ?: run { logI("Place '$placeSystemName': the config is unavailable, the SDK has no answer for the place") return EmbeddedResolveOutcome.ConfigUnavailable } + val session = sessionStorageManager.state inAppRepository.saveCurrentSessionInApps(inApps) val trigger = placeTrigger(placeSystemName, triggerEvent) val candidates = inAppFilteringManager.filterEmbeddedInAppsByPlace(inApps, placeSystemName) .let { inAppFilteringManager.filterOutDirectCallInApps(it) } + if (candidates.isNotEmpty()) { + inAppProcessingManager.prefetchTargetingDependencies(inApps) + if (sessionStorageManager.state !== session && !isRetryAfterSessionReset) { + return resolveAgainForNewSession(placeSystemName, "while its targeting dependencies were fetched") + } + } val matched = candidates.filter { candidate -> inAppProcessingManager.matchesTargeting(candidate, trigger) } @@ -144,6 +157,9 @@ internal class InAppInteractorImpl( .let { inAppFrequencyManager.filterInAppsFrequency(it) } .sortByPriority() .firstOrNull { candidate -> candidate.embeddedVariantFor(placeSystemName) != null } + if (sessionStorageManager.state !== session && !isRetryAfterSessionReset) { + return resolveAgainForNewSession(placeSystemName, "while its candidates were checked") + } sendPlaceTargetings(placeSystemName, matched, winner) if (winner == null) { @@ -162,6 +178,12 @@ internal class InAppInteractorImpl( return EmbeddedResolveOutcome.Content(variant = variant, delayTime = delayTime) } + private suspend fun resolveAgainForNewSession(placeSystemName: PlaceKey, moment: String): EmbeddedResolveOutcome { + logI("Place '$placeSystemName': the session was reset $moment, resolving again as a place request of the new session") + inAppFailureTracker.clearFailures() + return selectInAppForPlace(placeSystemName, InAppEventType.EmbeddedPlaceRequested(placeSystemName), isRetryAfterSessionReset = true) + } + private fun placeTrigger(place: PlaceKey, triggerEvent: InAppEventType): InAppEventType { if (triggerEvent is InAppEventType.OrdinalEvent) { sessionStorageManager.state.embeddedLastOperationByPlace[place] = triggerEvent @@ -197,6 +219,16 @@ internal class InAppInteractorImpl( } } + private fun InApp.overlayHostsWebPage(): Boolean { + val layers = when (val variant = firstOverlayVariant()) { + is InAppType.WebView -> variant.layers + is InAppType.ModalWindow -> variant.layers + is InAppType.Snackbar -> variant.layers + else -> return false + } + return layers.any { layer -> layer is Layer.WebViewLayer } + } + private fun InApp.embeddedVariantFor(place: PlaceKey): InAppType.Embedded? = form.variants .filterIsInstance() @@ -258,7 +290,11 @@ internal class InAppInteractorImpl( override suspend fun filterShowableInAppIds(hostInAppId: String, inAppIds: List): List { if (inAppIds.isEmpty()) return emptyList() - val inApps = mobileConfigRepository.getInAppsSection() + val inApps = mobileConfigRepository.getInAppsSectionIfAvailable() ?: run { + logI("Page of $hostInAppId asks about ${inAppIds.size} id(s) while the config is unavailable, cutting them all") + return emptyList() + } + inAppRepository.saveCurrentSessionInApps(inApps) val inAppsPool = inAppABTestLogic.getInAppsPool(inApps.map { it.id }) val showableIds = inAppFilteringManager.filterABTestsInApps(inApps, inAppsPool) .map { inApp -> inApp.id } @@ -302,14 +338,7 @@ internal class InAppInteractorImpl( } private suspend fun matchesRequestedTargeting(inApp: InApp): Boolean = - runCatching { - inApp.targeting.fetchTargetingInfo(placeRequestTargetingData) - inApp.targeting.checkTargeting(placeRequestTargetingData) - } - .getOrElse { error -> - logI("Requested id ${inApp.id} targeting could not be checked ($error), cutting it") - false - } + inAppProcessingManager.matchesRequestedTargeting(inApp) .also { matches -> if (!matches) logI("Requested id ${inApp.id} targeting did not match, cutting it") } override fun reservePlaceShow(placeSystemName: PlaceKey, content: InAppType.Embedded): Boolean { 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 f82b5122..ab22b70c 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 @@ -39,6 +39,9 @@ internal class InAppProcessingManagerImpl( "CheckCustomerSegments requires customer" } + private val requestedTargetingData = + TargetingDataWrapper(InAppEventType.EmbeddedPlaceRequested.EVENT_NAME) + private fun isTagsFeatureEnabled(): Boolean = featureToggleManager.isEnabled(SEND_INAPP_TAGS_FEATURE) @@ -88,6 +91,8 @@ internal class InAppProcessingManagerImpl( targetingCheck = inApp.targeting.checkTargeting(data) }.onFailure { throwable -> when (throwable) { + is CancellationException -> throw throwable + is GeoError -> { isTargetingErrorOccurred = true inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) @@ -175,6 +180,8 @@ internal class InAppProcessingManagerImpl( inApp.targeting.fetchTargetingInfo(data) }.onFailure { throwable -> when (throwable) { + is CancellationException -> throw throwable + is GeoError -> { isTargetingErrorOccurred = true inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) @@ -210,8 +217,13 @@ internal class InAppProcessingManagerImpl( ) } - override suspend fun matchesTargeting(inApp: InApp, triggerEvent: InAppEventType): Boolean { - val data = getTargetingData(triggerEvent) + override suspend fun matchesTargeting(inApp: InApp, triggerEvent: InAppEventType): Boolean = + matchesTargeting(inApp, getTargetingData(triggerEvent), collectsFailures = true) + + override suspend fun matchesRequestedTargeting(inApp: InApp): Boolean = + matchesTargeting(inApp, requestedTargetingData, collectsFailures = false) + + private suspend fun matchesTargeting(inApp: InApp, data: TargetingData, collectsFailures: Boolean): Boolean { val tags = inApp.gatedTags(isTagsFeatureEnabled()) var isTargetingErrorOccurred = false var targetingCheck = false @@ -219,35 +231,12 @@ internal class InAppProcessingManagerImpl( inApp.targeting.fetchTargetingInfo(data) targetingCheck = inApp.targeting.checkTargeting(data) }.onFailure { throwable -> - when (throwable) { - is GeoError -> { - isTargetingErrorOccurred = true - inAppGeoRepository.setGeoStatus(GeoFetchStatus.GEO_FETCH_ERROR) - if (throwable.shouldTrackTargetingError()) { - inAppTargetingErrorRepository.saveError( - key = TargetingErrorKey.Geo, - error = throwable - ) - } - mindboxLogE("Error fetching geo", throwable) - } - - is CustomerSegmentationError -> { - isTargetingErrorOccurred = true - inAppSegmentationRepository.setCustomerSegmentationStatus( - CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR - ) - if (throwable.shouldTrackTargetingError()) { - inAppTargetingErrorRepository.saveError( - key = TargetingErrorKey.CustomerSegmentation, - error = throwable - ) - } - handleCustomerSegmentationErrorLog(throwable) - } - - else -> { - mindboxLogE(throwable.message ?: "", throwable) + if (throwable is CancellationException) throw throwable + if (handleTargetingFetchError(throwable)) { + isTargetingErrorOccurred = true + } else { + mindboxLogE(throwable.message ?: "", throwable) + if (collectsFailures) { inAppFailureTracker.sendFailure( inAppId = inApp.id, failureReason = FailureReason.UNKNOWN_ERROR, @@ -257,11 +246,55 @@ internal class InAppProcessingManagerImpl( } } } - if (isTargetingErrorOccurred) return matchesTargeting(inApp, triggerEvent) - trackTargetingErrorIfAny(inApp, data, tags) + if (isTargetingErrorOccurred) return matchesTargeting(inApp, data, collectsFailures) + if (collectsFailures) trackTargetingErrorIfAny(inApp, data, tags) return targetingCheck } + override suspend fun prefetchTargetingDependencies(inApps: List) { + val fetchesSegmentations = inAppSegmentationRepository.getCustomerSegmentationFetched() == + CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED && + inApps.any { inApp -> inApp.targeting.hasSegmentationNode() } + val fetchesGeo = inAppGeoRepository.getGeoFetchedStatus() == GeoFetchStatus.GEO_NOT_FETCHED && + inApps.any { inApp -> inApp.targeting.hasGeoNode() } + if (!fetchesSegmentations && !fetchesGeo) return + mindboxLogI("Prefetching targeting dependencies: segmentations=$fetchesSegmentations, geo=$fetchesGeo") + coroutineScope { + if (fetchesSegmentations) launch { fetchTargetingDependency { inAppSegmentationRepository.fetchCustomerSegmentations() } } + if (fetchesGeo) launch { fetchTargetingDependency { inAppGeoRepository.fetchGeo() } } + } + } + + private fun handleTargetingFetchError(error: Throwable): Boolean = when (error) { + is GeoError -> { + if (error.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError(key = TargetingErrorKey.Geo, error = error) + } + mindboxLogE("Error fetching geo", error) + true + } + + is CustomerSegmentationError -> { + if (error.shouldTrackTargetingError()) { + inAppTargetingErrorRepository.saveError(key = TargetingErrorKey.CustomerSegmentation, error = error) + } + handleCustomerSegmentationErrorLog(error) + true + } + + else -> false + } + + private suspend fun fetchTargetingDependency(fetch: suspend () -> Unit) { + try { + fetch() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Throwable) { + if (!handleTargetingFetchError(error)) mindboxLogE("Error prefetching targeting dependencies", error) + } + } + private fun getTargetingData(triggerEvent: InAppEventType): TargetingData { val ordinalEvent = triggerEvent as? InAppEventType.OrdinalEvent diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt index 0e1dcda3..7d21cd5a 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/interfaces/managers/InAppProcessingManager.kt @@ -22,4 +22,8 @@ internal interface InAppProcessingManager { fun sendTargetedInApp(inApp: InApp) suspend fun matchesTargeting(inApp: InApp, triggerEvent: InAppEventType): Boolean + + suspend fun matchesRequestedTargeting(inApp: InApp): Boolean + + suspend fun prefetchTargetingDependencies(inApps: List) } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt index c2dbd38f..3461aa1d 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/GatewayManager.kt @@ -347,23 +347,23 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic } ?: false suspend fun checkGeoTargeting(configuration: Configuration): GeoTargetingDto { - return suspendCoroutine { continuation -> - mindboxServiceGenerator.addToRequestQueue( - MindboxRequest( - Request.Method.GET, - "${SdkValidation.toBaseUrl(configuration.domain)}/geo", - configuration, - null, - { response -> - continuation.resumeFromJson( - json = response.toString() - ) - }, - { error -> - continuation.resumeWithException(GeoError(error)) - } - ) + return suspendCancellableCoroutine { continuation -> + val request = MindboxRequest( + Request.Method.GET, + "${SdkValidation.toBaseUrl(configuration.domain)}/geo", + configuration, + null, + { response -> + continuation.resumeFromJson( + json = response.toString() + ) + }, + { error -> + continuation.resumeWithException(GeoError(error)) + } ) + continuation.invokeOnCancellation { request.cancel() } + mindboxServiceGenerator.addToRequestQueue(request) } } @@ -400,28 +400,28 @@ internal class GatewayManager(private val mindboxServiceGenerator: MindboxServic configuration: Configuration, segmentationCheckRequest: SegmentationCheckRequest, ): SegmentationCheckResponse { - return suspendCoroutine { continuation -> - mindboxServiceGenerator.addToRequestQueue( - MindboxRequest( - Request.Method.POST, - getCustomerSegmentationsUrl(configuration), - configuration, - convertBodyToJson( - gson.toJson( - segmentationCheckRequest, - SegmentationCheckRequest::class.java - ) - )!!, - { response -> - continuation.resumeFromJson( - json = response.toString() - ) - }, - { error -> - continuation.resumeWithException(CustomerSegmentationError(error)) - } - ) + return suspendCancellableCoroutine { continuation -> + val request = MindboxRequest( + Request.Method.POST, + getCustomerSegmentationsUrl(configuration), + configuration, + convertBodyToJson( + gson.toJson( + segmentationCheckRequest, + SegmentationCheckRequest::class.java + ) + )!!, + { response -> + continuation.resumeFromJson( + json = response.toString() + ) + }, + { error -> + continuation.resumeWithException(CustomerSegmentationError(error)) + } ) + continuation.invokeOnCancellation { request.cancel() } + mindboxServiceGenerator.addToRequestQueue(request) } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt index 122c1d42..e7a003df 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/utils/Constants.kt @@ -11,6 +11,7 @@ internal object Constants { internal const val APP_UID_NAME = "app_uid" internal const val SCHEME_PACKAGE = "package" internal const val SDK_VERSION_CODE = 4 + internal val targetingFetchWaitLimit = Milliseconds(15_000L) internal object WebView { internal val readyTimeout = Milliseconds(7_000L) diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt index 3beadf4b..61430f2a 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppGeoRepositoryTest.kt @@ -1,10 +1,14 @@ package cloud.mindbox.mobile_sdk.inapp.data.repositories +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.launch +import kotlinx.coroutines.CompletableDeferred import android.content.Context import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionState import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.GeoSerializationManager +import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoError import cloud.mindbox.mobile_sdk.inapp.domain.models.GeoFetchStatus import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager @@ -17,11 +21,13 @@ import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.OverrideMockKs import io.mockk.junit4.MockKRule import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -204,4 +210,112 @@ internal class InAppGeoRepositoryTest { every { sessionStorageManager.state } throws Error() assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, inAppGeoRepository.getGeoFetchedStatus()) } + + @Test + fun `failed geo fetch is cached for the session`() = runTest { + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } throws GeoError(VolleyError("timeout")) + + assertTrue(runCatching { inAppGeoRepository.fetchGeo() }.exceptionOrNull() is GeoError) + inAppGeoRepository.fetchGeo() + + assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionState.geoFetchStatus) + coVerify(exactly = 1) { gatewayManager.checkGeoTargeting(any()) } + } + + @Test + fun `a geo fetch the session reset outlived writes into its own session only`() = runTest { + val nextSession = SessionState() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } answers { + every { sessionStorageManager.state } returns nextSession + throw GeoError(VolleyError("timeout")) + } + + runCatching { inAppGeoRepository.fetchGeo() } + + assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionState.geoFetchStatus) + assertEquals(GeoFetchStatus.GEO_NOT_FETCHED, nextSession.geoFetchStatus) + } + + @Test + fun `a geo request the queue dropped without an answer ends as a cached fetch error`() = runTest { + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } coAnswers { awaitCancellation() } + + assertTrue(runCatching { inAppGeoRepository.fetchGeo() }.exceptionOrNull() is GeoError) + assertEquals(GeoFetchStatus.GEO_FETCH_ERROR, sessionState.geoFetchStatus) + } + + @Test + fun `a geo fetch the session reset outlived writes its success into its own session only`() = runTest { + val nextSession = SessionState() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + every { configuration.domain } returns "" + val geoTargetingDto = GeoTargetingStub.getGeoTargetingDto() + val geoTargeting = GeoTargetingStub.getGeoTargeting() + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } answers { + every { sessionStorageManager.state } returns nextSession + geoTargetingDto + } + coEvery { inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto) } returns geoTargeting + every { geoSerializationManager.serializeToGeoString(geoTargeting) } returns "{}" + + inAppGeoRepository.fetchGeo() + + assertEquals(GeoFetchStatus.GEO_FETCH_SUCCESS, sessionState.geoFetchStatus) + assertEquals(GeoFetchStatus.GEO_NOT_FETCHED, nextSession.geoFetchStatus) + } + + @Test + fun `two callers on a cold cache make one geo request and both see its result`() = runTest { + val answer = CompletableDeferred() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + every { configuration.domain } returns "" + val geoTargetingDto = GeoTargetingStub.getGeoTargetingDto() + val geoTargeting = GeoTargetingStub.getGeoTargeting() + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } coAnswers { + answer.await() + geoTargetingDto + } + coEvery { inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto) } returns geoTargeting + every { geoSerializationManager.serializeToGeoString(geoTargeting) } returns "{}" + + val first = launch { inAppGeoRepository.fetchGeo() } + val second = launch { inAppGeoRepository.fetchGeo() } + runCurrent() + answer.complete(Unit) + first.join() + second.join() + + coVerify(exactly = 1) { gatewayManager.checkGeoTargeting(any()) } + assertEquals(GeoFetchStatus.GEO_FETCH_SUCCESS, sessionState.geoFetchStatus) + } + + @Test + fun `a geo caller cancelled while it holds the lock leaves the status not fetched for the next caller`() = runTest { + var dropsTheRequest = true + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + every { configuration.domain } returns "" + val geoTargetingDto = GeoTargetingStub.getGeoTargetingDto() + val geoTargeting = GeoTargetingStub.getGeoTargeting() + coEvery { gatewayManager.checkGeoTargeting(configuration = configuration) } coAnswers { + if (dropsTheRequest) awaitCancellation() + geoTargetingDto + } + coEvery { inAppMapper.mapGeoTargetingDtoToGeoTargeting(geoTargetingDto) } returns geoTargeting + every { geoSerializationManager.serializeToGeoString(geoTargeting) } returns "{}" + + val cancelled = launch { inAppGeoRepository.fetchGeo() } + runCurrent() + cancelled.cancel() + cancelled.join() + assertEquals(GeoFetchStatus.GEO_NOT_FETCHED, sessionState.geoFetchStatus) + + dropsTheRequest = false + inAppGeoRepository.fetchGeo() + + assertEquals(GeoFetchStatus.GEO_FETCH_SUCCESS, sessionState.geoFetchStatus) + coVerify(exactly = 2) { gatewayManager.checkGeoTargeting(any()) } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt index b248bd52..f2ae3972 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/data/repositories/InAppSegmentationRepositoryTest.kt @@ -1,8 +1,12 @@ package cloud.mindbox.mobile_sdk.inapp.data.repositories +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.launch +import kotlinx.coroutines.CompletableDeferred import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionState import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.data.mapper.InAppMapper +import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationError import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationFetchStatus import cloud.mindbox.mobile_sdk.inapp.domain.models.CustomerSegmentationInApp import cloud.mindbox.mobile_sdk.inapp.domain.models.ProductSegmentationFetchStatus @@ -16,6 +20,7 @@ import com.android.volley.VolleyError import io.mockk.* import io.mockk.impl.annotations.MockK import io.mockk.junit4.MockKRule +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -81,7 +86,7 @@ class InAppSegmentationRepositoryTest { fun `request customer segmentations no inApps`() = runTest { sessionStorageManager.state.currentSessionInApps = mutableListOf() inAppSegmentationRepository.fetchCustomerSegmentations() - assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.state.customerSegmentationFetchStatus) + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, sessionStorageManager.state.customerSegmentationFetchStatus) coVerify(exactly = 0) { gatewayManager.checkCustomerSegmentations( @@ -256,4 +261,112 @@ class InAppSegmentationRepositoryTest { val actualResult = inAppSegmentationRepository.getCustomerSegmentations() assertEquals(expectedResult, actualResult) } + + @Test + fun `failed customer segmentations fetch is cached for the session`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { + gatewayManager.checkCustomerSegmentations(any(), any()) + } throws CustomerSegmentationError(VolleyError("timeout")) + + assertTrue(runCatching { inAppSegmentationRepository.fetchCustomerSegmentations() }.exceptionOrNull() is CustomerSegmentationError) + inAppSegmentationRepository.fetchCustomerSegmentations() + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionStorageManager.state.customerSegmentationFetchStatus) + coVerify(exactly = 1) { gatewayManager.checkCustomerSegmentations(any(), any()) } + } + + @Test + fun `a fetch the session reset outlived writes into its own session only`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + val nextSession = SessionState() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } answers { + every { sessionStorageManager.state } returns nextSession + throw CustomerSegmentationError(VolleyError("timeout")) + } + + runCatching { inAppSegmentationRepository.fetchCustomerSegmentations() } + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionState.customerSegmentationFetchStatus) + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, nextSession.customerSegmentationFetchStatus) + } + + @Test + fun `a request the queue dropped without an answer ends as a cached fetch error`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } coAnswers { awaitCancellation() } + + assertTrue(runCatching { inAppSegmentationRepository.fetchCustomerSegmentations() }.exceptionOrNull() is CustomerSegmentationError) + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR, sessionState.customerSegmentationFetchStatus) + } + + @Test + fun `a session the reset just started is not marked failed before its in-apps arrive`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + val nextSession = SessionState() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } answers { + every { sessionStorageManager.state } returns nextSession + SegmentationCheckInAppStub.getSegmentationCheckResponse().copy("Success", listOf()) + } + + inAppSegmentationRepository.fetchCustomerSegmentations() + inAppSegmentationRepository.fetchCustomerSegmentations() + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, sessionState.customerSegmentationFetchStatus) + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, nextSession.customerSegmentationFetchStatus) + + nextSession.currentSessionInApps = listOf(InAppStub.getInApp()) + inAppSegmentationRepository.fetchCustomerSegmentations() + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, nextSession.customerSegmentationFetchStatus) + coVerify(exactly = 2) { gatewayManager.checkCustomerSegmentations(any(), any()) } + } + + @Test + fun `two callers on a cold cache make one segmentation request and both see its result`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + val answer = CompletableDeferred() + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } coAnswers { + answer.await() + SegmentationCheckInAppStub.getSegmentationCheckResponse().copy("Success", listOf()) + } + + val first = launch { inAppSegmentationRepository.fetchCustomerSegmentations() } + val second = launch { inAppSegmentationRepository.fetchCustomerSegmentations() } + runCurrent() + answer.complete(Unit) + first.join() + second.join() + + coVerify(exactly = 1) { gatewayManager.checkCustomerSegmentations(any(), any()) } + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, sessionState.customerSegmentationFetchStatus) + } + + @Test + fun `a caller cancelled while it holds the lock leaves the status not fetched for the next caller`() = runTest { + sessionStorageManager.state.currentSessionInApps = mutableListOf(InAppStub.getInApp()) + var dropsTheRequest = true + coEvery { DbManager.listenConfigurations() } answers { flow { emit(configuration) } } + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } coAnswers { + if (dropsTheRequest) awaitCancellation() + SegmentationCheckInAppStub.getSegmentationCheckResponse().copy("Success", listOf()) + } + + val cancelled = launch { inAppSegmentationRepository.fetchCustomerSegmentations() } + runCurrent() + cancelled.cancel() + cancelled.join() + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED, sessionState.customerSegmentationFetchStatus) + + dropsTheRequest = false + inAppSegmentationRepository.fetchCustomerSegmentations() + + assertEquals(CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS, sessionState.customerSegmentationFetchStatus) + coVerify(exactly = 2) { gatewayManager.checkCustomerSegmentations(any(), any()) } + } } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedResolveInteractorTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedResolveInteractorTest.kt index b102b096..9e5caeb7 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedResolveInteractorTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/inapp/domain/EmbeddedResolveInteractorTest.kt @@ -3,6 +3,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.interactors.EmbeddedResolveOutcome import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.models.PlaceKey +import org.junit.Assert.assertSame import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.managers.ShowReservationOutcome @@ -20,7 +21,6 @@ import cloud.mindbox.mobile_sdk.inapp.domain.models.DisplayConditions import cloud.mindbox.mobile_sdk.inapp.domain.models.Form import cloud.mindbox.mobile_sdk.inapp.domain.models.Frequency import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp -import cloud.mindbox.mobile_sdk.inapp.domain.models.TreeTargeting import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.models.EventType import cloud.mindbox.mobile_sdk.models.InAppEventType @@ -31,12 +31,12 @@ import cloud.mindbox.mobile_sdk.utils.TimeProvider import io.mockk.Called import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.RelaxedMockK import io.mockk.junit4.MockKRule import io.mockk.just -import io.mockk.mockk import io.mockk.mockkObject import io.mockk.runs import io.mockk.unmockkObject @@ -120,6 +120,8 @@ class EmbeddedResolveInteractorTest { every { inAppProcessingManager.sendTargetedInApp(any()) } just runs coEvery { inAppProcessingManager.sendTargetedInApp(any(), any()) } just runs coEvery { inAppProcessingManager.matchesTargeting(any(), any()) } returns true + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } just runs + coEvery { inAppProcessingManager.matchesRequestedTargeting(any()) } returns true every { showBudgetManager.reserve(any(), any(), any(), any()) } returns ShowReservationOutcome.GRANTED every { showBudgetManager.commit(any(), any(), any(), any()) } just runs every { showBudgetManager.release(any()) } just runs @@ -686,38 +688,10 @@ class EmbeddedResolveInteractorTest { @Test fun `filterShowableInAppIds cuts id with unmatched targeting`() = runTest { - // An operation node never matches the dictionary answer — no operation is happening. - givenConfig( - modalInApp(id = "inapp-1").copy(targeting = InAppStub.getTargetingOperationNode()) - ) - - assertEquals(emptyList(), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) - } - - @Test - fun `filterShowableInAppIds fetches the targeting dependencies before checking`() = runTest { - // A segment-targeted in-app is answerable only from fetched data. The dictionary question is - // the only path that ever evaluates a directCall in-app's targeting, so it has to fetch - // for itself — the session status and the repository mutexes keep it one network trip. - val targeting = mockk() - coEvery { targeting.fetchTargetingInfo(any()) } just runs - every { targeting.checkTargeting(any()) } returns true - givenConfig(modalInApp(id = "inapp-1").copy(targeting = targeting)) - - assertEquals(listOf("inapp-1"), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) - coVerify(exactly = 1) { targeting.fetchTargetingInfo(any()) } - } - - @Test - fun `filterShowableInAppIds cuts the id whose dependencies could not be fetched`() = runTest { - // Fail closed: a fetch that failed leaves the targeting unverifiable, and unverified - // is never "allowed". - val targeting = mockk() - coEvery { targeting.fetchTargetingInfo(any()) } throws RuntimeException("offline") - givenConfig(modalInApp(id = "inapp-1").copy(targeting = targeting)) + givenConfig(modalInApp(id = "inapp-1")) + coEvery { inAppProcessingManager.matchesRequestedTargeting(match { it.id == "inapp-1" }) } returns false assertEquals(emptyList(), interactor.filterShowableInAppIds("host-form", listOf("inapp-1"))) - verify(exactly = 0) { targeting.checkTargeting(any()) } } @Test @@ -821,9 +795,8 @@ class EmbeddedResolveInteractorTest { @Test fun `filterShowableInAppIds sends no targeting for a cut id`() = runTest { - givenConfig( - modalInApp(id = "inapp-1").copy(targeting = InAppStub.getTargetingOperationNode()) - ) + givenConfig(modalInApp(id = "inapp-1")) + coEvery { inAppProcessingManager.matchesRequestedTargeting(match { it.id == "inapp-1" }) } returns false interactor.filterShowableInAppIds("host-form", listOf("inapp-1", "ghost")) @@ -1097,5 +1070,141 @@ class EmbeddedResolveInteractorTest { private fun EmbeddedResolveOutcome.contentOrNull(): EmbeddedResolveOutcome.Content? = this as? EmbeddedResolveOutcome.Content + @Test + fun `selectInAppForPlace fetches the whole config's targeting dependencies before matching the place`() = runTest { + val config = listOf(embeddedInApp(), modalInApp(id = "story-1")) + givenConfig(*config.toTypedArray()) + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + coVerifyOrder { + inAppRepository.saveCurrentSessionInApps(config) + inAppProcessingManager.prefetchTargetingDependencies(config) + inAppProcessingManager.matchesTargeting(match { it.id == "embedded-id" }, any()) + } + } + + @Test + fun `selectInAppForPlace fetches no targeting dependencies for a place without candidates`() = runTest { + givenConfig(embeddedInApp(), modalInApp()) + + interactor.selectInAppForPlace(PlaceKey.of("no-such-place"), InAppEventType.EmbeddedPlaceRequested(PlaceKey.of("no-such-place"))) + + coVerify(exactly = 0) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + } + + @Test + fun `selectInAppForPlace resolves again for the new session when the session was reset during the prefetch`() = runTest { + val config = listOf(embeddedInApp(), modalInApp(id = "story-1")) + givenConfig(*config.toTypedArray()) + val nextSession = SessionState() + var prefetches = 0 + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } coAnswers { + if (prefetches++ == 0) every { sessionStorageManager.state } returns nextSession + } + + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)).variantOrNull() + + assertEquals("embedded-id", content?.inAppId) + coVerifyOrder { + inAppRepository.saveCurrentSessionInApps(config) + inAppProcessingManager.prefetchTargetingDependencies(config) + inAppRepository.saveCurrentSessionInApps(config) + inAppProcessingManager.prefetchTargetingDependencies(config) + inAppProcessingManager.matchesTargeting(match { it.id == "embedded-id" }, any()) + } + coVerify(exactly = 2) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + } + + @Test + fun `selectInAppForPlace resolves again only once however often the session is reset`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } coAnswers { + every { sessionStorageManager.state } returns SessionState() + } + + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)).variantOrNull() + + assertEquals("embedded-id", content?.inAppId) + coVerify(exactly = 2) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + } + + @Test + fun `selectInAppForPlace resolves once while the session stays the same`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + coVerify(exactly = 1) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + verify(exactly = 1) { inAppRepository.saveCurrentSessionInApps(any()) } + } + + @Test + fun `filterShowableInAppIds saves the config's in-apps into the session before checking`() = runTest { + val config = listOf(modalInApp(id = "inapp-1")) + givenConfig(*config.toTypedArray()) + + interactor.filterShowableInAppIds("host-form", listOf("inapp-1")) + + coVerifyOrder { + inAppRepository.saveCurrentSessionInApps(config) + inAppProcessingManager.matchesRequestedTargeting(match { it.id == "inapp-1" }) + } + } + + @Test + fun `selectInAppForPlace resolves again when the session was reset while its candidates were checked`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + val nextSession = SessionState() + var checks = 0 + coEvery { inAppProcessingManager.matchesTargeting(any(), any()) } coAnswers { + if (checks++ == 0) every { sessionStorageManager.state } returns nextSession + true + } + + val content = interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)).variantOrNull() + + assertEquals("embedded-id", content?.inAppId) + coVerify(exactly = 2) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + verify(exactly = 1) { inAppProcessingManager.sendTargetedInApp(any()) } + } + + @Test + fun `selectInAppForPlace resolves again as a plain place request, leaving the old session's operation behind`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + val operation = InAppEventType.OrdinalEvent(EventType.SyncOperation("viewProduct"), null) + val nextSession = SessionState() + var prefetches = 0 + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } coAnswers { + if (prefetches++ == 0) every { sessionStorageManager.state } returns nextSession + } + + interactor.selectInAppForPlace(place, operation) + + coVerify(exactly = 1) { inAppProcessingManager.matchesTargeting(any(), ofType()) } + coVerify(exactly = 0) { inAppProcessingManager.matchesTargeting(any(), operation) } + assertFalse(nextSession.embeddedLastOperationByPlace.containsKey(place)) + assertSame(operation, sessionState.embeddedLastOperationByPlace[place]) + } + + @Test + fun `selectInAppForPlace drops the failures the abandoned pass collected before resolving again`() = runTest { + givenConfig(embeddedInApp(), modalInApp(id = "story-1")) + val nextSession = SessionState() + var checks = 0 + coEvery { inAppProcessingManager.matchesTargeting(any(), any()) } coAnswers { + if (checks++ == 0) every { sessionStorageManager.state } returns nextSession + true + } + + interactor.selectInAppForPlace(place, InAppEventType.EmbeddedPlaceRequested(place)) + + coVerifyOrder { + inAppProcessingManager.matchesTargeting(any(), any()) + inAppFailureTracker.clearFailures() + inAppProcessingManager.prefetchTargetingDependencies(any()) + } + } + private fun EmbeddedResolveOutcome.variantOrNull(): InAppType.Embedded? = contentOrNull()?.variant } 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 43fbdcb4..a922f37a 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 @@ -1,6 +1,7 @@ package cloud.mindbox.mobile_sdk.inapp.domain import app.cash.turbine.test +import cloud.mindbox.mobile_sdk.InitializeLock import cloud.mindbox.mobile_sdk.abtests.InAppABTestLogic import cloud.mindbox.mobile_sdk.inapp.data.managers.SessionStorageManager import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.InAppContentFetcher @@ -17,6 +18,8 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppReposi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.MobileConfigRepository +import cloud.mindbox.mobile_sdk.inapp.domain.models.InApp +import cloud.mindbox.mobile_sdk.inapp.domain.models.InAppType import cloud.mindbox.mobile_sdk.logger.MindboxLoggerImpl import cloud.mindbox.mobile_sdk.models.InAppEventType import cloud.mindbox.mobile_sdk.models.InAppStub @@ -274,4 +277,52 @@ class InAppInteractorImplTest { cancelAndIgnoreRemainingEvents() } } + + private fun givenEventWinner(variant: InAppType): InApp { + val winner = InAppStub.getInApp().copy( + id = "winner", + targeting = InAppStub.getTargetingTrueNode(), + form = InAppStub.getInApp().form.copy(variants = listOf(variant)) + ) + coEvery { mobileConfigRepository.getInAppsSection() } returns listOf(winner) + every { inAppFilteringManager.filterUnShownInAppsByEvent(any(), any()) } returns listOf(winner) + every { inAppFilteringManager.filterOutNonOverlayInApps(any()) } answers { firstArg() } + every { inAppFilteringManager.filterOutDirectCallInApps(any()) } answers { firstArg() } + coEvery { inAppFrequencyManager.filterInAppsFrequency(any()) } answers { firstArg() } + coEvery { inAppProcessingManager.chooseInAppToShow(any(), any(), any()) } returns winner + coEvery { inAppProcessingManager.sendTargetedInApp(any(), any()) } just runs + coEvery { inAppProcessingManager.prefetchTargetingDependencies(any()) } just runs + return winner + } + + @Test + fun `overlay winner hosting a web page fetches the session's targeting dependencies after the app start is released`() = runTest { + val sessionInApps = listOf(InAppStub.getInApp().copy(id = "story")) + every { inAppRepository.getCurrentSessionInApps() } returns sessionInApps + givenEventWinner(InAppStub.getWebView().copy(inAppId = "winner")) + mockkObject(InitializeLock) + try { + interactor.processEventAndConfig().test { + awaitItem() + coVerifyOrder { + InitializeLock.complete(InitializeLock.State.APP_STARTED) + inAppProcessingManager.prefetchTargetingDependencies(sessionInApps) + } + cancelAndIgnoreRemainingEvents() + } + } finally { + unmockkObject(InitializeLock) + } + } + + @Test + fun `overlay winner without a web page fetches no targeting dependencies`() = runTest { + givenEventWinner(InAppStub.getModalWindow().copy(inAppId = "winner")) + + interactor.processEventAndConfig().test { + awaitItem() + coVerify(exactly = 0) { inAppProcessingManager.prefetchTargetingDependencies(any()) } + cancelAndIgnoreRemainingEvents() + } + } } 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 540151b8..c2562ad6 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 @@ -1,5 +1,6 @@ package cloud.mindbox.mobile_sdk.inapp.domain +import kotlinx.coroutines.flow.flowOf import android.content.Context import cloud.mindbox.mobile_sdk.di.MindboxDI import cloud.mindbox.mobile_sdk.inapp.data.managers.SEND_INAPP_TAGS_FEATURE @@ -17,6 +18,7 @@ import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppReposi import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppSegmentationRepository import cloud.mindbox.mobile_sdk.inapp.domain.interfaces.repositories.InAppTargetingErrorRepository import cloud.mindbox.mobile_sdk.inapp.domain.models.* +import cloud.mindbox.mobile_sdk.managers.DbManager import cloud.mindbox.mobile_sdk.managers.GatewayManager import cloud.mindbox.mobile_sdk.models.* import cloud.mindbox.mobile_sdk.models.operation.request.FailureReason @@ -26,6 +28,10 @@ import com.android.volley.VolleyError import com.google.gson.Gson import io.mockk.* import io.mockk.junit4.MockKRule +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert.* @@ -1258,4 +1264,270 @@ internal class InAppProcessingManagerTest { ) } } + + @Test + fun `prefetch fetches segmentations and geo when the config targets them`() = runTest { + every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED + + inAppProcessingManager.prefetchTargetingDependencies( + listOf( + inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()), + inAppTargeting("story-city", InAppStub.getTargetingCityNode()), + ) + ) + + coVerify(exactly = 1) { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } + coVerify(exactly = 1) { mockkInAppGeoRepository.fetchGeo() } + } + + @Test + fun `prefetch fetches nothing when no in-app targets segmentations or geo`() = runTest { + every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED + + inAppProcessingManager.prefetchTargetingDependencies(listOf(inAppTargeting("story", InAppStub.getTargetingTrueNode()))) + + coVerify(exactly = 0) { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } + coVerify(exactly = 0) { mockkInAppGeoRepository.fetchGeo() } + } + + @Test + fun `prefetch fetches nothing the session already fetched`() = runTest { + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_FETCH_ERROR + + inAppProcessingManager.prefetchTargetingDependencies( + listOf( + inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()), + inAppTargeting("story-city", InAppStub.getTargetingCityNode()), + ) + ) + + coVerify(exactly = 0) { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } + coVerify(exactly = 0) { mockkInAppGeoRepository.fetchGeo() } + } + + private val serverError = VolleyError(NetworkResponse(500, null, false, 0, emptyList())) + + private fun failSegmentationsLikeTheRepository(error: Throwable) { + sessionStorageManager.state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + every { inAppSegmentationRepositoryTestImpl.getCustomerSegmentationFetched() } answers { callOriginal() } + coEvery { inAppSegmentationRepositoryTestImpl.fetchCustomerSegmentations() } answers { + sessionStorageManager.state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_ERROR + throw error + } + setDIModule(mockkInAppGeoRepository, inAppSegmentationRepositoryTestImpl) + } + + @Test + fun `prefetch keeps a failed segmentation fetch's show failure details`() = runTest { + failSegmentationsLikeTheRepository(CustomerSegmentationError(serverError)) + + inAppProcessingManagerTestImpl.prefetchTargetingDependencies(listOf(inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()))) + + assertNotNull(inAppTargetingErrorRepository.getError(TargetingErrorKey.CustomerSegmentation)) + } + + @Test + fun `prefetch fetches geo even when the segmentations fail`() = runTest { + every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + coEvery { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } throws CustomerSegmentationError(VolleyError("timeout")) + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED + + inAppProcessingManager.prefetchTargetingDependencies( + listOf( + inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()), + inAppTargeting("story-city", InAppStub.getTargetingCityNode()), + ) + ) + + coVerify(exactly = 1) { mockkInAppGeoRepository.fetchGeo() } + } + + @Test + fun `prefetch runs the segmentation and geo fetches at the same time`() = runTest { + val segmentationsAnswered = CompletableDeferred() + every { mockkInAppSegmentationRepository.getCustomerSegmentationFetched() } returns CustomerSegmentationFetchStatus.SEGMENTATION_NOT_FETCHED + coEvery { mockkInAppSegmentationRepository.fetchCustomerSegmentations() } coAnswers { segmentationsAnswered.await() } + every { mockkInAppGeoRepository.getGeoFetchedStatus() } returns GeoFetchStatus.GEO_NOT_FETCHED + + val prefetch = launch { + inAppProcessingManager.prefetchTargetingDependencies( + listOf( + inAppTargeting("story-segment", InAppStub.getTargetingSegmentNode()), + inAppTargeting("story-city", InAppStub.getTargetingCityNode()), + ) + ) + } + runCurrent() + + coVerify(exactly = 1) { mockkInAppGeoRepository.fetchGeo() } + assertTrue(prefetch.isActive) + segmentationsAnswered.complete(Unit) + prefetch.join() + } + + @Test + fun `requested targeting fetches the dependencies before checking`() = runTest { + val targeting = mockk() + coEvery { targeting.fetchTargetingInfo(any()) } just runs + every { targeting.checkTargeting(any()) } returns true + + assertTrue(inAppProcessingManager.matchesRequestedTargeting(inAppTargeting("story", targeting))) + coVerifyOrder { + targeting.fetchTargetingInfo(any()) + targeting.checkTargeting(any()) + } + } + + @Test + fun `requested targeting never matches an operation node - the page asks outside any operation`() = runTest { + assertFalse(inAppProcessingManager.matchesRequestedTargeting(inAppTargeting("story", InAppStub.getTargetingOperationNode()))) + } + + @Test + fun `requested targeting cuts an unverifiable id without reporting a failure`() = runTest { + val targeting = mockk() + coEvery { targeting.fetchTargetingInfo(any()) } throws RuntimeException("offline") + + assertFalse(inAppProcessingManager.matchesRequestedTargeting(inAppTargeting("story", targeting))) + verify(exactly = 0) { targeting.checkTargeting(any()) } + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + } + + @Test + fun `requested targeting re-checks from the session cache and collects no failure`() = runTest { + failSegmentationsLikeTheRepository(CustomerSegmentationError(serverError)) + val segmentOrTrue = TreeTargeting.UnionNode( + type = "", + nodes = listOf(InAppStub.getTargetingSegmentNode(), InAppStub.getTargetingTrueNode()) + ) + + assertTrue(inAppProcessingManagerTestImpl.matchesRequestedTargeting(inAppTargeting("story", segmentOrTrue))) + coVerify(exactly = 1) { inAppSegmentationRepositoryTestImpl.fetchCustomerSegmentations() } + verify(exactly = 0) { inAppFailureTracker.collectFailure(any(), any(), any(), any()) } + } + + @Test + fun `place targeting cut by a failed segmentation fetch collects its show failure`() = runTest { + failSegmentationsLikeTheRepository(CustomerSegmentationError(serverError)) + + assertFalse(inAppProcessingManagerTestImpl.matchesTargeting(inAppTargeting("ribbon", InAppStub.getTargetingSegmentNode()), event)) + verify(exactly = 1) { + inAppFailureTracker.collectFailure( + inAppId = "ribbon", + failureReason = FailureReason.CUSTOMER_SEGMENT_REQUEST_FAILED, + errorDetails = any(), + tags = any() + ) + } + } + + @Test + fun `place targeting reports an unknown error and cuts the candidate`() = runTest { + val targeting = mockk(relaxed = true) + coEvery { targeting.fetchTargetingInfo(any()) } throws RuntimeException("broken") + + assertFalse(inAppProcessingManager.matchesTargeting(inAppTargeting("ribbon", targeting), event)) + verify(exactly = 1) { + inAppFailureTracker.sendFailure(inAppId = "ribbon", failureReason = FailureReason.UNKNOWN_ERROR, errorDetails = any(), tags = any()) + } + } + + @Test + fun `place targeting passes cancellation through without reporting a failure`() = runTest { + val targeting = mockk() + coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("place left the screen") + + assertTrue( + runCatching { inAppProcessingManager.matchesTargeting(inAppTargeting("ribbon", targeting), event) } + .exceptionOrNull() is CancellationException + ) + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + } + + @Test + fun `requested targeting passes cancellation through without reporting a failure`() = runTest { + val targeting = mockk() + coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("page closed") + + assertTrue( + runCatching { inAppProcessingManager.matchesRequestedTargeting(inAppTargeting("story", targeting)) } + .exceptionOrNull() is CancellationException + ) + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + } + + @Test + fun `choose inApp to show sends no UNKNOWN_ERROR failure on cancellation`() = runTest { + val targeting = mockk(relaxed = true) + coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("session expired") + + runCatching { inAppProcessingManager.chooseInAppToShow(listOf(inAppTargeting("overlay", targeting)), event) } + + verify(exactly = 0) { inAppFailureTracker.sendFailure(any(), any(), any(), any()) } + } + + @Test + fun `place targeting matches from the session cache and collects nothing`() = runTest { + sessionStorageManager.state.customerSegmentationFetchStatus = CustomerSegmentationFetchStatus.SEGMENTATION_FETCH_SUCCESS + sessionStorageManager.state.inAppCustomerSegmentations = SegmentationCheckWrapper( + "Success", + listOf(SegmentationCheckInAppStub.getCustomerSegmentation().copy(segmentation = "segmentationEI", segment = "segmentEI")) + ) + setDIModule(mockkInAppGeoRepository, inAppSegmentationRepositoryTestImpl) + val inSegment = InAppStub.getTargetingSegmentNode().copy( + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ) + + assertTrue(inAppProcessingManagerTestImpl.matchesTargeting(inAppTargeting("ribbon", inSegment), event)) + coVerify(exactly = 0) { gatewayManager.checkCustomerSegmentations(any(), any()) } + verify(exactly = 0) { inAppFailureTracker.collectFailure(any(), any(), any(), any()) } + } + + @Test + fun `send targeted inApp passes cancellation through and sends no targeting`() = runTest { + val targeting = mockk(relaxed = true) + coEvery { targeting.fetchTargetingInfo(any()) } throws CancellationException("session expired") + every { targeting.checkTargeting(any()) } returns true + + val error = runCatching { inAppProcessingManager.sendTargetedInApp(inAppTargeting("overlay", targeting), event) }.exceptionOrNull() + + assertTrue(error is CancellationException) + verify(exactly = 0) { mockInAppRepository.sendUserTargeted(any(), any()) } + } + + @Test + fun `a page question on a fresh session fetches the segmentations once and keeps the circle`() = runTest { + sessionStorageManager.state.currentSessionInApps = listOf(InAppStub.getInApp().copy(id = "story")) + every { inAppSegmentationRepositoryTestImpl.getCustomerSegmentationFetched() } answers { callOriginal() } + coEvery { inAppSegmentationRepositoryTestImpl.fetchCustomerSegmentations() } answers { callOriginal() } + every { inAppSegmentationRepositoryTestImpl.getCustomerSegmentations() } answers { callOriginal() } + mockkObject(DbManager) + try { + coEvery { DbManager.listenConfigurations() } returns flowOf(mockk(relaxed = true)) + coEvery { gatewayManager.checkCustomerSegmentations(any(), any()) } returns + SegmentationCheckInAppStub.getSegmentationCheckResponse().copy("Success", listOf()) + every { inAppMapper.mapToSegmentationCheck(any()) } returns SegmentationCheckWrapper( + "Success", + listOf(SegmentationCheckInAppStub.getCustomerSegmentation().copy(segmentation = "segmentationEI", segment = "segmentEI")) + ) + setDIModule(mockkInAppGeoRepository, inAppSegmentationRepositoryTestImpl) + val inSegment = InAppStub.getTargetingSegmentNode().copy( + kind = Kind.POSITIVE, + segmentationExternalId = "segmentationEI", + segmentExternalId = "segmentEI" + ) + + assertTrue(inAppProcessingManagerTestImpl.matchesRequestedTargeting(inAppTargeting("story", inSegment))) + assertTrue(inAppProcessingManagerTestImpl.matchesRequestedTargeting(inAppTargeting("story", inSegment))) + coVerify(exactly = 1) { gatewayManager.checkCustomerSegmentations(any(), any()) } + } finally { + unmockkObject(DbManager) + } + } + + private fun inAppTargeting(id: String, targeting: TreeTargeting): InApp = InAppStub.getInApp().copy(id = id, targeting = targeting) } diff --git a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt index c87da918..93fc466c 100644 --- a/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt +++ b/sdk/src/test/java/cloud/mindbox/mobile_sdk/managers/GatewayManagerTest.kt @@ -1,5 +1,12 @@ package cloud.mindbox.mobile_sdk.managers +import cloud.mindbox.mobile_sdk.models.operation.request.SegmentationCheckRequest +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.launch +import io.mockk.verify +import io.mockk.slot +import cloud.mindbox.mobile_sdk.models.MindboxRequest import cloud.mindbox.mobile_sdk.models.Configuration import cloud.mindbox.mobile_sdk.network.MindboxServiceGenerator import cloud.mindbox.mobile_sdk.repository.MindboxPreferences @@ -340,4 +347,30 @@ class GatewayManagerTest { .split('=') .last() } + + @Test + fun `checkGeoTargeting cancels its Volley request when the waiting coroutine is cancelled`() = runTest { + val job = launch { gatewayManager.checkGeoTargeting(mockConfiguration) } + runCurrent() + job.cancel() + job.join() + + val request = slot() + verify { mockMindboxServiceGenerator.addToRequestQueue(capture(request)) } + assertTrue(request.captured.isCanceled) + } + + @Test + fun `checkCustomerSegmentations cancels its Volley request when the waiting coroutine is cancelled`() = runTest { + val job = launch { + gatewayManager.checkCustomerSegmentations(mockConfiguration, SegmentationCheckRequest(segmentations = emptyList())) + } + runCurrent() + job.cancel() + job.join() + + val request = slot() + verify { mockMindboxServiceGenerator.addToRequestQueue(capture(request)) } + assertTrue(request.captured.isCanceled) + } }