diff --git a/build.gradle b/build.gradle index 35613f76..ded52ad4 100644 --- a/build.gradle +++ b/build.gradle @@ -62,11 +62,17 @@ java { withSourcesJar() } +tasks.register('testJar', Jar) { + archiveClassifier.set('tests') + from sourceSets.test.output +} + publishing { publications { mavenJava(MavenPublication) { artifactId = 'sdk-common-jvm' from components.java + artifact testJar // Include the test-jar in the published artifacts versionMapping { usage('java-api') { fromResolutionOf('runtimeClasspath') @@ -114,7 +120,7 @@ publishing { // Custom task to ensure we can conditionally publish either a release or snapshot artifact // based on a command line switch. See github workflow files for more details on usage. -task checkVersion { +tasks.register('checkVersion') { doLast { if (!project.hasProperty('release') && !project.hasProperty('snapshot')) { throw new GradleException("You must specify either -Prelease or -Psnapshot") @@ -135,7 +141,7 @@ tasks.named('publish').configure { } // Conditionally enable or disable publishing tasks -tasks.withType(PublishToMavenRepository) { +tasks.withType(PublishToMavenRepository).configureEach { onlyIf { project.ext.has('shouldPublish') && project.ext.shouldPublish } diff --git a/src/main/java/cloud/eppo/BaseEppoClient.java b/src/main/java/cloud/eppo/BaseEppoClient.java index 295ca91e..8ed889ec 100644 --- a/src/main/java/cloud/eppo/BaseEppoClient.java +++ b/src/main/java/cloud/eppo/BaseEppoClient.java @@ -22,11 +22,11 @@ public class BaseEppoClient { private final ObjectMapper mapper = new ObjectMapper() .registerModule(EppoModule.eppoModule()); // TODO: is this the best place for this? - private static final String DEFAULT_HOST = "https://fscdn.eppo.cloud"; - private static final boolean DEFAULT_IS_GRACEFUL_MODE = true; + + protected static final String DEFAULT_HOST = "https://fscdn.eppo.cloud"; + protected final ConfigurationRequestor requestor; private final ConfigurationStore configurationStore; - private final ConfigurationRequestor requestor; private final AssignmentLogger assignmentLogger; private final BanditLogger banditLogger; private final String sdkName; @@ -34,25 +34,34 @@ public class BaseEppoClient { private final boolean isConfigObfuscated; private boolean isGracefulMode; - private static BaseEppoClient instance; - // Fields useful for testing in situations where we want to mock the http client or configuration // store (accessed via reflection) /** @noinspection FieldMayBeFinal */ private static EppoHttpClient httpClientOverride = null; - private BaseEppoClient( + protected BaseEppoClient( String apiKey, String sdkName, String sdkVersion, String host, - ConfigurationStore configurationStore, AssignmentLogger assignmentLogger, BanditLogger banditLogger, - boolean isGracefulMode) { + boolean isGracefulMode, + boolean expectObfuscatedConfig) { + + if (apiKey == null) { + throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key"); + } + if (sdkName == null || sdkVersion == null) { + throw new IllegalArgumentException( + "Unable to initialize Eppo SDK due to missing SDK name or version"); + } + if (host == null) { + host = DEFAULT_HOST; + } EppoHttpClient httpClient = buildHttpClient(host, apiKey, sdkName, sdkVersion); - this.configurationStore = configurationStore; + this.configurationStore = new ConfigurationStore(); requestor = new ConfigurationRequestor(configurationStore, httpClient); this.assignmentLogger = assignmentLogger; this.banditLogger = banditLogger; @@ -61,8 +70,10 @@ private BaseEppoClient( this.sdkName = sdkName; this.sdkVersion = sdkVersion; // For now, the configuration is only obfuscated for Android clients - this.isConfigObfuscated = sdkName.toLowerCase().contains("android"); + this.isConfigObfuscated = expectObfuscatedConfig; + // TODO: caching initialization (such as setting an API-key-specific prefix + // will probably involve passing in configurationStore to the constructor } private EppoHttpClient buildHttpClient( @@ -78,51 +89,7 @@ private EppoHttpClient buildHttpClient( return httpClient; } - public static BaseEppoClient init( - String apiKey, - String sdkName, - String sdkVersion, - String host, - AssignmentLogger assignmentLogger, - BanditLogger banditLogger, - boolean isGracefulMode) { - - if (apiKey == null) { - throw new IllegalArgumentException("Unable to initialize Eppo SDK due to missing API key"); - } - if (sdkName == null || sdkVersion == null) { - throw new IllegalArgumentException( - "Unable to initialize Eppo SDK due to missing SDK name or version"); - } - - if (instance != null) { - // TODO: also check we're not running a test - log.warn("Reinitializing an Eppo Client instance that was already initialized"); - } - instance = - new BaseEppoClient( - apiKey, - sdkName, - sdkVersion, - host, - new ConfigurationStore(), - assignmentLogger, - banditLogger, - isGracefulMode); - instance.refreshConfiguration(); - - return instance; - } - - /** - * Ability to ad-hoc kick off a configuration load. Will load from a filesystem cached file as - * well as fire off an HTTPS request for an updated configuration. If the cache load finishes - * first, those assignments will be used until the fetch completes. - * - *

Deprecated, as we plan to make a more targeted and configurable way to do so in the future. - */ - @Deprecated - public void refreshConfiguration() { + protected void loadConfiguration() { requestor.load(); } @@ -479,65 +446,7 @@ private T throwIfNotGraceful(Exception e, T defaultValue) { throw new RuntimeException(e); } - public static BaseEppoClient getInstance() { - if (BaseEppoClient.instance == null) { - throw new IllegalStateException("Eppo SDK has not been initialized"); - } - - return BaseEppoClient.instance; - } - public void setIsGracefulFailureMode(boolean isGracefulFailureMode) { this.isGracefulMode = isGracefulFailureMode; } - - public static class Builder { - private String apiKey; - private String sdkName; - private String sdkVersion; - private String host = DEFAULT_HOST; - private AssignmentLogger assignmentLogger; - private BanditLogger banditLogger; - private boolean isGracefulMode = DEFAULT_IS_GRACEFUL_MODE; - - public Builder apiKey(String apiKey) { - this.apiKey = apiKey; - return this; - } - - public Builder sdkName(String sdkName) { - this.sdkName = sdkName; - return this; - } - - public Builder sdkVersion(String sdkVersion) { - this.sdkVersion = sdkVersion; - return this; - } - - public Builder host(String host) { - this.host = host; - return this; - } - - public Builder assignmentLogger(AssignmentLogger assignmentLogger) { - this.assignmentLogger = assignmentLogger; - return this; - } - - public Builder banditLogger(BanditLogger banditLogger) { - this.banditLogger = banditLogger; - return this; - } - - public Builder isGracefulMode(boolean isGracefulMode) { - this.isGracefulMode = isGracefulMode; - return this; - } - - public BaseEppoClient buildAndInit() { - return BaseEppoClient.init( - apiKey, sdkName, sdkVersion, host, assignmentLogger, banditLogger, isGracefulMode); - } - } } diff --git a/src/main/java/cloud/eppo/EppoHttpClient.java b/src/main/java/cloud/eppo/EppoHttpClient.java index 6daf2fe7..54d66db4 100644 --- a/src/main/java/cloud/eppo/EppoHttpClient.java +++ b/src/main/java/cloud/eppo/EppoHttpClient.java @@ -59,7 +59,7 @@ public Response get(String path) { } } - public void get(String path, RequestCallback callback) { + public void get(String path, EppoHttpClientRequestCallback callback) { HttpUrl httpUrl = HttpUrl.parse(baseUrl + path) .newBuilder() @@ -83,13 +83,11 @@ public void onResponse(Call call, Response response) { callback.onFailure("Failed to read response from URL " + httpUrl); } } else { - switch (response.code()) { - case HttpURLConnection.HTTP_FORBIDDEN: - callback.onFailure("Invalid API key"); - break; - default: - log.debug("Fetch failed with status code: " + response.code()); - callback.onFailure("Bad response from URL " + httpUrl); + if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) { + callback.onFailure("Invalid API key"); + } else { + log.debug("Fetch failed with status code: {}", response.code()); + callback.onFailure("Bad response from URL " + httpUrl); } } response.close(); @@ -98,19 +96,12 @@ public void onResponse(Call call, Response response) { @Override public void onFailure(Call call, IOException e) { log.error( - "Http request failure: " - + e.getMessage() - + " " - + Arrays.toString(e.getStackTrace()), + "Http request failure: {} {}", + e.getMessage(), + Arrays.toString(e.getStackTrace()), e); callback.onFailure("Unable to fetch from URL " + httpUrl); } }); } } - -interface RequestCallback { - void onSuccess(String responseBody); - - void onFailure(String errorMessage); -} diff --git a/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java b/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java new file mode 100644 index 00000000..6d67a2c4 --- /dev/null +++ b/src/main/java/cloud/eppo/EppoHttpClientRequestCallback.java @@ -0,0 +1,7 @@ +package cloud.eppo; + +public interface EppoHttpClientRequestCallback { + void onSuccess(String responseBody); + + void onFailure(String errorMessage); +} diff --git a/src/main/java/cloud/eppo/ufc/dto/DiscriminableAttributes.java b/src/main/java/cloud/eppo/ufc/dto/DiscriminableAttributes.java index 48d9432c..80b5ee3e 100644 --- a/src/main/java/cloud/eppo/ufc/dto/DiscriminableAttributes.java +++ b/src/main/java/cloud/eppo/ufc/dto/DiscriminableAttributes.java @@ -2,9 +2,9 @@ public interface DiscriminableAttributes { - public Attributes getNumericAttributes(); + Attributes getNumericAttributes(); - public Attributes getCategoricalAttributes(); + Attributes getCategoricalAttributes(); - public Attributes getAllAttributes(); + Attributes getAllAttributes(); } diff --git a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java index b1463419..5d93c1af 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientBanditTest.java @@ -1,5 +1,7 @@ package cloud.eppo; +import static cloud.eppo.helpers.BanditTestCase.parseBanditTestCaseFile; +import static cloud.eppo.helpers.BanditTestCase.runBanditTestCase; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -9,13 +11,9 @@ import cloud.eppo.logging.BanditAssignment; import cloud.eppo.logging.BanditLogger; import cloud.eppo.ufc.dto.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; import java.io.File; -import java.io.IOException; import java.util.*; import java.util.stream.Stream; -import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -33,26 +31,29 @@ public class BaseEppoClientBanditTest { "dummy-bandits-api-key"; // Will load bandit-flags-v1 private static final String TEST_HOST = "https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile"; - private static final ObjectMapper mapper = new ObjectMapper().registerModule(module()); private static final AssignmentLogger mockAssignmentLogger = mock(AssignmentLogger.class); private static final BanditLogger mockBanditLogger = mock(BanditLogger.class); private static final Date testStart = new Date(); + private static BaseEppoClient eppoClient; + // TODO: possibly consolidate code between this and the non-bandit test @BeforeAll public static void initClient() { - - new BaseEppoClient.Builder() - .apiKey(DUMMY_BANDIT_API_KEY) - .sdkName("java") - .sdkVersion("3.0.0") - .isGracefulMode(false) - .host(TEST_HOST) - .assignmentLogger(mockAssignmentLogger) - .banditLogger(mockBanditLogger) - .buildAndInit(); + eppoClient = + new BaseEppoClient( + DUMMY_BANDIT_API_KEY, + "java", + "3.0.0", + TEST_HOST, + mockAssignmentLogger, + mockBanditLogger, + false, + false); + + eppoClient.loadConfiguration(); log.info("Test client initialized"); } @@ -62,82 +63,21 @@ public void reset() { clearInvocations(mockAssignmentLogger); clearInvocations(mockBanditLogger); doNothing().when(mockBanditLogger).logBanditAssignment(any()); - BaseEppoClient.getInstance().setIsGracefulFailureMode(false); + eppoClient.setIsGracefulFailureMode(false); } @ParameterizedTest @MethodSource("getBanditTestData") public void testUnobfuscatedBanditAssignments(File testFile) { - BanditTestCase testCase = parseTestCaseFile(testFile); - runBanditTestCase(testCase); - } - - private 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 BanditTestCase parseTestCaseFile(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; - } - - private void runBanditTestCase(BanditTestCase testCase) { - 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 = - BaseEppoClient.getInstance() - .getBanditAction(flagKey, subjectKey, attributes, actions, defaultValue); - assertBanditAssignment(flagKey, subjectAssignment, assignment); - } + BanditTestCase testCase = parseBanditTestCaseFile(testFile); + runBanditTestCase(testCase, eppoClient); } - /** Helper method for asserting a bandit assignment with a useful failure message. */ - private 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); + public static Stream getBanditTestData() { + return BanditTestCase.getBanditTestData(); } + @SuppressWarnings("ExtractMethodRecommender") @Test public void testBanditLogsAction() { String flagKey = "banner_bandit_flag"; @@ -165,8 +105,7 @@ public void testBanditLogsAction() { actions.put("reebok", rebookAttributes); BanditResult banditResult = - BaseEppoClient.getInstance() - .getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); + eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); // Verify assignment assertEquals("banner_bandit", banditResult.getVariation()); @@ -239,8 +178,7 @@ public void testNoBanditLogsWhenNotBandit() { actions.put("adidas", new Attributes()); BanditResult banditResult = - BaseEppoClient.getInstance() - .getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "default"); + eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "default"); // Verify assignment assertEquals("control", banditResult.getVariation()); @@ -267,8 +205,7 @@ public void testNoBanditLogsWhenNoActions() { BanditActions actions = new BanditActions(); BanditResult banditResult = - BaseEppoClient.getInstance() - .getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); + eppoClient.getBanditAction(flagKey, subjectKey, subjectAttributes, actions, "control"); // Verify assignment assertEquals("banner_bandit", banditResult.getVariation()); @@ -286,9 +223,8 @@ public void testNoBanditLogsWhenNoActions() { @Test public void testBanditErrorGracefulModeOff() { - BaseEppoClient.getInstance() - .setIsGracefulFailureMode( - false); // Should be set by @BeforeEach but repeated here for test clarity + eppoClient.setIsGracefulFailureMode( + false); // Should be set by @BeforeEach but repeated here for test clarity try (MockedStatic mockedStatic = mockStatic(BanditEvaluator.class)) { // Configure the mock to throw an exception mockedStatic @@ -302,15 +238,14 @@ public void testBanditErrorGracefulModeOff() { assertThrows( RuntimeException.class, () -> - BaseEppoClient.getInstance() - .getBanditAction( - "banner_bandit_flag", "subject", new Attributes(), actions, "default")); + eppoClient.getBanditAction( + "banner_bandit_flag", "subject", new Attributes(), actions, "default")); } } @Test public void testBanditErrorGracefulModeOn() { - BaseEppoClient.getInstance().setIsGracefulFailureMode(true); + eppoClient.setIsGracefulFailureMode(true); try (MockedStatic mockedStatic = mockStatic(BanditEvaluator.class)) { // Configure the mock to throw an exception mockedStatic @@ -322,9 +257,8 @@ public void testBanditErrorGracefulModeOn() { actions.put("nike", new Attributes()); actions.put("adidas", new Attributes()); BanditResult banditResult = - BaseEppoClient.getInstance() - .getBanditAction( - "banner_bandit_flag", "subject", new Attributes(), actions, "default"); + eppoClient.getBanditAction( + "banner_bandit_flag", "subject", new Attributes(), actions, "default"); assertEquals("banner_bandit", banditResult.getVariation()); assertNull(banditResult.getAction()); } @@ -341,8 +275,8 @@ public void testBanditLogErrorNonFatal() { actions.put("nike", new Attributes()); actions.put("adidas", new Attributes()); BanditResult banditResult = - BaseEppoClient.getInstance() - .getBanditAction("banner_bandit_flag", "subject", new Attributes(), actions, "default"); + eppoClient.getBanditAction( + "banner_bandit_flag", "subject", new Attributes(), actions, "default"); assertEquals("banner_bandit", banditResult.getVariation()); assertEquals("nike", banditResult.getAction()); @@ -350,10 +284,4 @@ public void testBanditLogErrorNonFatal() { ArgumentCaptor.forClass(BanditAssignment.class); verify(mockBanditLogger, times(1)).logBanditAssignment(banditLogCaptor.capture()); } - - private static SimpleModule module() { - SimpleModule module = new SimpleModule(); - module.addDeserializer(BanditTestCase.class, new BanditTestCaseDeserializer()); - return module; - } } diff --git a/src/test/java/cloud/eppo/BaseEppoClientTest.java b/src/test/java/cloud/eppo/BaseEppoClientTest.java index 9b047cc0..80670daf 100644 --- a/src/test/java/cloud/eppo/BaseEppoClientTest.java +++ b/src/test/java/cloud/eppo/BaseEppoClientTest.java @@ -1,35 +1,26 @@ package cloud.eppo; +import static cloud.eppo.helpers.AssignmentTestCase.parseTestCaseFile; +import static cloud.eppo.helpers.AssignmentTestCase.runTestCase; +import static cloud.eppo.helpers.TestUtils.mockHttpResponse; +import static cloud.eppo.helpers.TestUtils.setBaseClientHttpClientOverrideField; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.fail; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import cloud.eppo.helpers.AssignmentTestCase; -import cloud.eppo.helpers.AssignmentTestCaseDeserializer; -import cloud.eppo.helpers.SubjectAssignment; -import cloud.eppo.helpers.TestCaseValue; import cloud.eppo.logging.Assignment; import cloud.eppo.logging.AssignmentLogger; import cloud.eppo.ufc.dto.Attributes; import cloud.eppo.ufc.dto.EppoValue; import cloud.eppo.ufc.dto.VariationType; import com.fasterxml.jackson.core.JsonProcessingException; -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.lang.reflect.Field; import java.util.*; import java.util.stream.Stream; -import okhttp3.*; -import org.apache.commons.io.FileUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -44,185 +35,67 @@ public class BaseEppoClientTest { private static final String DUMMY_FLAG_API_KEY = "dummy-flags-api-key"; // Will load flags-v1 private static final String TEST_HOST = "https://us-central1-eppo-qa.cloudfunctions.net/serveGitHubRacTestFile"; - private final ObjectMapper mapper = new ObjectMapper().registerModule(module()); + private final ObjectMapper mapper = + new ObjectMapper().registerModule(AssignmentTestCase.assignmentTestCaseModule()); + private BaseEppoClient eppoClient; private AssignmentLogger mockAssignmentLogger; // TODO: async init client tests private void initClient() { - initClient(TEST_HOST, false, false, DUMMY_FLAG_API_KEY); + initClient(false, false); } - private void initClient( - String host, boolean isGracefulMode, boolean isConfigObfuscated, String apiKey) { + private void initClient(boolean isGracefulMode, boolean isConfigObfuscated) { mockAssignmentLogger = mock(AssignmentLogger.class); - new BaseEppoClient.Builder() - .apiKey(apiKey) - .sdkName(isConfigObfuscated ? "android" : "java") - .sdkVersion("3.0.0") - .isGracefulMode(isGracefulMode) - .host(host) - .assignmentLogger(mockAssignmentLogger) - .buildAndInit(); - + eppoClient = + new BaseEppoClient( + DUMMY_FLAG_API_KEY, + isConfigObfuscated ? "android" : "java", + "3.0.0", + TEST_HOST, + mockAssignmentLogger, + null, + isGracefulMode, + isConfigObfuscated); + + eppoClient.loadConfiguration(); log.info("Test client initialized"); } @BeforeEach public void cleanUp() { // TODO: Clear any caches - setHttpClientOverrideField(null); + setBaseClientHttpClientOverrideField(null); } @ParameterizedTest @MethodSource("getAssignmentTestData") public void testUnobfuscatedAssignments(File testFile) { - initClient(TEST_HOST, false, false, DUMMY_FLAG_API_KEY); + initClient(false, false); AssignmentTestCase testCase = parseTestCaseFile(testFile); - runTestCase(testCase); + runTestCase(testCase, eppoClient); } @ParameterizedTest @MethodSource("getAssignmentTestData") public void testObfuscatedAssignments(File testFile) { - initClient(TEST_HOST, false, true, DUMMY_FLAG_API_KEY); + initClient(false, true); AssignmentTestCase testCase = parseTestCaseFile(testFile); - runTestCase(testCase); + runTestCase(testCase, eppoClient); } private 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(); - } - - private 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; - } - - private void runTestCase(AssignmentTestCase testCase) { - String flagKey = testCase.getFlag(); - TestCaseValue defaultValue = testCase.getDefaultValue(); - BaseEppoClient eppoClient = BaseEppoClient.getInstance(); - assertFalse(testCase.getSubjects().isEmpty()); - - for (SubjectAssignment subjectAssignment : testCase.getSubjects()) { - 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 - switch (testCase.getVariationType()) { - case BOOLEAN: - boolean boolAssignment = - eppoClient.getBooleanAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); - assertAssignment(flagKey, subjectAssignment, boolAssignment); - break; - case INTEGER: - int intAssignment = - eppoClient.getIntegerAssignment( - flagKey, - subjectKey, - subjectAttributes, - Double.valueOf(defaultValue.doubleValue()).intValue()); - assertAssignment(flagKey, subjectAssignment, intAssignment); - break; - case NUMERIC: - double doubleAssignment = - eppoClient.getDoubleAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); - assertAssignment(flagKey, subjectAssignment, doubleAssignment); - break; - case STRING: - String stringAssignment = - eppoClient.getStringAssignment( - flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); - assertAssignment(flagKey, subjectAssignment, stringAssignment); - break; - case JSON: - 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 a subject assignment with a useful failure message. */ - private 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()); - } + return AssignmentTestCase.getAssignmentTestData(); } @Test public void testErrorGracefulModeOn() throws JsonProcessingException { - initClient(TEST_HOST, true, false, DUMMY_FLAG_API_KEY); + initClient(true, false); - BaseEppoClient realClient = BaseEppoClient.getInstance(); + BaseEppoClient realClient = eppoClient; BaseEppoClient spyClient = spy(realClient); doThrow(new RuntimeException("Exception thrown by mock")) .when(spyClient) @@ -269,9 +142,9 @@ public void testErrorGracefulModeOn() throws JsonProcessingException { @Test public void testErrorGracefulModeOff() { - initClient(TEST_HOST, false, false, DUMMY_FLAG_API_KEY); + initClient(false, false); - BaseEppoClient realClient = BaseEppoClient.getInstance(); + BaseEppoClient realClient = eppoClient; BaseEppoClient spyClient = spy(realClient); doThrow(new RuntimeException("Exception thrown by mock")) .when(spyClient) @@ -325,13 +198,11 @@ public void testErrorGracefulModeOff() { @Test public void testInvalidConfigJSON() { - mockHttpResponse("{}"); + mockHttpResponse(TEST_HOST, "{}"); - initClient(TEST_HOST, false, false, DUMMY_FLAG_API_KEY); + initClient(false, false); - String result = - BaseEppoClient.getInstance() - .getStringAssignment("dummy subject", "dummy flag", "not-populated"); + String result = eppoClient.getStringAssignment("dummy subject", "dummy flag", "not-populated"); assertEquals("not-populated", result); } @@ -343,8 +214,7 @@ public void testAssignmentEventCorrectlyCreated() { subjectAttributes.put("age", EppoValue.valueOf(30)); subjectAttributes.put("employer", EppoValue.valueOf("Eppo")); double assignment = - BaseEppoClient.getInstance() - .getDoubleAssignment("numeric_flag", "alice", subjectAttributes, 0.0); + eppoClient.getDoubleAssignment("numeric_flag", "alice", subjectAttributes, 0.0); assertEquals(3.1415926, assignment, 0.0000001); @@ -381,8 +251,7 @@ public void testAssignmentLogErrorNonFatal() { .when(mockAssignmentLogger) .logAssignment(any()); double assignment = - BaseEppoClient.getInstance() - .getDoubleAssignment("numeric_flag", "alice", new Attributes(), 0.0); + eppoClient.getDoubleAssignment("numeric_flag", "alice", new Attributes(), 0.0); assertEquals(3.1415926, assignment, 0.0000001); @@ -390,61 +259,5 @@ public void testAssignmentLogErrorNonFatal() { verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); } - private void mockHttpResponse(String responseBody) { - // Create a mock instance of EppoHttpClient - EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); - - // Mock sync get - Response dummyResponse = - new Response.Builder() - // Used by test - .code(200) - .body(ResponseBody.create(responseBody, MediaType.get("application/json"))) - // Below properties are required to build the Response (but unused) - .request(new Request.Builder().url(TEST_HOST).build()) - .protocol(Protocol.HTTP_1_1) - .message("OK") - .build(); - when(mockHttpClient.get(anyString())).thenReturn(dummyResponse); - - // Mock async get - doAnswer( - invocation -> { - RequestCallback callback = invocation.getArgument(1); - callback.onSuccess(responseBody); - return null; // doAnswer doesn't require a return value - }) - .when(mockHttpClient) - .get(anyString(), any(RequestCallback.class)); - - setHttpClientOverrideField(mockHttpClient); - } - - private void setHttpClientOverrideField(EppoHttpClient httpClient) { - setOverrideField("httpClientOverride", httpClient); - } - - private void setConfigurationStoreOverrideField(ConfigurationStore configurationStore) { - setOverrideField("configurationStoreOverride", configurationStore); - } - - /** Uses reflection to set a static override field used for tests (e.g., httpClientOverride) */ - private void setOverrideField(String fieldName, T override) { - try { - Field httpClientOverrideField = BaseEppoClient.class.getDeclaredField(fieldName); - httpClientOverrideField.setAccessible(true); - httpClientOverrideField.set(null, override); - httpClientOverrideField.setAccessible(false); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new RuntimeException(e); - } - } - - private static SimpleModule module() { - SimpleModule module = new SimpleModule(); - module.addDeserializer(AssignmentTestCase.class, new AssignmentTestCaseDeserializer()); - return module; - } - // TODO: tests for the cache } diff --git a/src/test/java/cloud/eppo/FlagEvaluatorTest.java b/src/test/java/cloud/eppo/FlagEvaluatorTest.java index c8e2dd84..d5effda1 100644 --- a/src/test/java/cloud/eppo/FlagEvaluatorTest.java +++ b/src/test/java/cloud/eppo/FlagEvaluatorTest.java @@ -3,7 +3,6 @@ import static cloud.eppo.Utils.base64Encode; import static cloud.eppo.Utils.getMD5Hex; import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertEquals; import cloud.eppo.model.ShardRange; import cloud.eppo.ufc.dto.Allocation; diff --git a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java index 7c2e9ea0..4513f359 100644 --- a/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java +++ b/src/test/java/cloud/eppo/helpers/AssignmentTestCase.java @@ -1,14 +1,26 @@ package cloud.eppo.helpers; +import static org.junit.jupiter.api.Assertions.*; + +import cloud.eppo.BaseEppoClient; +import cloud.eppo.ufc.dto.Attributes; import cloud.eppo.ufc.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.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; - private String fileName; public AssignmentTestCase( String flag, @@ -37,11 +49,138 @@ public List getSubjects() { return subjects; } - public void setFileName(String fileName) { - this.fileName = fileName; + 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) { + 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, we will need to change which assignment method we call and + // how we get the default value + switch (testCase.getVariationType()) { + case BOOLEAN: + boolean boolAssignment = + eppoClient.getBooleanAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.booleanValue()); + assertAssignment(flagKey, subjectAssignment, boolAssignment); + break; + case INTEGER: + int intAssignment = + eppoClient.getIntegerAssignment( + flagKey, + subjectKey, + subjectAttributes, + Double.valueOf(defaultValue.doubleValue()).intValue()); + assertAssignment(flagKey, subjectAssignment, intAssignment); + break; + case NUMERIC: + double doubleAssignment = + eppoClient.getDoubleAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.doubleValue()); + assertAssignment(flagKey, subjectAssignment, doubleAssignment); + break; + case STRING: + String stringAssignment = + eppoClient.getStringAssignment( + flagKey, subjectKey, subjectAttributes, defaultValue.stringValue()); + assertAssignment(flagKey, subjectAssignment, stringAssignment); + break; + case JSON: + 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"); + } + } } - public String getFileName() { - return fileName; + /** 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/BanditTestCase.java b/src/test/java/cloud/eppo/helpers/BanditTestCase.java index 90e6ff86..5c6efe67 100644 --- a/src/test/java/cloud/eppo/helpers/BanditTestCase.java +++ b/src/test/java/cloud/eppo/helpers/BanditTestCase.java @@ -1,6 +1,21 @@ 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.ufc.dto.Actions; +import cloud.eppo.ufc.dto.BanditResult; +import cloud.eppo.ufc.dto.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; @@ -26,11 +41,77 @@ public List getSubjects() { return subjects; } - public void setFileName(String fileName) { - this.fileName = fileName; + 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 String getFileName() { - return fileName; + 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/TestUtils.java b/src/test/java/cloud/eppo/helpers/TestUtils.java new file mode 100644 index 00000000..ab0c65e6 --- /dev/null +++ b/src/test/java/cloud/eppo/helpers/TestUtils.java @@ -0,0 +1,62 @@ +package cloud.eppo.helpers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import cloud.eppo.BaseEppoClient; +import cloud.eppo.EppoHttpClient; +import cloud.eppo.EppoHttpClientRequestCallback; +import java.lang.reflect.Field; +import okhttp3.*; + +public class TestUtils { + + @SuppressWarnings("SameParameterValue") + public static void mockHttpResponse(String host, String responseBody) { + // Create a mock instance of EppoHttpClient + EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + + // Mock sync get + Response dummyResponse = + new Response.Builder() + // Used by test + .code(200) + .body(ResponseBody.create(responseBody, MediaType.get("application/json"))) + // Below properties are required to build the Response (but unused) + .request(new Request.Builder().url(host).build()) + .protocol(Protocol.HTTP_1_1) + .message("OK") + .build(); + when(mockHttpClient.get(anyString())).thenReturn(dummyResponse); + + // Mock async get + doAnswer( + invocation -> { + EppoHttpClientRequestCallback callback = invocation.getArgument(1); + callback.onSuccess(responseBody); + return null; // doAnswer doesn't require a return value + }) + .when(mockHttpClient) + .get(anyString(), any(EppoHttpClientRequestCallback.class)); + + setBaseClientHttpClientOverrideField(mockHttpClient); + } + + public static void setBaseClientHttpClientOverrideField(EppoHttpClient httpClient) { + setBaseClientOverrideField("httpClientOverride", httpClient); + } + + /** Uses reflection to set a static override field used for tests (e.g., httpClientOverride) */ + @SuppressWarnings("SameParameterValue") + public static void setBaseClientOverrideField(String fieldName, T override) { + try { + Field httpClientOverrideField = BaseEppoClient.class.getDeclaredField(fieldName); + httpClientOverrideField.setAccessible(true); + httpClientOverrideField.set(null, override); + httpClientOverrideField.setAccessible(false); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } +}