diff --git a/code/core/api/core.api b/code/core/api/core.api index 4d9277c3d..cf2e4e08d 100644 --- a/code/core/api/core.api +++ b/code/core/api/core.api @@ -323,14 +323,18 @@ public final class com/adobe/marketing/mobile/WrapperType : java/lang/Enum { public final class com/adobe/marketing/mobile/launch/rulesengine/LaunchRule : com/adobe/marketing/mobile/rulesengine/Rule { public static final field $stable I public fun (Lcom/adobe/marketing/mobile/rulesengine/Evaluable;Ljava/util/List;)V + public fun (Lcom/adobe/marketing/mobile/rulesengine/Evaluable;Ljava/util/List;Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta;)V + public synthetic fun (Lcom/adobe/marketing/mobile/rulesengine/Evaluable;Ljava/util/List;Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/adobe/marketing/mobile/rulesengine/Evaluable; public final fun component2 ()Ljava/util/List; - public final fun copy (Lcom/adobe/marketing/mobile/rulesengine/Evaluable;Ljava/util/List;)Lcom/adobe/marketing/mobile/launch/rulesengine/LaunchRule; - public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/launch/rulesengine/LaunchRule;Lcom/adobe/marketing/mobile/rulesengine/Evaluable;Ljava/util/List;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/launch/rulesengine/LaunchRule; + public final fun component3 ()Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta; + public final fun copy (Lcom/adobe/marketing/mobile/rulesengine/Evaluable;Ljava/util/List;Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta;)Lcom/adobe/marketing/mobile/launch/rulesengine/LaunchRule; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/launch/rulesengine/LaunchRule;Lcom/adobe/marketing/mobile/rulesengine/Evaluable;Ljava/util/List;Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta;ILjava/lang/Object;)Lcom/adobe/marketing/mobile/launch/rulesengine/LaunchRule; public fun equals (Ljava/lang/Object;)Z public final fun getCondition ()Lcom/adobe/marketing/mobile/rulesengine/Evaluable; public final fun getConsequenceList ()Ljava/util/List; public fun getEvaluable ()Lcom/adobe/marketing/mobile/rulesengine/Evaluable; + public final fun getMeta ()Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta; public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -341,6 +345,7 @@ public class com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesEngine { public fun evaluateEvent (Lcom/adobe/marketing/mobile/Event;)Ljava/util/List; public fun processEvent (Lcom/adobe/marketing/mobile/Event;)Lcom/adobe/marketing/mobile/Event; public fun replaceRules (Ljava/util/List;)V + public fun setRuleReevaluationInterceptor (Lcom/adobe/marketing/mobile/launch/rulesengine/RuleReevaluationInterceptor;)V } public final class com/adobe/marketing/mobile/launch/rulesengine/RuleConsequence { @@ -359,6 +364,24 @@ public final class com/adobe/marketing/mobile/launch/rulesengine/RuleConsequence public fun toString ()Ljava/lang/String; } +public final class com/adobe/marketing/mobile/launch/rulesengine/RuleMeta { + public static final field $stable I + public fun ()V + public fun (Z)V + public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun copy (Z)Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta; + public static synthetic fun copy$default (Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta;ZILjava/lang/Object;)Lcom/adobe/marketing/mobile/launch/rulesengine/RuleMeta; + public fun equals (Ljava/lang/Object;)Z + public final fun getReEvaluate ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/adobe/marketing/mobile/launch/rulesengine/RuleReevaluationInterceptor { + public abstract fun onReevaluationTriggered (Lcom/adobe/marketing/mobile/Event;Ljava/util/List;Lcom/adobe/marketing/mobile/AdobeCallback;)V +} + public class com/adobe/marketing/mobile/launch/rulesengine/download/RulesLoadResult { public fun (Ljava/lang/String;Lcom/adobe/marketing/mobile/launch/rulesengine/download/RulesLoadResult$Reason;)V public fun getData ()Ljava/lang/String; diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/internal/CoreConstants.kt b/code/core/src/main/java/com/adobe/marketing/mobile/internal/CoreConstants.kt index f548dba69..b8d667721 100644 --- a/code/core/src/main/java/com/adobe/marketing/mobile/internal/CoreConstants.kt +++ b/code/core/src/main/java/com/adobe/marketing/mobile/internal/CoreConstants.kt @@ -13,7 +13,7 @@ package com.adobe.marketing.mobile.internal internal object CoreConstants { const val LOG_TAG = "MobileCore" - const val VERSION = "3.5.0" + const val VERSION = "3.5.1" object EventDataKeys { /** diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRule.kt b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRule.kt index ec205275a..c21b6f63d 100644 --- a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRule.kt +++ b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRule.kt @@ -19,9 +19,14 @@ import com.adobe.marketing.mobile.rulesengine.Rule * * @property condition an object of [Evaluable] * @property consequenceList a list of [RuleConsequence] objects - * @constructor Constructs a new [LaunchRule] + * @property meta an object containing relevant meta data regarding the rule + * @constructor Constructs a new [LaunchRule] (Optional) */ -data class LaunchRule(val condition: Evaluable, val consequenceList: List) : Rule { +data class LaunchRule @JvmOverloads constructor( + val condition: Evaluable, + val consequenceList: List, + val meta: RuleMeta = RuleMeta() +) : Rule { override fun getEvaluable(): Evaluable { return condition } diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesConsequence.kt b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesConsequence.kt index 07a383b84..fcc0ea13e 100644 --- a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesConsequence.kt +++ b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesConsequence.kt @@ -69,6 +69,7 @@ internal class LaunchRulesConsequence( private const val CONSEQUENCE_EVENT_HISTORY_OPERATION_INSERT_IF_NOT_EXISTS = "insertIfNotExists" private const val ASYNC_TIMEOUT = 1000L + private val REEVALUABLE_CONSEQUENCE_TYPES = setOf(CONSEQUENCE_TYPE_SCHEMA) } /** @@ -170,6 +171,36 @@ internal class LaunchRulesConsequence( return processedConsequences } + fun getReevaluableRules(rules: MutableList): MutableList { + val revaluableRules: MutableList = java.util.ArrayList() + for (rule in rules) { + if (rule.meta.reEvaluate && rule.hasReevaluableSupportedConsequence) { + revaluableRules.add(rule) + } + } + return revaluableRules + } + + fun getRulesToHoldForReevaluation(rules: MutableList): MutableList { + val rulesToHold: MutableList = ArrayList() + for (rule in rules) { + if (rule.hasReevaluableSupportedConsequence) { + rulesToHold.add(rule) + } + } + return rulesToHold + } + + private val LaunchRule.hasReevaluableSupportedConsequence: Boolean + get() { + for (consequence in this.consequenceList) { + if (REEVALUABLE_CONSEQUENCE_TYPES.contains(consequence.type)) { + return true + } + } + return false + } + /** * Replace tokens inside the provided [RuleConsequence] with the right value * diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesEngine.java b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesEngine.java index 577282742..9b48f039c 100644 --- a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesEngine.java +++ b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesEngine.java @@ -27,6 +27,8 @@ public class LaunchRulesEngine { + private RuleReevaluationInterceptor reevaluationInterceptor; + @VisibleForTesting static final String RULES_ENGINE_NAME = "name"; private final String name; private final RulesEngine ruleRulesEngine; @@ -61,6 +63,15 @@ public LaunchRulesEngine(@NonNull final String name, @NonNull final ExtensionApi this.ruleRulesEngine = ruleEngine; } + /** + * Sets the {@link RuleReevaluationInterceptor} for this {@link LaunchRulesEngine}. + * + * @param interceptor the interceptor to be set. + */ + public void setRuleReevaluationInterceptor(final RuleReevaluationInterceptor interceptor) { + this.reevaluationInterceptor = interceptor; + } + /** * Set a new set of rules, the new rules replace the current rules. * @@ -102,20 +113,19 @@ public Event processEvent(@NonNull final Event event) { throw new IllegalArgumentException("Cannot evaluate null event."); } - final List matchedRules = - ruleRulesEngine.evaluate(new LaunchTokenFinder(event, extensionApi)); - // if initial rule set has not been received, cache the event to be processed // when rules are set if (!initialRulesReceived) { handleCaching(event); } - return launchRulesConsequence.process(event, matchedRules); + + return processAndIntercept(event); } /** * Evaluates the supplied event against the all current rules and returns the {@link - * RuleConsequence}'s from the rules that matched the supplied event. + * RuleConsequence}'s from the rules that matched the supplied event. This method is synchronous + * and does not trigger any re-evaluation interceptors. * * @param event the event to be evaluated * @return a {@code List} that match the supplied event. @@ -125,13 +135,50 @@ public List evaluateEvent(@NonNull final Event event) { throw new IllegalArgumentException("Cannot evaluate null event."); } - final List matchedRules = - ruleRulesEngine.evaluate(new LaunchTokenFinder(event, extensionApi)); + final LaunchTokenFinder tokenFinder = new LaunchTokenFinder(event, extensionApi); + final List matchedRules = ruleRulesEngine.evaluate(tokenFinder); // get token replaced consequences return launchRulesConsequence.evaluate(event, matchedRules); } + private Event processAndIntercept(final Event event) { + final LaunchTokenFinder tokenFinder = new LaunchTokenFinder(event, extensionApi); + final List matchedRules = ruleRulesEngine.evaluate(tokenFinder); + + // If no interceptor is set, process consequences immediately. + if (reevaluationInterceptor == null) { + return launchRulesConsequence.process(event, matchedRules); + } + + final List revaluableRules = + launchRulesConsequence.getReevaluableRules(matchedRules); + if (revaluableRules.isEmpty()) { + return launchRulesConsequence.process(event, matchedRules); + } + + final List rulesToHold = + launchRulesConsequence.getRulesToHoldForReevaluation(matchedRules); + final ArrayList rulesToProcess = new ArrayList<>(matchedRules); + rulesToProcess.removeAll(rulesToHold); + Event processedEvent = launchRulesConsequence.process(event, rulesToProcess); + reevaluationInterceptor.onReevaluationTriggered( + processedEvent, + revaluableRules, + (success) -> { + // After the interceptor has updated the rules, re-evaluate and process + // consequences. If update is not success intercepted rules are not + // processed + if (success) { + final ArrayList newlyMatchedRules = + new ArrayList<>(ruleRulesEngine.evaluate(tokenFinder)); + newlyMatchedRules.removeAll(rulesToProcess); + launchRulesConsequence.process(processedEvent, newlyMatchedRules); + } + }); + return processedEvent; + } + List getRules() { return ruleRulesEngine.getRules(); } @@ -142,14 +189,14 @@ int getCachedEventCount() { } private void reprocessCachedEvents() { - for (Event cachedEvent : cachedEvents) { - final List matchedRules = - ruleRulesEngine.evaluate(new LaunchTokenFinder(cachedEvent, extensionApi)); - launchRulesConsequence.process(cachedEvent, matchedRules); - } - // clear cached events and set the flag to indicate that rules were set at least once + // To avoid ConcurrentModificationException, we process a copy of the cached events. + final List eventsToReprocess = new ArrayList<>(cachedEvents); cachedEvents.clear(); - initialRulesReceived = true; + initialRulesReceived = true; // Set before processing to prevent re-caching + + for (final Event cachedEvent : eventsToReprocess) { + processEvent(cachedEvent); + } } private void handleCaching(final Event event) { diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/RuleMeta.kt b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/RuleMeta.kt new file mode 100644 index 000000000..cc80266a6 --- /dev/null +++ b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/RuleMeta.kt @@ -0,0 +1,23 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.launch.rulesengine + +/** + * The data class representing a rule's consequence object + * + * @property reEvaluate the flag reEvaluate for sensitive rules + * @constructor Constructs a new [RuleMeta] + */ + +data class RuleMeta( + val reEvaluate: Boolean = false, +) diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/RuleReevaluationInterceptor.kt b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/RuleReevaluationInterceptor.kt new file mode 100644 index 000000000..24ca18afc --- /dev/null +++ b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/RuleReevaluationInterceptor.kt @@ -0,0 +1,28 @@ +/* + Copyright 2026 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.launch.rulesengine + +import com.adobe.marketing.mobile.AdobeCallback +import com.adobe.marketing.mobile.Event + +/** + * An interface for an interceptor that is triggered when a [LaunchRule] with the + * re-evaluation flag is triggered. The interceptor is responsible for updating the rules and + * invoking the [AdobeCallback] when complete. success is passed as a boolean value. + */ +interface RuleReevaluationInterceptor { + fun onReevaluationTriggered( + event: Event?, + revaluableRules: List?, + callback: AdobeCallback? + ) +} diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONMeta.kt b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONMeta.kt new file mode 100644 index 000000000..3926e14c8 --- /dev/null +++ b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONMeta.kt @@ -0,0 +1,54 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.launch.rulesengine.json + +import com.adobe.marketing.mobile.launch.rulesengine.RuleMeta +import org.json.JSONObject + +/** + * Generic utility to parse a meta object from JSON for rules engine. + * + * This class is responsible for extracting meta information from a JSON object. + * Currently, it only parses the `reEvaluate` flag, but it is designed to be extended + * in the future to support additional meta keys as needed. + * + * Example of a meta JSON object: + * ```json + * { + * "reEvaluate": true + * } + * ``` + * + * Future meta keys can be added to this class as requirements evolve. + */ +internal class JSONMeta private constructor( + private val reEvaluate: Boolean, +) { + companion object { + private const val KEY_REEVALUATE = "reEvaluate" + operator fun invoke(jsonObject: JSONObject?): JSONMeta { + return JSONMeta( + jsonObject?.optBoolean(KEY_REEVALUATE, false) ?: false + ) + } + } + + /** + * Converts this object into a validated [RuleMeta]. + * + * @return a valid [RuleMeta] or `null` if any validation check fails + */ + @JvmSynthetic + internal fun toMeta(): RuleMeta { + return RuleMeta(reEvaluate) + } +} diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONRule.kt b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONRule.kt index d50aabde3..cd591ddfe 100644 --- a/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONRule.kt +++ b/code/core/src/main/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONRule.kt @@ -25,13 +25,15 @@ import org.json.JSONObject */ internal class JSONRule private constructor( val condition: JSONObject, - val consequences: JSONArray + val consequences: JSONArray, + val meta: JSONObject? ) { companion object { private const val LOG_TAG = "JSONRule" private const val KEY_CONDITION = "condition" private const val KEY_CONSEQUENCES = "consequences" + private const val KEY_META = "meta" /** * Optionally constructs a new [JSONRule] @@ -43,6 +45,7 @@ internal class JSONRule private constructor( if (jsonObject !is JSONObject) return null val condition = jsonObject.getJSONObject(KEY_CONDITION) val consequences = jsonObject.getJSONArray(KEY_CONSEQUENCES) + val meta = jsonObject.optJSONObject(KEY_META) if (condition !is JSONObject || consequences !is JSONArray) { Log.error( LaunchRulesEngineConstants.LOG_TAG, @@ -51,7 +54,7 @@ internal class JSONRule private constructor( ) return null } - return JSONRule(condition, consequences) + return JSONRule(condition, consequences, meta) } } @@ -74,6 +77,7 @@ internal class JSONRule private constructor( val consequenceList = consequences.map { JSONConsequence(it as? JSONObject)?.toRuleConsequence() ?: throw Exception() } - return LaunchRule(evaluable, consequenceList) + val metaObject = JSONMeta(meta).toMeta() + return LaunchRule(evaluable, consequenceList, metaObject) } } diff --git a/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRuleJavaTests.java b/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRuleJavaTests.java new file mode 100644 index 000000000..83ead7557 --- /dev/null +++ b/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRuleJavaTests.java @@ -0,0 +1,54 @@ +/* + Copyright 2026 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.launch.rulesengine; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +import com.adobe.marketing.mobile.rulesengine.Evaluable; +import java.util.Collections; +import org.junit.Test; + +public class LaunchRuleJavaTests { + + private final Evaluable mockEvaluable = mock(Evaluable.class); + private final RuleConsequence mockConsequence = mock(RuleConsequence.class); + + @Test + public void testLaunchRuleCreationWithoutRevaluable() { + final LaunchRule rule = + new LaunchRule(mockEvaluable, Collections.singletonList(mockConsequence)); + assertFalse(rule.getMeta().getReEvaluate()); + assertEquals(mockEvaluable, rule.getEvaluable()); + } + + @Test + public void testLaunchRuleCreationWithRevaluableFalse() { + final LaunchRule rule = + new LaunchRule(mockEvaluable, Collections.singletonList(mockConsequence)); + assertFalse(rule.getMeta().getReEvaluate()); + assertEquals(mockEvaluable, rule.getEvaluable()); + } + + @Test + public void testLaunchRuleCreationWithRevaluableTrue() { + final LaunchRule rule = + new LaunchRule( + mockEvaluable, + Collections.singletonList(mockConsequence), + new RuleMeta(true)); + assertTrue(rule.getMeta().getReEvaluate()); + assertEquals(mockEvaluable, rule.getEvaluable()); + } +} diff --git a/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRuleTests.kt b/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRuleTests.kt new file mode 100644 index 000000000..59fd032c5 --- /dev/null +++ b/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRuleTests.kt @@ -0,0 +1,46 @@ +/* + Copyright 2026 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.launch.rulesengine + +import com.adobe.marketing.mobile.rulesengine.Evaluable +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock + +class LaunchRuleTests { + + private val mockEvaluable: Evaluable = mock() + private val mockConsequence: RuleConsequence = mock() + + @Test + fun `test LaunchRule creation without revaluable parameter`() { + val rule = LaunchRule(mockEvaluable, listOf(mockConsequence)) + assertFalse(rule.meta.reEvaluate) + assertEquals(mockEvaluable, rule.evaluable) + } + + @Test + fun `test LaunchRule creation with revaluable as false`() { + val rule = LaunchRule(mockEvaluable, listOf(mockConsequence)) + assertFalse(rule.meta.reEvaluate) + assertEquals(mockEvaluable, rule.evaluable) + } + + @Test + fun `test LaunchRule creation with revaluable as true`() { + val rule = LaunchRule(mockEvaluable, listOf(mockConsequence), RuleMeta(true)) + assertTrue(rule.meta.reEvaluate) + assertEquals(mockEvaluable, rule.evaluable) + } +} diff --git a/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesEngineReevaluationTests.kt b/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesEngineReevaluationTests.kt new file mode 100644 index 000000000..8798126dd --- /dev/null +++ b/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/LaunchRulesEngineReevaluationTests.kt @@ -0,0 +1,689 @@ +/* + Copyright 2022 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.launch.rulesengine + +import com.adobe.marketing.mobile.AdobeCallback +import com.adobe.marketing.mobile.Event +import com.adobe.marketing.mobile.EventSource +import com.adobe.marketing.mobile.EventType +import com.adobe.marketing.mobile.ExtensionApi +import com.adobe.marketing.mobile.launch.rulesengine.json.JSONRulesParser +import com.adobe.marketing.mobile.test.util.readTestResources +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Unit tests for LaunchRulesEngine reevaluation interceptor functionality. + * + * These tests cover the reevaluation feature which allows rules with schema consequences + * to be held back while an interceptor fetches updated rules before processing. + */ +@RunWith(MockitoJUnitRunner.Silent::class) +class LaunchRulesEngineReevaluationTests { + private lateinit var extensionApi: ExtensionApi + private lateinit var launchRulesEngine: LaunchRulesEngine + + @Before + fun setup() { + extensionApi = mock(ExtensionApi::class.java) + launchRulesEngine = LaunchRulesEngine("TestLaunchRulesEngine", extensionApi) + } + + // ======================================== + // Category 1: Basic Interceptor Triggering + // ======================================== + + @Test + fun `Test reevaluation interceptor is triggered when reevaluable schema rule matches`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + val eventCaptor: KArgumentCaptor = argumentCaptor() + val rulesCaptor: KArgumentCaptor> = argumentCaptor() + val callbackCaptor: KArgumentCaptor> = argumentCaptor() + + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + eventCaptor.capture(), + rulesCaptor.capture(), + callbackCaptor.capture() + ) + + assertEquals(testEvent.uniqueIdentifier, eventCaptor.firstValue.uniqueIdentifier) + assertEquals(1, rulesCaptor.firstValue.size) + assertTrue(rulesCaptor.firstValue[0].meta.reEvaluate) + assertEquals("schema", rulesCaptor.firstValue[0].consequenceList[0].type) + } + + @Test + fun `Test reevaluation interceptor is NOT triggered when reEvaluate is false`() { + val json = readTestResources("rules_module_tests/rules_testNonReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + // Interceptor should NOT be called for non-reevaluable rules + Mockito.verifyNoInteractions(mockInterceptor) + } + + @Test + fun `Test reevaluation interceptor is NOT triggered when no schema consequences exist`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_nonSchemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + // Interceptor should NOT be called when there are no schema consequences + Mockito.verifyNoInteractions(mockInterceptor) + } + + @Test + fun `Test reevaluation interceptor is NOT triggered when rule does not match`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + // Event with different type that won't match the rule + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.lifecycle", + "com.adobe.eventSource.requestContent" + ).build() + + launchRulesEngine.processEvent(testEvent) + + // Interceptor should NOT be called when rules don't match + Mockito.verifyNoInteractions(mockInterceptor) + } + + @Test + fun `Test reevaluation interceptor is NOT triggered when interceptor not set`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + // Don't set interceptor + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + val eventCaptor: KArgumentCaptor = argumentCaptor() + launchRulesEngine.processEvent(testEvent) + + // Should dispatch consequence event normally + verify(extensionApi, Mockito.atLeastOnce()).dispatch(eventCaptor.capture()) + + // Verify a consequence event was dispatched + val dispatchedEvents = eventCaptor.allValues + val consequenceEvents = dispatchedEvents.filter { + it.type == EventType.RULES_ENGINE && it.source == EventSource.RESPONSE_CONTENT + } + assertTrue(consequenceEvents.isNotEmpty()) + } + + // ======================================== + // Category 2: Rule Holding and Separation + // ======================================== + + @Test + fun `Test all schema consequence rules are held for reevaluation`() { + // This test verifies that when we have SEPARATE RULES (not mixed consequences in one rule): + // 1. Reevaluable rules with schema consequences -> held + // 2. Reevaluable rules with add consequences -> processed immediately (add is not a reevaluable-supported type) + // 3. Non-reevaluable rules with add consequences -> processed immediately + + val reEvaluateRuleFile = readTestResources("rules_module_tests/rules_testReevaluable_mixedRules.json") + assertNotNull(reEvaluateRuleFile) + val reEvaluateRules = JSONRulesParser.parse(reEvaluateRuleFile, extensionApi) + // This file has 3 rules (all with reEvaluate=true): + // - Rule 1: schema consequence (will be held) + // - Rule 2: add consequence (will process immediately - add is not a reevaluable-supported type) + // - Rule 3: schema consequence (will be held) + + // Load an additional NON-reevaluable add rule + val addRuleFile = readTestResources("rules_module_tests/rules_testNonReevaluable_addConsequence.json") + assertNotNull(addRuleFile) + val addRule = JSONRulesParser.parse(addRuleFile, extensionApi) + assertNotNull(addRule) + + launchRulesEngine.replaceRules(reEvaluateRules) + launchRulesEngine.addRules(addRule) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + val eventCaptor: KArgumentCaptor = argumentCaptor() + val processedEvent = launchRulesEngine.processEvent(testEvent) + + // Verify interceptor was called + val rulesCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + eventCaptor.capture(), + rulesCaptor.capture(), + any() + ) + + // Only the 2 reevaluable schema rules should be passed to interceptor + // The add rules (both reevaluable and non-reevaluable) should NOT be in the list + assertEquals(1, rulesCaptor.firstValue.size) + assertTrue(rulesCaptor.firstValue[0].meta.reEvaluate) + + // Verify that BOTH add rule consequences were processed immediately + // (Even the reevaluable one, because add is not a reevaluable-supported consequence type) + assertNotNull(processedEvent.eventData) + // Check that initial data is preserved + assertEquals("initialValue", processedEvent.eventData?.get("initialKey")) + // Check that attached data was added + val attachedData = processedEvent.eventData?.get("attached_data") as? Map<*, *> + assertEquals("addedValue", attachedData?.get("addedKey")) + } + + @Test + fun `Test single rule with mixed consequences - all consequences held together`() { + // This test verifies that if a SINGLE RULE has BOTH schema and non-schema consequences, + // the ENTIRE RULE is held (including the non-schema consequences) + + val json = readTestResources("rules_module_tests/rules_testReevaluable_singleRuleMixedConsequences.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + val processedEvent = launchRulesEngine.processEvent(testEvent) + + // Verify interceptor was called + val rulesCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + any(), + rulesCaptor.capture(), + any() + ) + + // The single reevaluable rule should be passed to interceptor + assertEquals(1, rulesCaptor.firstValue.size) + assertTrue(rulesCaptor.firstValue[0].meta.reEvaluate) + // Verify the rule has both consequences + assertEquals(2, rulesCaptor.firstValue[0].consequenceList.size) + + // CRITICAL: The add consequence should NOT have been processed immediately + // because the entire rule is held due to having a schema consequence + assertNotNull(processedEvent.eventData) + // Check that initial data is preserved + assertEquals("initialValue", processedEvent.eventData?.get("initialKey")) + // The attached_data should NOT be present because the rule is held + val attachedData = processedEvent.eventData?.get("attached_data") as? Map<*, *> + assertEquals(null, attachedData?.get("mixedRuleKey")) + } + + @Test + fun `Test non-schema rules processed immediately while schema rules held`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_eventModification.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + val processedEvent = launchRulesEngine.processEvent(testEvent) + + // Verify the event was modified by the add consequence (processed immediately) + assertNotNull(processedEvent.eventData) + assertEquals("modifiedValue", processedEvent.eventData?.get("modifiedKey")) + assertEquals("12345", processedEvent.eventData?.get("timestamp")) + + // Verify interceptor was called (schema consequence held) + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + } + + @Test + fun `Test multiple reevaluable schema rules trigger single interceptor call`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_multipleSchemaRules.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + val rulesCaptor: KArgumentCaptor> = argumentCaptor() + + // Interceptor should be called exactly once + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + any(), + rulesCaptor.capture(), + any() + ) + + // Both reevaluable rules should be passed + assertEquals(2, rulesCaptor.firstValue.size) + assertTrue(rulesCaptor.firstValue.all { it.meta.reEvaluate }) + } + + // ======================================== + // Category 3: Callback and Re-evaluation + // ======================================== + + @Test + fun `Test callback completion processes held rules`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + val eventCaptor: KArgumentCaptor = argumentCaptor() + + launchRulesEngine.processEvent(testEvent) + + val callbackCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + any(), + any(), + callbackCaptor.capture() + ) + + // Invoke the callback to simulate completion + callbackCaptor.firstValue.call(true) + + // Verify that consequence event was dispatched after callback + verify(extensionApi, Mockito.atLeastOnce()).dispatch(eventCaptor.capture()) + + val dispatchedEvents = eventCaptor.allValues + val consequenceEvents = dispatchedEvents.filter { + it.type == EventType.RULES_ENGINE && it.source == EventSource.RESPONSE_CONTENT + } + assertTrue(consequenceEvents.isNotEmpty()) + } + + @Test + fun `Test callback re-evaluates event against current rules`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + val callbackCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + any(), + any(), + callbackCaptor.capture() + ) + + // Simulate adding new rules before callback + val newJson = readTestResources("rules_module_tests/rules_testReevaluable_nonSchemaConsequence.json") + assertNotNull(newJson) + val newRules = JSONRulesParser.parse(newJson, extensionApi) + assertNotNull(newRules) + launchRulesEngine.addRules(newRules) + + // Invoke callback - should re-evaluate with new rules + callbackCaptor.firstValue.call(true) + + // The new rule (add consequence) should have been evaluated and event modified + // We can't directly test this without more complex mocking, but we verified callback executes + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + } + + @Test + fun `Test event passed to interceptor is correct`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event-unique", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData( + mapOf( + "customKey" to "customValue", + "userId" to "12345" + ) + ).build() + + launchRulesEngine.processEvent(testEvent) + + val eventCaptor: KArgumentCaptor = argumentCaptor() + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + eventCaptor.capture(), + any(), + any() + ) + + // Verify the exact event was passed + assertEquals(testEvent.uniqueIdentifier, eventCaptor.firstValue.uniqueIdentifier) + assertEquals("test-event-unique", eventCaptor.firstValue.name) + assertEquals("customValue", eventCaptor.firstValue.eventData?.get("customKey")) + assertEquals("12345", eventCaptor.firstValue.eventData?.get("userId")) + } + + // ======================================== + // Category 4: evaluateEvent() bypasses reevaluation + // ======================================== + + @Test + fun `Test evaluateEvent does NOT trigger reevaluation interceptor`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + // Use evaluateEvent instead of processEvent + val consequences = launchRulesEngine.evaluateEvent(testEvent) + + // Interceptor should NOT be called + Mockito.verifyNoInteractions(mockInterceptor) + + // Consequences should be returned synchronously + assertEquals(1, consequences.size) + assertEquals("schema", consequences[0].type) + } + + // ======================================== + // Category 5: Edge Cases + // ======================================== + + @Test + fun `Test reevaluation with event modification from immediate rules`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_eventModification.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData( + mapOf("originalKey" to "originalValue") + ).build() + + val processedEvent = launchRulesEngine.processEvent(testEvent) + + // Verify immediate modification happened + assertNotNull(processedEvent.eventData) + assertEquals("originalValue", processedEvent.eventData?.get("originalKey")) + assertEquals("modifiedValue", processedEvent.eventData?.get("modifiedKey")) + assertEquals("12345", processedEvent.eventData?.get("timestamp")) + + // Verify interceptor was called + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + } + + @Test + fun `Test reevaluation rules list contains only reevaluable rules`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_mixedRules.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + val rulesCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + any(), + rulesCaptor.capture(), + any() + ) + + val reevaluableRules = rulesCaptor.firstValue + // Only the reevaluable schema rule should be in the list + assertEquals(1, reevaluableRules.size) + assertTrue(reevaluableRules[0].meta.reEvaluate) + assertTrue(reevaluableRules[0].consequenceList.any { it.type == "schema" }) + } + + @Test + fun `Test reevaluation with callback invoked multiple times has no effect`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + val callbackCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered( + any(), + any(), + callbackCaptor.capture() + ) + + val callback = callbackCaptor.firstValue + + // Invoke callback multiple times + callback.call(true) + callback.call(true) + callback.call(true) + + // Should not cause issues - just processes rules multiple times + // This is implementation-defined behavior + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + } + + @Test + fun `Test reevaluation interceptor can be updated`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor1 = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor1) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + // First interceptor should be called + verify(mockInterceptor1, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + + // Update interceptor + val mockInterceptor2 = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor2) + + launchRulesEngine.processEvent(testEvent) + + // Second interceptor should be called + verify(mockInterceptor2, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + + // First interceptor should not be called again + verify(mockInterceptor1, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + } + + @Test + fun `Test reevaluation interceptor can be cleared`() { + val json = readTestResources("rules_module_tests/rules_testReevaluable_schemaConsequence.json") + assertNotNull(json) + val rules = JSONRulesParser.parse(json, extensionApi) + assertNotNull(rules) + launchRulesEngine.replaceRules(rules) + + val mockInterceptor = mock(RuleReevaluationInterceptor::class.java) + launchRulesEngine.setRuleReevaluationInterceptor(mockInterceptor) + + val testEvent = Event.Builder( + "test-event", + "com.adobe.eventType.generic", + "com.adobe.eventSource.requestContent" + ).setEventData(mapOf("initialKey" to "initialValue")).build() + + launchRulesEngine.processEvent(testEvent) + + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + + // Clear interceptor by setting to null + launchRulesEngine.setRuleReevaluationInterceptor(null) + + val eventCaptor: KArgumentCaptor = argumentCaptor() + launchRulesEngine.processEvent(testEvent) + + // Should not call interceptor again, but should dispatch consequence normally + verify(mockInterceptor, Mockito.times(1)).onReevaluationTriggered(any(), any(), any()) + verify(extensionApi, Mockito.atLeastOnce()).dispatch(eventCaptor.capture()) + } +} diff --git a/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONRuleTests.kt b/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONRuleTests.kt index 65867219a..f1a69c2b2 100644 --- a/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONRuleTests.kt +++ b/code/core/src/test/java/com/adobe/marketing/mobile/launch/rulesengine/json/JSONRuleTests.kt @@ -22,6 +22,7 @@ import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.junit.MockitoJUnitRunner import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -68,6 +69,7 @@ class JSONRuleTests { assertEquals(1, launchRule.consequenceList.size) assertEquals("pb", launchRule.consequenceList[0].type) assertTrue(launchRule.condition is ComparisonExpression<*, *>) + assertFalse(launchRule.meta.reEvaluate) } @Test diff --git a/code/core/src/test/resources/rules_module_tests/rules_testNonReevaluable_addConsequence.json b/code/core/src/test/resources/rules_module_tests/rules_testNonReevaluable_addConsequence.json new file mode 100644 index 000000000..7b024f47b --- /dev/null +++ b/code/core/src/test/resources/rules_module_tests/rules_testNonReevaluable_addConsequence.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "non-reevaluable-add-1", + "type": "add", + "detail": { + "eventdata": { + "attached_data": { + "addedKey": "addedValue" + } + } + } + } + ], + "meta": { + "reEvaluate": false + } + } + ] +} diff --git a/code/core/src/test/resources/rules_module_tests/rules_testNonReevaluable_schemaConsequence.json b/code/core/src/test/resources/rules_module_tests/rules_testNonReevaluable_schemaConsequence.json new file mode 100644 index 000000000..9759bc9c6 --- /dev/null +++ b/code/core/src/test/resources/rules_module_tests/rules_testNonReevaluable_schemaConsequence.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "non-reevaluable-schema-1", + "type": "schema", + "detail": { + "id": "test-schema-id", + "schema": "https://ns.adobe.com/personalization/message", + "data": { + "content": "test-content" + } + } + } + ], + "meta": { + "reEvaluate": false + } + } + ] +} diff --git a/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_eventModification.json b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_eventModification.json new file mode 100644 index 000000000..63a8b5148 --- /dev/null +++ b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_eventModification.json @@ -0,0 +1,76 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "modify-event-add", + "type": "add", + "detail": { + "eventdata": { + "modifiedKey": "modifiedValue", + "timestamp": "12345" + } + } + } + ], + "meta": { + "reEvaluate": false + } + }, + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "held-schema-consequence", + "type": "schema", + "detail": { + "id": "test-schema-id", + "schema": "https://ns.adobe.com/personalization/message", + "data": { + "content": "schema-content" + } + } + } + ], + "meta": { + "reEvaluate": true + } + } + ] +} diff --git a/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_mixedRules.json b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_mixedRules.json new file mode 100644 index 000000000..777b84b4d --- /dev/null +++ b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_mixedRules.json @@ -0,0 +1,107 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "reevaluable-schema-mixed", + "type": "schema", + "detail": { + "id": "test-schema-id", + "schema": "https://ns.adobe.com/personalization/message", + "data": { + "content": "schema-content" + } + } + } + ], + "meta": { + "reEvaluate": true + } + }, + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "non-reevaluable-add-mixed", + "type": "add", + "detail": { + "eventdata": { + "attached_data": { + "addedKey": "addedValue" + } + } + } + } + ] + }, + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "non-reevaluable-schema-mixed", + "type": "schema", + "detail": { + "id": "test-schema-id-2", + "schema": "https://ns.adobe.com/personalization/message", + "data": { + "content": "non-reevaluable-schema" + } + } + } + ] + } + ] +} diff --git a/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_multipleSchemaRules.json b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_multipleSchemaRules.json new file mode 100644 index 000000000..7549b40ab --- /dev/null +++ b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_multipleSchemaRules.json @@ -0,0 +1,77 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "reevaluable-schema-1", + "type": "schema", + "detail": { + "id": "test-schema-id-1", + "schema": "https://ns.adobe.com/personalization/message", + "data": { + "messageId": "message-1" + } + } + } + ], + "meta": { + "reEvaluate": true + } + }, + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "reevaluable-schema-2", + "type": "schema", + "detail": { + "id": "test-schema-id-2", + "schema": "https://ns.adobe.com/personalization/message", + "data": { + "messageId": "message-2" + } + } + } + ], + "meta": { + "reEvaluate": true + } + } + ] +} diff --git a/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_nonSchemaConsequence.json b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_nonSchemaConsequence.json new file mode 100644 index 000000000..c9412573a --- /dev/null +++ b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_nonSchemaConsequence.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "reevaluable-add-1", + "type": "add", + "detail": { + "eventdata": { + "addedKey": "addedValue" + } + } + } + ], + "meta": { + "reEvaluate": true + } + } + ] +} diff --git a/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_schemaConsequence.json b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_schemaConsequence.json new file mode 100644 index 000000000..38481991a --- /dev/null +++ b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_schemaConsequence.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + }, + { + "type": "matcher", + "definition": { + "key": "~source", + "matcher": "eq", + "values": [ + "com.adobe.eventSource.requestContent" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "reevaluable-schema-1", + "type": "schema", + "detail": { + "id": "test-schema-id", + "schema": "https://ns.adobe.com/personalization/message", + "data": { + "content": "test-content", + "key": "value" + } + } + } + ], + "meta": { + "reEvaluate": true + } + } + ] +} diff --git a/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_singleRuleMixedConsequences.json b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_singleRuleMixedConsequences.json new file mode 100644 index 000000000..f3d7ee20b --- /dev/null +++ b/code/core/src/test/resources/rules_module_tests/rules_testReevaluable_singleRuleMixedConsequences.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "rules": [ + { + "condition": { + "type": "group", + "definition": { + "logic": "and", + "conditions": [ + { + "type": "matcher", + "definition": { + "key": "~type", + "matcher": "eq", + "values": [ + "com.adobe.eventType.generic" + ] + } + } + ] + } + }, + "consequences": [ + { + "id": "mixed-rule-schema", + "type": "schema", + "detail": { + "id": "test-schema-id", + "schema": "https://ns.adobe.com/personalization/message", + "data": { + "content": "schema-content" + } + } + }, + { + "id": "mixed-rule-add", + "type": "add", + "detail": { + "eventdata": { + "attached_data": { + "mixedRuleKey": "mixedRuleValue" + } + } + } + } + ], + "meta": { + "reEvaluate": true + } + } + ] +} diff --git a/code/gradle.properties b/code/gradle.properties index de0242d3d..883c6a22b 100644 --- a/code/gradle.properties +++ b/code/gradle.properties @@ -5,7 +5,7 @@ android.useAndroidX=true #Maven artifacts #Core extension -coreExtensionVersion=3.5.0 +coreExtensionVersion=3.5.1 coreExtensionName=core coreMavenRepoName=AdobeMobileCoreSdk coreMavenRepoDescription=Android Core Extension for Adobe Mobile Marketing