From 1bee4e48f9d29d573e3b49e2984687e352b5b866 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Wed, 26 Nov 2025 17:01:33 -0600 Subject: [PATCH 01/28] add runtime eval rule; deprecate legacy one TODO: update constructors, then write first +/- test using rules engine --- .../featureflags/model/Rollout.java | 13 +++++---- .../provider/LocalFlagsProvider.java | 2 +- .../provider/LocalFlagsProviderTest.java | 29 +++++++++++++++++-- 3 files changed, 34 insertions(+), 10 deletions(-) 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..764bad0 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -15,7 +15,8 @@ */ public final class Rollout { private final float rolloutPercentage; - private final Map runtimeEvaluationDefinition; + private Map runtimeEvaluationRule; + private final Map legacyRuntimeEvaluationDefinition; private final VariantOverride variantOverride; private final Map variantSplits; @@ -29,7 +30,7 @@ public final class Rollout { */ public Rollout(float rolloutPercentage, Map runtimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { this.rolloutPercentage = rolloutPercentage; - this.runtimeEvaluationDefinition = runtimeEvaluationDefinition != null + this.legacyRuntimeEvaluationDefinition = runtimeEvaluationDefinition != null ? Collections.unmodifiableMap(runtimeEvaluationDefinition) : null; this.variantOverride = variantOverride; @@ -57,8 +58,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; } /** @@ -79,7 +80,7 @@ public Map getVariantSplits() { * @return true if this rollout has runtime evaluation criteria */ public boolean hasRuntimeEvaluation() { - return runtimeEvaluationDefinition != null && !runtimeEvaluationDefinition.isEmpty(); + return legacyRuntimeEvaluationDefinition != null && !legacyRuntimeEvaluationDefinition.isEmpty(); } /** @@ -100,7 +101,7 @@ public boolean hasVariantSplits() { public String toString() { return "Rollout{" + "rolloutPercentage=" + rolloutPercentage + - ", runtimeEvaluationDefinition=" + runtimeEvaluationDefinition + + ", runtimeEvaluationDefinition=" + legacyRuntimeEvaluationDefinition + ", variantOverride='" + variantOverride + '\'' + ", variantSplits=" + variantSplits + '}'; 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..1d80a9f 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -444,7 +444,7 @@ private boolean matchesRuntimeConditions(Rollout rollout, Map co 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/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java index 80fcc39..14bf445 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -283,7 +283,7 @@ private JSONObject buildFlagJsonObject(FlagDefinition def, String flagId) { rolloutJson.put("variant_override", variantOverrideObj); } if (r.hasRuntimeEvaluation()) { - JSONObject runtimeEval = new JSONObject(r.getRuntimeEvaluationDefinition()); + JSONObject runtimeEval = new JSONObject(r.getLegacyRuntimeEvaluationDefinition()); rolloutJson.put("runtime_evaluation_definition", runtimeEval); } if (r.hasVariantSplits()) { @@ -577,10 +577,33 @@ public void testApplyVariantOverrideCorrectly() { // #endregion // #region Runtime Evaluation Tests - @Test public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); + Map runtimeEval = new HashMap<>(); + runtimeEval.put("plan", "premium"); + List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null)); + + String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + + provider = createProviderWithResponse(response); + + // Context with matching custom properties + provider.startPollingForDefinitions(); + Map customProps = new HashMap<>(); + customProps.put("plan", "premium"); + Map context = buildContextWithProperties("user-123", customProps); + + String result = provider.getVariantValue("test-flag", "fallback", context); + + assertEquals("gold", result); + assertEquals(1, eventSender.getEvents().size()); + } + + @Test + public void testReturnVariantWhenLegacyRuntimeEvaluationConditionsSatisfied() { + List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); + // Runtime evaluation: requires plan=premium Map runtimeEval = new HashMap<>(); runtimeEval.put("plan", "premium"); @@ -603,7 +626,7 @@ public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { } @Test - public void testReturnFallbackWhenRuntimeEvaluationConditionsNotSatisfied() { + public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied() { List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); // Runtime evaluation: requires plan=premium From 5a322de9c7e0e23432c1223bd0249c3a5c3bd0a4 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 12:43:21 -0600 Subject: [PATCH 02/28] succint test harness --- .../featureflags/model/Rollout.java | 30 ++- .../provider/LocalFlagsProviderTest.java | 195 +++++++++--------- 2 files changed, 123 insertions(+), 102 deletions(-) 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 764bad0..55237c8 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -24,14 +24,36 @@ 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 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 runtimeEvaluationRule, Map legacyRuntimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { this.rolloutPercentage = rolloutPercentage; - this.legacyRuntimeEvaluationDefinition = runtimeEvaluationDefinition != null - ? Collections.unmodifiableMap(runtimeEvaluationDefinition) + this.legacyRuntimeEvaluationDefinition = legacyRuntimeEvaluationDefinition != null + ? Collections.unmodifiableMap(legacyRuntimeEvaluationDefinition) + : null; + this.runtimeEvaluationRule = runtimeEvaluationRule != null + ? Collections.unmodifiableMap(runtimeEvaluationRule) + : null; + 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 legacyRuntimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { + this.rolloutPercentage = rolloutPercentage; + this.legacyRuntimeEvaluationDefinition = legacyRuntimeEvaluationDefinition != null + ? Collections.unmodifiableMap(legacyRuntimeEvaluationDefinition) : null; this.variantOverride = variantOverride; this.variantSplits = variantSplits != null 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 14bf445..7d8c706 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -577,74 +577,73 @@ public void testApplyVariantOverrideCorrectly() { // #endregion // #region Runtime Evaluation Tests + String variantKey = "premium-variant"; + String variantValue = "gold"; + String flagKey = "test-flag"; + String distinctIdContextKey = "distinct_id"; + public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { - List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); - Map runtimeEval = new HashMap<>(); - runtimeEval.put("plan", "premium"); - List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null)); + Map runtimeEval = Map.of( + "==", + List.of( + Map.of("var", "plan"), // Key + "premium" // Value + ) + ); + List rollouts = toRuntimeRule(runtimeEval); + createFlag(rollouts); - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + Map customProps = Map.of("plan", "premium"); + String result = evaluateFlagsWithRuntimeParameters(customProps); + + 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("test-flag", "fallback", context); - - assertEquals("gold", result); - assertEquals(1, eventSender.getEvents().size()); + String result = provider.getVariantValue(flagKey, "fallback", context); + return result; } @Test public void testReturnVariantWhenLegacyRuntimeEvaluationConditionsSatisfied() { - List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); + Map runtimeEval = Map.of("plan", "premium"); + List rollouts = toLegacyRuntimeRule(runtimeEval); + createFlag(rollouts); - // 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 response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + Map customProps = Map.of("plan", "premium"); + String result = evaluateFlagsWithRuntimeParameters(customProps); - provider = createProviderWithResponse(response); - - // Context with matching custom properties - provider.startPollingForDefinitions(); - Map customProps = new HashMap<>(); - customProps.put("plan", "premium"); - Map context = buildContextWithProperties("user-123", customProps); - - String result = provider.getVariantValue("test-flag", "fallback", context); - - assertEquals("gold", result); + assertEquals(variantValue, result); assertEquals(1, eventSender.getEvents().size()); } - @Test - public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied() { - List variants = Arrays.asList(new Variant("premium-variant", "gold", false, 1.0f)); - - // Runtime evaluation: requires plan=premium - Map runtimeEval = new HashMap<>(); - runtimeEval.put("plan", "premium"); + private List toLegacyRuntimeRule(Map runtimeEval) { List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null)); + return rollouts; + } + private List toRuntimeRule(Map runtimeEval) { + List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null, null)); + return rollouts; + } - String response = buildFlagsResponse("test-flag", "distinct_id", variants, rollouts, null); + @Test + public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied() { + Map runtimeEval = Map.of("plan", "premium"); - provider = createProviderWithResponse(response); + List rollouts = toLegacyRuntimeRule(runtimeEval); + createFlag(rollouts); - // Context with non-matching custom properties - provider.startPollingForDefinitions(); - Map customProps = new HashMap<>(); - customProps.put("plan", "free"); - Map context = buildContextWithProperties("user-123", customProps); - - String result = provider.getVariantValue("test-flag", "fallback", context); + Map customProps = Map.of("plan", "free"); + String result = evaluateFlagsWithRuntimeParameters(customProps); assertEquals("fallback", result); assertEquals(0, eventSender.getEvents().size()); @@ -657,19 +656,19 @@ public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied( 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, "fallback", 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); @@ -683,7 +682,7 @@ public void testDoNotTrackExposureWhenReturningFallback() { Map context = buildContext("user-123"); provider.startPollingForDefinitions(); - provider.getVariantValue("test-flag", "fallback", context); + provider.getVariantValue(flagKey, "fallback", context); assertEquals(0, eventSender.getEvents().size()); } @@ -692,14 +691,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, "fallback", context); // No exposure should be tracked (and it returns fallback anyway) assertEquals(0, eventSender.getEvents().size()); @@ -712,7 +711,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); @@ -761,13 +760,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); } @@ -787,7 +786,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); @@ -796,20 +795,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, "fallback", 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, "fallback", context); assertEquals("new-value", result2); provider.stopPollingForDefinitions(); @@ -822,13 +821,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))) ); @@ -866,13 +865,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 ); @@ -897,13 +896,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))) ); @@ -925,13 +924,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))) ); @@ -962,11 +961,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) @@ -1019,13 +1018,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<>("fallback"), context, true); // Verify variant was selected assertTrue(result.isSuccess()); @@ -1037,7 +1036,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")); } @@ -1053,13 +1052,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<>("fallback"), context, true); // Verify variant was selected via normal rollout assertTrue(result.isSuccess()); @@ -1071,7 +1070,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")); } @@ -1095,7 +1094,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(); @@ -1103,7 +1102,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, "fallback", context); assertEquals("All users should get treatment-b due to 100% variant split override", "green", result); } @@ -1126,7 +1125,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(); @@ -1134,7 +1133,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, "fallback", context); assertEquals("variant_override should take precedence over variant_splits", "red", result); } @@ -1150,7 +1149,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(); @@ -1158,7 +1157,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, "fallback", context); assertEquals("Should use flag-level splits when no variant_splits in rollout", "red", result); } @@ -1178,7 +1177,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(); @@ -1192,7 +1191,7 @@ public void testHashSaltIsUsedForRolloutCalculation() { // Evaluate the flag Map context = buildContext("user-123"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, "fallback", context); // Verify hash calls List hashCalls = hashingProvider.getHashCalls(); @@ -1209,7 +1208,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); } @@ -1226,7 +1225,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(); @@ -1240,7 +1239,7 @@ public void testHashSaltIsUsedForVariantCalculation() { // Evaluate the flag Map context = buildContext("user-456"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, "fallback", context); // Verify hash calls List hashCalls = hashingProvider.getHashCalls(); @@ -1256,7 +1255,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); } @@ -1278,7 +1277,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(); @@ -1292,7 +1291,7 @@ public void testMultipleRolloutsWithHashSaltUseDifferentHashes() { // Evaluate the flag Map context = buildContext("user-789"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, "fallback", context); // Verify hash calls - should have 2 rollout hash calls with indices 0 and 1 List hashCalls = hashingProvider.getHashCalls(); @@ -1330,7 +1329,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(); @@ -1344,7 +1343,7 @@ public void testLegacyFlagsWithoutHashSaltUseRolloutSalt() { // Evaluate the flag Map context = buildContext("user-legacy"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, "fallback", context); // Verify hash calls use legacy "rollout" salt List hashCalls = hashingProvider.getHashCalls(); @@ -1373,7 +1372,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(); @@ -1387,7 +1386,7 @@ public void testLegacyFlagsWithoutHashSaltUseVariantSalt() { // Evaluate the flag Map context = buildContext("user-legacy-variant"); - hashingProvider.getVariantValue("test-flag", "fallback", context); + hashingProvider.getVariantValue(flagKey, "fallback", context); // Verify hash calls use legacy "variant" salt List hashCalls = hashingProvider.getHashCalls(); From 0361a6e471f1ea2d137101fed70a0b177cc7dc6f Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 12:48:25 -0600 Subject: [PATCH 03/28] test should fail, but doesn't --- .../provider/LocalFlagsProviderTest.java | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) 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 7d8c706..20873fa 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -581,25 +581,31 @@ public void testApplyVariantOverrideCorrectly() { String variantValue = "gold"; String flagKey = "test-flag"; String distinctIdContextKey = "distinct_id"; - - public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { - - Map runtimeEval = Map.of( + String fallbackVariantValue = "fallback"; + Map planEqualsPremium = Map.of( "==", List.of( Map.of("var", "plan"), // Key "premium" // Value ) ); - List rollouts = toRuntimeRule(runtimeEval); - createFlag(rollouts); - Map customProps = Map.of("plan", "premium"); - String result = evaluateFlagsWithRuntimeParameters(customProps); + public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { + createFlag(toRuntimeRule(planEqualsPremium)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "premium")); assertEquals(variantValue, result); } + public void testReturnVariantWhenRuntimeEvaluationConditionsNotSatisfied() { + createFlag(toRuntimeRule(planEqualsPremium)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "free")); + + assertEquals(fallbackVariantValue, 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); @@ -609,7 +615,7 @@ private void createFlag(List rollouts) { private String evaluateFlagsWithRuntimeParameters(Map customProps) { provider.startPollingForDefinitions(); Map context = buildContextWithProperties("user-123", customProps); - String result = provider.getVariantValue(flagKey, "fallback", context); + String result = provider.getVariantValue(flagKey, fallbackVariantValue, context); return result; } @@ -645,7 +651,7 @@ public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied( Map customProps = Map.of("plan", "free"); String result = evaluateFlagsWithRuntimeParameters(customProps); - assertEquals("fallback", result); + assertEquals(fallbackVariantValue, result); assertEquals(0, eventSender.getEvents().size()); } @@ -662,7 +668,7 @@ public void testTrackExposureWhenVariantIsSelected() { Map context = buildContext("user-123"); provider.startPollingForDefinitions(); - provider.getVariantValue(flagKey, "fallback", context); + provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals(1, eventSender.getEvents().size()); MockEventSender.ExposureEvent event = eventSender.getEvents().get(eventSender.getEvents().size() - 1); @@ -682,7 +688,7 @@ public void testDoNotTrackExposureWhenReturningFallback() { Map context = buildContext("user-123"); provider.startPollingForDefinitions(); - provider.getVariantValue(flagKey, "fallback", context); + provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals(0, eventSender.getEvents().size()); } @@ -698,7 +704,7 @@ public void testDoNotTrackExposureWhenDistinctIdIsMissing() { // Context without distinct_id provider.startPollingForDefinitions(); Map context = new HashMap<>(); - provider.getVariantValue(flagKey, "fallback", context); + provider.getVariantValue(flagKey, fallbackVariantValue, context); // No exposure should be tracked (and it returns fallback anyway) assertEquals(0, eventSender.getEvents().size()); @@ -795,7 +801,7 @@ public void testUseMostRecentPolledFlagDefinitions() throws Exception { Map context = buildContext("user-123"); // First evaluation should return old value - String result1 = provider.getVariantValue(flagKey, "fallback", context); + String result1 = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("old-value", result1); // Simulate a polling update by changing the mock response @@ -808,7 +814,7 @@ public void testUseMostRecentPolledFlagDefinitions() throws Exception { Thread.sleep(1500); // Second evaluation should return new value after polling update - String result2 = provider.getVariantValue(flagKey, "fallback", context); + String result2 = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("new-value", result2); provider.stopPollingForDefinitions(); @@ -1024,7 +1030,7 @@ public void testIsQaTesterTrueWhenUserIsInTestUserOverrides() { eventSender.reset(); Map context = buildContext("test-user-123"); - SelectedVariant result = provider.getVariant(flagKey, new SelectedVariant<>("fallback"), context, true); + SelectedVariant result = provider.getVariant(flagKey, new SelectedVariant<>(fallbackVariantValue), context, true); // Verify variant was selected assertTrue(result.isSuccess()); @@ -1058,7 +1064,7 @@ public void testIsQaTesterFalseWhenUserGoesThroughNormalRollout() { eventSender.reset(); Map context = buildContext("normal-user-456"); - SelectedVariant result = provider.getVariant(flagKey, 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()); @@ -1102,7 +1108,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(flagKey, "fallback", context); + String result = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("All users should get treatment-b due to 100% variant split override", "green", result); } @@ -1133,7 +1139,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(flagKey, "fallback", context); + String result = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("variant_override should take precedence over variant_splits", "red", result); } @@ -1157,7 +1163,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(flagKey, "fallback", context); + String result = provider.getVariantValue(flagKey, fallbackVariantValue, context); assertEquals("Should use flag-level splits when no variant_splits in rollout", "red", result); } @@ -1191,7 +1197,7 @@ public void testHashSaltIsUsedForRolloutCalculation() { // Evaluate the flag Map context = buildContext("user-123"); - hashingProvider.getVariantValue(flagKey, "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls List hashCalls = hashingProvider.getHashCalls(); @@ -1239,7 +1245,7 @@ public void testHashSaltIsUsedForVariantCalculation() { // Evaluate the flag Map context = buildContext("user-456"); - hashingProvider.getVariantValue(flagKey, "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls List hashCalls = hashingProvider.getHashCalls(); @@ -1291,7 +1297,7 @@ public void testMultipleRolloutsWithHashSaltUseDifferentHashes() { // Evaluate the flag Map context = buildContext("user-789"); - hashingProvider.getVariantValue(flagKey, "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(); @@ -1343,7 +1349,7 @@ public void testLegacyFlagsWithoutHashSaltUseRolloutSalt() { // Evaluate the flag Map context = buildContext("user-legacy"); - hashingProvider.getVariantValue(flagKey, "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls use legacy "rollout" salt List hashCalls = hashingProvider.getHashCalls(); @@ -1386,7 +1392,7 @@ public void testLegacyFlagsWithoutHashSaltUseVariantSalt() { // Evaluate the flag Map context = buildContext("user-legacy-variant"); - hashingProvider.getVariantValue(flagKey, "fallback", context); + hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context); // Verify hash calls use legacy "variant" salt List hashCalls = hashingProvider.getHashCalls(); From f5b2303aa1eba190e4510e795438f63aaf1af6e0 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 13:01:28 -0600 Subject: [PATCH 04/28] =?UTF-8?q?runtime=20rules=20engine=20basic=20exact?= =?UTF-8?q?=20match=20=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../featureflags/provider/LocalFlagsProviderTest.java | 2 ++ 1 file changed, 2 insertions(+) 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 20873fa..9b01148 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -590,6 +590,7 @@ public void testApplyVariantOverrideCorrectly() { ) ); + @Test public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { createFlag(toRuntimeRule(planEqualsPremium)); @@ -598,6 +599,7 @@ public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { assertEquals(variantValue, result); } + @Test public void testReturnVariantWhenRuntimeEvaluationConditionsNotSatisfied() { createFlag(toRuntimeRule(planEqualsPremium)); From 1cb4b982536f5cb43ea90a9ae62e2798d7881d68 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 15:03:58 -0600 Subject: [PATCH 05/28] add plumbing of method that doesn't work --- .../mixpanelapi/featureflags/model/Rollout.java | 9 ++++++++- .../featureflags/provider/LocalFlagsProvider.java | 12 +++++++++++- .../provider/LocalFlagsProviderTest.java | 6 +++--- 3 files changed, 22 insertions(+), 5 deletions(-) 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 55237c8..a5f8ef0 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -101,10 +101,17 @@ public Map getVariantSplits() { /** * @return true if this rollout has runtime evaluation criteria */ - public boolean hasRuntimeEvaluation() { + public boolean hasLegacyRuntimeEvaluation() { return legacyRuntimeEvaluationDefinition != null && !legacyRuntimeEvaluationDefinition.isEmpty(); } + /** + * @return true if this rollout has runtime evaluation criteria + */ + public boolean hasRuntimeEvaluation() { + return runtimeEvaluationRule != null && !runtimeEvaluationRule.isEmpty(); + } + /** * @return true if this rollout has a variant override */ 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 1d80a9f..e2405f0 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -386,6 +386,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,12 +439,16 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } } + private boolean matchesRuntimeConditions(Rollout rollout, Map context) { + return false; // TODO Joshua + } + /** * 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; 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 9b01148..9718560 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -282,7 +282,7 @@ private JSONObject buildFlagJsonObject(FlagDefinition def, String flagId) { variantOverrideObj.put("key", r.getVariantOverride().getKey()); rolloutJson.put("variant_override", variantOverrideObj); } - if (r.hasRuntimeEvaluation()) { + if (r.hasLegacyRuntimeEvaluation()) { JSONObject runtimeEval = new JSONObject(r.getLegacyRuntimeEvaluationDefinition()); rolloutJson.put("runtime_evaluation_definition", runtimeEval); } @@ -591,7 +591,7 @@ public void testApplyVariantOverrideCorrectly() { ); @Test - public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfied() { createFlag(toRuntimeRule(planEqualsPremium)); String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "premium")); @@ -600,7 +600,7 @@ public void testReturnVariantWhenRuntimeEvaluationConditionsSatisfied() { } @Test - public void testReturnVariantWhenRuntimeEvaluationConditionsNotSatisfied() { + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsNotSatisfied() { createFlag(toRuntimeRule(planEqualsPremium)); String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "free")); From 6f981b7addaec0cdf8804026016a20fcf1a78206 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 15:36:31 -0600 Subject: [PATCH 06/28] try json logic - doesn't work yet --- pom.xml | 6 ++++++ .../mixpanelapi/featureflags/model/Rollout.java | 13 +++++++++++++ .../featureflags/provider/LocalFlagsProvider.java | 15 ++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index d312d09..0b22c19 100644 --- a/pom.xml +++ b/pom.xml @@ -139,6 +139,12 @@ 20231013 + + io.github.jamsesso + json-logic-java + 1.1.0 + + 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 a5f8ef0..6ef62c8 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 com.fasterxml.jackson.databind.ObjectMapper; + /** * Represents a rollout rule within a feature flag experiment. *

@@ -135,4 +137,15 @@ public String toString() { ", variantSplits=" + variantSplits + '}'; } + + public String getRuntimeEvaluationRule() { + ObjectMapper mapper = new ObjectMapper(); + String runtimeEvaluationRule;; + try { + runtimeEvaluationRule = mapper.writeValueAsString(this.runtimeEvaluationRule); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize runtime evaluation rule", e); + } + 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 e2405f0..d2e7268 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -19,6 +19,8 @@ import java.util.logging.Level; import java.util.logging.Logger; +import io.github.jamsesso.jsonlogic.JsonLogic; + /** * Local feature flags evaluation provider. *

@@ -440,7 +442,18 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } private boolean matchesRuntimeConditions(Rollout rollout, Map context) { - return false; // TODO Joshua + JsonLogic jsonLogic = new JsonLogic(); + Map customProperties = getCustomProperties(context); + if (customProperties == null) { + return false; + } + try { + Object result = jsonLogic.apply(rollout.getRuntimeEvaluationRule(), customProperties); + return JsonLogic.truthy(result); + } catch (Exception e) { + logger.log(Level.WARNING, "Error evaluating runtime conditions", e); + return false; + } } /** From 4bda0c368619dcd37fc1dd66c4f61995c724658d Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 15:55:58 -0600 Subject: [PATCH 07/28] =?UTF-8?q?simple=20exact=20match=20rule=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 12 ++++++++ .../featureflags/model/Rollout.java | 17 ++++++----- .../provider/LocalFlagsProvider.java | 28 +++++++++++++------ .../provider/LocalFlagsProviderTest.java | 4 +++ 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/pom.xml b/pom.xml index 0b22c19..aff6f2a 100644 --- a/pom.xml +++ b/pom.xml @@ -122,6 +122,18 @@ + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + src/test/resources/logging.properties + + + 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 6ef62c8..1c89370 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -3,8 +3,6 @@ import java.util.Collections; import java.util.Map; -import com.fasterxml.jackson.databind.ObjectMapper; - /** * Represents a rollout rule within a feature flag experiment. *

@@ -138,14 +136,15 @@ public String toString() { '}'; } + public Map getRuntimeEvaluationRuleMap() { + return runtimeEvaluationRule; + } + public String getRuntimeEvaluationRule() { - ObjectMapper mapper = new ObjectMapper(); - String runtimeEvaluationRule;; - try { - runtimeEvaluationRule = mapper.writeValueAsString(this.runtimeEvaluationRule); - } catch (Exception e) { - throw new RuntimeException("Failed to serialize runtime evaluation rule", e); + if (runtimeEvaluationRule == null) { + return null; } - return runtimeEvaluationRule; + // Convert Map to JSON string for JsonLogic library using org.json + return new org.json.JSONObject(runtimeEvaluationRule).toString(); } } 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 d2e7268..f5f9975 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -290,12 +290,23 @@ 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)); + } + } + + // Parse new declarative runtime evaluation rule (jsonLogic format) + Map runtimeEvaluationRule = null; + JSONObject runtimeRuleJson = json.optJSONObject("runtime_evaluation_rule"); + if (runtimeRuleJson != null) { + runtimeEvaluationRule = new HashMap<>(); + for (String key : runtimeRuleJson.keySet()) { + runtimeEvaluationRule.put(key, runtimeRuleJson.get(key)); } } @@ -308,7 +319,7 @@ private Rollout parseRollout(JSONObject json) { } } - return new Rollout(rolloutPercentage, runtimeEval, variantOverride, variantSplits); + return new Rollout(rolloutPercentage, runtimeEvaluationRule, legacyRuntimeEval, variantOverride, variantSplits); } // #endregion @@ -448,7 +459,8 @@ private boolean matchesRuntimeConditions(Rollout rollout, Map con return false; } try { - Object result = jsonLogic.apply(rollout.getRuntimeEvaluationRule(), customProperties); + String ruleJson = rollout.getRuntimeEvaluationRule(); + Object result = jsonLogic.apply(ruleJson, customProperties); return JsonLogic.truthy(result); } catch (Exception e) { logger.log(Level.WARNING, "Error evaluating runtime conditions", e); 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 9718560..9cac73d 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -286,6 +286,10 @@ private JSONObject buildFlagJsonObject(FlagDefinition def, String flagId) { JSONObject runtimeEval = new JSONObject(r.getLegacyRuntimeEvaluationDefinition()); rolloutJson.put("runtime_evaluation_definition", runtimeEval); } + if (r.hasRuntimeEvaluation()) { + JSONObject runtimeRule = new JSONObject(r.getRuntimeEvaluationRuleMap()); + rolloutJson.put("runtime_evaluation_rule", runtimeRule); + } if (r.hasVariantSplits()) { JSONObject variantSplitsObj = new JSONObject(r.getVariantSplits()); rolloutJson.put("variant_splits", variantSplitsObj); From d89a959bf43789fd187f968b9b68db120d29a4d0 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 16:40:28 -0600 Subject: [PATCH 08/28] don't convert in a getter --- .../featureflags/model/Rollout.java | 26 ++++++++----------- .../provider/LocalFlagsProvider.java | 12 ++------- .../provider/LocalFlagsProviderTest.java | 16 +++--------- 3 files changed, 16 insertions(+), 38 deletions(-) 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 1c89370..5c11dc0 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,10 @@ import java.util.Collections; import java.util.Map; +import org.json.JSONObject; + +import com.google.gson.JsonObject; + /** * Represents a rollout rule within a feature flag experiment. *

@@ -15,7 +19,7 @@ */ public final class Rollout { private final float rolloutPercentage; - private Map runtimeEvaluationRule; + private final JSONObject runtimeEvaluationRule; private final Map legacyRuntimeEvaluationDefinition; private final VariantOverride variantOverride; private final Map variantSplits; @@ -24,18 +28,17 @@ public final class Rollout { * Creates a new Rollout with all parameters. * * @param rolloutPercentage the percentage of users to include (0.0-1.0) + * @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, Map runtimeEvaluationRule, Map legacyRuntimeEvaluationDefinition, VariantOverride variantOverride, Map variantSplits) { + 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 != null - ? Collections.unmodifiableMap(runtimeEvaluationRule) - : null; + this.runtimeEvaluationRule = runtimeEvaluationRule; this.variantOverride = variantOverride; this.variantSplits = variantSplits != null ? Collections.unmodifiableMap(variantSplits) @@ -55,6 +58,7 @@ public Rollout(float rolloutPercentage, Map legacyRuntimeEvaluat this.legacyRuntimeEvaluationDefinition = legacyRuntimeEvaluationDefinition != null ? Collections.unmodifiableMap(legacyRuntimeEvaluationDefinition) : null; + this.runtimeEvaluationRule = null; this.variantOverride = variantOverride; this.variantSplits = variantSplits != null ? Collections.unmodifiableMap(variantSplits) @@ -109,7 +113,7 @@ public boolean hasLegacyRuntimeEvaluation() { * @return true if this rollout has runtime evaluation criteria */ public boolean hasRuntimeEvaluation() { - return runtimeEvaluationRule != null && !runtimeEvaluationRule.isEmpty(); + return runtimeEvaluationRule != null && runtimeEvaluationRule.length() > 0; } /** @@ -136,15 +140,7 @@ public String toString() { '}'; } - public Map getRuntimeEvaluationRuleMap() { + public JSONObject getRuntimeEvaluationRule() { return runtimeEvaluationRule; } - - public String getRuntimeEvaluationRule() { - if (runtimeEvaluationRule == null) { - return null; - } - // Convert Map to JSON string for JsonLogic library using org.json - return new org.json.JSONObject(runtimeEvaluationRule).toString(); - } } 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 f5f9975..5f089ef 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -132,7 +132,6 @@ private void fetchDefinitions() { Map newDefinitions = parseDefinitions(response); flagDefinitions.set(newDefinitions); ready.set(true); - logger.log(Level.FINE, "Successfully fetched " + newDefinitions.size() + " flag definitions"); } catch (Exception e) { logger.log(Level.WARNING, "Failed to fetch flag definitions", e); @@ -301,14 +300,7 @@ private Rollout parseRollout(JSONObject json) { } // Parse new declarative runtime evaluation rule (jsonLogic format) - Map runtimeEvaluationRule = null; - JSONObject runtimeRuleJson = json.optJSONObject("runtime_evaluation_rule"); - if (runtimeRuleJson != null) { - runtimeEvaluationRule = new HashMap<>(); - for (String key : runtimeRuleJson.keySet()) { - runtimeEvaluationRule.put(key, runtimeRuleJson.get(key)); - } - } + JSONObject runtimeEvaluationRule = json.optJSONObject("runtime_evaluation_rule"); Map variantSplits = null; JSONObject variantSplitsJson = json.optJSONObject("variant_splits"); @@ -459,7 +451,7 @@ private boolean matchesRuntimeConditions(Rollout rollout, Map con return false; } try { - String ruleJson = rollout.getRuntimeEvaluationRule(); + String ruleJson = rollout.getRuntimeEvaluationRule().toString(); Object result = jsonLogic.apply(ruleJson, customProperties); return JsonLogic.truthy(result); } catch (Exception e) { 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 9cac73d..4ae9e73 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -6,21 +6,11 @@ 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 org.junit.Assert.*; @@ -287,8 +277,7 @@ private JSONObject buildFlagJsonObject(FlagDefinition def, String flagId) { rolloutJson.put("runtime_evaluation_definition", runtimeEval); } if (r.hasRuntimeEvaluation()) { - JSONObject runtimeRule = new JSONObject(r.getRuntimeEvaluationRuleMap()); - rolloutJson.put("runtime_evaluation_rule", runtimeRule); + rolloutJson.put("runtime_evaluation_rule", r.getRuntimeEvaluationRule()); } if (r.hasVariantSplits()) { JSONObject variantSplitsObj = new JSONObject(r.getVariantSplits()); @@ -643,7 +632,8 @@ private List toLegacyRuntimeRule(Map runtimeEval) { return rollouts; } private List toRuntimeRule(Map runtimeEval) { - List rollouts = Arrays.asList(new Rollout(1.0f, runtimeEval, null, null, null)); + JSONObject runtimeRuleJson = new JSONObject(runtimeEval); + List rollouts = Arrays.asList(new Rollout(1.0f, runtimeRuleJson, null, null, null)); return rollouts; } From a0aebb3c730a58e54f584aefd95d703ded455721 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 16:45:02 -0600 Subject: [PATCH 09/28] =?UTF-8?q?case=20insensitive=20params=20=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../provider/LocalFlagsProviderTest.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 4ae9e73..d647180 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -601,6 +601,15 @@ public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsNotSatisfied() assertEquals(fallbackVariantValue, result); } + @Test + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseInsensitive() { + createFlag(toRuntimeRule(planEqualsPremium)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("Plan", "prEmiUm")); + + 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); @@ -617,11 +626,9 @@ private String evaluateFlagsWithRuntimeParameters(Map customProp @Test public void testReturnVariantWhenLegacyRuntimeEvaluationConditionsSatisfied() { Map runtimeEval = Map.of("plan", "premium"); - List rollouts = toLegacyRuntimeRule(runtimeEval); - createFlag(rollouts); + createFlag(toLegacyRuntimeRule(runtimeEval)); - Map customProps = Map.of("plan", "premium"); - String result = evaluateFlagsWithRuntimeParameters(customProps); + String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "premium")); assertEquals(variantValue, result); assertEquals(1, eventSender.getEvents().size()); @@ -641,11 +648,9 @@ private List toRuntimeRule(Map runtimeEval) { public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied() { Map runtimeEval = Map.of("plan", "premium"); - List rollouts = toLegacyRuntimeRule(runtimeEval); - createFlag(rollouts); + createFlag(toLegacyRuntimeRule(runtimeEval)); - Map customProps = Map.of("plan", "free"); - String result = evaluateFlagsWithRuntimeParameters(customProps); + String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "free")); assertEquals(fallbackVariantValue, result); assertEquals(0, eventSender.getEvents().size()); From 9c3a9e6f1c1f7a8fd9ab203135fdbae6103aecfe Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:02:51 -0600 Subject: [PATCH 10/28] wrap third-party lib, use util classes --- .../provider/LocalFlagsProvider.java | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) 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 5f089ef..7a9bc76 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; @@ -132,6 +133,7 @@ private void fetchDefinitions() { Map newDefinitions = parseDefinitions(response); flagDefinitions.set(newDefinitions); ready.set(true); + logger.log(Level.FINE, "Successfully fetched " + newDefinitions.size() + " flag definitions"); } catch (Exception e) { logger.log(Level.WARNING, "Failed to fetch flag definitions", e); @@ -299,8 +301,8 @@ private Rollout parseRollout(JSONObject json) { } } - // Parse new declarative runtime evaluation rule (jsonLogic format) JSONObject runtimeEvaluationRule = json.optJSONObject("runtime_evaluation_rule"); + // TODO Joshua JSONObject runtimeEvaluationRule = JsonCaseDesensitizer.lowercaseAllNodes(json.optJSONObject("runtime_evaluation_rule")); Map variantSplits = null; JSONObject variantSplitsJson = json.optJSONObject("variant_splits"); @@ -445,19 +447,8 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall } private boolean matchesRuntimeConditions(Rollout rollout, Map context) { - JsonLogic jsonLogic = new JsonLogic(); Map customProperties = getCustomProperties(context); - if (customProperties == null) { - return false; - } - try { - String ruleJson = rollout.getRuntimeEvaluationRule().toString(); - Object result = jsonLogic.apply(ruleJson, customProperties); - return JsonLogic.truthy(result); - } catch (Exception e) { - logger.log(Level.WARNING, "Error evaluating runtime conditions", e); - return false; - } + return JsonLogicEngine.evaluate(rollout.getRuntimeEvaluationRule(), customProperties); } /** From db94845008fb9135b92f98bdd8a272c134f06efa Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:12:26 -0600 Subject: [PATCH 11/28] =?UTF-8?q?case-insensitive=20params=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../util/JsonCaseDesensitizer.java | 67 +++++++++++++++++++ .../featureflags/util/JsonLogicEngine.java | 28 ++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java create mode 100644 src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java 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..c2ea727 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java @@ -0,0 +1,67 @@ +package com.mixpanel.mixpanelapi.featureflags.util; + +import java.util.Map; + +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) { + // lowercase keys and values in 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 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(((String) key).toLowerCase(), lowercaseAllNodes(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(lowercaseAllNodes(jsonArray.get(i))); + // } + // return result; + // } + else { + return object; + } + } +} 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..2fa4847 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -0,0 +1,28 @@ +package com.mixpanel.mixpanelapi.featureflags.util; + +import java.util.Map; +import java.util.logging.Level; + +import org.json.JSONObject; + +import io.github.jamsesso.jsonlogic.JsonLogic; + +public class JsonLogicEngine { + private static final java.util.logging.Logger logger = java.util.logging.Logger.getLogger(JsonLogicEngine.class.getName()); + + public static boolean evaluate(JSONObject rule, Map data) { + if (data == null) { + data = Map.of(); + } + data = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); + JsonLogic jsonLogic = new JsonLogic(); + try { + String ruleJson = rule.toString(); + Object result = jsonLogic.apply(ruleJson, data); + return JsonLogic.truthy(result); + } catch (Exception e) { + logger.log(Level.WARNING, "Error evaluating runtime rule", e); + return false; + } + } +} From bea8b8851225415e62088e646d9ac78420e184e9 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:15:46 -0600 Subject: [PATCH 12/28] =?UTF-8?q?case=20insensitive=20rule=20=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../util/JsonCaseDesensitizer.java | 16 ---------------- .../provider/LocalFlagsProviderTest.java | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java index c2ea727..4f81d73 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java @@ -34,7 +34,6 @@ public static Object lowercaseAllNodes(Object object) { else if (object instanceof String){ return ((String) object).toLowerCase(); } else if (object instanceof Map) { - // lowercase keys and values in map Map map = (Map) object; Map result = new java.util.HashMap<>(); for (Map.Entry entry : map.entrySet()) { @@ -45,21 +44,6 @@ else if (object instanceof String){ } return result; } - // 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(((String) key).toLowerCase(), lowercaseAllNodes(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(lowercaseAllNodes(jsonArray.get(i))); - // } - // return result; - // } else { return object; } 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 d647180..dd3ede1 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -582,6 +582,13 @@ public void testApplyVariantOverrideCorrectly() { "premium" // Value ) ); + Map planEqualsPremiumCaseInsensitive = Map.of( + "==", + List.of( + Map.of("var", "pLan"), // Key + "Premium" // Value + ) + ); @Test public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfied() { @@ -602,7 +609,7 @@ public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsNotSatisfied() } @Test - public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseInsensitive() { + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseInsensitiveParams() { createFlag(toRuntimeRule(planEqualsPremium)); String result = evaluateFlagsWithRuntimeParameters(Map.of("Plan", "prEmiUm")); @@ -610,6 +617,15 @@ public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseI assertEquals(variantValue, result); } + @Test + public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseInsensitiveRule() { + createFlag(toRuntimeRule(planEqualsPremiumCaseInsensitive)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "premium")); + + 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); From f17596105cb94d09917c15c67eb506b7b279a863 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:19:36 -0600 Subject: [PATCH 13/28] =?UTF-8?q?complex=20rule=20case=20insensitive=20?= =?UTF-8?q?=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../provider/LocalFlagsProviderTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 dd3ede1..d0160ea 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -589,6 +589,20 @@ public void testApplyVariantOverrideCorrectly() { "Premium" // Value ) ); + Map emailContainsGmailCaseInsensitive = Map.of( + "in", + List.of( + Map.of("var", "emAil"), // Key + "gmaIl" // Value + ) + ); + Map planEqualsPremiumAndEmailContainsGmailCaseInsensitive = Map.of( + "and", + List.of( + planEqualsPremiumCaseInsensitive, + emailContainsGmailCaseInsensitive + ) + ); @Test public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfied() { @@ -626,6 +640,15 @@ public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseI assertEquals(variantValue, result); } + @Test + public void testReturnVariantWhenComplexRuntimeEvaluationConditionsSatisfiedCaseInsensitiveRule() { + createFlag(toRuntimeRule(planEqualsPremiumAndEmailContainsGmailCaseInsensitive)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("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); From 507862a6e3c8fa93cc6d2d67d2bd54d0f28644b2 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:21:52 -0600 Subject: [PATCH 14/28] =?UTF-8?q?simple=20case=20insensitive=20rule=20?= =?UTF-8?q?=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java index 2fa4847..02a3a55 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -17,7 +17,7 @@ public static boolean evaluate(JSONObject rule, Map data) { data = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); JsonLogic jsonLogic = new JsonLogic(); try { - String ruleJson = rule.toString(); + String ruleJson = JsonCaseDesensitizer.lowercaseLeafNodes(rule).toString(); Object result = jsonLogic.apply(ruleJson, data); return JsonLogic.truthy(result); } catch (Exception e) { From a8ba289236558cfca020ed5959f9a15eedf970b9 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:26:22 -0600 Subject: [PATCH 15/28] =?UTF-8?q?evaluate=20complex=20rule=20case=20insens?= =?UTF-8?q?itive=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a faulty rule, logic works perfectly --- .../mixpanelapi/featureflags/util/JsonLogicEngine.java | 4 ++++ .../featureflags/provider/LocalFlagsProviderTest.java | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java index 02a3a55..87c6cf3 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -14,10 +14,14 @@ public static boolean evaluate(JSONObject rule, Map data) { if (data == null) { data = Map.of(); } + logger.log(Level.FINE, "Evaluating data: " + data.toString()); data = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); + logger.log(Level.FINE, "Evaluating data (lowercased): " + data.toString()); JsonLogic jsonLogic = new JsonLogic(); try { + logger.log(Level.FINE, "Evaluating rule: " + rule.toString()); String ruleJson = JsonCaseDesensitizer.lowercaseLeafNodes(rule).toString(); + logger.log(Level.FINE, "Evaluating rule (lowercased): " + ruleJson); Object result = jsonLogic.apply(ruleJson, data); return JsonLogic.truthy(result); } catch (Exception e) { 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 d0160ea..89eb7c0 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -592,8 +592,8 @@ public void testApplyVariantOverrideCorrectly() { Map emailContainsGmailCaseInsensitive = Map.of( "in", List.of( - Map.of("var", "emAil"), // Key - "gmaIl" // Value + "gmaIl", // Value + Map.of("var", "emAil") // Key ) ); Map planEqualsPremiumAndEmailContainsGmailCaseInsensitive = Map.of( From 337e1b7bfa046296d63a561959b352722ba56f1b Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:35:55 -0600 Subject: [PATCH 16/28] =?UTF-8?q?Add=20more=20tests=20for=20parity=20with?= =?UTF-8?q?=20other=20sdks=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../featureflags/util/JsonLogicEngine.java | 2 - .../provider/LocalFlagsProviderTest.java | 139 +++++++++++++++++- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java index 87c6cf3..3032935 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -14,9 +14,7 @@ public static boolean evaluate(JSONObject rule, Map data) { if (data == null) { data = Map.of(); } - logger.log(Level.FINE, "Evaluating data: " + data.toString()); data = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); - logger.log(Level.FINE, "Evaluating data (lowercased): " + data.toString()); JsonLogic jsonLogic = new JsonLogic(); try { logger.log(Level.FINE, "Evaluating rule: " + rule.toString()); 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 89eb7c0..5c09f47 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -597,12 +597,47 @@ public void testApplyVariantOverrideCorrectly() { ) ); Map planEqualsPremiumAndEmailContainsGmailCaseInsensitive = Map.of( - "and", + "and", List.of( planEqualsPremiumCaseInsensitive, emailContainsGmailCaseInsensitive ) ); + Map springfieldInUrl = Map.of( + "in", + List.of( + "Springfield", + Map.of("var", "url") + ) + ); + Map nameInArray = Map.of( + "in", + List.of( + Map.of("var", "name"), + List.of("a", "b", "c", "all-from-the-ui") + ) + ); + Map nameAndCountry = Map.of( + "and", + List.of( + Map.of("==", List.of(Map.of("var", "name"), "Johannes")), + Map.of("==", List.of(Map.of("var", "country"), "Deutschland")) + ) + ); + Map queriesGreaterThan25 = Map.of( + ">", + List.of( + Map.of("var", "queries_ran"), + 25 + ) + ); + Map invalidRuntimeRule = Map.of( + "=oops=", + List.of( + Map.of("var", "plan"), + "Premium" + ) + ); @Test public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfied() { @@ -695,6 +730,108 @@ public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied( assertEquals(0, eventSender.getEvents().size()); } + @Test + public void testReturnFallbackWhenNoRuntimeParametersProvided() { + createFlag(toRuntimeRule(planEqualsPremium)); + + String result = evaluateFlagsWithRuntimeParameters(null); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeRuleIsInvalid() { + createFlag(toRuntimeRule(invalidRuntimeRule)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "Premium")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenRuntimeEvaluationWithInOperatorSatisfied() { + createFlag(toRuntimeRule(springfieldInUrl)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("url", "https://helloworld.com/Springfield/all-about-it")); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationWithInOperatorNotSatisfied() { + createFlag(toRuntimeRule(springfieldInUrl)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("url", "https://helloworld.com/Boston/all-about-it")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenRuntimeEvaluationWithInOperatorForArraySatisfied() { + createFlag(toRuntimeRule(nameInArray)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("name", "b")); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationWithInOperatorForArrayNotSatisfied() { + createFlag(toRuntimeRule(nameInArray)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("name", "d")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenRuntimeEvaluationWithAndOperatorSatisfied() { + createFlag(toRuntimeRule(nameAndCountry)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("name", "Johannes", "country", "Deutschland")); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationWithAndOperatorNotSatisfied() { + createFlag(toRuntimeRule(nameAndCountry)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("name", "Johannes", "country", "USA")); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnVariantWhenRuntimeEvaluationWithGreaterThanOperatorSatisfied() { + createFlag(toRuntimeRule(queriesGreaterThan25)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("queries_ran", 27)); + + assertEquals(variantValue, result); + } + + @Test + public void testReturnFallbackWhenRuntimeEvaluationWithGreaterThanOperatorNotSatisfied() { + createFlag(toRuntimeRule(queriesGreaterThan25)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("queries_ran", 20)); + + assertEquals(fallbackVariantValue, result); + } + + @Test + public void testReturnFallbackWhenLegacyRuntimeEvaluationMultipleConditionsNotSatisfied() { + Map runtimeEval = Map.of("plan", "premium", "region", "US"); + + createFlag(toLegacyRuntimeRule(runtimeEval)); + + String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "free", "region", "US")); + + assertEquals(fallbackVariantValue, result); + assertEquals(0, eventSender.getEvents().size()); + } + // #endregion // #region Exposure Tracking Tests From 5e585ac25952cf9ea71596b591d864e562379c62 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:42:53 -0600 Subject: [PATCH 17/28] =?UTF-8?q?log=20to=20stdout=20when=20running=20test?= =?UTF-8?q?s=20=F0=9F=92=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Helps with debugging. --- src/test/resources/logging.properties | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/test/resources/logging.properties 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 From 0bf760cb0b9919453d7d521920435f4e6ccbed1a Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:48:05 -0600 Subject: [PATCH 18/28] correct logging; remove TODO --- .../com/mixpanel/mixpanelapi/featureflags/model/Rollout.java | 2 -- .../mixpanelapi/featureflags/provider/LocalFlagsProvider.java | 1 - .../mixpanelapi/featureflags/util/JsonLogicEngine.java | 3 +-- 3 files changed, 1 insertion(+), 5 deletions(-) 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 5c11dc0..c24e095 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -5,8 +5,6 @@ import org.json.JSONObject; -import com.google.gson.JsonObject; - /** * Represents a rollout rule within a feature flag experiment. *

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 7a9bc76..c5afb84 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -302,7 +302,6 @@ private Rollout parseRollout(JSONObject json) { } JSONObject runtimeEvaluationRule = json.optJSONObject("runtime_evaluation_rule"); - // TODO Joshua JSONObject runtimeEvaluationRule = JsonCaseDesensitizer.lowercaseAllNodes(json.optJSONObject("runtime_evaluation_rule")); Map variantSplits = null; JSONObject variantSplitsJson = json.optJSONObject("variant_splits"); diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java index 3032935..64065d2 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -17,9 +17,8 @@ public static boolean evaluate(JSONObject rule, Map data) { data = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); JsonLogic jsonLogic = new JsonLogic(); try { - logger.log(Level.FINE, "Evaluating rule: " + rule.toString()); String ruleJson = JsonCaseDesensitizer.lowercaseLeafNodes(rule).toString(); - logger.log(Level.FINE, "Evaluating rule (lowercased): " + ruleJson); + logger.log(Level.FINE, "Evaluating JsonLogic rule: " + ruleJson + " with data: " + data.toString()); Object result = jsonLogic.apply(ruleJson, data); return JsonLogic.truthy(result); } catch (Exception e) { From d74ddeeb5a6194d20642a472330ec99448175ddd Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:56:13 -0600 Subject: [PATCH 19/28] roll our own Map.of() for Java 8 support --- .../provider/LocalFlagsProviderTest.java | 97 ++++++++++--------- 1 file changed, 49 insertions(+), 48 deletions(-) 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 5c09f47..3e3e3af 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.util.*; +import static com.mixpanel.mixpanelapi.featureflags.provider.TestUtils.*; import static org.junit.Assert.*; /** @@ -575,66 +576,66 @@ public void testApplyVariantOverrideCorrectly() { String flagKey = "test-flag"; String distinctIdContextKey = "distinct_id"; String fallbackVariantValue = "fallback"; - Map planEqualsPremium = Map.of( + Map planEqualsPremium = mapOf( "==", - List.of( - Map.of("var", "plan"), // Key + listOf( + mapOf("var", "plan"), // Key "premium" // Value ) ); - Map planEqualsPremiumCaseInsensitive = Map.of( + Map planEqualsPremiumCaseInsensitive = mapOf( "==", - List.of( - Map.of("var", "pLan"), // Key + listOf( + mapOf("var", "pLan"), // Key "Premium" // Value ) ); - Map emailContainsGmailCaseInsensitive = Map.of( + Map emailContainsGmailCaseInsensitive = mapOf( "in", - List.of( + listOf( "gmaIl", // Value - Map.of("var", "emAil") // Key + mapOf("var", "emAil") // Key ) ); - Map planEqualsPremiumAndEmailContainsGmailCaseInsensitive = Map.of( + Map planEqualsPremiumAndEmailContainsGmailCaseInsensitive = mapOf( "and", - List.of( + listOf( planEqualsPremiumCaseInsensitive, emailContainsGmailCaseInsensitive ) ); - Map springfieldInUrl = Map.of( + Map springfieldInUrl = mapOf( "in", - List.of( + listOf( "Springfield", - Map.of("var", "url") + mapOf("var", "url") ) ); - Map nameInArray = Map.of( + Map nameInArray = mapOf( "in", - List.of( - Map.of("var", "name"), - List.of("a", "b", "c", "all-from-the-ui") + listOf( + mapOf("var", "name"), + listOf("a", "b", "c", "all-from-the-ui") ) ); - Map nameAndCountry = Map.of( + Map nameAndCountry = mapOf( "and", - List.of( - Map.of("==", List.of(Map.of("var", "name"), "Johannes")), - Map.of("==", List.of(Map.of("var", "country"), "Deutschland")) + listOf( + mapOf("==", listOf(mapOf("var", "name"), "Johannes")), + mapOf("==", listOf(mapOf("var", "country"), "Deutschland")) ) ); - Map queriesGreaterThan25 = Map.of( + Map queriesGreaterThan25 = mapOf( ">", - List.of( - Map.of("var", "queries_ran"), + listOf( + mapOf("var", "queries_ran"), 25 ) ); - Map invalidRuntimeRule = Map.of( + Map invalidRuntimeRule = mapOf( "=oops=", - List.of( - Map.of("var", "plan"), + listOf( + mapOf("var", "plan"), "Premium" ) ); @@ -643,7 +644,7 @@ public void testApplyVariantOverrideCorrectly() { public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfied() { createFlag(toRuntimeRule(planEqualsPremium)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "premium")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "premium")); assertEquals(variantValue, result); } @@ -652,7 +653,7 @@ public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfied() { public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsNotSatisfied() { createFlag(toRuntimeRule(planEqualsPremium)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "free")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "free")); assertEquals(fallbackVariantValue, result); } @@ -661,7 +662,7 @@ public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsNotSatisfied() public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseInsensitiveParams() { createFlag(toRuntimeRule(planEqualsPremium)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("Plan", "prEmiUm")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("Plan", "prEmiUm")); assertEquals(variantValue, result); } @@ -670,7 +671,7 @@ public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseI public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseInsensitiveRule() { createFlag(toRuntimeRule(planEqualsPremiumCaseInsensitive)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "premium")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "premium")); assertEquals(variantValue, result); } @@ -679,7 +680,7 @@ public void testReturnVariantWhenSimpleRuntimeEvaluationConditionsSatisfiedCaseI public void testReturnVariantWhenComplexRuntimeEvaluationConditionsSatisfiedCaseInsensitiveRule() { createFlag(toRuntimeRule(planEqualsPremiumAndEmailContainsGmailCaseInsensitive)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "premium", "email", "user@gmail.com")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "premium", "email", "user@gmail.com")); assertEquals(variantValue, result); } @@ -699,10 +700,10 @@ private String evaluateFlagsWithRuntimeParameters(Map customProp @Test public void testReturnVariantWhenLegacyRuntimeEvaluationConditionsSatisfied() { - Map runtimeEval = Map.of("plan", "premium"); + Map runtimeEval = mapOf("plan", "premium"); createFlag(toLegacyRuntimeRule(runtimeEval)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "premium")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "premium")); assertEquals(variantValue, result); assertEquals(1, eventSender.getEvents().size()); @@ -720,11 +721,11 @@ private List toRuntimeRule(Map runtimeEval) { @Test public void testReturnFallbackWhenLegacyRuntimeEvaluationConditionsNotSatisfied() { - Map runtimeEval = Map.of("plan", "premium"); + Map runtimeEval = mapOf("plan", "premium"); createFlag(toLegacyRuntimeRule(runtimeEval)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "free")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "free")); assertEquals(fallbackVariantValue, result); assertEquals(0, eventSender.getEvents().size()); @@ -743,7 +744,7 @@ public void testReturnFallbackWhenNoRuntimeParametersProvided() { public void testReturnFallbackWhenRuntimeRuleIsInvalid() { createFlag(toRuntimeRule(invalidRuntimeRule)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "Premium")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "Premium")); assertEquals(fallbackVariantValue, result); } @@ -752,7 +753,7 @@ public void testReturnFallbackWhenRuntimeRuleIsInvalid() { public void testReturnVariantWhenRuntimeEvaluationWithInOperatorSatisfied() { createFlag(toRuntimeRule(springfieldInUrl)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("url", "https://helloworld.com/Springfield/all-about-it")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("url", "https://helloworld.com/Springfield/all-about-it")); assertEquals(variantValue, result); } @@ -761,7 +762,7 @@ public void testReturnVariantWhenRuntimeEvaluationWithInOperatorSatisfied() { public void testReturnFallbackWhenRuntimeEvaluationWithInOperatorNotSatisfied() { createFlag(toRuntimeRule(springfieldInUrl)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("url", "https://helloworld.com/Boston/all-about-it")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("url", "https://helloworld.com/Boston/all-about-it")); assertEquals(fallbackVariantValue, result); } @@ -770,7 +771,7 @@ public void testReturnFallbackWhenRuntimeEvaluationWithInOperatorNotSatisfied() public void testReturnVariantWhenRuntimeEvaluationWithInOperatorForArraySatisfied() { createFlag(toRuntimeRule(nameInArray)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("name", "b")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("name", "b")); assertEquals(variantValue, result); } @@ -779,7 +780,7 @@ public void testReturnVariantWhenRuntimeEvaluationWithInOperatorForArraySatisfie public void testReturnFallbackWhenRuntimeEvaluationWithInOperatorForArrayNotSatisfied() { createFlag(toRuntimeRule(nameInArray)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("name", "d")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("name", "d")); assertEquals(fallbackVariantValue, result); } @@ -788,7 +789,7 @@ public void testReturnFallbackWhenRuntimeEvaluationWithInOperatorForArrayNotSati public void testReturnVariantWhenRuntimeEvaluationWithAndOperatorSatisfied() { createFlag(toRuntimeRule(nameAndCountry)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("name", "Johannes", "country", "Deutschland")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("name", "Johannes", "country", "Deutschland")); assertEquals(variantValue, result); } @@ -797,7 +798,7 @@ public void testReturnVariantWhenRuntimeEvaluationWithAndOperatorSatisfied() { public void testReturnFallbackWhenRuntimeEvaluationWithAndOperatorNotSatisfied() { createFlag(toRuntimeRule(nameAndCountry)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("name", "Johannes", "country", "USA")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("name", "Johannes", "country", "USA")); assertEquals(fallbackVariantValue, result); } @@ -806,7 +807,7 @@ public void testReturnFallbackWhenRuntimeEvaluationWithAndOperatorNotSatisfied() public void testReturnVariantWhenRuntimeEvaluationWithGreaterThanOperatorSatisfied() { createFlag(toRuntimeRule(queriesGreaterThan25)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("queries_ran", 27)); + String result = evaluateFlagsWithRuntimeParameters(mapOf("queries_ran", 27)); assertEquals(variantValue, result); } @@ -815,18 +816,18 @@ public void testReturnVariantWhenRuntimeEvaluationWithGreaterThanOperatorSatisfi public void testReturnFallbackWhenRuntimeEvaluationWithGreaterThanOperatorNotSatisfied() { createFlag(toRuntimeRule(queriesGreaterThan25)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("queries_ran", 20)); + String result = evaluateFlagsWithRuntimeParameters(mapOf("queries_ran", 20)); assertEquals(fallbackVariantValue, result); } @Test public void testReturnFallbackWhenLegacyRuntimeEvaluationMultipleConditionsNotSatisfied() { - Map runtimeEval = Map.of("plan", "premium", "region", "US"); + Map runtimeEval = mapOf("plan", "premium", "region", "US"); createFlag(toLegacyRuntimeRule(runtimeEval)); - String result = evaluateFlagsWithRuntimeParameters(Map.of("plan", "free", "region", "US")); + String result = evaluateFlagsWithRuntimeParameters(mapOf("plan", "free", "region", "US")); assertEquals(fallbackVariantValue, result); assertEquals(0, eventSender.getEvents().size()); From ebed7ffdc78be94f1b59bda1f6276d70f1c33489 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:57:32 -0600 Subject: [PATCH 20/28] Update src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java index 64065d2..763ff5c 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -18,7 +18,7 @@ public static boolean evaluate(JSONObject rule, Map data) { JsonLogic jsonLogic = new JsonLogic(); try { String ruleJson = JsonCaseDesensitizer.lowercaseLeafNodes(rule).toString(); - logger.log(Level.FINE, "Evaluating JsonLogic rule: " + ruleJson + " with data: " + data.toString()); + logger.log(Level.FINE, () -> "Evaluating JsonLogic rule: " + ruleJson + " with data: " + data.toString()); Object result = jsonLogic.apply(ruleJson, data); return JsonLogic.truthy(result); } catch (Exception e) { From eb9b950a766c4399ad64957b988f9c638233d0e9 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:57:42 -0600 Subject: [PATCH 21/28] Update src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../mixpanelapi/featureflags/util/JsonCaseDesensitizer.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java index 4f81d73..f92e568 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java @@ -43,8 +43,7 @@ else if (object instanceof String){ result.put(lowerKey, lowercaseAllNodes(entry.getValue())); } return result; - } - else { + } else { return object; } } From 7157550bc7fd9d071c3ace17126623a618bd56f3 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 17:59:38 -0600 Subject: [PATCH 22/28] Update src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../mixpanelapi/featureflags/provider/LocalFlagsProvider.java | 1 - 1 file changed, 1 deletion(-) 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 c5afb84..2b03b64 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -20,7 +20,6 @@ import java.util.logging.Level; import java.util.logging.Logger; -import io.github.jamsesso.jsonlogic.JsonLogic; /** * Local feature flags evaluation provider. From ce3117ead415bcdbda434a6c31faad9acb0fbcc9 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 18:01:13 -0600 Subject: [PATCH 23/28] Update src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/mixpanel/mixpanelapi/featureflags/model/Rollout.java | 3 +++ 1 file changed, 3 insertions(+) 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 c24e095..4237e09 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -138,6 +138,9 @@ public String toString() { '}'; } + /** + * @return optional JSONObject containing JsonLogic rule for runtime evaluation, or null if not set + */ public JSONObject getRuntimeEvaluationRule() { return runtimeEvaluationRule; } From 1e35fc7d14fb44a6f714237c3081ed85b0b65711 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 18:05:26 -0600 Subject: [PATCH 24/28] docs; handle array inputs; memory management; --- .../mixpanelapi/featureflags/model/Rollout.java | 3 ++- .../featureflags/util/JsonCaseDesensitizer.java | 15 ++++++++++++--- .../featureflags/util/JsonLogicEngine.java | 6 +++++- 3 files changed, 19 insertions(+), 5 deletions(-) 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 c24e095..d993e34 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/Rollout.java @@ -132,7 +132,8 @@ public boolean hasVariantSplits() { public String toString() { return "Rollout{" + "rolloutPercentage=" + rolloutPercentage + - ", runtimeEvaluationDefinition=" + legacyRuntimeEvaluationDefinition + + ", legacyRuntimeEvaluationDefinition=" + legacyRuntimeEvaluationDefinition + + ", runtimeEvaluationRule=" + runtimeEvaluationRule + ", variantOverride='" + variantOverride + '\'' + ", variantSplits=" + variantSplits + '}'; diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java index 4f81d73..2d4785d 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizer.java @@ -2,6 +2,9 @@ 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) { @@ -43,9 +46,15 @@ else if (object instanceof String){ result.put(lowerKey, lowercaseAllNodes(entry.getValue())); } return result; - } - else { + } 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 index 64065d2..ccefe70 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -7,15 +7,19 @@ 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 = Map.of(); } data = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); - JsonLogic jsonLogic = new JsonLogic(); try { String ruleJson = JsonCaseDesensitizer.lowercaseLeafNodes(rule).toString(); logger.log(Level.FINE, "Evaluating JsonLogic rule: " + ruleJson + " with data: " + data.toString()); From a4fff2bccb040bb2455e584fe1bf77e5e404be76 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 1 Dec 2025 18:08:18 -0600 Subject: [PATCH 25/28] final var in supplier --- .../mixpanelapi/featureflags/util/JsonLogicEngine.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java index eaf6437..89ccc7f 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/util/JsonLogicEngine.java @@ -1,5 +1,6 @@ package com.mixpanel.mixpanelapi.featureflags.util; +import java.util.HashMap; import java.util.Map; import java.util.logging.Level; @@ -17,13 +18,13 @@ public class JsonLogicEngine { public static boolean evaluate(JSONObject rule, Map data) { if (data == null) { - data = Map.of(); + data = new HashMap<>(); } - data = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); + Map lowercasedData = (Map) JsonCaseDesensitizer.lowercaseAllNodes(data); try { String ruleJson = JsonCaseDesensitizer.lowercaseLeafNodes(rule).toString(); - logger.log(Level.FINE, () -> "Evaluating JsonLogic rule: " + ruleJson + " with data: " + data.toString()); - Object result = jsonLogic.apply(ruleJson, data); + 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); From cbbe9086080741afd1b0c49a5b08b286da75983f Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Tue, 2 Dec 2025 10:57:08 -0600 Subject: [PATCH 26/28] add test util (forgot the git add file) --- .../featureflags/provider/TestUtils.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/TestUtils.java 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); + } +} From a817ed82ffdcfdb8ede2c0dae4053528ff714cbd Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Tue, 2 Dec 2025 11:13:13 -0600 Subject: [PATCH 27/28] add case-insensitive util tests reduce likelihood of future bug due to edge case in the util --- .../util/JsonCaseDesensitizerTest.java | 718 ++++++++++++++++++ 1 file changed, 718 insertions(+) create mode 100644 src/test/java/com/mixpanel/mixpanelapi/featureflags/util/JsonCaseDesensitizerTest.java 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 +} From abef1984759a90f704458bbd5f8664f53df60220 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 12 Dec 2025 20:33:30 -0600 Subject: [PATCH 28/28] bump minor version for runtime engine --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e42a4f3..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