diff --git a/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java b/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java index c45e3b01..9f9297a3 100755 --- a/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java +++ b/src/main/java/com/devcycle/sdk/server/cloud/api/DevCycleCloudClient.java @@ -3,6 +3,8 @@ import com.devcycle.sdk.server.cloud.model.DevCycleCloudOptions; import com.devcycle.sdk.server.common.api.IDevCycleApi; import com.devcycle.sdk.server.common.api.IDevCycleClient; +import com.devcycle.sdk.server.common.exception.AfterHookError; +import com.devcycle.sdk.server.common.exception.BeforeHookError; import com.devcycle.sdk.server.common.exception.DevCycleException; import com.devcycle.sdk.server.common.logging.DevCycleLogger; import com.devcycle.sdk.server.common.model.*; @@ -17,9 +19,7 @@ import retrofit2.Response; import java.io.IOException; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; +import java.util.*; public final class DevCycleCloudClient implements IDevCycleClient { @@ -27,6 +27,7 @@ public final class DevCycleCloudClient implements IDevCycleClient { private final IDevCycleApi api; private final DevCycleCloudOptions dvcOptions; private final DevCycleProvider openFeatureProvider; + private final EvalHooksRunner evalHooksRunner; public DevCycleCloudClient(String sdkKey) { this(sdkKey, DevCycleCloudOptions.builder().build()); @@ -50,6 +51,7 @@ public DevCycleCloudClient(String sdkKey, DevCycleCloudOptions options) { OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); this.openFeatureProvider = new DevCycleProvider(this); + this.evalHooksRunner = new EvalHooksRunner(); } /** @@ -109,23 +111,46 @@ public Variable variable(DevCycleUser user, String key, T defaultValue) { } TypeEnum variableType = TypeEnum.fromClass(defaultValue.getClass()); - Variable variable; + Variable variable = null; + HookContext context = new HookContext(user, key, defaultValue); + ArrayList> hooks = new ArrayList>(evalHooksRunner.getHooks()); + ArrayList> reversedHooks = new ArrayList<>(hooks); + Collections.reverse(reversedHooks); try { + Throwable beforeError = null; + + try { + context = context.merge(evalHooksRunner.executeBefore(hooks, context)); + } catch (Throwable e) { + beforeError = e; + } + Call response = api.getVariableByKey(user, key, dvcOptions.getEnableEdgeDB()); variable = getResponseWithRetries(response, 5); if (variable.getType() != variableType) { throw new IllegalArgumentException("Variable type mismatch, returning default value"); } + if (beforeError != null) { + throw beforeError; + } + + evalHooksRunner.executeAfter(reversedHooks, context, variable); variable.setIsDefaulted(false); - } catch (Exception exception) { - variable = (Variable) Variable.builder() - .key(key) - .type(variableType) - .value(defaultValue) - .defaultValue(defaultValue) - .isDefaulted(true) - .build(); + } catch (Throwable exception) { + if (!(exception instanceof BeforeHookError || exception instanceof AfterHookError)) { + variable = (Variable) Variable.builder() + .key(key) + .type(variableType) + .value(defaultValue) + .defaultValue(defaultValue) + .isDefaulted(true) + .build(); + } + + evalHooksRunner.executeError(reversedHooks, context, exception); + } finally { + evalHooksRunner.executeFinally(reversedHooks, context, Optional.ofNullable(variable)); } return variable; } @@ -226,6 +251,13 @@ private T getResponseWithRetries(Call call, int maxRetries) throws DevCyc throw new DevCycleException(HttpResponseCode.SERVER_ERROR, errorResponse); } + public void addHook(EvalHook hook) { + this.evalHooksRunner.addHook(hook); + } + + public void clearHooks() { + this.evalHooksRunner.clearHooks(); + } private T getResponse(Call call) throws DevCycleException { ErrorResponse errorResponse = ErrorResponse.builder().build(); diff --git a/src/main/java/com/devcycle/sdk/server/common/exception/AfterHookError.java b/src/main/java/com/devcycle/sdk/server/common/exception/AfterHookError.java new file mode 100644 index 00000000..9be07e5e --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/common/exception/AfterHookError.java @@ -0,0 +1,14 @@ +package com.devcycle.sdk.server.common.exception; + +/** + * Exception thrown when an after hook fails during variable evaluation. + */ +public class AfterHookError extends RuntimeException { + public AfterHookError(String message) { + super(message); + } + + public AfterHookError(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/devcycle/sdk/server/common/exception/BeforeHookError.java b/src/main/java/com/devcycle/sdk/server/common/exception/BeforeHookError.java new file mode 100644 index 00000000..bd595ee6 --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/common/exception/BeforeHookError.java @@ -0,0 +1,14 @@ +package com.devcycle.sdk.server.common.exception; + +/** + * Exception thrown when a before hook fails during variable evaluation. + */ +public class BeforeHookError extends RuntimeException { + public BeforeHookError(String message) { + super(message); + } + + public BeforeHookError(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/devcycle/sdk/server/common/model/EvalHook.java b/src/main/java/com/devcycle/sdk/server/common/model/EvalHook.java new file mode 100644 index 00000000..066a94b7 --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/common/model/EvalHook.java @@ -0,0 +1,13 @@ +package com.devcycle.sdk.server.common.model; + +import java.util.Optional; + +public interface EvalHook { + + default Optional> before(HookContext ctx) { + return Optional.empty(); + } + default void after(HookContext ctx, Variable variable) {} + default void error(HookContext ctx, Throwable e) {} + default void onFinally(HookContext ctx, Optional> variable) {} +} \ No newline at end of file diff --git a/src/main/java/com/devcycle/sdk/server/common/model/EvalHooksRunner.java b/src/main/java/com/devcycle/sdk/server/common/model/EvalHooksRunner.java new file mode 100644 index 00000000..3a25d6e0 --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/common/model/EvalHooksRunner.java @@ -0,0 +1,120 @@ +package com.devcycle.sdk.server.common.model; + +import com.devcycle.sdk.server.common.exception.AfterHookError; +import com.devcycle.sdk.server.common.exception.BeforeHookError; +import com.devcycle.sdk.server.common.logging.DevCycleLogger; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * A class that manages evaluation hooks for the DevCycle SDK. + * Provides functionality to add and clear hooks, storing them in an array. + */ +public class EvalHooksRunner { + private List> hooks; + + /** + * Default constructor initializes an empty list of hooks. + */ + public EvalHooksRunner() { + this.hooks = new ArrayList<>(); + } + + /** + * Adds a single hook to the collection. + * + * @param hook The hook to add + */ + public void addHook(EvalHook hook) { + if (hook != null) { + hooks.add(hook); + } + } + + /** + * Clears all hooks from the collection. + */ + public void clearHooks() { + hooks.clear(); + } + + public List> getHooks() { + return this.hooks; + } + + /** + * Runs all before hooks in order. + * + * @param context The context to pass to the hooks + * @param The type of the variable value + * @return The potentially modified context + */ + public HookContext executeBefore(ArrayList> hooks, HookContext context) { + HookContext beforeContext = context; + for (EvalHook hook : hooks) { + try { + Optional> newContext = hook.before(beforeContext); + if (newContext.isPresent()) { + beforeContext = beforeContext.merge(newContext.get()); + } + } catch (Exception e) { + throw new BeforeHookError("Before hook failed", e); + } + } + return beforeContext; + } + + /** + * Runs all after hooks in reverse order. + * + * @param context The context to pass to the hooks + * @param variable The variable result to pass to the hooks + * @param The type of the variable value + */ + public void executeAfter(ArrayList> hooks, HookContext context, Variable variable) { + for (EvalHook hook : hooks) { + try { + hook.after(context, variable); + } catch (Exception e) { + throw new AfterHookError("After hook failed", e); + } + } + } + + /** + * Runs all error hooks in reverse order. + * + * @param context The context to pass to the hooks + * @param error The error that occurred + * @param The type of the variable value + */ + public void executeError(ArrayList> hooks, HookContext context, Throwable error) { + for (EvalHook hook : hooks) { + try { + hook.error(context, error); + } catch (Exception hookError) { + // Log hook error but don't throw to avoid masking the original error + DevCycleLogger.error("Error hook failed: " + hookError.getMessage(), hookError); + } + } + } + + /** + * Runs all finally hooks in reverse order. + * + * @param context The context to pass to the hooks + * @param variable The variable result to pass to the hooks (may be null) + */ + public void executeFinally(ArrayList> hooks, HookContext context, Optional> variable) { + for (EvalHook hook : hooks) { + try { + hook.onFinally(context, variable); + } catch (Exception e) { + // Log finally hook error but don't throw + DevCycleLogger.error("Finally hook failed: " + e.getMessage(), e); + } + } + } +} diff --git a/src/main/java/com/devcycle/sdk/server/common/model/HookContext.java b/src/main/java/com/devcycle/sdk/server/common/model/HookContext.java new file mode 100644 index 00000000..320a71f6 --- /dev/null +++ b/src/main/java/com/devcycle/sdk/server/common/model/HookContext.java @@ -0,0 +1,48 @@ +package com.devcycle.sdk.server.common.model; + +import java.util.Map; + +/** + * Context object passed to hooks during variable evaluation. + * Contains the user, variable key, default value, and additional context data. + */ +public class HookContext { + private DevCycleUser user; + private final String key; + private final T defaultValue; + private Variable variableDetails; + + public HookContext(DevCycleUser user, String key, T defaultValue) { + this.user = user; + this.key = key; + this.defaultValue = defaultValue; + } + + public HookContext(DevCycleUser user, String key, T defaultValue, Variable variable) { + this.user = user; + this.key = key; + this.defaultValue = defaultValue; + this.variableDetails = variable; + } + + public DevCycleUser getUser() { + return user; + } + + public String getKey() { + return key; + } + + public T getDefaultValue() { + return defaultValue; + } + + public Variable getVariableDetails() { return variableDetails; } + + public HookContext merge(HookContext other) { + if (other == null) { + return this; + } + return new HookContext<>(other.getUser(), key, defaultValue, variableDetails); + } +} \ No newline at end of file diff --git a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java index 6de89d8d..094be70b 100755 --- a/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java +++ b/src/main/java/com/devcycle/sdk/server/local/api/DevCycleLocalClient.java @@ -1,6 +1,7 @@ package com.devcycle.sdk.server.local.api; import com.devcycle.sdk.server.common.api.IDevCycleClient; +import com.devcycle.sdk.server.common.exception.BeforeHookError; import com.devcycle.sdk.server.common.logging.DevCycleLogger; import com.devcycle.sdk.server.common.model.*; import com.devcycle.sdk.server.common.model.Variable.TypeEnum; @@ -18,9 +19,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import dev.openfeature.sdk.FeatureProvider; -import java.util.Collections; -import java.util.Map; -import java.util.UUID; +import java.util.*; public final class DevCycleLocalClient implements IDevCycleClient { @@ -29,6 +28,7 @@ public final class DevCycleLocalClient implements IDevCycleClient { private final EnvironmentConfigManager configManager; private EventQueueManager eventQueueManager; private final String clientUUID; + private EvalHooksRunner evalHooksRunner; public DevCycleLocalClient(String sdkKey) { this(sdkKey, DevCycleLocalOptions.builder().build()); @@ -60,6 +60,7 @@ public DevCycleLocalClient(String sdkKey, DevCycleLocalOptions dvcOptions) { } catch (Exception e) { DevCycleLogger.error("Error creating event queue due to error: " + e.getMessage()); } + this.evalHooksRunner = new EvalHooksRunner(); } /** @@ -155,24 +156,50 @@ public Variable variable(DevCycleUser user, String key, T defaultValue) { .setShouldTrackEvent(true) .build(); + HookContext hookContext = new HookContext(user, key, defaultValue); + Variable variable = null; + ArrayList> hooks = new ArrayList>(evalHooksRunner.getHooks()); + ArrayList> reversedHooks = new ArrayList>(evalHooksRunner.getHooks()); + Collections.reverse(reversedHooks); + try { byte[] paramsBuffer = params.toByteArray(); byte[] variableData = localBucketing.getVariableForUserProtobuf(paramsBuffer); + Throwable beforeError = null; + try { + evalHooksRunner.executeBefore(hooks, hookContext); + } catch (Throwable e) { + beforeError = e; + } + if (variableData == null || variableData.length == 0) { - return defaultVariable; + variable = defaultVariable; } else { SDKVariable_PB sdkVariable = SDKVariable_PB.parseFrom(variableData); if (sdkVariable.getType() != pbVariableType) { DevCycleLogger.warning("Variable type mismatch, returning default value"); - return defaultVariable; + variable = defaultVariable; + } else { + variable = ProtobufUtils.createVariable(sdkVariable, defaultValue); } - return ProtobufUtils.createVariable(sdkVariable, defaultValue); } - } catch (Exception e) { - DevCycleLogger.error("Unable to evaluate Variable " + key + " due to error: " + e, e); + if (beforeError != null) { + throw beforeError; + } + evalHooksRunner.executeAfter(reversedHooks, hookContext, variable); + } catch (Throwable e) { + if (!(e instanceof BeforeHookError)) { + DevCycleLogger.error("Unable to evaluate Variable " + key + " due to error: " + e, e); + } + evalHooksRunner.executeError(reversedHooks, hookContext, e); + } finally { + if (variable == null) { + variable = defaultVariable; + } + evalHooksRunner.executeFinally(reversedHooks, hookContext, Optional.of(variable)); + return variable; } - return defaultVariable; } @@ -247,6 +274,21 @@ public void close() { } } + /** + * Add an evaluation hook to the client + * + * @param hook The hook to add + */ + public void addHook(EvalHook hook) { + this.evalHooksRunner.addHook(hook); + } + + /** + * Remove all evaluation hooks from the client + */ + public void clearHooks() { + this.evalHooksRunner.clearHooks(); + } private static DevCycleProvider openFeatureProvider = null; diff --git a/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java b/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java index 466d6fd5..1001ba2e 100755 --- a/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java +++ b/src/test/java/com/devcycle/sdk/server/cloud/DevCycleCloudClientTest.java @@ -7,17 +7,21 @@ import com.devcycle.sdk.server.common.exception.DevCycleException; import com.devcycle.sdk.server.common.model.*; import com.devcycle.sdk.server.helpers.WhiteBox; +import dev.openfeature.sdk.Hook; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import retrofit2.Call; +import retrofit2.mock.Calls; import java.math.BigDecimal; import java.time.Instant; import java.util.Collections; import java.util.Map; +import java.util.Optional; import java.util.UUID; import static org.mockito.Mockito.when; @@ -57,7 +61,7 @@ public void getFeaturesTest() throws DevCycleException { .country("US") .build(); - when(apiInterface.getFeatures(user, dvcOptions.getEnableEdgeDB())).thenReturn(dvcApiMock.getFeatures(user, dvcOptions.getEnableEdgeDB())); + when(apiInterface.getFeatures(user, false)).thenReturn(dvcApiMock.getFeatures(user, false)); Map features = api.allFeatures(user); @@ -195,6 +199,554 @@ public void postEventsTest() throws DevCycleException { assertUserDefaultsCorrect(user); } + @Test + public void variable_withEvalHooks_callsHooksInOrder() throws DevCycleException { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + Assert.assertTrue(beforeCalled[0]); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + Assert.assertTrue(afterCalled[0]); + } + }); + + when(apiInterface.getVariableByKey(user, "test-key", dvcOptions.getEnableEdgeDB())).thenReturn(dvcApiMock.getVariableByKey(user, "test-key", dvcOptions.getEnableEdgeDB())); + + Variable result = api.variable(user, "test-key", true); + + Assert.assertTrue(beforeCalled[0]); + Assert.assertTrue(afterCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_callsErrorHookOnException() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + return Optional.of(ctx); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + when(apiInterface.getVariableByKey(user, "test-key", dvcOptions.getEnableEdgeDB())).thenThrow(new RuntimeException("Test error")); + + Variable result = api.variable(user, "test-key", true); + + Assert.assertTrue(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenBeforeHookThrows() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + throw new RuntimeException("Test before hook error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-true") + .value(true) + .type(Variable.TypeEnum.BOOLEAN) + .isDefaulted(false) + .defaultValue(false) + .build(); + + when(apiInterface.getVariableByKey(user, "test-true", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-true", false); + + Assert.assertEquals(expected, result); + Assert.assertFalse(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenAfterHookThrows() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + throw new RuntimeException("Test after hook error"); + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-string") + .value("test value") + .type(Variable.TypeEnum.STRING) + .isDefaulted(false) + .defaultValue("default string") + .build(); + + when(apiInterface.getVariableByKey(user, "test-string", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-string", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertTrue(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenFinallyHookThrows() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + throw new RuntimeException("Test finally hook error"); + } + }); + + Variable expected = Variable.builder() + .key("test-string") + .value("test value") + .type(Variable.TypeEnum.STRING) + .isDefaulted(false) + .defaultValue("default string") + .build(); + + when(apiInterface.getVariableByKey(user, "test-string", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-string", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertTrue(afterCalled[0]); + Assert.assertFalse(errorCalled[0]); // No error hook should be called for finally errors + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenErrorHookThrows() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + throw new RuntimeException("Test before hook error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + throw new RuntimeException("Test error hook error"); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-string") + .value("test value") + .type(Variable.TypeEnum.STRING) + .isDefaulted(false) + .defaultValue("default string") + .build(); + + when(apiInterface.getVariableByKey(user, "test-string", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-string", "default string"); + + Assert.assertNotNull(result); + Assert.assertFalse(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenMultipleHooksThrow() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] hook1BeforeCalled = {false}; + final boolean[] hook1AfterCalled = {false}; + final boolean[] hook1ErrorCalled = {false}; + final boolean[] hook1FinallyCalled = {false}; + final boolean[] hook2BeforeCalled = {false}; + final boolean[] hook2AfterCalled = {false}; + final boolean[] hook2ErrorCalled = {false}; + final boolean[] hook2FinallyCalled = {false}; + + // First hook throws in before + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + hook1BeforeCalled[0] = true; + throw new RuntimeException("Test hook1 before error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + hook1AfterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + hook1ErrorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + hook1FinallyCalled[0] = true; + } + }); + + // Second hook throws in after + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + hook2BeforeCalled[0] = true; + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + hook2AfterCalled[0] = true; + throw new RuntimeException("Test hook2 after error"); + } + + @Override + public void error(HookContext ctx, Throwable error) { + hook2ErrorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + hook2FinallyCalled[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-string") + .value("test value") + .type(Variable.TypeEnum.STRING) + .isDefaulted(false) + .defaultValue("default string") + .build(); + + when(apiInterface.getVariableByKey(user, "test-string", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-string", "default string"); + + Assert.assertNotNull(result); + Assert.assertFalse(result.getIsDefaulted()); + // First hook should be called and throw + Assert.assertTrue(hook1BeforeCalled[0]); + Assert.assertFalse(hook1AfterCalled[0]); + Assert.assertTrue(hook1ErrorCalled[0]); + Assert.assertTrue(hook1FinallyCalled[0]); + // Second hook should not be called due to first hook error + Assert.assertFalse(hook2BeforeCalled[0]); + Assert.assertFalse(hook2AfterCalled[0]); + Assert.assertTrue(hook2ErrorCalled[0]); + Assert.assertTrue(hook2FinallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenHookThrowsCheckedException() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + throw new RuntimeException("Test checked exception in before hook"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + Assert.assertTrue(error instanceof RuntimeException); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-string") + .value("test value") + .type(Variable.TypeEnum.STRING) + .isDefaulted(false) + .defaultValue("default string") + .build(); + + when(apiInterface.getVariableByKey(user, "test-string", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-string", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenHookThrowsInFinallyAfterError() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + throw new RuntimeException("Test before hook error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + throw new RuntimeException("Test finally hook error after previous error"); + } + }); + + Variable expected = Variable.builder() + .key("test-string") + .value("test value") + .type(Variable.TypeEnum.STRING) + .isDefaulted(false) + .defaultValue("default string") + .build(); + + when(apiInterface.getVariableByKey(user, "test-string", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-string", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenHookThrowsInErrorAfterFinally() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + api.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + throw new RuntimeException("Test before hook error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + throw new RuntimeException("Test error hook error after before error"); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("test-string") + .value("test value") + .type(Variable.TypeEnum.STRING) + .isDefaulted(false) + .defaultValue("default string") + .build(); + + when(apiInterface.getVariableByKey(user, "test-string", dvcOptions.getEnableEdgeDB())).thenReturn(Calls.response(expected)); + + Variable result = api.variable(user, "test-string", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + private void assertUserDefaultsCorrect(DevCycleUser user) { Assert.assertEquals("Java", user.getPlatform()); Assert.assertEquals(PlatformData.SdkTypeEnum.SERVER, user.getSdkType()); diff --git a/src/test/java/com/devcycle/sdk/server/cloud/EvalHooksRunnerTest.java b/src/test/java/com/devcycle/sdk/server/cloud/EvalHooksRunnerTest.java new file mode 100644 index 00000000..0917d1f3 --- /dev/null +++ b/src/test/java/com/devcycle/sdk/server/cloud/EvalHooksRunnerTest.java @@ -0,0 +1,151 @@ +package com.devcycle.sdk.server.cloud; + +import com.devcycle.sdk.server.common.exception.AfterHookError; +import com.devcycle.sdk.server.common.exception.BeforeHookError; +import com.devcycle.sdk.server.common.model.*; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Optional; + +public class EvalHooksRunnerTest { + + private EvalHooksRunner hookRunner; + private DevCycleUser testUser; + private Variable testVariable; + + @Before + public void setup() { + hookRunner = new EvalHooksRunner(); + testUser = DevCycleUser.builder().userId("test-user").build(); + testVariable = Variable.builder() + .key("test-var") + .value(true) + .type(Variable.TypeEnum.BOOLEAN) + .build(); + } + + @Test + public void testBeforeHook() { + HookContext context = new HookContext<>(testUser, "test-key", false); + + ArrayList> hooks = new ArrayList<>(); + hooks.add(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + DevCycleUser modifiedUser = DevCycleUser.builder() + .userId("modified-user") + .build(); + return Optional.of(new HookContext<>(modifiedUser, ctx.getKey(), ctx.getDefaultValue())); + } + }); + + HookContext result = hookRunner.executeBefore(hooks, context); + Assert.assertEquals("modified-user", result.getUser().getUserId()); + } + + @Test(expected = BeforeHookError.class) + public void testBeforeHookError() { + HookContext context = new HookContext<>(testUser, "test-key", false); + + ArrayList> hooks = new ArrayList<>(); + hooks.add(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + throw new RuntimeException("Test error"); + } + }); + + hookRunner.executeBefore(hooks, context); + } + + @Test + public void testAfterHook() { + final boolean[] hookCalled = {false}; + HookContext context = new HookContext<>(testUser, "test-key", false); + + ArrayList> hooks = new ArrayList<>(); + hooks.add(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + hookCalled[0] = true; + Assert.assertEquals("test-var", variable.getKey()); + } + }); + + hookRunner.executeAfter(hooks, context, testVariable); + Assert.assertTrue(hookCalled[0]); + } + + @Test(expected = AfterHookError.class) + public void testAfterHookError() { + HookContext context = new HookContext<>(testUser, "test-key", false); + + ArrayList> hooks = new ArrayList<>(); + hooks.add(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + throw new RuntimeException("Test error"); + } + }); + + hookRunner.executeAfter(hooks, context, testVariable); + } + + @Test + public void testErrorHook() { + final boolean[] hookCalled = {false}; + HookContext context = new HookContext<>(testUser, "test-key", false); + Exception testError = new Exception("Test error"); + + ArrayList> hooks = new ArrayList<>(); + hooks.add(new EvalHook() { + @Override + public void error(HookContext ctx, Throwable error) { + hookCalled[0] = true; + Assert.assertEquals("Test error", error.getMessage()); + } + }); + + hookRunner.executeError(hooks, context, testError); + Assert.assertTrue(hookCalled[0]); + } + + @Test + public void testFinallyHook() { + final boolean[] hookCalled = {false}; + HookContext context = new HookContext<>(testUser, "test-key", false); + + ArrayList> hooks = new ArrayList<>(); + hooks.add(new EvalHook() { + @Override + public void onFinally(HookContext ctx, Optional> variable) { + hookCalled[0] = true; + } + }); + + hookRunner.executeFinally(hooks, context, Optional.ofNullable(testVariable)); + Assert.assertTrue(hookCalled[0]); + } + + @Test + public void testClearHooks() { + final boolean[] hookCalled = {false}; + HookContext context = new HookContext<>(testUser, "test-key", false); + + ArrayList> hooks = new ArrayList<>(); + hooks.add(new EvalHook() { + @Override + public void after(HookContext ctx, Variable variable) { + hookCalled[0] = true; + } + }); + + // Test that empty hooks array doesn't call any hooks + ArrayList> emptyHooks = new ArrayList<>(); + hookRunner.executeAfter(emptyHooks, context, testVariable); + Assert.assertFalse(hookCalled[0]); + } +} diff --git a/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java b/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java index fce62bb5..0cbc27c6 100644 --- a/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java +++ b/src/test/java/com/devcycle/sdk/server/local/DevCycleLocalClientTest.java @@ -1,6 +1,7 @@ package com.devcycle.sdk.server.local; import com.devcycle.sdk.server.common.api.IRestOptions; +import com.devcycle.sdk.server.common.exception.DevCycleException; import com.devcycle.sdk.server.common.logging.IDevCycleLogger; import com.devcycle.sdk.server.common.model.*; import com.devcycle.sdk.server.helpers.LocalConfigServer; @@ -11,6 +12,7 @@ import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Test; +import org.junit.After; import org.junit.runner.RunWith; import org.mockito.junit.MockitoJUnitRunner; @@ -20,6 +22,7 @@ import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.UUID; @RunWith(MockitoJUnitRunner.class) @@ -365,6 +368,487 @@ public void allFeaturesWithSpecialCharsTest() { Assert.assertEquals(features.size(), 1); } + @Test + public void variable_withEvalHooks_callsHooksInOrder() throws DevCycleException { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + Assert.assertTrue(beforeCalled[0]); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + Assert.assertTrue(afterCalled[0]); + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertTrue(beforeCalled[0]); + Assert.assertTrue(afterCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenBeforeHookThrows() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + throw new RuntimeException("Test before hook error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull(result); + Assert.assertFalse(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenAfterHookThrows() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + throw new RuntimeException("Test after hook error"); + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertTrue(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenFinallyHookThrows() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + throw new RuntimeException("Test finally hook error"); + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertTrue(afterCalled[0]); + Assert.assertFalse(errorCalled[0]); // No error hook should be called for finally errors + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenErrorHookThrows() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + throw new RuntimeException("Test before hook error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + throw new RuntimeException("Test error hook error"); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull(result); + Assert.assertFalse(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenMultipleHooksThrow() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .email("giveMeVariationOn@email.com") + .build(); + + final boolean[] hook1BeforeCalled = {false}; + final boolean[] hook1AfterCalled = {false}; + final boolean[] hook1ErrorCalled = {false}; + final boolean[] hook1FinallyCalled = {false}; + final boolean[] hook2BeforeCalled = {false}; + final boolean[] hook2AfterCalled = {false}; + final boolean[] hook2ErrorCalled = {false}; + final boolean[] hook2FinallyCalled = {false}; + + // First hook throws in before + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + hook1BeforeCalled[0] = true; + throw new RuntimeException("Test hook1 before error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + hook1AfterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + hook1ErrorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + hook1FinallyCalled[0] = true; + } + }); + + // Second hook throws in after + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + hook2BeforeCalled[0] = true; + return Optional.empty(); + } + + @Override + public void after(HookContext ctx, Variable variable) { + hook2AfterCalled[0] = true; + throw new RuntimeException("Test hook2 after error"); + } + + @Override + public void error(HookContext ctx, Throwable error) { + hook2ErrorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + hook2FinallyCalled[0] = true; + } + }); + + Variable expected = Variable.builder() + .key("string-var") + .value("variationOn") + .type(Variable.TypeEnum.STRING) + .isDefaulted(false) + .defaultValue("default string") + .build(); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertEquals(expected, result); + // First hook should be called and throw + Assert.assertTrue(hook1BeforeCalled[0]); + Assert.assertFalse(hook1AfterCalled[0]); + Assert.assertTrue(hook1ErrorCalled[0]); + Assert.assertTrue(hook1FinallyCalled[0]); + // Second hook should not be called due to first hook error + Assert.assertFalse(hook2BeforeCalled[0]); + Assert.assertFalse(hook2AfterCalled[0]); + Assert.assertTrue(hook2ErrorCalled[0]); + Assert.assertTrue(hook2FinallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenHookThrowsCheckedException() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + throw new RuntimeException("Test checked exception in before hook"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + Assert.assertTrue(error instanceof RuntimeException); + Assert.assertEquals("Test checked exception in before hook", error.getMessage()); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenHookThrowsNullPointerException() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + throw new NullPointerException("Test NPE in before hook"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + Assert.assertTrue(error instanceof NullPointerException); + Assert.assertEquals("Test NPE in before hook", error.getMessage()); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenHookThrowsInFinallyAfterError() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + throw new RuntimeException("Test before hook error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + throw new RuntimeException("Test finally hook error after previous error"); + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @Test + public void variable_withEvalHooks_returnsVariableWhenHookThrowsInErrorAfterFinally() { + DevCycleUser user = DevCycleUser.builder() + .userId("j_test") + .build(); + + final boolean[] beforeCalled = {false}; + final boolean[] afterCalled = {false}; + final boolean[] errorCalled = {false}; + final boolean[] finallyCalled = {false}; + + client.addHook(new EvalHook() { + @Override + public Optional> before(HookContext ctx) { + beforeCalled[0] = true; + throw new RuntimeException("Test before hook error"); + } + + @Override + public void after(HookContext ctx, Variable variable) { + afterCalled[0] = true; + } + + @Override + public void error(HookContext ctx, Throwable error) { + errorCalled[0] = true; + throw new RuntimeException("Test error hook error after before error"); + } + + @Override + public void onFinally(HookContext ctx, Optional> variable) { + finallyCalled[0] = true; + } + }); + + Variable result = client.variable(user, "string-var", "default string"); + + Assert.assertNotNull(result); + Assert.assertTrue(beforeCalled[0]); + Assert.assertFalse(afterCalled[0]); + Assert.assertTrue(errorCalled[0]); + Assert.assertTrue(finallyCalled[0]); + } + + @After + public void clearHooks() { + client.clearHooks(); + } + private DevCycleUser getUser() { return DevCycleUser.builder() .userId("j_test")