diff --git a/eppo/build.gradle b/eppo/build.gradle index ad10cecd..4b667606 100644 --- a/eppo/build.gradle +++ b/eppo/build.gradle @@ -4,7 +4,7 @@ plugins { } group = "cloud.eppo" -version = "1.0.3" +version = "1.0.4" android { compileSdk 33 diff --git a/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java b/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java index 1ade4f7d..4a0f0082 100644 --- a/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java +++ b/eppo/src/androidTest/java/cloud/eppo/android/EppoClientTest.java @@ -21,7 +21,9 @@ import com.google.gson.JsonElement; import com.google.gson.JsonParseException; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Matchers.any; import static org.mockito.Matchers.anyString; @@ -32,7 +34,9 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.StringReader; import java.lang.reflect.Type; +import java.lang.reflect.Field; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -360,13 +364,85 @@ private List getJSONAssignments(AssignmentTestCase testCase) { return (List) this.getAssignments(testCase, AssignmentValueType.JSON); } - private static String getMockRandomizedAssignmentResponse() { + @Test + public void testInvalidConfigJSON() { + + // Create a mock instance of EppoHttpClient + EppoHttpClient mockHttpClient = mock(EppoHttpClient.class); + + doAnswer(invocation -> { + RequestCallback callback = invocation.getArgument(1); + callback.onSuccess(new StringReader("{}")); + return null; // doAnswer doesn't require a return value + }).when(mockHttpClient).get(anyString(), any(RequestCallback.class)); + + Field httpClientOverrideField = null; + try { + // Use reflection to set the httpClientOverride field + httpClientOverrideField = EppoClient.class.getDeclaredField("httpClientOverride"); + httpClientOverrideField.setAccessible(true); + httpClientOverrideField.set(null, mockHttpClient); + + + initClient(TEST_HOST, true, true, false); + } catch (InterruptedException | NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } finally { + if (httpClientOverrideField != null) { + try { + httpClientOverrideField.set(null, null); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + httpClientOverrideField.setAccessible(false); + } + } + + String result = EppoClient.getInstance().getStringAssignment("dummy subject", "dummy flag"); + assertNull(result); + } + + @Test + public void testCachedBadResponseAllowsLaterFetching() { + // Populate the cache with a bad response + ConfigCacheFile cacheFile = new ConfigCacheFile(ApplicationProvider.getApplicationContext()); + cacheFile.delete(); try { - InputStream in = ApplicationProvider.getApplicationContext().getAssets() - .open("rac-experiments-v3-hashed-keys.json"); - return IOUtils.toString(in, Charsets.toCharset("UTF8")); + cacheFile.getOutputWriter().write("{}"); + cacheFile.getOutputWriter().close(); } catch (IOException e) { - throw new RuntimeException("Error reading mock RAC data", e); + throw new RuntimeException(e); + } + try { + initClient(TEST_HOST, false, false, false); + } catch (InterruptedException e) { + throw new RuntimeException(e); + }; + + String result = EppoClient.getInstance().getStringAssignment("dummy subject", "dummy flag"); + assertNull(result); + // Failure callback will have fired from cache read error, but configuration request will still be fired off on init + // Wait for the configuration request to load the configuration + waitForNonNullAssignment(); + String assignment = EppoClient.getInstance().getStringAssignment("6255e1a7fc33a9c050ce9508", "randomization_algo"); + assertEquals("control", assignment); + } + + private void waitForNonNullAssignment() { + long waitStart = System.currentTimeMillis(); + long waitEnd = waitStart + 15 * 1000; // allow up to 15 seconds + String assignment = null; + try { + while (assignment == null) { + if (System.currentTimeMillis() > waitEnd) { + throw new InterruptedException("Non-null assignment never received; assuming configuration not loaded"); + } + // Uses third subject in test-case-0 + assignment = EppoClient.getInstance().getStringAssignment("6255e1a7fc33a9c050ce9508", "randomization_algo"); + Thread.sleep(100); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); } } } diff --git a/eppo/src/main/java/cloud/eppo/android/ConfigurationRequestor.java b/eppo/src/main/java/cloud/eppo/android/ConfigurationRequestor.java index 06d335b5..0e462679 100644 --- a/eppo/src/main/java/cloud/eppo/android/ConfigurationRequestor.java +++ b/eppo/src/main/java/cloud/eppo/android/ConfigurationRequestor.java @@ -33,7 +33,7 @@ public void onSuccess(Reader response) { } catch (JsonSyntaxException | JsonIOException e) { Log.e(TAG, "Error loading configuration response", e); if (callback != null && !usedCache) { - callback.onError("Unable to load configuration from network"); + callback.onError("Unable to load configuration"); } return; } diff --git a/eppo/src/main/java/cloud/eppo/android/ConfigurationStore.java b/eppo/src/main/java/cloud/eppo/android/ConfigurationStore.java index df5ad5db..6e364442 100644 --- a/eppo/src/main/java/cloud/eppo/android/ConfigurationStore.java +++ b/eppo/src/main/java/cloud/eppo/android/ConfigurationStore.java @@ -9,6 +9,7 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; @@ -55,6 +56,10 @@ public boolean loadFromCache(InitializationCallback callback) { InputStreamReader reader = cacheFile.getInputReader(); RandomizationConfigResponse configResponse = gson.fromJson(reader, RandomizationConfigResponse.class); reader.close(); + if (configResponse == null || configResponse.getFlags() == null) { + // Invalid cached configuration, initialize as an empty map and delete file + throw new JsonSyntaxException("Configuration file missing flags"); + } flags = configResponse.getFlags(); } Log.d(TAG, "Cache loaded successfully"); @@ -77,6 +82,10 @@ public boolean loadFromCache(InitializationCallback callback) { public void setFlags(Reader response) { RandomizationConfigResponse config = gson.fromJson(response, RandomizationConfigResponse.class); flags = config.getFlags(); + if (flags == null) { + Log.w(TAG, "Flags missing in configuration response"); + flags = new ConcurrentHashMap<>(); + } // update any existing flags already in shared prefs updateConfigsInSharedPrefs(); diff --git a/eppo/src/main/java/cloud/eppo/android/EppoClient.java b/eppo/src/main/java/cloud/eppo/android/EppoClient.java index 3109ba2b..03d47485 100644 --- a/eppo/src/main/java/cloud/eppo/android/EppoClient.java +++ b/eppo/src/main/java/cloud/eppo/android/EppoClient.java @@ -36,8 +36,11 @@ public class EppoClient { private boolean isGracefulMode; private static EppoClient instance; + // Useful for testing in situations where we want to mock the http client + private static EppoHttpClient httpClientOverride = null; + private EppoClient(Application application, String apiKey, String host, AssignmentLogger assignmentLogger, boolean isGracefulMode) { - EppoHttpClient httpClient = new EppoHttpClient(host, apiKey); + EppoHttpClient httpClient = httpClientOverride == null ? new EppoHttpClient(host, apiKey) : httpClientOverride; ConfigurationStore configStore = new ConfigurationStore(application); requestor = new ConfigurationRequestor(configStore, httpClient); this.isGracefulMode = isGracefulMode;