diff --git a/pom.xml b/pom.xml index a77f301..110485e 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.mixpanel mixpanel-java - 1.6.1 + 1.7.0 jar mixpanel-java @@ -122,6 +122,18 @@ + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + src/test/resources/logging.properties + + + @@ -138,5 +150,20 @@ json 20231013 + + + io.github.jamsesso + json-logic-java + 1.1.0 + + + + + + com.fasterxml.jackson.core + jackson-databind + 2.20.0 + provided + diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java index ed4ba32..377810d 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -3,6 +3,8 @@ import java.util.Collections; import java.util.Map; +import org.json.JSONObject; + /** * Represents a rollout rule within a feature flag experiment. *

@@ -15,7 +17,8 @@ */ public final class Rollout { private final float rolloutPercentage; - private final Map runtimeEvaluationDefinition; + private final JSONObject runtimeEvaluationRule; + private final Map legacyRuntimeEvaluationDefinition; private final VariantOverride variantOverride; private final Map variantSplits; @@ -23,15 +26,37 @@ public final class Rollout { * Creates a new Rollout with all parameters. * * @param rolloutPercentage the percentage of users to include (0.0-1.0) - * @param runtimeEvaluationDefinition optional map of property name to expected value for targeting + * @param runtimeEvaluationRule optional JSONObject containing jsonLogic rule for targeting + * @param legacyRuntimeEvaluationDefinition optional map of property name to expected value for targeting + * @param variantOverride optional variant override to force selection + * @param variantSplits optional map of variant key to split percentage at assignment group level + */ + public Rollout(float rolloutPercentage, JSONObject runtimeEvaluationRule, Map legacyRuntimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { + this.rolloutPercentage = rolloutPercentage; + this.legacyRuntimeEvaluationDefinition = legacyRuntimeEvaluationDefinition != null + ? Collections.unmodifiableMap(legacyRuntimeEvaluationDefinition) + : null; + this.runtimeEvaluationRule = runtimeEvaluationRule; + this.variantOverride = variantOverride; + this.variantSplits = variantSplits != null + ? Collections.unmodifiableMap(variantSplits) + : null; + } + + /** + * Creates a new Rollout with all legacy parameters. + * + * @param rolloutPercentage the percentage of users to include (0.0-1.0) + * @param legacyRuntimeEvaluationDefinition optional map of property name to expected value for targeting * @param variantOverride optional variant override to force selection * @param variantSplits optional map of variant key to split percentage at assignment group level */ - public Rollout(float rolloutPercentage, Map runtimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { + public Rollout(float rolloutPercentage, Map legacyRuntimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { this.rolloutPercentage = rolloutPercentage; - this.runtimeEvaluationDefinition = runtimeEvaluationDefinition != null - ? Collections.unmodifiableMap(runtimeEvaluationDefinition) + this.legacyRuntimeEvaluationDefinition = legacyRuntimeEvaluationDefinition != null + ? Collections.unmodifiableMap(legacyRuntimeEvaluationDefinition) : null; + this.runtimeEvaluationRule = null; this.variantOverride = variantOverride; this.variantSplits = variantSplits != null ? Collections.unmodifiableMap(variantSplits) @@ -57,8 +82,8 @@ public float getRolloutPercentage() { /** * @return optional map of property name to expected value for runtime evaluation, or null if not set */ - public Map getRuntimeEvaluationDefinition() { - return runtimeEvaluationDefinition; + public Map getLegacyRuntimeEvaluationDefinition() { + return legacyRuntimeEvaluationDefinition; } /** @@ -75,11 +100,18 @@ public Map getVariantSplits() { return variantSplits; } + /** + * @return true if this rollout has runtime evaluation criteria + */ + public boolean hasLegacyRuntimeEvaluation() { + return legacyRuntimeEvaluationDefinition != null && !legacyRuntimeEvaluationDefinition.isEmpty(); + } + /** * @return true if this rollout has runtime evaluation criteria */ public boolean hasRuntimeEvaluation() { - return runtimeEvaluationDefinition != null && !runtimeEvaluationDefinition.isEmpty(); + return runtimeEvaluationRule != null && runtimeEvaluationRule.length() > 0; } /** @@ -100,9 +132,17 @@ public boolean hasVariantSplits() { public String toString() { return "Rollout{" + "rolloutPercentage=" + rolloutPercentage + - ", runtimeEvaluationDefinition=" + runtimeEvaluationDefinition + + ", legacyRuntimeEvaluationDefinition=" + legacyRuntimeEvaluationDefinition + + ", runtimeEvaluationRule=" + runtimeEvaluationRule + ", variantOverride='" + variantOverride + '\'' + ", variantSplits=" + variantSplits + '}'; } + + /** + * @return optional JSONObject containing JsonLogic rule for runtime evaluation, or null if not set + */ + public JSONObject getRuntimeEvaluationRule() { + return runtimeEvaluationRule; + } } diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index 7e064a2..2b03b64 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -4,6 +4,7 @@ import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; import com.mixpanel.mixpanelapi.featureflags.model.*; import com.mixpanel.mixpanelapi.featureflags.util.HashUtils; +import com.mixpanel.mixpanelapi.featureflags.util.JsonLogicEngine; import org.json.JSONArray; import org.json.JSONObject; @@ -19,6 +20,7 @@ import java.util.logging.Level; import java.util.logging.Logger; + /** * Local feature flags evaluation provider. *

@@ -288,15 +290,18 @@ private Rollout parseRollout(JSONObject json) { } } - Map runtimeEval = null; - JSONObject runtimeEvalJson = json.optJSONObject("runtime_evaluation_definition"); - if (runtimeEvalJson != null) { - runtimeEval = new HashMap<>(); - for (String key : runtimeEvalJson.keySet()) { - runtimeEval.put(key, runtimeEvalJson.get(key)); + // Parse legacy runtime evaluation (simple key-value format) + Map legacyRuntimeEval = null; + JSONObject legacyRuntimeEvalJson = json.optJSONObject("runtime_evaluation_definition"); + if (legacyRuntimeEvalJson != null) { + legacyRuntimeEval = new HashMap<>(); + for (String key : legacyRuntimeEvalJson.keySet()) { + legacyRuntimeEval.put(key, legacyRuntimeEvalJson.get(key)); } } + JSONObject runtimeEvaluationRule = json.optJSONObject("runtime_evaluation_rule"); + Map variantSplits = null; JSONObject variantSplitsJson = json.optJSONObject("variant_splits"); if (variantSplitsJson != null) { @@ -306,7 +311,7 @@ private Rollout parseRollout(JSONObject json) { } } - return new Rollout(rolloutPercentage, runtimeEval, variantOverride, variantSplits); + return new Rollout(rolloutPercentage, runtimeEvaluationRule, legacyRuntimeEval, variantOverride, variantSplits); } // #endregion @@ -386,6 +391,12 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } // Check runtime evaluation, continue if this rollout has runtime conditions and it doesn't match + if (rollout.hasLegacyRuntimeEvaluation()) { + if (!matchesLegacyRuntimeConditions(rollout, context)) { + continue; + } + } + if (rollout.hasRuntimeEvaluation()) { if (!matchesRuntimeConditions(rollout, context)) { continue; @@ -433,18 +444,23 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } } + private boolean matchesRuntimeConditions(Rollout rollout, Map context) { + Map customProperties = getCustomProperties(context); + return JsonLogicEngine.evaluate(rollout.getRuntimeEvaluationRule(), customProperties); + } + /** * Evaluates runtime conditions for a rollout. * * @return true if all runtime conditions match, false otherwise (or if custom_properties is missing) */ - private boolean matchesRuntimeConditions(Rollout rollout, Map context) { + private boolean matchesLegacyRuntimeConditions(Rollout rollout, Map context) { Map customProperties = getCustomProperties(context); if (customProperties == null) { return false; } - Map runtimeEval = rollout.getRuntimeEvaluationDefinition(); + Map runtimeEval = rollout.getLegacyRuntimeEvaluationDefinition(); for (Map.Entry entry : runtimeEval.entrySet()) { String key = entry.getKey(); Object expectedValue = entry.getValue(); diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java new file mode 100644 index 0000000..1413dd5 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java @@ -0,0 +1,60 @@ +package com.mixpanel.mixpanelapi.featureflags.util; + +import java.util.Map; + +/** + * Implements case-insensitive comparison for runtime evaluation rule definitions and runtime parameters. + */ +public class JsonCaseDesensitizer { + public static Object lowercaseLeafNodes(Object object) { + if (object == null) { + return null; + } + else if (object instanceof String){ + return ((String) object).toLowerCase(); + } else if (object instanceof org.json.JSONObject) { + org.json.JSONObject jsonObject = (org.json.JSONObject) object; + org.json.JSONObject result = new org.json.JSONObject(); + for (String key : jsonObject.keySet()) { + result.put(key, lowercaseLeafNodes(jsonObject.get(key))); + } + return result; + } else if (object instanceof org.json.JSONArray) { + org.json.JSONArray jsonArray = (org.json.JSONArray) object; + org.json.JSONArray result = new org.json.JSONArray(); + for (int i = 0; i < jsonArray.length(); i++) { + result.put(lowercaseLeafNodes(jsonArray.get(i))); + } + return result; + } else { + return object; + } + } + public static Object lowercaseAllNodes(Object object) { + if (object == null) { + return null; + } + else if (object instanceof String){ + return ((String) object).toLowerCase(); + } else if (object instanceof Map) { + Map map = (Map) object; + Map result = new java.util.HashMap<>(); + for (Map.Entry entry : map.entrySet()) { + Object lowerKey = entry.getKey() instanceof String + ? ((String) entry.getKey()).toLowerCase() + : entry.getKey(); + result.put(lowerKey, lowercaseAllNodes(entry.getValue())); + } + return result; + } else if( object instanceof Iterable) { + Iterable iterable = (Iterable) object; + java.util.List result = new java.util.ArrayList<>(); + for (Object item : iterable) { + result.add(lowercaseAllNodes(item)); + } + return result; + } else { + return object; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java new file mode 100644 index 0000000..89ccc7f --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -0,0 +1,34 @@ +package com.mixpanel.mixpanelapi.featureflags.util; + +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; + +import org.json.JSONObject; + +import io.github.jamsesso.jsonlogic.JsonLogic; + +/** + * Wrapper for third-party library to evaluate JsonLogic DML rules. + */ +public class JsonLogicEngine { + private static final java.util.logging.Logger logger = java.util.logging.Logger.getLogger(JsonLogicEngine.class.getName()); + + private static final JsonLogic jsonLogic = new JsonLogic(); + + public static boolean evaluate(JSONObject rule, Map data) { + if (data == null) { + data = new HashMap<>(); + } + Map lowercasedData = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); + try { + String ruleJson = JsonCaseDesensitizer.lowercaseLeafNodes(rule).toString(); + logger.log(Level.FINE, () -> "Evaluating JsonLogic rule: " + ruleJson + " with data: " + lowercasedData.toString()); + Object result = jsonLogic.apply(ruleJson, lowercasedData); + return JsonLogic.truthy(result); + } catch (Exception e) { + logger.log(Level.WARNING, "Error evaluating runtime rule", e); + return false; + } + } +} diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java index 80fcc39..3e3e3af 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -6,22 +6,13 @@ import org.json.JSONArray; import org.json.JSONObject; -import org.junit.After; import org.junit.Before; import org.junit.Test; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLConnection; -import java.net.URLStreamHandler; import java.util.*; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; +import static com.mixpanel.mixpanelapi.featureflags.provider.TestUtils.*; import static org.junit.Assert.*; /** @@ -282,10 +273,13 @@ private JSONObject buildFlagJsonObject(FlagDefinition def, String flagId) { variantOverrideObj.put("key", r.getVariantOverride().getKey()); rolloutJson.put("variant_override", variantOverrideObj); } - if (r.hasRuntimeEvaluation()) { - JSONObject runtimeEval = new JSONObject(r.getRuntimeEvaluationDefinition()); + if (r.hasLegacyRuntimeEvaluation()) { + JSONObject runtimeEval = new JSONObject(r.getLegacyRuntimeEvaluationDefinition()); rolloutJson.put("runtime_evaluation_definition", runtimeEval); } + if (r.hasRuntimeEvaluation()) { + rolloutJson.put("runtime_evaluation_rule", r.getRuntimeEvaluationRule()); + } if (r.hasVariantSplits()) { JSONObject variantSplitsObj = new JSONObject(r.getVariantSplits()); rolloutJson.put("variant_splits", variantSplitsObj); @@ -577,53 +571,265 @@ public void testApplyVariantOverrideCorrectly() { // #endregion // #region Runtime Evaluation Tests + String variantKey = "premium-variant"; + String variantValue = "gold"; + String flagKey = "test-flag"; + String distinctIdContextKey = "distinct_id"; + String fallbackVariantValue = "fallback"; + Map planEqualsPremium = mapOf( + "==", + listOf( + mapOf("var", "plan"), // Key + "premium" // Value + ) + ); + Map planEqualsPremiumCaseInsensitive = mapOf( + "==", + listOf( + mapOf("var", "pLan"), // Key + "Premium" // Value + ) + ); + Map emailContainsGmailCaseInsensitive = mapOf( + "in", + listOf( + "gmaIl", // Value + mapOf("var", "emAil") // Key + ) + ); + Map planEqualsPremiumAndEmailContainsGmailCaseInsensitive = mapOf( + "and", + listOf( + planEqualsPremiumCaseInsensitive, + emailContainsGmailCaseInsensitive + ) + ); + Map springfieldInUrl = mapOf( + "in", + listOf( + "Springfield", + mapOf("var", "url") + ) + ); + Map nameInArray = mapOf( + "in", + listOf( + mapOf("var", "name"), + listOf("a", "b", "c", "all-from-the-ui") + ) + ); + Map nameAndCountry = mapOf( + "and", + listOf( + mapOf("==", listOf(mapOf("var", "name"), "Johannes")), + mapOf("==", listOf(mapOf("var", "country"), "Deutschland")) + ) + ); + Map queriesGreaterThan25 = mapOf( + ">", + listOf( + mapOf("var", "queries_ran"), + 25 + ) + ); + Map invalidRuntimeRule = mapOf( + "=oops=", + listOf( + mapOf("var", "plan"), + "Premium" + ) + ); + @Test - public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { - List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfied() { + createFlag(toRuntimeRule(planEqualsPremium)); - // Runtime evaluation: requires plan=premium - Map runtimeEval = new HashMap<>(); - runtimeEval.put("plan", "premium"); - List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null)); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "premium")); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + assertEquals(variantValue, result); + } + + @Test + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsNotSatisfied() { + createFlag(toRuntimeRule(planEqualsPremium)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "free")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseInsensitiveParams() { + createFlag(toRuntimeRule(planEqualsPremium)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("Plan", "prEmiUm")); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseInsensitiveRule() { + createFlag(toRuntimeRule(planEqualsPremiumCaseInsensitive)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "premium")); + assertEquals(variantValue, result); + } + + @Test + public void testReturnVariantWhenComplexRuntimeEvaluationConditionsSatisfiedCaseInsensitiveRule() { + createFlag(toRuntimeRule(planEqualsPremiumAndEmailContainsGmailCaseInsensitive)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "premium", "email", "user@gmail.com")); + + assertEquals(variantValue, result); + } + + private void createFlag(List rollouts) { + List variants = Arrays.asList(new Variant(variantKey, variantValue, false, 1.0f)); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); provider = createProviderWithResponse(response); + } - // Context with matching custom properties + private String evaluateFlagsWithRuntimeParameters(Map customProps) { provider.startPollingForDefinitions(); - Map customProps = new HashMap<>(); - customProps.put("plan", "premium"); Map context = buildContextWithProperties("user-123", customProps); + String result = provider.getVariantValue(flagKey, fallbackVariantValue, context); + return result; + } - String result = provider.getVariantValue("test-flag", "fallback", context); + @Test + public void testReturnVariantWhenLegacyRuntimeEvaluationConditionsSatisfied() { + Map runtimeEval = mapOf("plan", "premium"); + createFlag(toLegacyRuntimeRule(runtimeEval)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "premium")); - assertEquals("gold", result); + assertEquals(variantValue, result); assertEquals(1, eventSender.getEvents().size()); } + private List toLegacyRuntimeRule(Map runtimeEval) { + List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null)); + return rollouts; + } + private List toRuntimeRule(Map runtimeEval) { + JSONObject runtimeRuleJson = new JSONObject(runtimeEval); + List rollouts = Arrays.asList(new Rollout(1.0f, runtimeRuleJson, null, null, null)); + return rollouts; + } + @Test - public void testReturnFallbackWhenRuntimeEvaluationConditionsNotSatisfied() { - List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); + public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied() { + Map runtimeEval = mapOf("plan", "premium"); - // Runtime evaluation: requires plan=premium - Map runtimeEval = new HashMap<>(); - runtimeEval.put("plan", "premium"); - List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null)); + createFlag(toLegacyRuntimeRule(runtimeEval)); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "free")); - provider = createProviderWithResponse(response); + assertEquals(fallbackVariantValue, result); + assertEquals(0, eventSender.getEvents().size()); + } - // Context with non-matching custom properties - provider.startPollingForDefinitions(); - Map customProps = new HashMap<>(); - customProps.put("plan", "free"); - Map context = buildContextWithProperties("user-123", customProps); + @Test + public void testReturnFallbackWhenNoRuntimeParametersProvided() { + createFlag(toRuntimeRule(planEqualsPremium)); - String result = provider.getVariantValue("test-flag", "fallback", context); + String result = evaluateFlagsWithRuntimeParameters(null); - assertEquals("fallback", result); + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeRuleIsInvalid() { + createFlag(toRuntimeRule(invalidRuntimeRule)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "Premium")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenRuntimeEvaluationWithInOperatorSatisfied() { + createFlag(toRuntimeRule(springfieldInUrl)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("url", "https://helloworld.com/Springfield/all-about-it")); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationWithInOperatorNotSatisfied() { + createFlag(toRuntimeRule(springfieldInUrl)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("url", "https://helloworld.com/Boston/all-about-it")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenRuntimeEvaluationWithInOperatorForArraySatisfied() { + createFlag(toRuntimeRule(nameInArray)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("name", "b")); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationWithInOperatorForArrayNotSatisfied() { + createFlag(toRuntimeRule(nameInArray)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("name", "d")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenRuntimeEvaluationWithAndOperatorSatisfied() { + createFlag(toRuntimeRule(nameAndCountry)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("name", "Johannes", "country", "Deutschland")); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationWithAndOperatorNotSatisfied() { + createFlag(toRuntimeRule(nameAndCountry)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("name", "Johannes", "country", "USA")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenRuntimeEvaluationWithGreaterThanOperatorSatisfied() { + createFlag(toRuntimeRule(queriesGreaterThan25)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("queries_ran", 27)); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationWithGreaterThanOperatorNotSatisfied() { + createFlag(toRuntimeRule(queriesGreaterThan25)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("queries_ran", 20)); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnFallbackWhenLegacyRuntimeEvaluationMultipleConditionsNotSatisfied() { + Map runtimeEval = mapOf("plan", "premium", "region", "US"); + + createFlag(toLegacyRuntimeRule(runtimeEval)); + + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "free", "region", "US")); + + assertEquals(fallbackVariantValue, result); assertEquals(0, eventSender.getEvents().size()); } @@ -634,19 +840,19 @@ public void testReturnFallbackWhenRuntimeEvaluationConditionsNotSatisfied() { public void testTrackExposureWhenVariantIsSelected() { List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); List rollouts = Arrays.asList(new Rollout(1.0f)); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); provider = createProviderWithResponse(response); Map context = buildContext("user-123"); provider.startPollingForDefinitions(); - provider.getVariantValue("test-flag", "fallback", context); + provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals(1, eventSender.getEvents().size()); MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); assertEquals("user-123", event.distinctId); assertEquals("$experiment_started", event.eventName); - assertEquals("test-flag", event.properties.getString("Experiment name")); + assertEquals(flagKey, event.properties.getString("Experiment name")); assertEquals("variant-a", event.properties.getString("Variant name")); assertEquals("local", event.properties.getString("Flag evaluation mode")); assertTrue(event.properties.getLong("Variant fetch latency (ms)") >= 0); @@ -660,7 +866,7 @@ public void testDoNotTrackExposureWhenReturningFallback() { Map context = buildContext("user-123"); provider.startPollingForDefinitions(); - provider.getVariantValue("test-flag", "fallback", context); + provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals(0, eventSender.getEvents().size()); } @@ -669,14 +875,14 @@ public void testDoNotTrackExposureWhenReturningFallback() { public void testDoNotTrackExposureWhenDistinctIdIsMissing() { List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); List rollouts = Arrays.asList(new Rollout(1.0f)); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); provider = createProviderWithResponse(response); // Context without distinct_id provider.startPollingForDefinitions(); Map context = new HashMap<>(); - provider.getVariantValue("test-flag", "fallback", context); + provider.getVariantValue(flagKey, fallbackVariantValue, context); // No exposure should be tracked (and it returns fallback anyway) assertEquals(0, eventSender.getEvents().size()); @@ -689,7 +895,7 @@ public void testDoNotTrackExposureWhenDistinctIdIsMissing() { public void testReturnReadyWhenFlagsAreLoaded() { List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)); List rollouts = Arrays.asList(new Rollout(1.0f)); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); provider = createProviderWithResponse(response); @@ -738,13 +944,13 @@ public void testIsEnabledReturnsFalseForNonexistentFlag() { public void testIsEnabledReturnsTrueForBooleanTrueVariant() { List variants = Arrays.asList(new Variant("enabled", true, false, 1.0f)); List rollouts = Arrays.asList(new Rollout(1.0f)); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); provider = createProviderWithResponse(response); Map context = buildContext("user-123"); provider.startPollingForDefinitions(); - boolean result = provider.isEnabled("test-flag", context); + boolean result = provider.isEnabled(flagKey, context); assertTrue(result); } @@ -764,7 +970,7 @@ public void testUseMostRecentPolledFlagDefinitions() throws Exception { // Start with initial flag definition List variants1 = Arrays.asList(new Variant("variant-old", "old-value", false, 1.0f)); List rollouts1 = Arrays.asList(new Rollout(1.0f)); - String response1 = buildFlagsResponse("test-flag", "distinct_id", variants1, rollouts1, null); + String response1 = buildFlagsResponse(flagKey, distinctIdContextKey, variants1, rollouts1, null); provider = new TestableLocalFlagsProvider(config, SDK_VERSION, eventSender); provider.setMockResponse("/flags/definitions", response1); @@ -773,20 +979,20 @@ public void testUseMostRecentPolledFlagDefinitions() throws Exception { Map context = buildContext("user-123"); // First evaluation should return old value - String result1 = provider.getVariantValue("test-flag", "fallback", context); + String result1 = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("old-value", result1); // Simulate a polling update by changing the mock response List variants2 = Arrays.asList(new Variant("variant-new", "new-value", false, 1.0f)); List rollouts2 = Arrays.asList(new Rollout(1.0f)); - String response2 = buildFlagsResponse("test-flag", "distinct_id", variants2, rollouts2, null); + String response2 = buildFlagsResponse(flagKey, distinctIdContextKey, variants2, rollouts2, null); provider.setMockResponse("/flags/definitions", response2); // Wait for polling to occur Thread.sleep(1500); // Second evaluation should return new value after polling update - String result2 = provider.getVariantValue("test-flag", "fallback", context); + String result2 = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("new-value", result2); provider.stopPollingForDefinitions(); @@ -799,13 +1005,13 @@ public void testUseMostRecentPolledFlagDefinitions() throws Exception { public void testGetAllVariantsReturnsAllSuccessfullySelectedVariants() { // Create multiple flags with 100% rollout List flags = Arrays.asList( - new FlagDefinition("flag-1", "distinct_id", + new FlagDefinition("flag-1", distinctIdContextKey, Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), Arrays.asList(new Rollout(1.0f))), - new FlagDefinition("flag-2", "distinct_id", + new FlagDefinition("flag-2", distinctIdContextKey, Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), Arrays.asList(new Rollout(1.0f))), - new FlagDefinition("flag-3", "distinct_id", + new FlagDefinition("flag-3", distinctIdContextKey, Arrays.asList(new Variant("variant-c", "value-c", false, 1.0f)), Arrays.asList(new Rollout(1.0f))) ); @@ -843,13 +1049,13 @@ public void testGetAllVariantsReturnsEmptyListWhenNoFlagsDefined() { public void testGetAllVariantsReturnsOnlySuccessfulVariants() { // Create flags with mixed rollout percentages List flags = Arrays.asList( - new FlagDefinition("flag-success-1", "distinct_id", + new FlagDefinition("flag-success-1", distinctIdContextKey, Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), Arrays.asList(new Rollout(1.0f))), // 100% rollout - will succeed - new FlagDefinition("flag-fail-1", "distinct_id", + new FlagDefinition("flag-fail-1", distinctIdContextKey, Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), Arrays.asList(new Rollout(0.0f))), // 0% rollout - will fallback - new FlagDefinition("flag-success-2", "distinct_id", + new FlagDefinition("flag-success-2", distinctIdContextKey, Arrays.asList(new Variant("variant-c", "value-c", false, 1.0f)), Arrays.asList(new Rollout(1.0f))) // 100% rollout - will succeed ); @@ -874,13 +1080,13 @@ public void testGetAllVariantsReturnsOnlySuccessfulVariants() { public void testGetAllVariantsTracksExposureEventsWhenReportExposureTrue() { // Create 3 flags with 100% rollout List flags = Arrays.asList( - new FlagDefinition("flag-1", "distinct_id", + new FlagDefinition("flag-1", distinctIdContextKey, Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), Arrays.asList(new Rollout(1.0f))), - new FlagDefinition("flag-2", "distinct_id", + new FlagDefinition("flag-2", distinctIdContextKey, Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), Arrays.asList(new Rollout(1.0f))), - new FlagDefinition("flag-3", "distinct_id", + new FlagDefinition("flag-3", distinctIdContextKey, Arrays.asList(new Variant("variant-c", "value-c", false, 1.0f)), Arrays.asList(new Rollout(1.0f))) ); @@ -902,13 +1108,13 @@ public void testGetAllVariantsTracksExposureEventsWhenReportExposureTrue() { public void testGetAllVariantsDoesNotTrackExposureEventsWhenReportExposureFalse() { // Create 3 flags with 100% rollout List flags = Arrays.asList( - new FlagDefinition("flag-1", "distinct_id", + new FlagDefinition("flag-1", distinctIdContextKey, Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), Arrays.asList(new Rollout(1.0f))), - new FlagDefinition("flag-2", "distinct_id", + new FlagDefinition("flag-2", distinctIdContextKey, Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), Arrays.asList(new Rollout(1.0f))), - new FlagDefinition("flag-3", "distinct_id", + new FlagDefinition("flag-3", distinctIdContextKey, Arrays.asList(new Variant("variant-c", "value-c", false, 1.0f)), Arrays.asList(new Rollout(1.0f))) ); @@ -939,11 +1145,11 @@ public void testGetAllVariantsReturnsVariantsWithExperimentMetadata() { // Create flags with experiment metadata List flags = Arrays.asList( - new FlagDefinition("flag-1", "distinct_id", + new FlagDefinition("flag-1", distinctIdContextKey, Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f)), Arrays.asList(new Rollout(1.0f)), null, experimentId1, true), - new FlagDefinition("flag-2", "distinct_id", + new FlagDefinition("flag-2", distinctIdContextKey, Arrays.asList(new Variant("variant-b", "value-b", false, 1.0f)), Arrays.asList(new Rollout(1.0f)), null, experimentId2, false) @@ -996,13 +1202,13 @@ public void testIsQaTesterTrueWhenUserIsInTestUserOverrides() { Map testUsers = new HashMap<>(); testUsers.put("test-user-123", "treatment"); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, testUsers); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, testUsers); provider = createProviderWithResponse(response); provider.startPollingForDefinitions(); eventSender.reset(); Map context = buildContext("test-user-123"); - SelectedVariant result = provider.getVariant("test-flag", new SelectedVariant<>("fallback"), context, true); + SelectedVariant result = provider.getVariant(flagKey, new SelectedVariant<>(fallbackVariantValue), context, true); // Verify variant was selected assertTrue(result.isSuccess()); @@ -1014,7 +1220,7 @@ public void testIsQaTesterTrueWhenUserIsInTestUserOverrides() { MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); assertEquals("test-user-123", event.distinctId); assertEquals("$experiment_started", event.eventName); - assertEquals("test-flag", event.properties.getString("Experiment name")); + assertEquals(flagKey, event.properties.getString("Experiment name")); assertEquals("treatment", event.properties.getString("Variant name")); assertEquals(Boolean.TRUE, event.properties.getBoolean("$is_qa_tester")); } @@ -1030,13 +1236,13 @@ public void testIsQaTesterFalseWhenUserGoesThroughNormalRollout() { Map testUsers = new HashMap<>(); testUsers.put("different-user", "control"); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, testUsers); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, testUsers); provider = createProviderWithResponse(response); provider.startPollingForDefinitions(); eventSender.reset(); Map context = buildContext("normal-user-456"); - SelectedVariant result = provider.getVariant("test-flag", new SelectedVariant<>("fallback"), context, true); + SelectedVariant result = provider.getVariant(flagKey, new SelectedVariant<>(fallbackVariantValue), context, true); // Verify variant was selected via normal rollout assertTrue(result.isSuccess()); @@ -1048,7 +1254,7 @@ public void testIsQaTesterFalseWhenUserGoesThroughNormalRollout() { MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); assertEquals("normal-user-456", event.distinctId); assertEquals("$experiment_started", event.eventName); - assertEquals("test-flag", event.properties.getString("Experiment name")); + assertEquals(flagKey, event.properties.getString("Experiment name")); assertEquals("control", event.properties.getString("Variant name")); assertEquals(Boolean.FALSE, event.properties.getBoolean("$is_qa_tester")); } @@ -1072,7 +1278,7 @@ public void testVariantSplitsOverridesFlagLevelSplits() { variantSplits.put("treatment-b", 1.0f); List rollouts = Arrays.asList(new Rollout(1.0f, null, null, variantSplits)); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); provider = createProviderWithResponse(response); provider.startPollingForDefinitions(); @@ -1080,7 +1286,7 @@ public void testVariantSplitsOverridesFlagLevelSplits() { // Test multiple users - all should get treatment-b due to 100% override for (int i = 0; i < 10; i++) { Map context = buildContext("user-" + i); - String result = provider.getVariantValue("test-flag", "fallback", context); + String result = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("All users should get treatment-b due to 100% variant split override", "green", result); } @@ -1103,7 +1309,7 @@ public void testVariantOverrideTakesPrecedenceOverVariantSplits() { VariantOverride variantOverride = new VariantOverride("treatment"); // But override forces treatment List rollouts = Arrays.asList(new Rollout(1.0f, null, variantOverride, variantSplits)); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); provider = createProviderWithResponse(response); provider.startPollingForDefinitions(); @@ -1111,7 +1317,7 @@ public void testVariantOverrideTakesPrecedenceOverVariantSplits() { // Test multiple users - all should get treatment due to variant_override for (int i = 0; i < 10; i++) { Map context = buildContext("user-" + i); - String result = provider.getVariantValue("test-flag", "fallback", context); + String result = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("variant_override should take precedence over variant_splits", "red", result); } @@ -1127,7 +1333,7 @@ public void testNoVariantSplitsUsesDefaultBehavior() { // Rollout without variant_splits (null) List rollouts = Arrays.asList(new Rollout(1.0f, null, null, null)); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, rollouts, null); provider = createProviderWithResponse(response); provider.startPollingForDefinitions(); @@ -1135,7 +1341,7 @@ public void testNoVariantSplitsUsesDefaultBehavior() { // Test multiple users - all should get treatment based on flag-level splits for (int i = 0; i < 10; i++) { Map context = buildContext("user-" + i); - String result = provider.getVariantValue("test-flag", "fallback", context); + String result = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("Should use flag-level splits when no variant_splits in rollout", "red", result); } @@ -1155,7 +1361,7 @@ public void testHashSaltIsUsedForRolloutCalculation() { // Create flag definition with hash_salt String hashSalt = "abc123def456abc123def456abc12345"; // 32-char hex string - FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, hashSalt); + FlagDefinition flagDef = new FlagDefinition(flagKey, distinctIdContextKey, variants, rollouts, null, null, null, hashSalt); JSONObject root = new JSONObject(); JSONArray flagsArray = new JSONArray(); @@ -1169,7 +1375,7 @@ public void testHashSaltIsUsedForRolloutCalculation() { // Evaluate the flag Map context = buildContext("user-123"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls List hashCalls = hashingProvider.getHashCalls(); @@ -1186,7 +1392,7 @@ public void testHashSaltIsUsedForRolloutCalculation() { assertNotNull("Should have called calculateRolloutHash", rolloutHashCall); assertEquals("Context value should be user-123", "user-123", rolloutHashCall.contextValue); - assertEquals("Flag key should be test-flag", "test-flag", rolloutHashCall.flagKey); + assertEquals("Flag key should be test-flag", flagKey, rolloutHashCall.flagKey); assertEquals("Hash salt should include rollout index 0", hashSalt + "0", rolloutHashCall.hashSalt); assertEquals("Rollout index should be 0", Integer.valueOf(0), rolloutHashCall.rolloutIndex); } @@ -1203,7 +1409,7 @@ public void testHashSaltIsUsedForVariantCalculation() { // Create flag definition with hash_salt String hashSalt = "def789abc012def789abc012def78901"; // 32-char hex string - FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, hashSalt); + FlagDefinition flagDef = new FlagDefinition(flagKey, distinctIdContextKey, variants, rollouts, null, null, null, hashSalt); JSONObject root = new JSONObject(); JSONArray flagsArray = new JSONArray(); @@ -1217,7 +1423,7 @@ public void testHashSaltIsUsedForVariantCalculation() { // Evaluate the flag Map context = buildContext("user-456"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls List hashCalls = hashingProvider.getHashCalls(); @@ -1233,7 +1439,7 @@ public void testHashSaltIsUsedForVariantCalculation() { assertNotNull("Should have called calculateVariantHash", variantHashCall); assertEquals("Context value should be user-456", "user-456", variantHashCall.contextValue); - assertEquals("Flag key should be test-flag", "test-flag", variantHashCall.flagKey); + assertEquals("Flag key should be test-flag", flagKey, variantHashCall.flagKey); assertEquals("Hash salt should include 'variant'", hashSalt + "variant", variantHashCall.hashSalt); assertNull("Rollout index should be null for variant hash", variantHashCall.rolloutIndex); } @@ -1255,7 +1461,7 @@ public void testMultipleRolloutsWithHashSaltUseDifferentHashes() { // Create flag definition with hash_salt String hashSalt = "012345678901234567890123456789ab"; // 32-char hex string - FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, hashSalt); + FlagDefinition flagDef = new FlagDefinition(flagKey, distinctIdContextKey, variants, rollouts, null, null, null, hashSalt); JSONObject root = new JSONObject(); JSONArray flagsArray = new JSONArray(); @@ -1269,7 +1475,7 @@ public void testMultipleRolloutsWithHashSaltUseDifferentHashes() { // Evaluate the flag Map context = buildContext("user-789"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls - should have 2 rollout hash calls with indices 0 and 1 List hashCalls = hashingProvider.getHashCalls(); @@ -1307,7 +1513,7 @@ public void testLegacyFlagsWithoutHashSaltUseRolloutSalt() { List rollouts = Arrays.asList(new Rollout(1.0f, null, null, null)); // 100% rollout // Create flag definition WITHOUT hash_salt (null) - FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, null); + FlagDefinition flagDef = new FlagDefinition(flagKey, distinctIdContextKey, variants, rollouts, null, null, null, null); JSONObject root = new JSONObject(); JSONArray flagsArray = new JSONArray(); @@ -1321,7 +1527,7 @@ public void testLegacyFlagsWithoutHashSaltUseRolloutSalt() { // Evaluate the flag Map context = buildContext("user-legacy"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls use legacy "rollout" salt List hashCalls = hashingProvider.getHashCalls(); @@ -1350,7 +1556,7 @@ public void testLegacyFlagsWithoutHashSaltUseVariantSalt() { List rollouts = Arrays.asList(new Rollout(1.0f, null, null, null)); // 100% rollout // Create flag definition WITHOUT hash_salt (null) - FlagDefinition flagDef = new FlagDefinition("test-flag", "distinct_id", variants, rollouts, null, null, null, null); + FlagDefinition flagDef = new FlagDefinition(flagKey, distinctIdContextKey, variants, rollouts, null, null, null, null); JSONObject root = new JSONObject(); JSONArray flagsArray = new JSONArray(); @@ -1364,7 +1570,7 @@ public void testLegacyFlagsWithoutHashSaltUseVariantSalt() { // Evaluate the flag Map context = buildContext("user-legacy-variant"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls use legacy "variant" salt List hashCalls = hashingProvider.getHashCalls(); diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/TestUtils.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/TestUtils.java new file mode 100644 index 0000000..97d0348 --- /dev/null +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/TestUtils.java @@ -0,0 +1,38 @@ +package com.mixpanel.mixpanelapi.featureflags.provider; + +import java.util.*; + +/** + * Test utilities for Java 8 compatibility. + * Provides factory methods similar to Java 9+ Map.of() and List.of(). + */ +public class TestUtils { + + // Map factory methods + public static Map mapOf(K k1, V v1) { + Map map = new HashMap<>(); + map.put(k1, v1); + return map; + } + + public static Map mapOf(K k1, V v1, K k2, V v2) { + Map map = new HashMap<>(); + map.put(k1, v1); + map.put(k2, v2); + return map; + } + + public static Map mapOf(K k1, V v1, K k2, V v2, K k3, V v3) { + Map map = new HashMap<>(); + map.put(k1, v1); + map.put(k2, v2); + map.put(k3, v3); + return map; + } + + // List factory methods + @SafeVarargs + public static List listOf(T... elements) { + return Arrays.asList(elements); + } +} diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizerTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizerTest.java new file mode 100644 index 0000000..e19330e --- /dev/null +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizerTest.java @@ -0,0 +1,718 @@ +package com.mixpanel.mixpanelapi.featureflags.util; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; + +import java.util.*; + +import static com.mixpanel.mixpanelapi.featureflags.provider.TestUtils.*; +import static org.junit.Assert.*; + +/** + * Edge cases for both lowercaseLeafNodes() and lowercaseAllNodes(). + */ +public class JsonCaseDesensitizerTest { + + // #region lowercaseLeafNodes Tests + + @Test + public void testLowercaseLeafNodes_Null() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(null); + assertNull(result); + } + + @Test + public void testLowercaseLeafNodes_SimpleString() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes("HELLO"); + assertEquals("hello", result); + } + + @Test + public void testLowercaseLeafNodes_MixedCaseString() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes("HeLLo WoRLd"); + assertEquals("hello world", result); + } + + @Test + public void testLowercaseLeafNodes_AlreadyLowercaseString() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes("hello"); + assertEquals("hello", result); + } + + @Test + public void testLowercaseLeafNodes_EmptyString() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(""); + assertEquals("", result); + } + + @Test + public void testLowercaseLeafNodes_StringWithNumbers() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes("TEST123"); + assertEquals("test123", result); + } + + @Test + public void testLowercaseLeafNodes_StringWithSpecialChars() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes("TEST@#$%"); + assertEquals("test@#$%", result); + } + + @Test + public void testLowercaseLeafNodes_UnicodeString() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes("CAFÉ"); + assertEquals("café", result); + } + + @Test + public void testLowercaseLeafNodes_Integer() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(42); + assertEquals(42, result); + } + + @Test + public void testLowercaseLeafNodes_Long() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(999999999L); + assertEquals(999999999L, result); + } + + @Test + public void testLowercaseLeafNodes_Double() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(3.14159); + assertEquals(3.14159, result); + } + + @Test + public void testLowercaseLeafNodes_Boolean() { + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(true); + assertEquals(true, result); + } + + @Test + public void testLowercaseLeafNodes_EmptyJSONObject() { + JSONObject input = new JSONObject(); + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONObject); + assertEquals(0, ((JSONObject) result).length()); + } + + @Test + public void testLowercaseLeafNodes_JSONObjectWithSingleStringValue() { + JSONObject input = new JSONObject(); + input.put("key", "VALUE"); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONObject); + assertEquals("value", ((JSONObject) result).getString("key")); + } + + @Test + public void testLowercaseLeafNodes_JSONObjectWithMultipleStringValues() { + JSONObject input = new JSONObject(); + input.put("name", "JOHN"); + input.put("city", "NEW YORK"); + input.put("country", "USA"); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONObject); + JSONObject resultObj = (JSONObject) result; + assertEquals("john", resultObj.getString("name")); + assertEquals("new york", resultObj.getString("city")); + assertEquals("usa", resultObj.getString("country")); + } + + @Test + public void testLowercaseLeafNodes_JSONObjectWithMixedTypes() { + JSONObject input = new JSONObject(); + input.put("name", "ALICE"); + input.put("age", 30); + input.put("score", 95.5); + input.put("active", true); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONObject); + JSONObject resultObj = (JSONObject) result; + assertEquals("alice", resultObj.getString("name")); + assertEquals(30, resultObj.getInt("age")); + assertEquals(95.5, resultObj.getDouble("score"), 0.001); + assertTrue(resultObj.getBoolean("active")); + } + + @Test + public void testLowercaseLeafNodes_JSONObjectKeysPreserveCase() { + JSONObject input = new JSONObject(); + input.put("UserName", "BOB"); + input.put("EMAIL", "bob@example.com"); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONObject); + JSONObject resultObj = (JSONObject) result; + // Keys should preserve their case + assertEquals("bob", resultObj.getString("UserName")); + assertEquals("bob@example.com", resultObj.getString("EMAIL")); + } + + @Test + public void testLowercaseLeafNodes_NestedJSONObject() { + JSONObject inner = new JSONObject(); + inner.put("street", "MAIN STREET"); + inner.put("zipcode", "12345"); + + JSONObject outer = new JSONObject(); + outer.put("name", "ALICE"); + outer.put("address", inner); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(outer); + + assertTrue(result instanceof JSONObject); + JSONObject resultObj = (JSONObject) result; + assertEquals("alice", resultObj.getString("name")); + + JSONObject resultAddress = resultObj.getJSONObject("address"); + assertEquals("main street", resultAddress.getString("street")); + assertEquals("12345", resultAddress.getString("zipcode")); + } + + @Test + public void testLowercaseLeafNodes_DeeplyNestedJSONObject() { + JSONObject level3 = new JSONObject(); + level3.put("value", "DEEP"); + + JSONObject level2 = new JSONObject(); + level2.put("level3", level3); + + JSONObject level1 = new JSONObject(); + level1.put("level2", level2); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(level1); + + assertTrue(result instanceof JSONObject); + JSONObject resultObj = (JSONObject) result; + String deepValue = resultObj.getJSONObject("level2") + .getJSONObject("level3") + .getString("value"); + assertEquals("deep", deepValue); + } + + @Test + public void testLowercaseLeafNodes_EmptyJSONArray() { + JSONArray input = new JSONArray(); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONArray); + assertEquals(0, ((JSONArray) result).length()); + } + + @Test + public void testLowercaseLeafNodes_JSONArrayWithStrings() { + JSONArray input = new JSONArray(); + input.put("ALPHA"); + input.put("BETA"); + input.put("GAMMA"); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONArray); + JSONArray resultArray = (JSONArray) result; + assertEquals(3, resultArray.length()); + assertEquals("alpha", resultArray.getString(0)); + assertEquals("beta", resultArray.getString(1)); + assertEquals("gamma", resultArray.getString(2)); + } + + @Test + public void testLowercaseLeafNodes_JSONArrayWithMixedTypes() { + JSONArray input = new JSONArray(); + input.put("STRING"); + input.put(42); + input.put(3.14); + input.put(true); + input.put(false); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONArray); + JSONArray resultArray = (JSONArray) result; + assertEquals("string", resultArray.getString(0)); + assertEquals(42, resultArray.getInt(1)); + assertEquals(3.14, resultArray.getDouble(2), 0.001); + assertTrue(resultArray.getBoolean(3)); + assertFalse(resultArray.getBoolean(4)); + } + + @Test + public void testLowercaseLeafNodes_JSONArrayWithNestedArrays() { + JSONArray inner = new JSONArray(); + inner.put("INNER1"); + inner.put("INNER2"); + + JSONArray outer = new JSONArray(); + outer.put("OUTER"); + outer.put(inner); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(outer); + + assertTrue(result instanceof JSONArray); + JSONArray resultArray = (JSONArray) result; + assertEquals("outer", resultArray.getString(0)); + + JSONArray resultInner = resultArray.getJSONArray(1); + assertEquals("inner1", resultInner.getString(0)); + assertEquals("inner2", resultInner.getString(1)); + } + + @Test + public void testLowercaseLeafNodes_JSONArrayWithObjects() { + JSONObject obj1 = new JSONObject(); + obj1.put("name", "ALICE"); + + JSONObject obj2 = new JSONObject(); + obj2.put("name", "BOB"); + + JSONArray input = new JSONArray(); + input.put(obj1); + input.put(obj2); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONArray); + JSONArray resultArray = (JSONArray) result; + assertEquals("alice", resultArray.getJSONObject(0).getString("name")); + assertEquals("bob", resultArray.getJSONObject(1).getString("name")); + } + + @Test + public void testLowercaseLeafNodes_ComplexNestedStructure() { + // Create: {"users": [{"name": "ALICE", "tags": ["ADMIN", "SUPER"]}]} + JSONArray tags = new JSONArray(); + tags.put("ADMIN"); + tags.put("SUPER"); + + JSONObject user = new JSONObject(); + user.put("name", "ALICE"); + user.put("tags", tags); + + JSONArray users = new JSONArray(); + users.put(user); + + JSONObject root = new JSONObject(); + root.put("users", users); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(root); + + assertTrue(result instanceof JSONObject); + JSONObject resultObj = (JSONObject) result; + JSONArray resultUsers = resultObj.getJSONArray("users"); + JSONObject resultUser = resultUsers.getJSONObject(0); + assertEquals("alice", resultUser.getString("name")); + + JSONArray resultTags = resultUser.getJSONArray("tags"); + assertEquals("admin", resultTags.getString(0)); + assertEquals("super", resultTags.getString(1)); + } + + @Test + public void testLowercaseLeafNodes_JSONObjectWithNullValue() { + JSONObject input = new JSONObject(); + input.put("key", JSONObject.NULL); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONObject); + assertTrue(((JSONObject) result).isNull("key")); + } + + @Test + public void testLowercaseLeafNodes_JSONArrayWithNullValue() { + JSONArray input = new JSONArray(); + input.put(JSONObject.NULL); + input.put("STRING"); + + Object result = JsonCaseDesensitizer.lowercaseLeafNodes(input); + + assertTrue(result instanceof JSONArray); + JSONArray resultArray = (JSONArray) result; + assertTrue(resultArray.isNull(0)); + assertEquals("string", resultArray.getString(1)); + } + + // #endregion + + // #region lowercaseAllNodes Tests + + @Test + public void testLowercaseAllNodes_Null() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes(null); + assertNull(result); + } + + @Test + public void testLowercaseAllNodes_SimpleString() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes("HELLO"); + assertEquals("hello", result); + } + + @Test + public void testLowercaseAllNodes_MixedCaseString() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes("HeLLo WoRLd"); + assertEquals("hello world", result); + } + + @Test + public void testLowercaseAllNodes_EmptyString() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes(""); + assertEquals("", result); + } + + @Test + public void testLowercaseAllNodes_Integer() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes(42); + assertEquals(42, result); + } + + @Test + public void testLowercaseAllNodes_Double() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes(3.14); + assertEquals(3.14, result); + } + + @Test + public void testLowercaseAllNodes_Boolean() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes(false); + assertEquals(false, result); + } + + @Test + public void testLowercaseAllNodes_EmptyMap() { + Map input = new HashMap<>(); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof Map); + assertTrue(((Map) result).isEmpty()); + } + + @Test + public void testLowercaseAllNodes_MapWithStringValue() { + Map input = mapOf("KEY", "VALUE"); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + assertEquals("value", resultMap.get("key")); + assertNull(resultMap.get("KEY")); + } + + @Test + public void testLowercaseAllNodes_MapWithMultipleEntries() { + Map input = new HashMap<>(); + input.put("NAME", "ALICE"); + input.put("CITY", "NEW YORK"); + input.put("COUNTRY", "USA"); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + assertEquals("alice", resultMap.get("name")); + assertEquals("new york", resultMap.get("city")); + assertEquals("usa", resultMap.get("country")); + } + + @Test + public void testLowercaseAllNodes_MapWithMixedTypes() { + Map input = new HashMap<>(); + input.put("NAME", "BOB"); + input.put("AGE", 25); + input.put("SCORE", 88.5); + input.put("ACTIVE", true); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + assertEquals("bob", resultMap.get("name")); + assertEquals(25, resultMap.get("age")); + assertEquals(88.5, resultMap.get("score")); + assertEquals(true, resultMap.get("active")); + } + + @Test + public void testLowercaseAllNodes_MapWithNonStringKeys() { + Map input = new HashMap<>(); + input.put(123, "VALUE"); + input.put("STRING_KEY", "DATA"); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + assertEquals("value", resultMap.get(123)); // Non-string key preserved + assertEquals("data", resultMap.get("string_key")); // String key lowercased + } + + @Test + public void testLowercaseAllNodes_NestedMap() { + Map inner = mapOf("STREET", "MAIN STREET"); + Map outer = mapOf("NAME", "ALICE", "ADDRESS", inner); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(outer); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + assertEquals("alice", resultMap.get("name")); + + @SuppressWarnings("unchecked") + Map resultAddress = (Map) resultMap.get("address"); + assertEquals("main street", resultAddress.get("street")); + } + + @Test + public void testLowercaseAllNodes_DeeplyNestedMap() { + Map level3 = mapOf("VALUE", "DEEP"); + Map level2 = mapOf("LEVEL3", level3); + Map level1 = mapOf("LEVEL2", level2); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(level1); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + @SuppressWarnings("unchecked") + Map l2 = (Map) resultMap.get("level2"); + @SuppressWarnings("unchecked") + Map l3 = (Map) l2.get("level3"); + assertEquals("deep", l3.get("value")); + } + + @Test + public void testLowercaseAllNodes_EmptyList() { + List input = new ArrayList<>(); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof List); + assertTrue(((List) result).isEmpty()); + } + + @Test + public void testLowercaseAllNodes_ListWithStrings() { + List input = listOf("ALPHA", "BETA", "GAMMA"); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List resultList = (List) result; + assertEquals(3, resultList.size()); + assertEquals("alpha", resultList.get(0)); + assertEquals("beta", resultList.get(1)); + assertEquals("gamma", resultList.get(2)); + } + + @Test + public void testLowercaseAllNodes_ListWithMixedTypes() { + List input = listOf("STRING", 42, 3.14, true); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List resultList = (List) result; + assertEquals("string", resultList.get(0)); + assertEquals(42, resultList.get(1)); + assertEquals(3.14, resultList.get(2)); + assertEquals(true, resultList.get(3)); + } + + @Test + public void testLowercaseAllNodes_ListWithNestedLists() { + List inner = listOf("INNER1", "INNER2"); + List outer = listOf("OUTER", inner); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(outer); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List resultList = (List) result; + assertEquals("outer", resultList.get(0)); + + @SuppressWarnings("unchecked") + List resultInner = (List) resultList.get(1); + assertEquals("inner1", resultInner.get(0)); + assertEquals("inner2", resultInner.get(1)); + } + + @Test + public void testLowercaseAllNodes_ListWithMaps() { + Map map1 = mapOf("NAME", "ALICE"); + Map map2 = mapOf("NAME", "BOB"); + List input = listOf(map1, map2); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List resultList = (List) result; + + @SuppressWarnings("unchecked") + Map resultMap1 = (Map) resultList.get(0); + assertEquals("alice", resultMap1.get("name")); + + @SuppressWarnings("unchecked") + Map resultMap2 = (Map) resultList.get(1); + assertEquals("bob", resultMap2.get("name")); + } + + @Test + public void testLowercaseAllNodes_ComplexNestedStructure() { + // Create: {"USERS": [{"NAME": "ALICE", "TAGS": ["ADMIN", "SUPER"]}]} + List tags = listOf("ADMIN", "SUPER"); + Map user = new HashMap<>(); + user.put("NAME", "ALICE"); + user.put("TAGS", tags); + + List users = listOf(user); + Map root = mapOf("USERS", users); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(root); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + + @SuppressWarnings("unchecked") + List resultUsers = (List) resultMap.get("users"); + + @SuppressWarnings("unchecked") + Map resultUser = (Map) resultUsers.get(0); + assertEquals("alice", resultUser.get("name")); + + @SuppressWarnings("unchecked") + List resultTags = (List) resultUser.get("tags"); + assertEquals("admin", resultTags.get(0)); + assertEquals("super", resultTags.get(1)); + } + + @Test + public void testLowercaseAllNodes_MapWithNullValue() { + Map input = mapOf("KEY", null); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + assertNull(resultMap.get("key")); + } + + @Test + public void testLowercaseAllNodes_ListWithNullValue() { + List input = listOf(null, "STRING"); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List resultList = (List) result; + assertNull(resultList.get(0)); + assertEquals("string", resultList.get(1)); + } + + @Test + public void testLowercaseAllNodes_SetAsIterable() { + Set input = new HashSet<>(); + input.add("ALPHA"); + input.add("BETA"); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof List); + @SuppressWarnings("unchecked") + List resultList = (List) result; + assertEquals(2, resultList.size()); + assertTrue(resultList.contains("alpha")); + assertTrue(resultList.contains("beta")); + } + + @Test + public void testLowercaseAllNodes_StringWithWhitespace() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes(" TRIM ME "); + assertEquals(" trim me ", result); + } + + @Test + public void testLowercaseAllNodes_ZeroInteger() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes(0); + assertEquals(0, result); + } + + @Test + public void testLowercaseAllNodes_NegativeNumber() { + Object result = JsonCaseDesensitizer.lowercaseAllNodes(-42); + assertEquals(-42, result); + } + + @Test + public void testLowercaseAllNodes_MapWithEmptyStringKey() { + Map input = mapOf("", "VALUE"); + + Object result = JsonCaseDesensitizer.lowercaseAllNodes(input); + + assertTrue(result instanceof Map); + @SuppressWarnings("unchecked") + Map resultMap = (Map) result; + assertEquals("value", resultMap.get("")); + } + + // #endregion + + // #region Comparison Tests Between Methods + + @Test + public void testDifference_LeafVsAll_SimpleMap() { + Map input = mapOf("KEY", "VALUE"); + + // lowercaseLeafNodes doesn't lowercase keys + Object leafResult = JsonCaseDesensitizer.lowercaseAllNodes(input); + @SuppressWarnings("unchecked") + Map leafMap = (Map) leafResult; + + // lowercaseAllNodes lowercases keys + Object allResult = JsonCaseDesensitizer.lowercaseAllNodes(input); + @SuppressWarnings("unchecked") + Map allMap = (Map) allResult; + + // Both lowercase the value + assertEquals("value", leafMap.get("key")); + assertEquals("value", allMap.get("key")); + } + + @Test + public void testDifference_LeafVsAll_JSONObjectKeys() { + JSONObject input = new JSONObject(); + input.put("UserName", "ALICE"); + + // lowercaseLeafNodes preserves key case + Object leafResult = JsonCaseDesensitizer.lowercaseLeafNodes(input); + JSONObject leafObj = (JSONObject) leafResult; + + // Keys are not lowercased in JSONObject version + assertTrue(leafObj.has("UserName")); + assertEquals("alice", leafObj.getString("UserName")); + } + + // #endregion +} diff --git a/src/test/resources/logging.properties b/src/test/resources/logging.properties new file mode 100644 index 0000000..64fc314 --- /dev/null +++ b/src/test/resources/logging.properties @@ -0,0 +1,18 @@ +# Java Util Logging configuration for tests +# This sends all log output to the console (stdout) + +# Set root logger level +.level=ALL + +# Configure console handler +handlers=java.util.logging.ConsoleHandler + +# Console handler configuration +java.util.logging.ConsoleHandler.level=ALL +java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter + +# Format pattern: timestamp, level, logger name, message +java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] %4$s: %5$s%6$s%n + +# Set specific package log levels (optional - adjust as needed) +com.mixpanel.mixpanelapi.featureflags.provider.LocalFlagsProvider.level=ALL