diff --git a/README.md b/README.md index 81da6b8..fc7f8a7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ```groovy dependencies { - implementation 'cloud.eppo:eppo-server-sdk:5.3.3' + implementation 'cloud.eppo:eppo-server-sdk:6.0.0' } ``` @@ -58,6 +58,6 @@ repositories { } dependencies { - implementation 'cloud.eppo:eppo-server-sdk:4.0.1-SNAPSHOT' + implementation 'cloud.eppo:eppo-server-sdk:6.0.0-SNAPSHOT' } ``` diff --git a/build.gradle b/build.gradle index ca327e9..eeaad51 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ java { } group = 'cloud.eppo' -version = '5.3.4' +version = '6.0.0-SNAPSHOT' ext.isReleaseVersion = !version.endsWith("SNAPSHOT") import org.apache.tools.ant.filters.ReplaceTokens @@ -26,11 +26,11 @@ processResources { repositories { mavenCentral() mavenLocal() - maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } + maven { url 'https://central.sonatype.com/repository/maven-snapshots/' } } dependencies { - api 'cloud.eppo:sdk-common-jvm:3.13.2' + api 'cloud.eppo:sdk-common-jvm:4.0.0-SNAPSHOT' implementation 'com.github.zafarkhaja:java-semver:0.10.2' implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.1' @@ -40,7 +40,6 @@ dependencies { // Logback classic 1.3.x is compatible with java 8 - only needed for tests testImplementation 'ch.qos.logback:logback-classic:1.3.16' - testImplementation 'cloud.eppo:sdk-common-jvm:3.5.4:tests' testImplementation platform('org.junit:junit-bom:5.11.4') testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'com.github.tomakehurst:wiremock-jre8:2.35.2' diff --git a/src/main/java/cloud/eppo/EppoClient.java b/src/main/java/cloud/eppo/EppoClient.java index 8e351f4..215d943 100644 --- a/src/main/java/cloud/eppo/EppoClient.java +++ b/src/main/java/cloud/eppo/EppoClient.java @@ -6,6 +6,7 @@ import cloud.eppo.cache.LRUInMemoryAssignmentCache; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.logging.BanditLogger; +import com.fasterxml.jackson.databind.JsonNode; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; @@ -19,7 +20,7 @@ * buildAndInit() method. Then call getInstance() to access the singleton and call methods to get * assignments and bandit actions. */ -public class EppoClient extends BaseEppoClient { +public class EppoClient extends BaseEppoClient { private static final Logger log = LoggerFactory.getLogger(EppoClient.class); private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; @@ -50,7 +51,6 @@ private EppoClient( sdkKey, sdkName, sdkVersion, - null, baseUrl, assignmentLogger, banditLogger, @@ -60,7 +60,9 @@ private EppoClient( true, null, assignmentCache, - banditAssignmentCache); + banditAssignmentCache, + new JacksonConfigurationParser(), + new OkHttpEppoClient()); } /** diff --git a/src/test/java/cloud/eppo/EppoClientTest.java b/src/test/java/cloud/eppo/EppoClientTest.java index 2e0ab67..45a59f7 100644 --- a/src/test/java/cloud/eppo/EppoClientTest.java +++ b/src/test/java/cloud/eppo/EppoClientTest.java @@ -12,23 +12,21 @@ import cloud.eppo.api.BanditActions; import cloud.eppo.api.BanditResult; import cloud.eppo.api.Configuration; +import cloud.eppo.api.dto.VariationType; import cloud.eppo.helpers.AssignmentTestCase; import cloud.eppo.helpers.BanditTestCase; -import cloud.eppo.helpers.TestUtils; import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; -import cloud.eppo.ufc.dto.VariationType; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.stubbing.Scenario; import java.io.File; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.stream.Stream; import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.AfterAll; @@ -60,7 +58,10 @@ public class EppoClientTest { public static void initMockServer() { mockServer = new WireMockServer(TEST_PORT); mockServer.start(); + registerDefaultStubs(); + } + private static void registerDefaultStubs() { // If we get the dummy flag API key, return flags-v1.json String ufcFlagsResponseJson = readConfig("src/test/resources/shared/ufc/flags-v1.json"); mockServer.stubFor( @@ -97,12 +98,13 @@ private static String readConfig(String jsonToReturnFilePath) { @AfterEach public void cleanUp() { - TestUtils.setBaseClientHttpClientOverrideField(null); try { EppoClient.getInstance().stopPolling(); } catch (IllegalStateException ex) { // pass: Indicates that the singleton Eppo Client has not yet been initialized. } + mockServer.resetAll(); + registerDefaultStubs(); } @AfterAll @@ -224,29 +226,24 @@ public void testReinitializeWitForcing() { @Test public void testPolling() { - EppoHttpClient httpClient = new EppoHttpClient(TEST_HOST, DUMMY_FLAG_API_KEY, "java", "3.0.0"); - EppoHttpClient httpClientSpy = spy(httpClient); - TestUtils.setBaseClientHttpClientOverrideField(httpClientSpy); + // Reset request journal so we can count from zero + mockServer.resetRequests(); EppoClient.builder(DUMMY_FLAG_API_KEY) + .apiBaseUrl(TEST_HOST) .pollingIntervalMs(20) .forceReinitialize(true) .buildAndInit(); - // Method will be called immediately on init - verify(httpClientSpy, times(1)).get(anyString()); - - // Sleep for 25 ms to allow another polling cycle to complete - sleepUninterruptedly(25); + // Wait to allow polling cycles + sleepUninterruptedly(50); - // Now, the method should have been called twice - verify(httpClientSpy, times(2)).get(anyString()); + // Verify multiple requests were made (init + at least one poll) + mockServer.verify( + com.github.tomakehurst.wiremock.client.WireMock.moreThanOrExactly(2), + WireMock.getRequestedFor(WireMock.urlMatching(".*flag-config/v1/config.*"))); EppoClient.getInstance().stopPolling(); - sleepUninterruptedly(25); - - // No more calls since stopped - verify(httpClientSpy, times(2)).get(anyString()); } // NOTE: Graceful mode during init is intrinsically true since the call is non-blocking and @@ -254,7 +251,7 @@ public void testPolling() { @Test public void testClientMakesDefaultAssignmentsAfterFailingToInitialize() { - // Set up bad HTTP response + // Set up bad HTTP response via WireMock mockHttpError(); // Initialize and no exception should be thrown. @@ -277,59 +274,45 @@ public void testGetConfiguration() { } @Test - public void testConfigurationChangeListener() throws ExecutionException, InterruptedException { + public void testConfigurationChangeListener() { List received = new ArrayList<>(); - // Set up a changing response from the "server" - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get to return empty - when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG); - - // Mock async get to return empty - when(mockHttpClient.get(anyString())).thenReturn(EMPTY_CONFIG); + // Stub first response: empty config + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*")) + .inScenario("config-change") + .whenScenarioStateIs(Scenario.STARTED) + .willReturn(WireMock.okJson(new String(EMPTY_CONFIG))) + .willSetStateTo("has-config")); - setBaseClientHttpClientOverrideField(mockHttpClient); + // Stub second response: real config + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*")) + .inScenario("config-change") + .whenScenarioStateIs("has-config") + .willReturn(WireMock.okJson(new String(BOOL_FLAG_CONFIG)))); - EppoClient.Builder clientBuilder = + EppoClient eppoClient = EppoClient.builder(DUMMY_FLAG_API_KEY) + .apiBaseUrl(TEST_HOST) .forceReinitialize(true) .onConfigurationChange(received::add) - .isGracefulMode(false); + .isGracefulMode(false) + .buildAndInit(); - // Initialize and no exception should be thrown. - EppoClient eppoClient = clientBuilder.buildAndInit(); - - verify(mockHttpClient, times(1)).get(anyString()); assertEquals(1, received.size()); - // Now, return the boolean flag config so that the config has changed. - when(mockHttpClient.get(anyString())).thenReturn(BOOL_FLAG_CONFIG); - - // Trigger a reload of the client eppoClient.loadConfiguration(); - assertEquals(2, received.size()); - - // Reload the client again; the config hasn't changed, but Java doesn't check eTag (yet) - eppoClient.loadConfiguration(); - - assertEquals(3, received.size()); } public static void mockHttpError() { - // Create a mock instance of EppoHttpClient - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get - when(mockHttpClient.get(anyString())).thenThrow(new RuntimeException("Intentional Error")); - - // Mock async get - CompletableFuture mockAsyncResponse = new CompletableFuture<>(); - when(mockHttpClient.getAsync(anyString())).thenReturn(mockAsyncResponse); - mockAsyncResponse.completeExceptionally(new RuntimeException("Intentional Error")); - - setBaseClientHttpClientOverrideField(mockHttpClient); + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/config.*")) + .willReturn(WireMock.serverError())); + mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*flag-config/v1/bandits.*")) + .willReturn(WireMock.serverError())); } @SuppressWarnings("SameParameterValue") @@ -346,7 +329,7 @@ private EppoClient initClient(String apiKey) { mockBanditLogger = mock(BanditLogger.class); return EppoClient.builder(apiKey) - .apiBaseUrl(Constants.appendApiPathToHost(TEST_HOST)) + .apiBaseUrl(TEST_HOST) .assignmentLogger(mockAssignmentLogger) .banditLogger(mockBanditLogger) .isGracefulMode(false) @@ -359,7 +342,7 @@ private EppoClient initFailingGracefulClient(boolean isGracefulMode) { mockBanditLogger = mock(BanditLogger.class); return EppoClient.builder(DUMMY_FLAG_API_KEY) - .apiBaseUrl("blag") + .apiBaseUrl(TEST_HOST) .assignmentLogger(mockAssignmentLogger) .banditLogger(mockBanditLogger) .isGracefulMode(isGracefulMode) @@ -369,9 +352,9 @@ private EppoClient initFailingGracefulClient(boolean isGracefulMode) { private void uninitClient() { try { - Field httpClientOverrideField = EppoClient.class.getDeclaredField("instance"); - httpClientOverrideField.setAccessible(true); - httpClientOverrideField.set(null, null); + Field instanceField = EppoClient.class.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); } catch (NoSuchFieldException | IllegalAccessException e) { throw new RuntimeException(e); } @@ -380,21 +363,20 @@ private void uninitClient() { private void initBuggyClient() { try { EppoClient eppoClient = initClient(DUMMY_FLAG_API_KEY); + + // Create a mock IConfigurationStore that returns a mock Configuration. + // The mock Configuration throws on getFlag() to simulate evaluation errors, + // but returns null for getEnvironmentName()/getConfigFetchedAt()/getConfigPublishedAt() + // so the catch block in BaseEppoClient can build error details. + Configuration mockConfig = mock(Configuration.class); + when(mockConfig.getFlag(anyString())) + .thenThrow(new RuntimeException("Intentional test error")); + IConfigurationStore mockStore = mock(IConfigurationStore.class); + when(mockStore.getConfiguration()).thenReturn(mockConfig); + Field configurationStoreField = BaseEppoClient.class.getDeclaredField("configurationStore"); configurationStoreField.setAccessible(true); - configurationStoreField.set(eppoClient, null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); - } - } - - public static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) { - // Uses reflection to set a static override field used for tests (e.g., httpClientOverride) - try { - Field httpClientOverrideField = BaseEppoClient.class.getDeclaredField("httpClientOverride"); - httpClientOverrideField.setAccessible(true); - httpClientOverrideField.set(null, httpClient); - httpClientOverrideField.setAccessible(false); + configurationStoreField.set(eppoClient, mockStore); } catch (NoSuchFieldException | IllegalAccessException e) { throw new RuntimeException(e); } diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java new file mode 100644 index 0000000..fbd5f79 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -0,0 +1,425 @@ +package cloud.eppo.helpers; + +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.api.dto.VariationType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import java.io.File; +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; + +public class AssignmentTestCase { + private final String flag; + private final VariationType variationType; + private final TestCaseValue defaultValue; + private final List subjects; + + public AssignmentTestCase( + String flag, + VariationType variationType, + TestCaseValue defaultValue, + List subjects) { + this.flag = flag; + this.variationType = variationType; + this.defaultValue = defaultValue; + this.subjects = subjects; + } + + public String getFlag() { + return flag; + } + + public VariationType getVariationType() { + return variationType; + } + + public TestCaseValue getDefaultValue() { + return defaultValue; + } + + public List getSubjects() { + return subjects; + } + + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(assignmentTestCaseModule()); + + public static SimpleModule assignmentTestCaseModule() { + SimpleModule module = new SimpleModule(); + module.addDeserializer(AssignmentTestCase.class, new AssignmentTestCaseDeserializer()); + return module; + } + + public static Stream getAssignmentTestData() { + File testCaseFolder = new File("src/test/resources/shared/ufc/tests"); + File[] testCaseFiles = testCaseFolder.listFiles(); + assertNotNull(testCaseFiles); + assertTrue(testCaseFiles.length > 0); + List arguments = new ArrayList<>(); + for (File testCaseFile : testCaseFiles) { + arguments.add(Arguments.of(testCaseFile)); + } + return arguments.stream(); + } + + public static AssignmentTestCase parseTestCaseFile(File testCaseFile) { + AssignmentTestCase testCase; + try { + String json = FileUtils.readFileToString(testCaseFile, "UTF8"); + + testCase = mapper.readValue(json, AssignmentTestCase.class); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + return testCase; + } + + 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()); + + for (SubjectAssignment subjectAssignment : testCase.getSubjects()) { + String subjectKey = subjectAssignment.getSubjectKey(); + Attributes subjectAttributes = subjectAssignment.getSubjectAttributes(); + + // Depending on the variation type, call the appropriate assignment method + switch (testCase.getVariationType()) { + case BOOLEAN: + if (validateDetails) { + AssignmentDetails details = + eppoClient.getBooleanAssignmentDetails( + flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + boolean boolAssignment = + eppoClient.getBooleanAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); + assertAssignment(flagKey, subjectAssignment, boolAssignment); + } + break; + case INTEGER: + int castedDefault = Double.valueOf(defaultValue.doubleValue()).intValue(); + if (validateDetails) { + AssignmentDetails details = + eppoClient.getIntegerAssignmentDetails( + flagKey, subjectKey, subjectAttributes, castedDefault); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + int intAssignment = + eppoClient.getIntegerAssignment( + flagKey, subjectKey, subjectAttributes, castedDefault); + assertAssignment(flagKey, subjectAssignment, intAssignment); + } + break; + case NUMERIC: + if (validateDetails) { + AssignmentDetails details = + eppoClient.getDoubleAssignmentDetails( + flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + double doubleAssignment = + eppoClient.getDoubleAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); + assertAssignment(flagKey, subjectAssignment, doubleAssignment); + } + break; + case STRING: + if (validateDetails) { + AssignmentDetails details = + eppoClient.getStringAssignmentDetails( + flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + String stringAssignment = + eppoClient.getStringAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); + assertAssignment(flagKey, subjectAssignment, stringAssignment); + } + break; + case JSON: + if (validateDetails) { + AssignmentDetails details = + eppoClient.getJSONAssignmentDetails( + flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); + assertAssignment(flagKey, subjectAssignment, details.getVariation()); + assertAssignmentDetails(flagKey, subjectAssignment, details.getEvaluationDetails()); + } else { + JsonNode jsonAssignment = + eppoClient.getJSONAssignment( + flagKey, subjectKey, subjectAttributes, testCase.getDefaultValue().jsonValue()); + assertAssignment(flagKey, subjectAssignment, jsonAssignment); + } + break; + default: + throw new UnsupportedOperationException( + "Unexpected variation type " + + testCase.getVariationType() + + " for " + + flagKey + + " test case"); + } + } + } + + /** 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) { + + if (assignment == null) { + fail( + "Unexpected null " + + flagKey + + " assignment for subject " + + expectedSubjectAssignment.getSubjectKey()); + } + + String failureMessage = + "Incorrect " + + flagKey + + " assignment for subject " + + expectedSubjectAssignment.getSubjectKey(); + + if (assignment instanceof Boolean) { + assertEquals( + expectedSubjectAssignment.getAssignment().booleanValue(), assignment, failureMessage); + } else if (assignment instanceof Integer) { + assertEquals( + Double.valueOf(expectedSubjectAssignment.getAssignment().doubleValue()).intValue(), + assignment, + failureMessage); + } else if (assignment instanceof Double) { + assertEquals( + expectedSubjectAssignment.getAssignment().doubleValue(), + (Double) assignment, + 0.000001, + failureMessage); + } else if (assignment instanceof String) { + assertEquals( + expectedSubjectAssignment.getAssignment().stringValue(), assignment, failureMessage); + } else if (assignment instanceof JsonNode) { + assertEquals( + expectedSubjectAssignment.getAssignment().jsonValue().toString(), + assignment.toString(), + failureMessage); + } else { + throw new IllegalArgumentException( + "Unexpected assignment type " + assignment.getClass().getCanonicalName()); + } + } +} diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java new file mode 100644 index 0000000..1bf2d68 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCaseDeserializer.java @@ -0,0 +1,202 @@ +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.api.dto.VariationType; +import cloud.eppo.ufc.dto.adapters.EppoValueDeserializer; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +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(); + + public AssignmentTestCaseDeserializer() { + super(AssignmentTestCase.class); + } + + @Override + public AssignmentTestCase deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode rootNode = parser.getCodec().readTree(parser); + String flag = rootNode.get("flag").asText(); + VariationType variationType = VariationType.fromString(rootNode.get("variationType").asText()); + TestCaseValue defaultValue = deserializeTestCaseValue(rootNode.get("defaultValue")); + List subjects = deserializeSubjectAssignments(rootNode.get("subjects")); + return new AssignmentTestCase(flag, variationType, defaultValue, subjects); + } + + private List deserializeSubjectAssignments(JsonNode jsonNode) { + List subjectAssignments = new ArrayList<>(); + if (jsonNode != null && jsonNode.isArray()) { + for (JsonNode subjectAssignmentNode : jsonNode) { + String subjectKey = subjectAssignmentNode.get("subjectKey").asText(); + + Attributes subjectAttributes = new Attributes(); + JsonNode attributesNode = subjectAssignmentNode.get("subjectAttributes"); + if (attributesNode != null && attributesNode.isObject()) { + for (Iterator> it = attributesNode.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + String attributeName = entry.getKey(); + EppoValue attributeValue = eppoValueDeserializer.deserializeNode(entry.getValue()); + subjectAttributes.put(attributeName, attributeValue); + } + } + + TestCaseValue assignment = + deserializeTestCaseValue(subjectAssignmentNode.get("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); + } else { + return TestCaseValue.copyOf(eppoValueDeserializer.deserializeNode(jsonNode)); + } + } +} diff --git a/src/test/java/cloud/eppo/helpers/BanditSubjectAssignment.java b/src/test/java/cloud/eppo/helpers/BanditSubjectAssignment.java new file mode 100644 index 0000000..5832df0 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/BanditSubjectAssignment.java @@ -0,0 +1,36 @@ +package cloud.eppo.helpers; + +import cloud.eppo.api.Actions; +import cloud.eppo.api.BanditResult; +import cloud.eppo.api.ContextAttributes; + +public class BanditSubjectAssignment { + private final String subjectKey; + private final ContextAttributes subjectAttributes; + private final Actions actions; + private final BanditResult assignment; + + public BanditSubjectAssignment( + String subjectKey, ContextAttributes attributes, Actions actions, BanditResult assignment) { + this.subjectKey = subjectKey; + this.subjectAttributes = attributes; + this.actions = actions; + this.assignment = assignment; + } + + public String getSubjectKey() { + return subjectKey; + } + + public ContextAttributes getSubjectAttributes() { + return subjectAttributes; + } + + public Actions getActions() { + return actions; + } + + public BanditResult getAssignment() { + return assignment; + } +} diff --git a/src/test/java/cloud/eppo/helpers/BanditTestCase.java b/src/test/java/cloud/eppo/helpers/BanditTestCase.java new file mode 100644 index 0000000..1dcf8b3 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/BanditTestCase.java @@ -0,0 +1,117 @@ +package cloud.eppo.helpers; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import cloud.eppo.BaseEppoClient; +import cloud.eppo.api.Actions; +import cloud.eppo.api.BanditResult; +import cloud.eppo.api.ContextAttributes; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.params.provider.Arguments; + +public class BanditTestCase { + private final String flag; + private final String defaultValue; + private final List subjects; + private String fileName; + + public BanditTestCase(String flag, String defaultValue, List subjects) { + this.flag = flag; + this.defaultValue = defaultValue; + this.subjects = subjects; + } + + public String getFlag() { + return flag; + } + + public String getDefaultValue() { + return defaultValue; + } + + public List getSubjects() { + return subjects; + } + + public static Stream getBanditTestData() { + File testCaseFolder = new File("src/test/resources/shared/ufc/bandit-tests"); + File[] testCaseFiles = testCaseFolder.listFiles(); + assertNotNull(testCaseFiles); + assertTrue(testCaseFiles.length > 0); + List arguments = new ArrayList<>(); + for (File testCaseFile : testCaseFiles) { + arguments.add(Arguments.of(testCaseFile)); + } + return arguments.stream(); + } + + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(banditTestCaseModule()); + + public static SimpleModule banditTestCaseModule() { + SimpleModule module = new SimpleModule(); + module.addDeserializer(BanditTestCase.class, new BanditTestCaseDeserializer()); + return module; + } + + public static BanditTestCase parseBanditTestCaseFile(File testCaseFile) { + BanditTestCase testCase; + try { + String json = FileUtils.readFileToString(testCaseFile, "UTF8"); + testCase = mapper.readValue(json, BanditTestCase.class); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + return testCase; + } + + public static void runBanditTestCase(BanditTestCase testCase, BaseEppoClient eppoClient) { + assertFalse(testCase.getSubjects().isEmpty()); + + String flagKey = testCase.getFlag(); + String defaultValue = testCase.getDefaultValue(); + + for (BanditSubjectAssignment subjectAssignment : testCase.getSubjects()) { + String subjectKey = subjectAssignment.getSubjectKey(); + ContextAttributes attributes = subjectAssignment.getSubjectAttributes(); + Actions actions = subjectAssignment.getActions(); + BanditResult assignment = + eppoClient.getBanditAction(flagKey, subjectKey, attributes, actions, defaultValue); + assertBanditAssignment(flagKey, subjectAssignment, assignment); + } + } + + /** Helper method for asserting a bandit assignment with a useful failure message. */ + private static void assertBanditAssignment( + String flagKey, BanditSubjectAssignment expectedSubjectAssignment, BanditResult assignment) { + String failureMessage = + "Incorrect " + + flagKey + + " variation assignment for subject " + + expectedSubjectAssignment.getSubjectKey(); + + assertEquals( + expectedSubjectAssignment.getAssignment().getVariation(), + assignment.getVariation(), + failureMessage); + + failureMessage = + "Incorrect " + + flagKey + + " action assignment for subject " + + expectedSubjectAssignment.getSubjectKey(); + + assertEquals( + expectedSubjectAssignment.getAssignment().getAction(), + assignment.getAction(), + failureMessage); + } +} diff --git a/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java b/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java new file mode 100644 index 0000000..679cb88 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/BanditTestCaseDeserializer.java @@ -0,0 +1,87 @@ +package cloud.eppo.helpers; + +import cloud.eppo.api.*; +import cloud.eppo.ufc.dto.adapters.EppoValueDeserializer; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import java.io.IOException; +import java.util.*; + +public class BanditTestCaseDeserializer extends StdDeserializer { + private final EppoValueDeserializer eppoValueDeserializer = new EppoValueDeserializer(); + + public BanditTestCaseDeserializer() { + super(BanditTestCase.class); + } + + @Override + public BanditTestCase deserialize(JsonParser parser, DeserializationContext context) + throws IOException { + JsonNode rootNode = parser.getCodec().readTree(parser); + String flag = rootNode.get("flag").asText(); + String defaultValue = rootNode.get("defaultValue").asText(); + List subjects = + deserializeSubjectBanditAssignments(rootNode.get("subjects")); + return new BanditTestCase(flag, defaultValue, subjects); + } + + private List deserializeSubjectBanditAssignments(JsonNode jsonNode) { + List subjectAssignments = new ArrayList<>(); + if (jsonNode != null && jsonNode.isArray()) { + for (JsonNode subjectAssignmentNode : jsonNode) { + String subjectKey = subjectAssignmentNode.get("subjectKey").asText(); + JsonNode attributesNode = subjectAssignmentNode.get("subjectAttributes"); + ContextAttributes attributes = new ContextAttributes(); + if (attributesNode != null && attributesNode.isObject()) { + Attributes numericAttributes = + deserializeAttributes(attributesNode.get("numericAttributes")); + Attributes categoricalAttributes = + deserializeAttributes(attributesNode.get("categoricalAttributes")); + attributes = new ContextAttributes(numericAttributes, categoricalAttributes); + } + Actions actions = deserializeActions(subjectAssignmentNode.get("actions")); + JsonNode assignmentNode = subjectAssignmentNode.get("assignment"); + String variationAssignment = assignmentNode.get("variation").asText(); + JsonNode actionAssignmentNode = assignmentNode.get("action"); + String actionAssignment = + actionAssignmentNode.isNull() ? null : actionAssignmentNode.asText(); + BanditResult assignment = new BanditResult(variationAssignment, actionAssignment); + subjectAssignments.add( + new BanditSubjectAssignment(subjectKey, attributes, actions, assignment)); + } + } + + return subjectAssignments; + } + + private Actions deserializeActions(JsonNode jsonNode) { + BanditActions actions = new BanditActions(); + if (jsonNode != null && jsonNode.isArray()) { + for (JsonNode actionNode : jsonNode) { + String actionKey = actionNode.get("actionKey").asText(); + Attributes numericAttributes = deserializeAttributes(actionNode.get("numericAttributes")); + Attributes categoricalAttributes = + deserializeAttributes(actionNode.get("categoricalAttributes")); + ContextAttributes attributes = + new ContextAttributes(numericAttributes, categoricalAttributes); + actions.put(actionKey, attributes); + } + } + return actions; + } + + private Attributes deserializeAttributes(JsonNode jsonNode) { + Attributes attributes = new Attributes(); + if (jsonNode != null && jsonNode.isObject()) { + for (Iterator> it = jsonNode.fields(); it.hasNext(); ) { + Map.Entry entry = it.next(); + String attributeName = entry.getKey(); + EppoValue attributeValue = eppoValueDeserializer.deserializeNode(entry.getValue()); + attributes.put(attributeName, attributeValue); + } + } + return attributes; + } +} diff --git a/src/test/java/cloud/eppo/helpers/SubjectAssignment.java b/src/test/java/cloud/eppo/helpers/SubjectAssignment.java new file mode 100644 index 0000000..1b72deb --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/SubjectAssignment.java @@ -0,0 +1,47 @@ +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() { + return subjectKey; + } + + public Attributes getSubjectAttributes() { + return subjectAttributes; + } + + public TestCaseValue getAssignment() { + return assignment; + } + + public EvaluationDetails getEvaluationDetails() { + return evaluationDetails; + } + + public boolean hasEvaluationDetails() { + return evaluationDetails != null; + } +} diff --git a/src/test/java/cloud/eppo/helpers/TestCaseValue.java b/src/test/java/cloud/eppo/helpers/TestCaseValue.java new file mode 100644 index 0000000..7d5be45 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/TestCaseValue.java @@ -0,0 +1,62 @@ +package cloud.eppo.helpers; + +import cloud.eppo.api.EppoValue; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; + +public class TestCaseValue extends EppoValue { + private JsonNode jsonValue; + + private TestCaseValue() { + super(); + } + + private TestCaseValue(boolean boolValue) { + super(boolValue); + } + + private TestCaseValue(double doubleValue) { + super(doubleValue); + } + + private TestCaseValue(String stringValue) { + super(stringValue); + } + + private TestCaseValue(List stringArrayValue) { + super(stringArrayValue); + } + + private TestCaseValue(JsonNode jsonValue) { + super(jsonValue.toString()); + this.jsonValue = jsonValue; + } + + public static TestCaseValue copyOf(EppoValue eppoValue) { + if (eppoValue.isNull()) { + return new TestCaseValue(); + } else if (eppoValue.isBoolean()) { + return new TestCaseValue(eppoValue.booleanValue()); + } else if (eppoValue.isNumeric()) { + return new TestCaseValue(eppoValue.doubleValue()); + } else if (eppoValue.isString()) { + return new TestCaseValue(eppoValue.stringValue()); + } else if (eppoValue.isStringArray()) { + return new TestCaseValue(eppoValue.stringArrayValue()); + } else { + throw new IllegalArgumentException("Unable to copy EppoValue: " + eppoValue); + } + } + + public static TestCaseValue valueOf(JsonNode jsonValue) { + return new TestCaseValue(jsonValue); + } + + public boolean isJson() { + return this.jsonValue != null; + } + + public JsonNode jsonValue() { + return this.jsonValue; + } +} diff --git a/src/test/java/cloud/eppo/helpers/TestUtils.java b/src/test/java/cloud/eppo/helpers/TestUtils.java new file mode 100644 index 0000000..14ded59 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/TestUtils.java @@ -0,0 +1,73 @@ +package cloud.eppo.helpers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import cloud.eppo.http.EppoConfigurationClient; +import cloud.eppo.http.EppoConfigurationRequest; +import cloud.eppo.http.EppoConfigurationResponse; +import java.net.HttpURLConnection; +import java.util.concurrent.CompletableFuture; + +public class TestUtils { + + /** + * Creates a mock EppoConfigurationClient that returns the given response body for all requests. + * + * @param responseBody the response body to return + * @return a mock EppoConfigurationClient + */ + public static EppoConfigurationClient mockConfigurationClient(String responseBody) { + return mockConfigurationClient(responseBody.getBytes()); + } + + /** + * Creates a mock EppoConfigurationClient that returns the given response body for all requests. + * + * @param responseBody the response body to return + * @return a mock EppoConfigurationClient + */ + public static EppoConfigurationClient mockConfigurationClient(byte[] responseBody) { + EppoConfigurationClient mockClient = mock(EppoConfigurationClient.class); + EppoConfigurationResponse successResponse = + EppoConfigurationResponse.success(HttpURLConnection.HTTP_OK, "test-version", responseBody); + + when(mockClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(successResponse)); + + return mockClient; + } + + /** + * Creates a mock EppoConfigurationClient that returns an error for all requests. + * + * @return a mock EppoConfigurationClient that fails + */ + public static EppoConfigurationClient mockConfigurationClientError() { + EppoConfigurationClient mockClient = mock(EppoConfigurationClient.class); + + CompletableFuture failedFuture = new CompletableFuture<>(); + failedFuture.completeExceptionally(new RuntimeException("Intentional Error")); + + when(mockClient.execute(any(EppoConfigurationRequest.class))).thenReturn(failedFuture); + + return mockClient; + } + + /** + * Creates a mock EppoConfigurationClient that returns a 500 error response. + * + * @return a mock EppoConfigurationClient that returns error status + */ + public static EppoConfigurationClient mockConfigurationClientErrorResponse() { + EppoConfigurationClient mockClient = mock(EppoConfigurationClient.class); + EppoConfigurationResponse errorResponse = + EppoConfigurationResponse.error( + HttpURLConnection.HTTP_INTERNAL_ERROR, "Internal Server Error".getBytes()); + + when(mockClient.execute(any(EppoConfigurationRequest.class))) + .thenReturn(CompletableFuture.completedFuture(errorResponse)); + + return mockClient; + } +}