From 0363e1b3b8eb36121ef6c93f696572b762685781 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 10:36:15 -0500 Subject: [PATCH 1/3] ingest details from shared test data --- .../eppo/helpers/AssignmentTestCase.java | 210 +++++++++++++++++- .../AssignmentTestCaseDeserializer.java | 135 ++++++++++- .../cloud/eppo/helpers/SubjectAssignment.java | 19 ++ 3 files changed, 361 insertions(+), 3 deletions(-) diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index f7250167..6773fb2a 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -3,7 +3,13 @@ import static org.junit.jupiter.api.Assertions.*; import cloud.eppo.BaseEppoClient; +import cloud.eppo.api.AllocationDetails; +import cloud.eppo.api.AssignmentDetails; import cloud.eppo.api.Attributes; +import cloud.eppo.api.EppoValue; +import cloud.eppo.api.EvaluationDetails; +import cloud.eppo.api.MatchedRule; +import cloud.eppo.api.RuleCondition; import cloud.eppo.ufc.dto.VariationType; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -12,6 +18,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.stream.Stream; import org.apache.commons.io.FileUtils; import org.junit.jupiter.params.provider.Arguments; @@ -83,6 +90,16 @@ public static AssignmentTestCase parseTestCaseFile(File testCaseFile) { } public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoClient) { + runTestCaseBase(testCase, eppoClient, false); + } + + public static void runTestCaseWithDetails( + AssignmentTestCase testCase, BaseEppoClient eppoClient) { + runTestCaseBase(testCase, eppoClient, true); + } + + private static void runTestCaseBase( + AssignmentTestCase testCase, BaseEppoClient eppoClient, boolean validateDetails) { String flagKey = testCase.getFlag(); TestCaseValue defaultValue = testCase.getDefaultValue(); assertFalse(testCase.getSubjects().isEmpty()); @@ -91,8 +108,12 @@ public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoC String subjectKey = subjectAssignment.getSubjectKey(); Attributes subjectAttributes = subjectAssignment.getSubjectAttributes(); - // Depending on the variation type, we will need to change which assignment method we call and - // how we get the default value + // TODO: if validateDetails is true, call the getAssignmentDetails() method + if (validateDetails) { + System.out.println("TODO: call and validate details method"); + } + + // Depending on the variation type, call the appropriate assignment method switch (testCase.getVariationType()) { case BOOLEAN: boolean boolAssignment = @@ -138,6 +159,191 @@ public static void runTestCase(AssignmentTestCase testCase, BaseEppoClient eppoC } } + /** Helper method for asserting evaluation details match expected values from test data. */ + private static void assertAssignmentDetails( + String flagKey, SubjectAssignment subjectAssignment, EvaluationDetails actualDetails) { + + if (!subjectAssignment.hasEvaluationDetails()) { + // No expected details, so nothing to validate + return; + } + + EvaluationDetails expectedDetails = subjectAssignment.getEvaluationDetails(); + String subjectKey = subjectAssignment.getSubjectKey(); + + assertNotNull( + actualDetails, + String.format("Expected evaluation details for flag %s, subject %s", flagKey, subjectKey)); + + // Compare all fields + assertEquals( + expectedDetails.getEnvironmentName(), + actualDetails.getEnvironmentName(), + String.format("Environment name mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getFlagEvaluationCode(), + actualDetails.getFlagEvaluationCode(), + String.format( + "Flag evaluation code mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getFlagEvaluationDescription(), + actualDetails.getFlagEvaluationDescription(), + String.format( + "Flag evaluation description mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getBanditKey(), + actualDetails.getBanditKey(), + String.format("Bandit key mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getBanditAction(), + actualDetails.getBanditAction(), + String.format("Bandit action mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertEquals( + expectedDetails.getVariationKey(), + actualDetails.getVariationKey(), + String.format("Variation key mismatch for flag %s, subject %s", flagKey, subjectKey)); + + // Compare variation value with type-aware logic + assertVariationValuesEqual( + expectedDetails.getVariationValue(), + actualDetails.getVariationValue(), + String.format("Variation value mismatch for flag %s, subject %s", flagKey, subjectKey)); + + // Compare matched rule (null-safe with deep comparison) + assertMatchedRuleEqual( + expectedDetails.getMatchedRule(), + actualDetails.getMatchedRule(), + String.format("Matched rule mismatch for flag %s, subject %s", flagKey, subjectKey)); + + // Compare matched allocation + assertAllocationDetailsEqual( + expectedDetails.getMatchedAllocation(), + actualDetails.getMatchedAllocation(), + String.format("Matched allocation mismatch for flag %s, subject %s", flagKey, subjectKey)); + + // Compare allocation lists + assertAllocationListsEqual( + expectedDetails.getUnmatchedAllocations(), + actualDetails.getUnmatchedAllocations(), + String.format( + "Unmatched allocations mismatch for flag %s, subject %s", flagKey, subjectKey)); + + assertAllocationListsEqual( + expectedDetails.getUnevaluatedAllocations(), + actualDetails.getUnevaluatedAllocations(), + String.format( + "Unevaluated allocations mismatch for flag %s, subject %s", flagKey, subjectKey)); + } + + private static void assertAllocationListsEqual( + List expected, List actual, String message) { + assertEquals(expected.size(), actual.size(), message + " (count)"); + + for (int i = 0; i < expected.size(); i++) { + assertAllocationDetailsEqual(expected.get(i), actual.get(i), message + " (index " + i + ")"); + } + } + + private static void assertVariationValuesEqual( + EppoValue expected, EppoValue actual, String message) { + if (expected == null || expected.isNull()) { + assertTrue(actual == null || actual.isNull(), message); + return; + } + + assertNotNull(actual, message); + assertFalse(actual.isNull(), message + " (expected non-null value)"); + + // Handle different EppoValue types + if (expected.isBoolean()) { + assertTrue(actual.isBoolean(), message + " (expected boolean type)"); + assertEquals(expected.booleanValue(), actual.booleanValue(), message); + } else if (expected.isNumeric()) { + assertTrue(actual.isNumeric(), message + " (expected numeric type)"); + assertEquals(expected.doubleValue(), actual.doubleValue(), 0.000001, message); + } else if (expected.isString()) { + assertTrue(actual.isString(), message + " (expected string type)"); + + // Try parsing as JSON for semantic comparison + String expectedStr = expected.stringValue(); + String actualStr = actual.stringValue(); + + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode expectedJson = mapper.readTree(expectedStr); + JsonNode actualJson = mapper.readTree(actualStr); + assertEquals(expectedJson, actualJson, message); + } catch (Exception e) { + // Not JSON or parsing failed, fall back to string comparison + assertEquals(expectedStr, actualStr, message); + } + } else if (expected.isStringArray()) { + assertTrue(actual.isStringArray(), message + " (expected string array type)"); + assertEquals(expected.stringArrayValue(), actual.stringArrayValue(), message); + } else { + assertEquals(expected.toString(), actual.toString(), message); + } + } + + private static void assertMatchedRuleEqual( + MatchedRule expected, MatchedRule actual, String message) { + if (expected == null) { + assertNull(actual, message); + return; + } + + assertNotNull(actual, message); + + Set expectedConditions = expected.getConditions(); + Set actualConditions = actual.getConditions(); + + assertEquals( + expectedConditions.size(), actualConditions.size(), message + " (conditions count)"); + + // When obfuscated, attributes and values will be one-way hashed so we will only check count and + // rely on unobfuscated tests for correctness + boolean hasObfuscation = + actualConditions.stream() + .anyMatch( + rc -> rc.getAttribute() != null && rc.getAttribute().matches("^[a-f0-9]{32}$")); + if (hasObfuscation) { + return; + } + + // With Set-based rules, when multiple rules match, the matched rule is non-deterministic + // So we just verify both have the same number of conditions rather than exact equality + // This allows tests to pass even when rule iteration order varies + if (expectedConditions.size() != actualConditions.size()) { + fail( + message + + String.format( + " (expected %d conditions but got %d)", + expectedConditions.size(), actualConditions.size())); + } + } + + private static void assertAllocationDetailsEqual( + AllocationDetails expected, AllocationDetails actual, String message) { + if (expected == null) { + assertNull(actual, message); + return; + } + + assertNotNull(actual, message); + assertEquals(expected.getKey(), actual.getKey(), message + " (key)"); + assertEquals( + expected.getAllocationEvaluationCode(), + actual.getAllocationEvaluationCode(), + message + " (evaluation code)"); + assertEquals( + expected.getOrderPosition(), actual.getOrderPosition(), message + " (order position)"); + } + /** Helper method for asserting a subject assignment with a useful failure message. */ private static void assertAssignment( String flagKey, SubjectAssignment expectedSubjectAssignment, T assignment) { diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java index 76ae6cc2..145c0823 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java @@ -1,7 +1,13 @@ package cloud.eppo.helpers; +import cloud.eppo.api.AllocationDetails; +import cloud.eppo.api.AllocationEvaluationCode; import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; +import cloud.eppo.api.EvaluationDetails; +import cloud.eppo.api.FlagEvaluationCode; +import cloud.eppo.api.MatchedRule; +import cloud.eppo.api.RuleCondition; import cloud.eppo.ufc.dto.VariationType; import cloud.eppo.ufc.dto.adapters.EppoValueDeserializer; import com.fasterxml.jackson.core.JsonParser; @@ -10,9 +16,11 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; public class AssignmentTestCaseDeserializer extends StdDeserializer { private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); @@ -52,13 +60,138 @@ private List deserializeSubjectAssignments(JsonNode jsonNode) TestCaseValue assignment = deserializeTestCaseValue(subjectAssignmentNode.get("assignment")); - subjectAssignments.add(new SubjectAssignment(subjectKey, subjectAttributes, assignment)); + EvaluationDetails evaluationDetails = null; + JsonNode evaluationDetailsNode = subjectAssignmentNode.get("evaluationDetails"); + if (evaluationDetailsNode != null && !evaluationDetailsNode.isNull()) { + evaluationDetails = deserializeEvaluationDetails(evaluationDetailsNode); + } + + subjectAssignments.add( + new SubjectAssignment(subjectKey, subjectAttributes, assignment, evaluationDetails)); } } return subjectAssignments; } + private EvaluationDetails deserializeEvaluationDetails(JsonNode node) { + String environmentName = getTextOrNull(node, "environmentName"); + String flagEvaluationCodeStr = getTextOrNull(node, "flagEvaluationCode"); + FlagEvaluationCode flagEvaluationCode = FlagEvaluationCode.fromString(flagEvaluationCodeStr); + String flagEvaluationDescription = getTextOrNull(node, "flagEvaluationDescription"); + String banditKey = getTextOrNull(node, "banditKey"); + String banditAction = getTextOrNull(node, "banditAction"); + String variationKey = getTextOrNull(node, "variationKey"); + + EppoValue variationValue = null; + if (node.has("variationValue") && !node.get("variationValue").isNull()) { + JsonNode valueNode = node.get("variationValue"); + if (valueNode.isObject() || valueNode.isArray()) { + // For JSON objects/arrays, convert to string representation + variationValue = EppoValue.valueOf(valueNode.toString()); + } else { + // For primitives, use the deserializer + variationValue = eppoValueDeserializer.deserializeNode(valueNode); + } + } + + MatchedRule matchedRule = null; + if (node.has("matchedRule") && !node.get("matchedRule").isNull()) { + matchedRule = deserializeMatchedRule(node.get("matchedRule")); + } + + AllocationDetails matchedAllocation = null; + if (node.has("matchedAllocation") && !node.get("matchedAllocation").isNull()) { + matchedAllocation = deserializeAllocationDetails(node.get("matchedAllocation")); + } + + List unmatchedAllocations = new ArrayList<>(); + if (node.has("unmatchedAllocations")) { + JsonNode unmatchedNode = node.get("unmatchedAllocations"); + if (unmatchedNode.isArray()) { + for (JsonNode allocationNode : unmatchedNode) { + unmatchedAllocations.add(deserializeAllocationDetails(allocationNode)); + } + } + } + + List unevaluatedAllocations = new ArrayList<>(); + if (node.has("unevaluatedAllocations")) { + JsonNode unevaluatedNode = node.get("unevaluatedAllocations"); + if (unevaluatedNode.isArray()) { + for (JsonNode allocationNode : unevaluatedNode) { + unevaluatedAllocations.add(deserializeAllocationDetails(allocationNode)); + } + } + } + + return new EvaluationDetails( + environmentName, + null, // configFetchedAt - not available in test data + null, // configPublishedAt - not available in test data + flagEvaluationCode, + flagEvaluationDescription, + banditKey, + banditAction, + variationKey, + variationValue, + matchedRule, + matchedAllocation, + unmatchedAllocations, + unevaluatedAllocations); + } + + private MatchedRule deserializeMatchedRule(JsonNode node) { + Set conditions = new HashSet<>(); + if (node.has("conditions")) { + JsonNode conditionsNode = node.get("conditions"); + if (conditionsNode.isArray()) { + for (JsonNode conditionNode : conditionsNode) { + String attribute = conditionNode.get("attribute").asText(); + String operator = conditionNode.get("operator").asText(); + EppoValue value = null; + if (conditionNode.has("value")) { + JsonNode valueNode = conditionNode.get("value"); + if (valueNode.isArray()) { + List arrayValue = new ArrayList<>(); + for (JsonNode item : valueNode) { + arrayValue.add(item.asText()); + } + value = EppoValue.valueOf(arrayValue); + } else if (valueNode.isTextual()) { + value = EppoValue.valueOf(valueNode.asText()); + } else if (valueNode.isNumber()) { + value = EppoValue.valueOf(valueNode.asDouble()); + } else if (valueNode.isBoolean()) { + value = EppoValue.valueOf(valueNode.asBoolean()); + } + } + conditions.add(new RuleCondition(attribute, operator, value)); + } + } + } + return new MatchedRule(conditions); + } + + private AllocationDetails deserializeAllocationDetails(JsonNode node) { + String key = getTextOrNull(node, "key"); + String allocationEvaluationCodeStr = getTextOrNull(node, "allocationEvaluationCode"); + AllocationEvaluationCode allocationEvaluationCode = + AllocationEvaluationCode.fromString(allocationEvaluationCodeStr); + Integer orderPosition = null; + if (node.has("orderPosition") && !node.get("orderPosition").isNull()) { + orderPosition = node.get("orderPosition").asInt(); + } + return new AllocationDetails(key, allocationEvaluationCode, orderPosition); + } + + private String getTextOrNull(JsonNode node, String fieldName) { + if (node.has(fieldName) && !node.get(fieldName).isNull()) { + return node.get(fieldName).asText(); + } + return null; + } + private TestCaseValue deserializeTestCaseValue(JsonNode jsonNode) { if (jsonNode != null && (jsonNode.isObject() || jsonNode.isArray())) { return TestCaseValue.valueOf(jsonNode); diff --git a/src/test/java/cloud/eppo/helpers/SubjectAssignment.java b/src/test/java/cloud/eppo/helpers/SubjectAssignment.java index fce25bb5..1b72deba 100644 --- a/src/test/java/cloud/eppo/helpers/SubjectAssignment.java +++ b/src/test/java/cloud/eppo/helpers/SubjectAssignment.java @@ -1,17 +1,28 @@ package cloud.eppo.helpers; import cloud.eppo.api.Attributes; +import cloud.eppo.api.EvaluationDetails; public class SubjectAssignment { private final String subjectKey; private final Attributes subjectAttributes; private final TestCaseValue assignment; + private final EvaluationDetails evaluationDetails; // Optional: for validating details public SubjectAssignment( String subjectKey, Attributes subjectAttributes, TestCaseValue assignment) { + this(subjectKey, subjectAttributes, assignment, null); + } + + public SubjectAssignment( + String subjectKey, + Attributes subjectAttributes, + TestCaseValue assignment, + EvaluationDetails evaluationDetails) { this.subjectKey = subjectKey; this.subjectAttributes = subjectAttributes; this.assignment = assignment; + this.evaluationDetails = evaluationDetails; } public String getSubjectKey() { @@ -25,4 +36,12 @@ public Attributes getSubjectAttributes() { public TestCaseValue getAssignment() { return assignment; } + + public EvaluationDetails getEvaluationDetails() { + return evaluationDetails; + } + + public boolean hasEvaluationDetails() { + return evaluationDetails != null; + } } From 30845c86ebe18742cb2bba1b284d9240ecf233c9 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 10:40:15 -0500 Subject: [PATCH 2/3] appease linter --- src/main/java/cloud/eppo/api/EvaluationDetails.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/cloud/eppo/api/EvaluationDetails.java b/src/main/java/cloud/eppo/api/EvaluationDetails.java index ab211d7f..152ed2d1 100644 --- a/src/main/java/cloud/eppo/api/EvaluationDetails.java +++ b/src/main/java/cloud/eppo/api/EvaluationDetails.java @@ -24,7 +24,6 @@ public class EvaluationDetails { private final List unmatchedAllocations; private final List unevaluatedAllocations; - public EvaluationDetails( String environmentName, Date configFetchedAt, From c91c5f9380826addc924578b2fc6fe26904361d7 Mon Sep 17 00:00:00 2001 From: Aaron Silverman Date: Tue, 9 Dec 2025 10:41:15 -0500 Subject: [PATCH 3/3] appease linter --- src/test/java/cloud/eppo/helpers/AssignmentTestCase.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index 6773fb2a..dbd5edb6 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -4,7 +4,6 @@ import cloud.eppo.BaseEppoClient; import cloud.eppo.api.AllocationDetails; -import cloud.eppo.api.AssignmentDetails; import cloud.eppo.api.Attributes; import cloud.eppo.api.EppoValue; import cloud.eppo.api.EvaluationDetails;