diff --git a/core-api/src/main/java/com/optimizely/ab/Optimizely.java b/core-api/src/main/java/com/optimizely/ab/Optimizely.java index 44da0eb97..efb7b82d6 100644 --- a/core-api/src/main/java/com/optimizely/ab/Optimizely.java +++ b/core-api/src/main/java/com/optimizely/ab/Optimizely.java @@ -41,6 +41,7 @@ import com.optimizely.ab.event.internal.payload.Event.ClientEngine; import com.optimizely.ab.internal.EventTagUtils; import com.optimizely.ab.notification.NotificationBroadcaster; +import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.notification.NotificationListener; import org.slf4j.Logger; @@ -93,6 +94,8 @@ public class Optimizely { @VisibleForTesting final EventHandler eventHandler; @VisibleForTesting final ErrorHandler errorHandler; @VisibleForTesting final NotificationBroadcaster notificationBroadcaster = new NotificationBroadcaster(); + public final NotificationCenter notificationCenter = new NotificationCenter(); + @Nullable private final UserProfileService userProfileService; private Optimizely(@Nonnull ProjectConfig projectConfig, @@ -206,6 +209,9 @@ private void sendImpression(@Nonnull ProjectConfig projectConfig, } notificationBroadcaster.broadcastExperimentActivated(experiment, userId, filteredAttributes, variation); + + notificationCenter.sendNotifications(NotificationCenter.NotificationType.Activate, experiment, userId, + filteredAttributes, variation, impressionEvent); } else { logger.info("Experiment has \"Launched\" status so not dispatching event during activation."); } @@ -292,6 +298,8 @@ public void track(@Nonnull String eventName, notificationBroadcaster.broadcastEventTracked(eventName, userId, filteredAttributes, eventValue, conversionEvent); + notificationCenter.sendNotifications(NotificationCenter.NotificationType.Track, eventName, userId, + filteredAttributes, eventTags, conversionEvent); } //======== FeatureFlag APIs ========// @@ -688,6 +696,7 @@ public UserProfileService getUserProfileService() { * * @param listener listener to add */ + @Deprecated public void addNotificationListener(@Nonnull NotificationListener listener) { notificationBroadcaster.addListener(listener); } @@ -697,6 +706,7 @@ public void addNotificationListener(@Nonnull NotificationListener listener) { * * @param listener listener to remove */ + @Deprecated public void removeNotificationListener(@Nonnull NotificationListener listener) { notificationBroadcaster.removeListener(listener); } @@ -704,6 +714,7 @@ public void removeNotificationListener(@Nonnull NotificationListener listener) { /** * Remove all {@link NotificationListener}. */ + @Deprecated public void clearNotificationListeners() { notificationBroadcaster.clearListeners(); } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java new file mode 100644 index 000000000..9ee96afc9 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/ActivateNotification.java @@ -0,0 +1,64 @@ +/** + * + * Copyright 2017, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.optimizely.ab.notification; + +import com.optimizely.ab.config.Experiment; +import com.optimizely.ab.config.Variation; +import com.optimizely.ab.event.LogEvent; + +import java.util.Map; + + +public abstract class ActivateNotification extends NotificationListener { + + /** + * Base notify called with var args. This method parses the parameters and calls the abstract method. + * @param args - variable argument list based on the type of notification. + */ + @Override + public final void notify(Object... args) { + assert(args[0] instanceof Experiment); + Experiment experiment = (Experiment) args[0]; + assert(args[1] instanceof String); + String userId = (String) args[1]; + assert(args[2] instanceof java.util.Map); + Map attributes = (Map) args[2]; + assert(args[3] instanceof Variation); + Variation variation = (Variation) args[3]; + assert(args[4] instanceof LogEvent); + LogEvent logEvent = (LogEvent) args[4]; + + onActivate(experiment, userId, attributes, variation, logEvent); + } + + /** + * onActivate called when an activate was triggered + * @param experiment - The experiment object being activated. + * @param userId - The userId passed into activate. + * @param attributes - The filtered attribute list passed into activate + * @param variation - The variation that was returned from activate. + * @param event - The impression event that was triggered. + */ + public abstract void onActivate(@javax.annotation.Nonnull Experiment experiment, + @javax.annotation.Nonnull String userId, + @javax.annotation.Nonnull Map attributes, + @javax.annotation.Nonnull Variation variation, + @javax.annotation.Nonnull LogEvent event) ; + +} + diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationBroadcaster.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationBroadcaster.java index bb77468df..3455a1bb1 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationBroadcaster.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationBroadcaster.java @@ -33,6 +33,7 @@ /** * Manages Optimizely SDK notification listeners and broadcasts messages. */ +@Deprecated public class NotificationBroadcaster { private static final Logger logger = LoggerFactory.getLogger(NotificationBroadcaster.class); diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java new file mode 100644 index 000000000..a538fe3fa --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationCenter.java @@ -0,0 +1,160 @@ +/** + * + * Copyright 2017, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + + +/** + * This class handles impression and conversion notificationsListeners. It replaces NotificationBroadcaster and is intended to be + * more flexible. + */ +public class NotificationCenter { + /** + * NotificationType is used for the notification types supported. + */ + public enum NotificationType { + + Activate(ActivateNotification.class), // Activate was called. Track an impression event + Track(TrackNotification.class); // Track was called. Track a conversion event + + private Class notificationTypeClass; + + NotificationType(Class notificationClass) { + this.notificationTypeClass = notificationClass; + } + + public Class getNotificationTypeClass() { + return notificationTypeClass; + } + }; + + + // the notification id is incremented and is assigned as the callback id, it can then be used to remove the notification. + private int notificationListenerID = 1; + + final private static Logger logger = LoggerFactory.getLogger(NotificationCenter.class); + + // notification holder holds the id as well as the notification. + private static class NotificationHolder + { + int notificationId; + NotificationListener notificationListener; + + NotificationHolder(int id, NotificationListener notificationListener) { + notificationId = id; + this.notificationListener = notificationListener; + } + } + + /** + * Instantiate a new NotificationCenter + */ + public NotificationCenter() { + notificationsListeners.put(NotificationType.Activate, new ArrayList()); + notificationsListeners.put(NotificationType.Track, new ArrayList()); + } + + // private list of notification by notification type. + // we used a list so that notification order can mean something. + private Map> notificationsListeners =new HashMap>(); + + + /** + * Add a notification listener to the notification center. + * + * @param notificationType - enum NotificationType to add. + * @param notificationListener - Notification to add. + * @return the notification id used to remove the notification. It is greater than 0 on success. + */ + public int addNotification(NotificationType notificationType, NotificationListener notificationListener) { + + Class clazz = notificationType.notificationTypeClass; + if (clazz == null || !clazz.isInstance(notificationListener)) { + logger.warn("Notification listener was the wrong type. It was not added to the notification center."); + return -1; + } + + for (NotificationHolder holder : notificationsListeners.get(notificationType)) { + if (holder.notificationListener == notificationListener) { + logger.warn("Notificication listener was already added"); + return -1; + } + } + int id = notificationListenerID++; + notificationsListeners.get(notificationType).add(new NotificationHolder(id, notificationListener )); + logger.info("Notification listener {} was added with id {}", notificationListener.toString(), id); + return id; + } + + /** + * Remove the notification listener based on the notificationId passed back from addNotification. + * @param notificationID the id passed back from add notification. + * @return true if removed otherwise false (if the notification is already registered, it returns false). + */ + public boolean removeNotification(int notificationID) { + for (NotificationType type : NotificationType.values()) { + for (NotificationHolder holder : notificationsListeners.get(type)) { + if (holder.notificationId == notificationID) { + notificationsListeners.get(type).remove(holder); + logger.info("Notification listener removed {}", notificationID); + return true; + } + } + } + + logger.warn("Notification listener with id {} not found", notificationID); + + return false; + } + + /** + * Clear out all the notification listeners. + */ + public void clearAllNotifications() { + for (NotificationType type : NotificationType.values()) { + clearNotifications(type); + } + } + + /** + * Clear notification listeners by notification type. + * @param notificationType type of notificationsListeners to remove. + */ + public void clearNotifications(NotificationType notificationType) { + notificationsListeners.get(notificationType).clear(); + } + + // fire a notificaiton of a certain type. The arg list changes depending on the type of notification sent. + public void sendNotifications(NotificationType notificationType, Object ...args) { + ArrayList holders = notificationsListeners.get(notificationType); + for (NotificationHolder holder : holders) { + try { + holder.notificationListener.notify(args); + } + catch (Exception e) { + logger.error("Unexpected exception calling notification listener {}", holder.notificationId, e); + } + } + } + +} diff --git a/core-api/src/main/java/com/optimizely/ab/notification/NotificationListener.java b/core-api/src/main/java/com/optimizely/ab/notification/NotificationListener.java index 2fe6e6bc1..012e250d7 100644 --- a/core-api/src/main/java/com/optimizely/ab/notification/NotificationListener.java +++ b/core-api/src/main/java/com/optimizely/ab/notification/NotificationListener.java @@ -45,6 +45,7 @@ public abstract class NotificationListener { * @param eventValue an integer to be aggregated for the event * @param logEvent the log event sent to the event dispatcher */ + @Deprecated public void onEventTracked(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map attributes, @@ -60,9 +61,18 @@ public void onEventTracked(@Nonnull String eventKey, * @param attributes a map of attributes about the user * @param variation the key of the variation that was bucketed */ + @Deprecated public void onExperimentActivated(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map attributes, @Nonnull Variation variation) { } + + /** + * This is the new method of notification. Implementation classes such as {@link com.optimizely.ab.notification.ActivateNotification} + * will implement this call and provide another method with the correct parameters + * Notify called when a notification is triggered via the {@link com.optimizely.ab.notification.NotificationCenter} + * @param args - variable argument list based on the type of notification. + */ + public abstract void notify(Object... args); } diff --git a/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java new file mode 100644 index 000000000..3ba9e3153 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/notification/TrackNotification.java @@ -0,0 +1,62 @@ +/** + * + * Copyright 2017, Optimizely and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.notification; + +import java.util.Map; +import javax.annotation.Nonnull; + +import com.optimizely.ab.event.LogEvent; + +/** + * This class handles the track event notification. + */ +public abstract class TrackNotification extends NotificationListener { + + /** + * Base notify called with var args. This method parses the parameters and calls the abstract method. + * @param args - variable argument list based on the type of notification. + */ + @Override + public final void notify(Object... args) { + assert(args[0] instanceof String); + String eventKey = (String) args[0]; + assert(args[1] instanceof String); + String userId = (String) args[1]; + assert(args[2] instanceof java.util.Map); + Map attributes = (Map) args[2]; + assert(args[3] instanceof java.util.Map); + Map eventTags = (Map) args[3]; + assert(args[4] instanceof LogEvent); + LogEvent logEvent = (LogEvent) args[4]; + + onTrack(eventKey, userId,attributes,eventTags, logEvent); + } + + /** + * onTrack is called when a track event is triggered + * @param eventKey - The event key that was triggered. + * @param userId - user id passed into track. + * @param attributes - filtered attributes list after passed into track + * @param eventTags - event tags if any were passed in. + * @param event - The event being recorded. + */ + public abstract void onTrack(@Nonnull String eventKey, + @Nonnull String userId, + @Nonnull Map attributes, + @Nonnull Map eventTags, + @Nonnull LogEvent event) ; +} diff --git a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java index d8720913d..f5f4c27cd 100644 --- a/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java +++ b/core-api/src/test/java/com/optimizely/ab/OptimizelyTest.java @@ -37,8 +37,11 @@ import com.optimizely.ab.event.internal.EventBuilder; import com.optimizely.ab.event.internal.EventBuilderV2; import com.optimizely.ab.internal.LogbackVerifier; +import com.optimizely.ab.notification.ActivateNotification; +import com.optimizely.ab.notification.NotificationCenter; import com.optimizely.ab.notification.NotificationListener; +import com.optimizely.ab.notification.TrackNotification; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -62,6 +65,8 @@ import ch.qos.logback.classic.Level; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import javax.annotation.Nonnull; + import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV2; import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigJsonV3; import static com.optimizely.ab.config.ProjectConfigTestUtils.noAudienceProjectConfigV2; @@ -518,8 +523,7 @@ public void activateWithExperimentKey() throws Exception { when(mockBucketer.bucket(activatedExperiment, testBucketingId)) .thenReturn(bucketedVariation); - - + // activate the experiment Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); @@ -2186,6 +2190,141 @@ public void getVariationExperimentStatusPrecedesForcedVariation() throws Excepti //======== Notification listeners ========// + /** + * Verify that the {@link Optimizely#activate(String, String, Map)} call + * correctly builds an endpoint url and request params + * and passes them through {@link EventHandler#dispatchEvent(LogEvent)}. + */ + @Test + public void activateWithListener() throws Exception { + final Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + final Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + final Map testUserAttributes = new HashMap(); + if (datafileVersion >= 4) { + testUserAttributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + testUserAttributes.put("browser_type", "chrome"); + } + + testUserAttributes.put(testBucketingIdKey, testBucketingId); + + ActivateNotification activateNotification = new ActivateNotification() { + @Override + public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { + assertEquals(experiment.getKey(), activatedExperiment.getKey()); + assertEquals(bucketedVariation.getKey(), variation.getKey()); + assertEquals(userId, testUserId); + for (Map.Entry entry : attributes.entrySet()) { + assertEquals(testUserAttributes.get(entry.getKey()), entry.getValue()); + } + + assertEquals(event.getRequestMethod(), RequestMethod.GET); + } + + }; + + int notificationId = optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Activate, activateNotification); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createImpressionEvent(eq(validProjectConfig), eq(activatedExperiment), eq(bucketedVariation), + eq(testUserId), eq(testUserAttributes))) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, testBucketingId)) + .thenReturn(bucketedVariation); + + + // activate the experiment + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, testUserAttributes); + + assertTrue(optimizely.notificationCenter.removeNotification(notificationId)); + // verify that the bucketing algorithm was called correctly + verify(mockBucketer).bucket(activatedExperiment, testBucketingId); + assertThat(actualVariation, is(bucketedVariation)); + + // verify that dispatchEvent was called with the correct LogEvent object + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + } + + @Test + @SuppressFBWarnings( + value="NP_NONNULL_PARAM_VIOLATION", + justification="testing nullness contract violation") + public void activateWithListenerNullAttributes() throws Exception { + final Experiment activatedExperiment = noAudienceProjectConfig.getExperiments().get(0); + final Variation bucketedVariation = activatedExperiment.getVariations().get(0); + + // setup a mock event builder to return expected impression params + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(noAudienceDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(noAudienceProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createImpressionEvent(eq(noAudienceProjectConfig), eq(activatedExperiment), eq(bucketedVariation), + eq(testUserId), eq(Collections.emptyMap()))) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, testUserId)) + .thenReturn(bucketedVariation); + + ActivateNotification activateNotification = new ActivateNotification() { + @Override + public void onActivate(@Nonnull Experiment experiment, @Nonnull String userId, @Nonnull Map attributes, @Nonnull Variation variation, @Nonnull LogEvent event) { + assertEquals(experiment.getKey(), activatedExperiment.getKey()); + assertEquals(bucketedVariation.getKey(), variation.getKey()); + assertEquals(userId, testUserId); + assertTrue(attributes.isEmpty()); + + assertEquals(event.getRequestMethod(), RequestMethod.GET); + } + + }; + + int notificationId = optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Activate, activateNotification); + + // activate the experiment + Map attributes = null; + Variation actualVariation = optimizely.activate(activatedExperiment.getKey(), testUserId, attributes); + + optimizely.notificationCenter.removeNotification(notificationId); + + logbackVerifier.expectMessage(Level.WARN, "Attributes is null when non-null was expected. Defaulting to an empty attributes map."); + + // verify that the bucketing algorithm was called correctly + verify(mockBucketer).bucket(activatedExperiment, testUserId); + assertThat(actualVariation, is(bucketedVariation)); + + // setup the attribute map captor (so we can verify its content) + ArgumentCaptor attributeCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockEventBuilder).createImpressionEvent(eq(noAudienceProjectConfig), eq(activatedExperiment), + eq(bucketedVariation), eq(testUserId), attributeCaptor.capture()); + + Map actualValue = attributeCaptor.getValue(); + assertThat(actualValue, is(Collections.emptyMap())); + + // verify that dispatchEvent was called with the correct LogEvent object + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + } + /** * Verify that {@link Optimizely#addNotificationListener(NotificationListener)} properly calls * through to {@link com.optimizely.ab.notification.NotificationBroadcaster} and the listener is @@ -2427,6 +2566,438 @@ public void clearNotificationListeners() throws Exception { .onEventTracked(eventKey, genericUserId, attributes, null, logEventToDispatch); } + /** + * Verify that {@link com.optimizely.ab.notification.NotificationCenter#addNotification( + * com.optimizely.ab.notification.NotificationCenter.NotificationType, + * com.optimizely.ab.notification.NotificationListener)} properly used + * and the listener is + * added and notified when an experiment is activated. + */ + @Test + public void addNotificationListenerFromNotificationCenter() throws Exception { + Experiment activatedExperiment; + EventType eventType; + if (datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_BASIC_EXPERIMENT_KEY); + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + eventType = validProjectConfig.getEventTypes().get(0); + } + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withDecisionService(mockDecisionService) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + Map attributes = Collections.emptyMap(); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createImpressionEvent(validProjectConfig, activatedExperiment, + bucketedVariation, genericUserId, attributes)) + .thenReturn(logEventToDispatch); + + when(mockDecisionService.getVariation( + eq(activatedExperiment), + eq(genericUserId), + eq(Collections.emptyMap()))) + .thenReturn(bucketedVariation); + + // Add listener + ActivateNotification listener = mock(ActivateNotification.class); + optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Activate, listener); + + // Check if listener is notified when experiment is activated + Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); + verify(listener, times(1)) + .onActivate(activatedExperiment, genericUserId, attributes, bucketedVariation, logEventToDispatch); + + assertEquals(actualVariation.getKey(), bucketedVariation.getKey()); + // Check if listener is notified after an event is tracked + String eventKey = eventType.getKey(); + + Map experimentVariationMap = createExperimentVariationMap( + validProjectConfig, + mockDecisionService, + eventType.getKey(), + genericUserId, + attributes); + when(mockEventBuilder.createConversionEvent( + eq(validProjectConfig), + eq(experimentVariationMap), + eq(genericUserId), + eq(eventType.getId()), + eq(eventKey), + eq(attributes), + anyMapOf(String.class, Object.class))) + .thenReturn(logEventToDispatch); + + TrackNotification trackNotification = mock(TrackNotification.class); + + optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Track, trackNotification); + + optimizely.track(eventKey, genericUserId, attributes); + verify(trackNotification, times(1)) + .onTrack(eventKey, genericUserId, attributes, Collections.EMPTY_MAP, logEventToDispatch); + } + + /** + * Verify that {@link com.optimizely.ab.notification.NotificationCenter} properly + * calls and the listener is removed and no longer notified when an experiment is activated. + */ + @Test + public void removeNotificationListenerNotificationCenter() throws Exception { + Experiment activatedExperiment = validProjectConfig.getExperiments().get(0); + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + Map attributes = new HashMap(); + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createImpressionEvent(validProjectConfig, activatedExperiment, + bucketedVariation, genericUserId, attributes)) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, genericUserId)) + .thenReturn(bucketedVariation); + + when(mockEventBuilder.createImpressionEvent(validProjectConfig, activatedExperiment, bucketedVariation, genericUserId, + attributes)) + .thenReturn(logEventToDispatch); + + // Add and remove listener + ActivateNotification activateNotification = mock(ActivateNotification.class); + int notificationId = optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Activate, activateNotification); + assertTrue(optimizely.notificationCenter.removeNotification(notificationId)); + + TrackNotification trackNotification = mock(TrackNotification.class); + notificationId = optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Track, trackNotification); + assertTrue(optimizely.notificationCenter.removeNotification(notificationId)); + + // Check if listener is notified after an experiment is activated + Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); + verify(activateNotification, never()) + .onActivate(activatedExperiment, genericUserId, attributes, actualVariation, logEventToDispatch); + + // Check if listener is notified after a live variable is accessed + boolean activateExperiment = true; + verify(activateNotification, never()) + .onActivate(activatedExperiment, genericUserId, attributes, actualVariation, logEventToDispatch); + + // Check if listener is notified after an event is tracked + EventType eventType = validProjectConfig.getEventTypes().get(0); + String eventKey = eventType.getKey(); + + Map experimentVariationMap = createExperimentVariationMap( + validProjectConfig, + mockDecisionService, + eventType.getKey(), + genericUserId, + attributes); + when(mockEventBuilder.createConversionEvent( + eq(validProjectConfig), + eq(experimentVariationMap), + eq(genericUserId), + eq(eventType.getId()), + eq(eventKey), + eq(attributes), + anyMapOf(String.class, Object.class))) + .thenReturn(logEventToDispatch); + + optimizely.track(eventKey, genericUserId, attributes); + verify(trackNotification, never()) + .onTrack(eventKey, genericUserId, attributes, Collections.EMPTY_MAP, logEventToDispatch); + } + + /** + * Verify that {@link com.optimizely.ab.notification.NotificationCenter} + * clearAllListerners removes all listeners + * and no longer notified when an experiment is activated. + */ + @Test + public void clearNotificationListenersNotificationCenter() throws Exception { + Experiment activatedExperiment; + Map attributes = new HashMap(); + if (datafileVersion >= 4) { + activatedExperiment = validProjectConfig.getExperimentKeyMapping().get(EXPERIMENT_MULTIVARIATE_EXPERIMENT_KEY); + attributes.put(ATTRIBUTE_HOUSE_KEY, AUDIENCE_GRYFFINDOR_VALUE); + } + else { + activatedExperiment = validProjectConfig.getExperiments().get(0); + attributes.put("browser_type", "chrome"); + } + Variation bucketedVariation = activatedExperiment.getVariations().get(0); + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createImpressionEvent(validProjectConfig, activatedExperiment, + bucketedVariation, genericUserId, attributes)) + .thenReturn(logEventToDispatch); + + when(mockBucketer.bucket(activatedExperiment, genericUserId)) + .thenReturn(bucketedVariation); + + // set up argument captor for the attributes map to compare map equality + ArgumentCaptor attributeCaptor = ArgumentCaptor.forClass(Map.class); + + when(mockEventBuilder.createImpressionEvent( + eq(validProjectConfig), + eq(activatedExperiment), + eq(bucketedVariation), + eq(genericUserId), + attributeCaptor.capture() + )).thenReturn(logEventToDispatch); + + ActivateNotification activateNotification = mock(ActivateNotification.class); + TrackNotification trackNotification = mock(TrackNotification.class); + + optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Activate, activateNotification); + optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Track, trackNotification); + + optimizely.notificationCenter.clearAllNotifications(); + + // Check if listener is notified after an experiment is activated + Variation actualVariation = optimizely.activate(activatedExperiment, genericUserId, attributes); + + // check that the argument that was captured by the mockEventBuilder attribute captor, + // was equal to the attributes passed in to activate + assertEquals(attributes, attributeCaptor.getValue()); + verify(activateNotification, never()) + .onActivate(activatedExperiment, genericUserId, attributes, actualVariation, logEventToDispatch); + + // Check if listener is notified after a live variable is accessed + boolean activateExperiment = true; + verify(activateNotification, never()) + .onActivate(activatedExperiment, genericUserId, attributes, actualVariation, logEventToDispatch); + + // Check if listener is notified after a event is tracked + EventType eventType = validProjectConfig.getEventTypes().get(0); + String eventKey = eventType.getKey(); + + Map experimentVariationMap = createExperimentVariationMap( + validProjectConfig, + mockDecisionService, + eventType.getKey(), + OptimizelyTest.genericUserId, + attributes); + when(mockEventBuilder.createConversionEvent( + eq(validProjectConfig), + eq(experimentVariationMap), + eq(OptimizelyTest.genericUserId), + eq(eventType.getId()), + eq(eventKey), + eq(attributes), + anyMapOf(String.class, Object.class))) + .thenReturn(logEventToDispatch); + + optimizely.track(eventKey, genericUserId, attributes); + verify(trackNotification, never()) + .onTrack(eventKey, genericUserId, attributes, Collections.EMPTY_MAP, logEventToDispatch); + } + + /** + * Add notificaiton listener for track {@link com.optimizely.ab.notification.NotificationCenter}. Verify called and + * remove. + * @throws Exception + */ + @Test + @SuppressWarnings("unchecked") + public void trackEventWithListenerAttributes() throws Exception { + final Attribute attribute = validProjectConfig.getAttributes().get(0); + final EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + } + + // setup a mock event builder to return expected conversion params + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + final Map attributes = ImmutableMap.of(attribute.getKey(), "attributeValue"); + Map experimentVariationMap = createExperimentVariationMap( + validProjectConfig, + mockDecisionService, + eventType.getKey(), + genericUserId, + attributes); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createConversionEvent( + eq(validProjectConfig), + eq(experimentVariationMap), + eq(genericUserId), + eq(eventType.getId()), + eq(eventType.getKey()), + anyMapOf(String.class, String.class), + eq(Collections.emptyMap()))) + .thenReturn(logEventToDispatch); + + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); + logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + + testParams + " and payload \"\""); + + TrackNotification trackNotification = new TrackNotification() { + @Override + public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map _attributes, @Nonnull Map eventTags, @Nonnull LogEvent event) { + assertEquals(eventType.getKey(), eventKey); + assertEquals(genericUserId, userId); + assertEquals(attributes, _attributes); + assertTrue(eventTags.isEmpty()); + } + }; + + int notificationId = optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Track, trackNotification); + + // call track + optimizely.track(eventType.getKey(), genericUserId, attributes); + + optimizely.notificationCenter.removeNotification(notificationId); + + // setup the attribute map captor (so we can verify its content) + ArgumentCaptor attributeCaptor = ArgumentCaptor.forClass(Map.class); + + // verify that the event builder was called with the expected attributes + verify(mockEventBuilder).createConversionEvent( + eq(validProjectConfig), + eq(experimentVariationMap), + eq(genericUserId), + eq(eventType.getId()), + eq(eventType.getKey()), + attributeCaptor.capture(), + eq(Collections.emptyMap())); + + Map actualValue = attributeCaptor.getValue(); + assertThat(actualValue, hasEntry(attribute.getKey(), "attributeValue")); + + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + } + + /** + * Track with listener and verify that {@link Optimizely#track(String, String)} ignores null attributes. + */ + @Test + @SuppressFBWarnings( + value="NP_NONNULL_PARAM_VIOLATION", + justification="testing nullness contract violation") + public void trackEventWithListenerNullAttributes() throws Exception { + final EventType eventType; + if (datafileVersion >= 4) { + eventType = validProjectConfig.getEventNameMapping().get(EVENT_BASIC_EVENT_KEY); + } + else { + eventType = validProjectConfig.getEventTypes().get(0); + } + + // setup a mock event builder to return expected conversion params + EventBuilder mockEventBuilder = mock(EventBuilder.class); + + Optimizely optimizely = Optimizely.builder(validDatafile, mockEventHandler) + .withBucketing(mockBucketer) + .withEventBuilder(mockEventBuilder) + .withConfig(validProjectConfig) + .withErrorHandler(mockErrorHandler) + .build(); + + Map testParams = new HashMap(); + testParams.put("test", "params"); + Map experimentVariationMap = createExperimentVariationMap( + validProjectConfig, + mockDecisionService, + eventType.getKey(), + genericUserId, + Collections.emptyMap()); + LogEvent logEventToDispatch = new LogEvent(RequestMethod.GET, "test_url", testParams, ""); + when(mockEventBuilder.createConversionEvent( + eq(validProjectConfig), + eq(experimentVariationMap), + eq(genericUserId), + eq(eventType.getId()), + eq(eventType.getKey()), + eq(Collections.emptyMap()), + eq(Collections.emptyMap()))) + .thenReturn(logEventToDispatch); + + logbackVerifier.expectMessage(Level.INFO, "Tracking event \"" + eventType.getKey() + + "\" for user \"" + genericUserId + "\"."); + logbackVerifier.expectMessage(Level.DEBUG, "Dispatching conversion event to URL test_url with params " + + testParams + " and payload \"\""); + + TrackNotification trackNotification = new TrackNotification() { + @Override + public void onTrack(@Nonnull String eventKey, @Nonnull String userId, @Nonnull Map attributes, @Nonnull Map eventTags, @Nonnull LogEvent event) { + assertEquals(eventType.getKey(), eventKey); + assertEquals(genericUserId, userId); + assertTrue(attributes.isEmpty()); + assertTrue(eventTags.isEmpty()); + } + }; + + int notificationId = optimizely.notificationCenter.addNotification(NotificationCenter.NotificationType.Track, trackNotification); + + // call track + Map attributes = null; + optimizely.track(eventType.getKey(), genericUserId, attributes); + + optimizely.notificationCenter.removeNotification(notificationId); + + logbackVerifier.expectMessage(Level.WARN, "Attributes is null when non-null was expected. Defaulting to an empty attributes map."); + + // setup the attribute map captor (so we can verify its content) + ArgumentCaptor attributeCaptor = ArgumentCaptor.forClass(Map.class); + + // verify that the event builder was called with the expected attributes + verify(mockEventBuilder).createConversionEvent( + eq(validProjectConfig), + eq(experimentVariationMap), + eq(genericUserId), + eq(eventType.getId()), + eq(eventType.getKey()), + attributeCaptor.capture(), + eq(Collections.emptyMap())); + + Map actualValue = attributeCaptor.getValue(); + assertThat(actualValue, is(Collections.emptyMap())); + + verify(mockEventHandler).dispatchEvent(logEventToDispatch); + } + //======== Feature Accessor Tests ========// /** diff --git a/core-api/src/test/java/com/optimizely/ab/notification/NotificationCenterTest.java b/core-api/src/test/java/com/optimizely/ab/notification/NotificationCenterTest.java new file mode 100644 index 000000000..e23e1e695 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/notification/NotificationCenterTest.java @@ -0,0 +1,45 @@ +package com.optimizely.ab.notification; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.LogbackVerifier; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.mockito.Mockito.mock; + +public class NotificationCenterTest { + private NotificationCenter notificationCenter; + private ActivateNotification activateNotification; + private TrackNotification trackNotification; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Before + public void initialize() { + notificationCenter = new NotificationCenter(); + activateNotification = mock(ActivateNotification.class); + trackNotification = mock(TrackNotification.class); + } + + @Test + public void testAddWrongTrackNotificationListener() { + int notificationId = notificationCenter.addNotification(NotificationCenter.NotificationType.Activate, trackNotification); + logbackVerifier.expectMessage(Level.WARN,"Notification listener was the wrong type. It was not added to the notification center."); + assertEquals(notificationId, -1); + assertFalse(notificationCenter.removeNotification(notificationId)); + } + + @Test + public void testAddWrongActivateNotificationListener() { + int notificationId = notificationCenter.addNotification(NotificationCenter.NotificationType.Track, activateNotification); + logbackVerifier.expectMessage(Level.WARN,"Notification listener was the wrong type. It was not added to the notification center."); + assertEquals(notificationId, -1); + assertFalse(notificationCenter.removeNotification(notificationId)); + } +}