diff --git a/build.gradle b/build.gradle index 8650f159b..1abeacb15 100644 --- a/build.gradle +++ b/build.gradle @@ -145,7 +145,7 @@ dependencies { return candidates.find { findProject(it) != null } } - ['main', 'logger', 'events', 'events-domain', 'api', 'http-api', 'http', 'fallback', 'backoff'].each { moduleName -> + ['main', 'logger', 'events', 'events-domain', 'api', 'http-api', 'http', 'fallback', 'backoff', 'tracker'].each { moduleName -> def resolvedPath = resolveProjectPath(moduleName) if (resolvedPath != null) { include project(resolvedPath) diff --git a/main/build.gradle b/main/build.gradle index c8477731f..a0325264f 100644 --- a/main/build.gradle +++ b/main/build.gradle @@ -54,7 +54,8 @@ dependencies { api clientModuleProject('api') api clientModuleProject('http-api') api clientModuleProject('fallback') - api clientModuleProject('backoff') + implementation clientModuleProject('backoff') + implementation clientModuleProject('tracker') // Internal module dependencies implementation clientModuleProject('http') implementation clientModuleProject('events-domain') diff --git a/main/src/main/java/io/split/android/client/EventsTracker.java b/main/src/main/java/io/split/android/client/EventsTracker.java deleted file mode 100644 index 800b8c0c2..000000000 --- a/main/src/main/java/io/split/android/client/EventsTracker.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.split.android.client; - -import java.util.Map; - -public interface EventsTracker { - void enableTracking(boolean enable); - boolean track(String key, String trafficType, String eventType, double value, Map properties, boolean isSdkReady); -} \ No newline at end of file diff --git a/main/src/main/java/io/split/android/client/EventsTrackerImpl.java b/main/src/main/java/io/split/android/client/EventsTrackerImpl.java deleted file mode 100644 index 0b8d18982..000000000 --- a/main/src/main/java/io/split/android/client/EventsTrackerImpl.java +++ /dev/null @@ -1,98 +0,0 @@ -package io.split.android.client; - -import static io.split.android.client.utils.Utils.checkNotNull; - -import androidx.annotation.NonNull; - -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; - -import io.split.android.client.dtos.Event; -import io.split.android.client.service.synchronizer.SyncManager; -import io.split.android.client.telemetry.model.Method; -import io.split.android.client.telemetry.storage.TelemetryStorageProducer; -import io.split.android.client.utils.logger.Logger; -import io.split.android.client.validators.EventValidator; -import io.split.android.client.validators.PropertyValidator; -import io.split.android.client.validators.ValidationErrorInfo; -import io.split.android.client.validators.ValidationMessageLogger; - -public class EventsTrackerImpl implements EventsTracker { - // Estimated event size without properties - private final static int ESTIMATED_EVENT_SIZE_WITHOUT_PROPS = 1024; - - private final EventValidator mEventValidator; - private final ValidationMessageLogger mValidationLogger; - private final TelemetryStorageProducer mTelemetryStorageProducer; - private final PropertyValidator mPropertyValidator; - private final SyncManager mSyncManager; - private final AtomicBoolean isTrackingEnabled = new AtomicBoolean(true); - - public EventsTrackerImpl(@NonNull EventValidator eventValidator, - @NonNull ValidationMessageLogger validationLogger, - @NonNull TelemetryStorageProducer telemetryStorageProducer, - @NonNull PropertyValidator eventPropertiesProcessor, - @NonNull SyncManager syncManager) { - - mEventValidator = checkNotNull(eventValidator); - mValidationLogger = checkNotNull(validationLogger); - mTelemetryStorageProducer = checkNotNull(telemetryStorageProducer); - mPropertyValidator = checkNotNull(eventPropertiesProcessor); - mSyncManager = checkNotNull(syncManager); - } - - public void enableTracking(boolean enable) { - isTrackingEnabled.set(enable); - } - - public boolean track(String key, String trafficType, String eventType, - double value, Map properties, boolean isSdkReady) { - - if (!isTrackingEnabled.get()) { - Logger.v("Event not tracked because tracking is disabled"); - return false; - } - - try { - final String validationTag = "track"; - - Event event = new Event(); - event.eventTypeId = eventType; - event.trafficTypeName = trafficType; - event.key = key; - event.value = value; - event.timestamp = System.currentTimeMillis(); - event.properties = properties; - - ValidationErrorInfo errorInfo = mEventValidator.validate(event, isSdkReady); - if (errorInfo != null) { - - if (errorInfo.isError()) { - mValidationLogger.e(errorInfo, validationTag); - return false; - } - mValidationLogger.w(errorInfo, validationTag); - event.trafficTypeName = event.trafficTypeName.toLowerCase(); - } - - PropertyValidator.Result processedProperties = - mPropertyValidator.validate(event.properties, validationTag); - if (!processedProperties.isValid()) { - return false; - } - - long startTime = System.currentTimeMillis(); - - event.properties = processedProperties.getProperties(); - event.setSizeInBytes(ESTIMATED_EVENT_SIZE_WITHOUT_PROPS + processedProperties.getSizeInBytes()); - mSyncManager.pushEvent(event); - - mTelemetryStorageProducer.recordLatency(Method.TRACK, System.currentTimeMillis() - startTime); - - return true; - } catch (Exception exception) { - mTelemetryStorageProducer.recordException(Method.TRACK); - } - return false; - } -} diff --git a/main/src/main/java/io/split/android/client/PropertyValidatorImpl.java b/main/src/main/java/io/split/android/client/PropertyValidatorImpl.java deleted file mode 100644 index 01cc06ef6..000000000 --- a/main/src/main/java/io/split/android/client/PropertyValidatorImpl.java +++ /dev/null @@ -1,63 +0,0 @@ -package io.split.android.client; - -import java.util.HashMap; -import java.util.Map; - -import io.split.android.client.utils.logger.Logger; -import io.split.android.client.validators.PropertyValidator; -import io.split.android.client.validators.ValidationConfig; - - -public class PropertyValidatorImpl implements PropertyValidator { - - private final static int MAX_PROPS_COUNT = 300; - private final static int MAXIMUM_EVENT_PROPERTY_BYTES = - ValidationConfig.getInstance().getMaximumEventPropertyBytes(); - - @Override - public Result validate(Map properties, String validationTag) { - if (properties == null) { - return Result.valid(null, 0); - } - - if (properties.size() > MAX_PROPS_COUNT) { - Logger.w(validationTag + "Event has more than " + MAX_PROPS_COUNT + - " properties. Some of them will be trimmed when processed"); - } - int sizeInBytes = 0; - Map finalProperties = new HashMap<>(properties); - - for (Map.Entry entry : properties.entrySet()) { - Object value = entry.getValue(); - String key = entry.getKey(); - - if (value != null && isInvalidValueType(value)) { - finalProperties.put(key, null); - } - sizeInBytes += calculateEventSizeInBytes(key, value); - - if (sizeInBytes > MAXIMUM_EVENT_PROPERTY_BYTES) { - Logger.w(validationTag + - "The maximum size allowed for the " + - " properties is 32kb. Current is " + key + - ". Event not queued"); - return Result.invalid("Event properties size is too large", sizeInBytes); - } - } - return Result.valid(finalProperties, sizeInBytes); - } - - private static boolean isInvalidValueType(Object value) { - return !(value instanceof Number) && - !(value instanceof Boolean) && - !(value instanceof String); - } - - private static int calculateEventSizeInBytes(String key, Object value) { - int valueSize = 0; - if(value != null && value.getClass() == String.class) { - valueSize = value.toString().getBytes().length; - } - return valueSize + key.getBytes().length; - } -} diff --git a/main/src/main/java/io/split/android/client/SplitClientImpl.java b/main/src/main/java/io/split/android/client/SplitClientImpl.java index 571efa169..c3795a416 100644 --- a/main/src/main/java/io/split/android/client/SplitClientImpl.java +++ b/main/src/main/java/io/split/android/client/SplitClientImpl.java @@ -18,6 +18,7 @@ import io.split.android.client.events.SplitEventsManager; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.shared.SplitClientContainer; +import io.split.android.client.tracker.Tracker; import io.split.android.client.utils.logger.Logger; import io.split.android.client.validators.SplitValidator; import io.split.android.client.validators.TreatmentManager; @@ -35,7 +36,7 @@ public final class SplitClientImpl implements SplitClient { private final TreatmentManager mTreatmentManager; private final ValidationMessageLogger mValidationLogger; private final AttributesManager mAttributesManager; - private final EventsTracker mEventsTracker; + private final Tracker mEventsTracker; private static final double TRACK_DEFAULT_VALUE = 0.0; @@ -48,7 +49,7 @@ public SplitClientImpl(SplitFactory container, ImpressionListener impressionListener, SplitClientConfig config, SplitEventsManager eventsManager, - EventsTracker eventsTracker, + Tracker eventsTracker, AttributesManager attributesManager, SplitValidator splitValidator, TreatmentManager treatmentManager) { diff --git a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java index 3cd4f501b..e37792172 100644 --- a/main/src/main/java/io/split/android/client/SplitFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/SplitFactoryImpl.java @@ -70,15 +70,21 @@ import io.split.android.client.storage.general.GeneralInfoStorage; import io.split.android.client.storage.splits.SplitsStorage; import io.split.android.client.telemetry.TelemetrySynchronizer; +import io.split.android.client.dtos.Event; +import io.split.android.client.telemetry.model.Method; import io.split.android.client.telemetry.storage.TelemetryStorage; +import io.split.android.client.tracker.DefaultTracker; +import io.split.android.client.tracker.Tracker; +import io.split.android.client.tracker.TrackerEvent; import io.split.android.client.utils.logger.Logger; import io.split.android.client.validators.ApiKeyValidator; import io.split.android.client.validators.ApiKeyValidatorImpl; -import io.split.android.client.validators.EventValidator; import io.split.android.client.validators.EventValidatorImpl; import io.split.android.client.validators.KeyValidator; import io.split.android.client.validators.KeyValidatorImpl; +import io.split.android.client.validators.PropertyValidatorImpl; import io.split.android.client.validators.SplitValidatorImpl; +import io.split.android.client.validators.TrafficTypeValidatorImpl; import io.split.android.client.validators.ValidationConfig; import io.split.android.client.validators.ValidationErrorInfo; import io.split.android.client.validators.ValidationMessageLogger; @@ -545,7 +551,7 @@ public static class EventsTrackerProvider { private final SplitsStorage mSplitsStorage; private final TelemetryStorage mTelemetryStorage; private final SyncManager mSyncManager; - private volatile EventsTracker mEventsTracker; + private volatile Tracker mEventsTracker; public EventsTrackerProvider(SplitsStorage splitsStorage, TelemetryStorage telemetryStorage, SyncManager syncManager) { mSplitsStorage = splitsStorage; @@ -553,13 +559,32 @@ public EventsTrackerProvider(SplitsStorage splitsStorage, TelemetryStorage telem mSyncManager = syncManager; } - public EventsTracker getEventsTracker() { + public Tracker getEventsTracker() { if (mEventsTracker == null) { synchronized (this) { if (mEventsTracker == null) { - EventValidator eventsValidator = new EventValidatorImpl(new KeyValidatorImpl(), mSplitsStorage); - mEventsTracker = new EventsTrackerImpl(eventsValidator, new ValidationMessageLoggerImpl(), mTelemetryStorage, - new PropertyValidatorImpl(), mSyncManager); + mEventsTracker = new DefaultTracker( + new EventValidatorImpl( + new KeyValidatorImpl(), + new TrafficTypeValidatorImpl(mSplitsStorage) + ), + new ValidationMessageLoggerImpl(), + new PropertyValidatorImpl( + new ValidationMessageLoggerImpl() + ), + trackerEvent -> { + Event event = new Event(); + event.eventTypeId = trackerEvent.eventType; + event.trafficTypeName = trackerEvent.trafficType; + event.key = trackerEvent.key; + event.value = trackerEvent.value; + event.timestamp = trackerEvent.timestamp; + event.properties = trackerEvent.properties; + event.setSizeInBytes(trackerEvent.sizeInBytes); + mSyncManager.pushEvent(event); + }, + latencyMs -> mTelemetryStorage.recordLatency(Method.TRACK, latencyMs), + () -> mTelemetryStorage.recordException(Method.TRACK)); } } } diff --git a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java index 1b5e58499..5fb309e76 100644 --- a/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java +++ b/main/src/main/java/io/split/android/client/localhost/LocalhostSplitClient.java @@ -16,7 +16,8 @@ import io.split.android.client.EvaluationOptions; import io.split.android.client.EvaluatorImpl; import io.split.android.client.FlagSetsFilter; -import io.split.android.client.PropertyValidatorImpl; +import io.split.android.client.validators.PropertyValidatorImpl; +import io.split.android.client.validators.PropertyValidatorAdapter; import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; import io.split.android.client.SplitFactory; @@ -87,7 +88,9 @@ public LocalhostSplitClient(@NonNull LocalhostSplitFactory container, new SplitValidatorImpl(), getImpressionsListener(splitClientConfig), splitClientConfig.labelsEnabled(), eventsManager, attributesManager, attributesMerger, telemetryStorageProducer, flagSetsFilter, splitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), - new PropertyValidatorImpl(), mFallbackTreatmentsCalculator); + new PropertyValidatorAdapter( + new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), + mFallbackTreatmentsCalculator); } @Override diff --git a/main/src/main/java/io/split/android/client/localhost/LocalhostTrafficTypeValidator.java b/main/src/main/java/io/split/android/client/localhost/LocalhostTrafficTypeValidator.java new file mode 100644 index 000000000..2b4ed2010 --- /dev/null +++ b/main/src/main/java/io/split/android/client/localhost/LocalhostTrafficTypeValidator.java @@ -0,0 +1,18 @@ +package io.split.android.client.localhost; + +import io.split.android.client.tracker.TrafficTypeValidator; + +/** + * Traffic type validator for localhost mode. + *

+ * In localhost mode, all traffic types are considered valid since we're not + * connected to the Split backend and can't validate against real feature flags. + */ +public class LocalhostTrafficTypeValidator implements TrafficTypeValidator { + + @Override + public boolean isValid(String trafficTypeName) { + // In localhost mode, accept all traffic types + return true; + } +} diff --git a/main/src/main/java/io/split/android/client/validators/EventValidator.java b/main/src/main/java/io/split/android/client/validators/EventValidator.java deleted file mode 100644 index a1bd81220..000000000 --- a/main/src/main/java/io/split/android/client/validators/EventValidator.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.split.android.client.validators; - -import io.split.android.client.dtos.Event; - -/** - * Interface to implement by Track Events validators - */ -public interface EventValidator { - - /** - * Checks that a Track event is valid - * @param event: Event instance - * @return true when the key is valid, false when it is not - */ - ValidationErrorInfo validate(Event event, boolean validateTrafficType); - -} diff --git a/main/src/main/java/io/split/android/client/validators/EventValidatorImpl.java b/main/src/main/java/io/split/android/client/validators/EventValidatorImpl.java deleted file mode 100644 index a189a3a02..000000000 --- a/main/src/main/java/io/split/android/client/validators/EventValidatorImpl.java +++ /dev/null @@ -1,72 +0,0 @@ -package io.split.android.client.validators; - -import io.split.android.client.dtos.Event; -import io.split.android.client.storage.splits.SplitsStorage; -import io.split.android.client.utils.Utils; - -/** - * Contains func an instance of Event class. - */ -public class EventValidatorImpl implements EventValidator { - - private final String TYPE_REGEX = ValidationConfig.getInstance().getTrackEventNamePattern(); - private KeyValidator mKeyValidator; - private final SplitsStorage mSplitsStorage; - - public EventValidatorImpl(KeyValidator keyValidator, SplitsStorage splitsStorage) { - mKeyValidator = keyValidator; - mSplitsStorage = splitsStorage; - } - - @Override - public ValidationErrorInfo validate(Event event, boolean validateTrafficType) { - - if(event == null) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "Event could not be null"); - } - - ValidationErrorInfo errorInfo = mKeyValidator.validate(event.key, null); - if(errorInfo != null){ - return errorInfo; - } - - if (event.trafficTypeName == null) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed a null or undefined traffic_type_name, traffic_type_name must be a non-empty string"); - } - - if (Utils.isNullOrEmpty(event.trafficTypeName.trim())) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed an empty traffic_type_name, traffic_type_name must be a non-empty string"); - } - - if (event.eventTypeId == null) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed a null or undefined event_type, event_type must be a non-empty String"); - } - - if (Utils.isNullOrEmpty(event.eventTypeId.trim())) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed an empty event_type, event_type must be a non-empty String"); - } - - if (!event.eventTypeId.matches(TYPE_REGEX)) { - return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed " + event.eventTypeId - + ", event name must adhere to the regular expression " + TYPE_REGEX - + ". This means an event name must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, " - + " underscore, period, or colon as separators of alphanumeric characters."); - } - - if(!event.trafficTypeName.toLowerCase().equals(event.trafficTypeName)) { - errorInfo = new ValidationErrorInfo(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_HAS_UPPERCASE_CHARS, "traffic_type_name should be all lowercase - converting string to lowercase", true); - } - - if (validateTrafficType && !mSplitsStorage.isValidTrafficType(event.trafficTypeName)) { - String message = "Traffic Type " + event.trafficTypeName + " does not have any corresponding feature flags in this environment, " - + "make sure you’re tracking your events to a valid traffic type defined in the Split user interface"; - if(errorInfo == null) { - errorInfo = new ValidationErrorInfo(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_WITHOUT_SPLIT_IN_ENVIRONMENT, message, true); - } else { - errorInfo.addWarning(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_WITHOUT_SPLIT_IN_ENVIRONMENT, message); - } - } - - return errorInfo; - } -} diff --git a/main/src/main/java/io/split/android/client/validators/PropertyValidatorAdapter.java b/main/src/main/java/io/split/android/client/validators/PropertyValidatorAdapter.java new file mode 100644 index 000000000..4406cd4ee --- /dev/null +++ b/main/src/main/java/io/split/android/client/validators/PropertyValidatorAdapter.java @@ -0,0 +1,32 @@ +package io.split.android.client.validators; + +import java.util.Map; + +import io.split.android.client.tracker.TrackerLogger; +import io.split.android.client.tracker.TrackerPropertyValidator; + +/** + * Adapter that bridges the main module's PropertyValidator interface with + * the tracker module's TrackerPropertyValidator implementation. + */ +public class PropertyValidatorAdapter implements PropertyValidator { + + private final TrackerPropertyValidator mDelegate; + + public PropertyValidatorAdapter(TrackerPropertyValidator delegate) { + mDelegate = delegate; + } + + @Override + public Result validate(Map properties, String validationTag) { + // Call the tracker validator with initialSizeInBytes=0 since we're not tracking + TrackerPropertyValidator.TrackerPropertyResult trackerResult = + mDelegate.validate(properties, 0, validationTag); + + if (trackerResult.isValid()) { + return Result.valid(trackerResult.getProperties(), trackerResult.getSizeInBytes()); + } else { + return Result.invalid(trackerResult.getErrorMessage(), trackerResult.getSizeInBytes()); + } + } +} diff --git a/main/src/main/java/io/split/android/client/validators/TrafficTypeValidatorImpl.java b/main/src/main/java/io/split/android/client/validators/TrafficTypeValidatorImpl.java new file mode 100644 index 000000000..a46d998b8 --- /dev/null +++ b/main/src/main/java/io/split/android/client/validators/TrafficTypeValidatorImpl.java @@ -0,0 +1,23 @@ +package io.split.android.client.validators; + +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.client.tracker.TrafficTypeValidator; + +/** + * Implementation of {@link TrafficTypeValidator} that delegates to {@link SplitsStorage}. + *

+ * This implementation validates traffic type names by checking if they exist in the + * Split storage. It bridges the tracker module's abstraction with the SDK's storage layer. + */ +public class TrafficTypeValidatorImpl implements TrafficTypeValidator { + private final SplitsStorage mSplitsStorage; + + public TrafficTypeValidatorImpl(SplitsStorage splitsStorage) { + mSplitsStorage = splitsStorage; + } + + @Override + public boolean isValid(String trafficTypeName) { + return mSplitsStorage.isValidTrafficType(trafficTypeName); + } +} diff --git a/main/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java b/main/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java index 287fb94b4..28d54a578 100644 --- a/main/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java +++ b/main/src/main/java/io/split/android/client/validators/TreatmentManagerFactoryImpl.java @@ -8,7 +8,7 @@ import io.split.android.client.Evaluator; import io.split.android.client.EvaluatorImpl; import io.split.android.client.FlagSetsFilter; -import io.split.android.client.PropertyValidatorImpl; +import io.split.android.client.validators.PropertyValidatorImpl; import io.split.android.client.api.Key; import io.split.android.client.attributes.AttributesManager; import io.split.android.client.attributes.AttributesMerger; @@ -65,7 +65,8 @@ public TreatmentManagerFactoryImpl(@NonNull KeyValidator keyValidator, mSplitsStorage = checkNotNull(splitsStorage); mValidationMessageLogger = new ValidationMessageLoggerImpl(); mFlagSetsValidator = new FlagSetsValidatorImpl(); - mPropertyValidator = new PropertyValidatorImpl(); + mPropertyValidator = new PropertyValidatorAdapter( + new PropertyValidatorImpl(new ValidationMessageLoggerImpl())); } @Override diff --git a/main/src/main/java/io/split/android/client/validators/ValidationMessageLoggerImpl.java b/main/src/main/java/io/split/android/client/validators/ValidationMessageLoggerImpl.java index c6678b276..a56866a28 100644 --- a/main/src/main/java/io/split/android/client/validators/ValidationMessageLoggerImpl.java +++ b/main/src/main/java/io/split/android/client/validators/ValidationMessageLoggerImpl.java @@ -3,12 +3,14 @@ import java.util.ArrayList; import java.util.List; +import io.split.android.client.tracker.TrackerLogger; +import io.split.android.client.tracker.TrackerValidationError; import io.split.android.client.utils.logger.Logger; /** * Default implementation of ValidationMessageLogger interface */ -public class ValidationMessageLoggerImpl implements ValidationMessageLogger { +public class ValidationMessageLoggerImpl implements ValidationMessageLogger, TrackerLogger { @Override public void log(ValidationErrorInfo errorInfo, String tag) { @@ -52,4 +54,22 @@ private String sanitizeTag(String tag) { return (tag != null ? tag : ""); } + // TrackerLogger implementation + + @Override + public void log(TrackerValidationError errorInfo, String tag) { + if (errorInfo.isError()) { + logError(errorInfo.getMessage(), tag); + } else { + for (String warning : errorInfo.getWarnings()) { + logWarning(warning, tag); + } + } + } + + @Override + public void v(String message) { + Logger.v(message); + } + } diff --git a/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java b/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java index 88cd686ee..b89a4f6e4 100644 --- a/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientImplBaseTest.java @@ -14,6 +14,7 @@ import io.split.android.client.storage.mysegments.MySegmentsStorageContainer; import io.split.android.client.storage.rbs.RuleBasedSegmentStorage; import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.client.tracker.Tracker; import io.split.android.client.validators.SplitValidator; import io.split.android.client.validators.TreatmentManager; import io.split.android.engine.experiments.ParserCommons; @@ -41,7 +42,7 @@ public abstract class SplitClientImplBaseTest { @Mock protected SplitsStorage splitsStorage; @Mock - protected EventsTracker eventsTracker; + protected Tracker eventsTracker; @Mock protected SyncManager syncManager; @Mock diff --git a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java index 16d40a060..539982fc0 100644 --- a/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java +++ b/main/src/test/java/io/split/android/client/SplitClientImplEventRegistrationTest.java @@ -22,6 +22,7 @@ import io.split.android.client.events.SplitEventsManager; import io.split.android.client.impressions.ImpressionListener; import io.split.android.client.shared.SplitClientContainer; +import io.split.android.client.tracker.Tracker; import io.split.android.client.utils.logger.Logger; import io.split.android.client.validators.SplitValidator; import io.split.android.client.validators.TreatmentManager; @@ -38,7 +39,7 @@ public class SplitClientImplEventRegistrationTest { @Mock private ImpressionListener impressionListener; @Mock - private EventsTracker eventsTracker; + private Tracker eventsTracker; @Mock private AttributesManager attributesManager; @Mock diff --git a/main/src/test/java/io/split/android/client/SplitFactoryImplEventsTrackerProviderTest.java b/main/src/test/java/io/split/android/client/SplitFactoryImplEventsTrackerProviderTest.java new file mode 100644 index 000000000..456580004 --- /dev/null +++ b/main/src/test/java/io/split/android/client/SplitFactoryImplEventsTrackerProviderTest.java @@ -0,0 +1,141 @@ +package io.split.android.client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.dtos.Event; +import io.split.android.client.service.synchronizer.SyncManager; +import io.split.android.client.storage.splits.SplitsStorage; +import io.split.android.client.telemetry.model.Method; +import io.split.android.client.telemetry.storage.TelemetryStorage; +import io.split.android.client.tracker.Tracker; + +public class SplitFactoryImplEventsTrackerProviderTest { + + private SplitsStorage mSplitsStorage; + private TelemetryStorage mTelemetryStorage; + private SyncManager mSyncManager; + private SplitFactoryImpl.EventsTrackerProvider mProvider; + + @Before + public void setUp() { + mSplitsStorage = mock(SplitsStorage.class); + mTelemetryStorage = mock(TelemetryStorage.class); + mSyncManager = mock(SyncManager.class); + mProvider = new SplitFactoryImpl.EventsTrackerProvider( + mSplitsStorage, + mTelemetryStorage, + mSyncManager); + + // Set up default behavior for traffic type validation + when(mSplitsStorage.isValidTrafficType(anyString())).thenReturn(true); + } + + @Test + public void getEventsTrackerReturnsNonNullTracker() { + Tracker tracker = mProvider.getEventsTracker(); + + assertNotNull(tracker); + } + + @Test + public void getEventsTrackerReturnsSameInstanceOnSubsequentCalls() { + Tracker tracker1 = mProvider.getEventsTracker(); + Tracker tracker2 = mProvider.getEventsTracker(); + + assertSame(tracker1, tracker2); + } + + @Test + public void trackerCallbackInvokesSyncManagerPushEvent() { + Tracker tracker = mProvider.getEventsTracker(); + + Map properties = new HashMap<>(); + properties.put("key1", "value1"); + boolean result = tracker.track("user-key", "user", "purchase", 10.5, properties, true); + + assertTrue(result); + verify(mSyncManager).pushEvent(any(Event.class)); + } + + @Test + public void trackerCallbackCreatesEventWithCorrectFields() { + Tracker tracker = mProvider.getEventsTracker(); + + Map properties = new HashMap<>(); + properties.put("product", "widget"); + properties.put("quantity", 3); + + long beforeTrack = System.currentTimeMillis(); + tracker.track("test-key", "account", "conversion", 25.99, properties, true); + long afterTrack = System.currentTimeMillis(); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class); + verify(mSyncManager).pushEvent(eventCaptor.capture()); + + Event capturedEvent = eventCaptor.getValue(); + assertNotNull(capturedEvent); + assertEquals("conversion", capturedEvent.eventTypeId); + assertEquals("account", capturedEvent.trafficTypeName); + assertEquals("test-key", capturedEvent.key); + assertEquals(25.99, capturedEvent.value, 0.0001); + assertTrue(capturedEvent.timestamp >= beforeTrack && capturedEvent.timestamp <= afterTrack); + assertNotNull(capturedEvent.properties); + assertEquals("widget", capturedEvent.properties.get("product")); + assertEquals(3, capturedEvent.properties.get("quantity")); + assertTrue(capturedEvent.getSizeInBytes() > 0); + } + + @Test + public void trackerCallbackRecordsLatencyInTelemetry() { + Tracker tracker = mProvider.getEventsTracker(); + + tracker.track("key", "user", "event", 1.0, null, true); + + ArgumentCaptor latencyCaptor = ArgumentCaptor.forClass(Long.class); + verify(mTelemetryStorage).recordLatency(any(Method.class), latencyCaptor.capture()); + + Long latency = latencyCaptor.getValue(); + assertNotNull(latency); + assertTrue(latency >= 0); + } + + @Test + public void trackerCallbackRecordsExceptionInTelemetry() { + // Create a SyncManager that throws when pushEvent is called + SyncManager throwingSyncManager = mock(SyncManager.class); + doThrow(new RuntimeException("Push failed")) + .when(throwingSyncManager).pushEvent(any(Event.class)); + + SplitFactoryImpl.EventsTrackerProvider provider = new SplitFactoryImpl.EventsTrackerProvider( + mSplitsStorage, + mTelemetryStorage, + throwingSyncManager); + when(mSplitsStorage.isValidTrafficType(anyString())).thenReturn(true); + + Tracker tracker = provider.getEventsTracker(); + + boolean result = tracker.track("key", "user", "event", 1.0, null, true); + + // Track should return false due to exception + assertEquals(false, result); + verify(mTelemetryStorage).recordException(Method.TRACK); + } +} diff --git a/main/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java b/main/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java index 4f8432e18..3d06f2f89 100644 --- a/main/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java +++ b/main/src/test/java/io/split/android/client/TreatmentManagerExceptionsTest.java @@ -1,5 +1,7 @@ package io.split.android.client; +import io.split.android.client.validators.PropertyValidatorAdapter; +import io.split.android.client.validators.PropertyValidatorImpl; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; @@ -85,7 +87,7 @@ public void setUp() { mSplitsStorage, new ValidationMessageLoggerImpl(), mFlagSetsValidator, - new PropertyValidatorImpl(), + new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); when(evaluator.getTreatment(anyString(), anyString(), anyString(), anyMap())).thenReturn(new EvaluationResult("test", "label")); diff --git a/main/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java b/main/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java index 222de7750..c7a3e0ec6 100644 --- a/main/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java +++ b/main/src/test/java/io/split/android/client/TreatmentManagerTelemetryTest.java @@ -1,5 +1,7 @@ package io.split.android.client; +import io.split.android.client.validators.PropertyValidatorAdapter; +import io.split.android.client.validators.PropertyValidatorImpl; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; @@ -78,7 +80,7 @@ public void setUp() { mFlagSetsFilter, mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), - new PropertyValidatorImpl(), + new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); when(evaluator.getTreatment(anyString(), anyString(), anyString(), anyMap())).thenReturn(new EvaluationResult("test", "label")); diff --git a/main/src/test/java/io/split/android/client/TreatmentManagerTest.java b/main/src/test/java/io/split/android/client/TreatmentManagerTest.java index ce889d69d..6a2dd7988 100644 --- a/main/src/test/java/io/split/android/client/TreatmentManagerTest.java +++ b/main/src/test/java/io/split/android/client/TreatmentManagerTest.java @@ -1,5 +1,7 @@ package io.split.android.client; +import io.split.android.client.validators.PropertyValidatorAdapter; +import io.split.android.client.validators.PropertyValidatorImpl; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.argThat; @@ -372,7 +374,7 @@ private TreatmentManager createTreatmentManager(String matchingKey, String bucke new KeyValidatorImpl(), splitValidator, mock(ImpressionListener.FederatedImpressionListener.class), config.labelsEnabled(), eventsManager, mock(AttributesManager.class), mock(AttributesMerger.class), - mock(TelemetryStorageProducer.class), mFlagSetsFilter, mSplitsStorage, validationLogger, new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + mock(TelemetryStorageProducer.class), mFlagSetsFilter, mSplitsStorage, validationLogger, new FlagSetsValidatorImpl(), new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } @@ -403,7 +405,7 @@ private TreatmentManagerImpl initializeTreatmentManager(Evaluator evaluator) { telemetryStorageProducer, mFlagSetsFilter, mSplitsStorage, - new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } diff --git a/main/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java b/main/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java index aa12c3d5e..8d51f2263 100644 --- a/main/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java +++ b/main/src/test/java/io/split/android/client/TreatmentManagerWithFlagSetsTest.java @@ -1,5 +1,7 @@ package io.split.android.client; +import io.split.android.client.validators.PropertyValidatorAdapter; +import io.split.android.client.validators.PropertyValidatorImpl; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -157,7 +159,7 @@ private void initializeTreatmentManager() { mAttributesMerger, mTelemetryStorageProducer, mFlagSetsFilter, - mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorImpl(), + mSplitsStorage, new ValidationMessageLoggerImpl(), new FlagSetsValidatorImpl(), new PropertyValidatorAdapter(new PropertyValidatorImpl(new ValidationMessageLoggerImpl())), new FallbackTreatmentsCalculatorImpl(FallbackTreatmentsConfiguration.builder().build())); } diff --git a/main/src/test/java/io/split/android/client/UserConsentManagerTest.java b/main/src/test/java/io/split/android/client/UserConsentManagerTest.java index 8dc3a2194..0d133342f 100644 --- a/main/src/test/java/io/split/android/client/UserConsentManagerTest.java +++ b/main/src/test/java/io/split/android/client/UserConsentManagerTest.java @@ -17,6 +17,7 @@ import io.split.android.client.shared.UserConsent; import io.split.android.client.storage.events.EventsStorage; import io.split.android.client.storage.impressions.ImpressionsStorage; +import io.split.android.client.tracker.Tracker; import io.split.android.fake.SplitTaskExecutorStub; public class UserConsentManagerTest { @@ -30,7 +31,7 @@ public class UserConsentManagerTest { @Mock private SyncManager mSyncManager; @Mock - private EventsTracker mEventsTracker; + private Tracker mEventsTracker; @Mock private SplitFactoryImpl.EventsTrackerProvider mEventsTrackerProvider; @Mock diff --git a/main/src/test/java/io/split/android/client/localhost/LocalhostTrafficTypeValidatorTest.java b/main/src/test/java/io/split/android/client/localhost/LocalhostTrafficTypeValidatorTest.java new file mode 100644 index 000000000..6a0b04777 --- /dev/null +++ b/main/src/test/java/io/split/android/client/localhost/LocalhostTrafficTypeValidatorTest.java @@ -0,0 +1,43 @@ +package io.split.android.client.localhost; + +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +public class LocalhostTrafficTypeValidatorTest { + + private LocalhostTrafficTypeValidator mValidator; + + @Before + public void setUp() { + mValidator = new LocalhostTrafficTypeValidator(); + } + + @Test + public void isValidReturnsTrueForAnyTrafficType() { + assertTrue(mValidator.isValid("user")); + assertTrue(mValidator.isValid("account")); + assertTrue(mValidator.isValid("random_traffic_type")); + } + + @Test + public void isValidReturnsTrueForNull() { + assertTrue(mValidator.isValid(null)); + } + + @Test + public void isValidReturnsTrueForEmptyString() { + assertTrue(mValidator.isValid("")); + } + + @Test + public void isValidReturnsTrueForWhitespace() { + assertTrue(mValidator.isValid(" ")); + } + + @Test + public void isValidReturnsTrueForSpecialCharacters() { + assertTrue(mValidator.isValid("!@#$%^&*()")); + } +} diff --git a/main/src/test/java/io/split/android/client/service/events/EventsTrackerTest.java b/main/src/test/java/io/split/android/client/service/events/EventsTrackerTest.java deleted file mode 100644 index bf0b601e6..000000000 --- a/main/src/test/java/io/split/android/client/service/events/EventsTrackerTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package io.split.android.client.service.events; - -import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import io.split.android.client.EventsTracker; -import io.split.android.client.EventsTrackerImpl; -import io.split.android.client.ProcessedEventProperties; -import io.split.android.client.events.SplitEventsManager; -import io.split.android.client.service.synchronizer.SyncManager; -import io.split.android.client.telemetry.model.Method; -import io.split.android.client.telemetry.storage.TelemetryStorageProducer; -import io.split.android.client.validators.EventValidator; -import io.split.android.client.validators.PropertyValidator; -import io.split.android.client.validators.ValidationMessageLogger; - -public class EventsTrackerTest { - @Mock - private SplitEventsManager mEventsManager; - @Mock - private EventValidator mEventValidator; - @Mock - private ValidationMessageLogger mValidationLogger; - @Mock - private TelemetryStorageProducer mTelemetryStorageProducer; - @Mock - private PropertyValidator mPropertyValidator; - @Mock - private SyncManager mSyncManager; - - private EventsTracker mEventsTracker; - - @Before - public void setup() { - MockitoAnnotations.openMocks(this); - when(mEventValidator.validate(any(), anyBoolean())).thenReturn(null); - when(mEventsManager.eventAlreadyTriggered(any())).thenReturn(true); - when(mPropertyValidator.validate(any(), any())).thenReturn(PropertyValidator.Result.valid(null, 0)); - - mEventsTracker = new EventsTrackerImpl(mEventValidator, mValidationLogger, mTelemetryStorageProducer, - mPropertyValidator, mSyncManager); - } - - @Test - public void testTrackEnabled() throws InterruptedException { - trackingEnabledTest(true); - } - - @Test - public void testTrackDisabled() throws InterruptedException { - trackingEnabledTest(false); - } - - private void trackingEnabledTest(boolean enabled) throws InterruptedException { - mEventsTracker.enableTracking(enabled); - boolean res = mEventsTracker.track("pepe", "tt", null, 1.0, null, true); - Thread.sleep(500); - assertEquals(enabled, res); - if (enabled) { - verify(mSyncManager, times(1)).pushEvent(any()); - verify(mTelemetryStorageProducer, times(1)).recordLatency(Method.TRACK, 0L); - } else { - verify(mSyncManager, never()).pushEvent(any()); - verify(mTelemetryStorageProducer, never()).recordLatency(Method.TRACK, 0L); - } - } - - @Test - public void trackRecordsLatencyInEvaluationProducer() { - ProcessedEventProperties processedEventProperties = mock(ProcessedEventProperties.class); - when(processedEventProperties.isValid()).thenReturn(true); - mEventsTracker.track("any", "tt", "ev", 1, null, true); - - verify(mTelemetryStorageProducer).recordLatency(eq(Method.TRACK), anyLong()); - } - - @Test - public void trackRecordsExceptionInCaseThereIsOne() { - when(mPropertyValidator.validate(any(), any())).thenAnswer(invocation -> { - throw new Exception("test exception"); - }); - - mEventsTracker.track("event", "tt", "ev", 0, null, true); - - verify(mTelemetryStorageProducer).recordException(Method.TRACK); - } -} diff --git a/main/src/test/java/io/split/android/client/shared/SplitClientContainerImplTest.java b/main/src/test/java/io/split/android/client/shared/SplitClientContainerImplTest.java index 7ee312106..db0f905c3 100644 --- a/main/src/test/java/io/split/android/client/shared/SplitClientContainerImplTest.java +++ b/main/src/test/java/io/split/android/client/shared/SplitClientContainerImplTest.java @@ -28,7 +28,7 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; -import io.split.android.client.EventsTracker; +import io.split.android.client.tracker.Tracker; import io.split.android.client.SplitClient; import io.split.android.client.SplitClientConfig; import io.split.android.client.SplitClientFactory; @@ -68,7 +68,7 @@ public class SplitClientContainerImplTest { private MySegmentsWorkManagerWrapper mWorkManagerWrapper; @Mock - private EventsTracker mEventsTracker; + private Tracker mEventsTracker; private final String mDefaultMatchingKey = "matching_key"; private SplitClientContainer mClientContainer; diff --git a/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java b/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java index 50fec3e8f..e2857760b 100644 --- a/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java +++ b/main/src/test/java/io/split/android/client/utils/SplitClientImplFactory.java @@ -6,7 +6,7 @@ import java.util.Collections; -import io.split.android.client.EventsTracker; +import io.split.android.client.tracker.Tracker; import io.split.android.client.FlagSetsFilterImpl; import io.split.android.client.SplitClientConfig; import io.split.android.client.SplitClientImpl; @@ -53,7 +53,7 @@ false, new AttributesMergerImpl(), telemetryStorage, splitParser, new ImpressionListener.NoopImpressionListener(), cfg, eventsManager, - mock(EventsTracker.class), + mock(Tracker.class), attributesManager, mock(SplitValidator.class), treatmentManagerFactory.getTreatmentManager(key, eventsManager, attributesManager) @@ -74,7 +74,7 @@ public static SplitClientImpl get(Key key, ImpressionListener impressionListener impressionListener, cfg, new SplitEventsManager(new SplitTaskExecutorStub(), cfg.blockUntilReady()), - mock(EventsTracker.class), + mock(Tracker.class), mock(AttributesManager.class), mock(SplitValidator.class), mock(TreatmentManager.class) @@ -91,7 +91,7 @@ public static SplitClientImpl get(Key key, SplitEventsManager eventsManager) { new ImpressionListener.NoopImpressionListener(), SplitClientConfig.builder().build(), eventsManager, - mock(EventsTracker.class), + mock(Tracker.class), mock(AttributesManager.class), mock(SplitValidator.class), mock(TreatmentManager.class) diff --git a/main/src/test/java/io/split/android/client/validators/EventValidatorTest.java b/main/src/test/java/io/split/android/client/validators/EventValidatorTest.java deleted file mode 100644 index 7f1e033da..000000000 --- a/main/src/test/java/io/split/android/client/validators/EventValidatorTest.java +++ /dev/null @@ -1,305 +0,0 @@ -package io.split.android.client.validators; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -import io.split.android.client.dtos.Event; -import io.split.android.client.dtos.Split; -import io.split.android.client.dtos.Status; -import io.split.android.client.storage.splits.SplitsStorage; -import io.split.android.client.utils.Utils; - -public class EventValidatorTest { - - private EventValidator validator; - - @Before - public void setUp() { - - SplitsStorage splitsStorage = mock(SplitsStorage.class); - - when(splitsStorage.isValidTrafficType("traffic1")).thenReturn(true); - when(splitsStorage.isValidTrafficType("trafficType1")).thenReturn(true); - when(splitsStorage.isValidTrafficType("custom")).thenReturn(true); - - validator = new EventValidatorImpl(new KeyValidatorImpl(), splitsStorage); - } - - @Test - public void testValidEventAllValues() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = "pepe"; - event.value = 1.0; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNull(errorInfo); - } - - @Test - public void testValidEventNullValue() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = "pepe"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNull(errorInfo); - } - - @Test - public void testNullKey() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = null; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed a null key, matching key must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testEmptyKey() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = ""; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty string, matching key must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testAllSpacesInKey() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = " "; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty string, matching key must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testLongKey() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "traffic1"; - event.key = Utils.repeat("p", 300); - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("matching key too long - must be " + ValidationConfig.getInstance().getMaximumKeyLength() + " characters or less", errorInfo.getErrorMessage()); - } - - @Test - public void testNullType() { - Event event = new Event(); - event.eventTypeId = null; - event.trafficTypeName = "traffic1"; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed a null or undefined event_type, event_type must be a non-empty String", errorInfo.getErrorMessage()); - } - - @Test - public void testEmptyType() { - Event event = new Event(); - event.eventTypeId = ""; - event.trafficTypeName = "traffic1"; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty event_type, event_type must be a non-empty String", errorInfo.getErrorMessage()); - } - - @Test - public void testAllSpacesInType() { - Event event = new Event(); - event.eventTypeId = " "; - event.trafficTypeName = "traffic1"; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty event_type, event_type must be a non-empty String", errorInfo.getErrorMessage()); - } - - @Test - public void testTypeName() { - - EventTypeNameHelper nameHelper = new EventTypeNameHelper(); - Event event1 = newEventTypeName(); - Event event2 = newEventTypeName(); - Event event3 = newEventTypeName(); - Event event4 = newEventTypeName(); - Event event5 = newEventTypeName(); - - event1.eventTypeId = nameHelper.getValidAllValidChars(); - event2.eventTypeId = nameHelper.getValidStartNumber(); - event3.eventTypeId = nameHelper.getInvalidChars(); - event4.eventTypeId = nameHelper.getInvalidUndercoreStart(); - event5.eventTypeId = nameHelper.getInvalidHypenStart(); - - ValidationErrorInfo errorInfo1 = validator.validate(event1, true); - ValidationErrorInfo errorInfo2 = validator.validate(event2, true); - ValidationErrorInfo errorInfo3 = validator.validate(event3, true); - ValidationErrorInfo errorInfo4 = validator.validate(event4, true); - ValidationErrorInfo errorInfo5 = validator.validate(event5, true); - - Assert.assertNull(errorInfo1); - - Assert.assertNull(errorInfo2); - - Assert.assertNotNull(errorInfo3); - Assert.assertTrue(errorInfo3.isError()); - Assert.assertEquals(buildEventTypeValidationMessage(event3.eventTypeId), errorInfo3.getErrorMessage()); - - Assert.assertNotNull(errorInfo4); - Assert.assertTrue(errorInfo4.isError()); - Assert.assertEquals(buildEventTypeValidationMessage(event4.eventTypeId), errorInfo4.getErrorMessage()); - - Assert.assertNotNull(errorInfo5); - Assert.assertTrue(errorInfo5.isError()); - Assert.assertEquals(buildEventTypeValidationMessage(event5.eventTypeId), errorInfo5.getErrorMessage()); - } - - @Test - public void testNullTrafficType() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = null; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed a null or undefined traffic_type_name, traffic_type_name must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testEmptyTrafficType() { - - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = ""; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty traffic_type_name, traffic_type_name must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testAllSpacesInTrafficType() { - - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = " "; - event.key = "key1"; - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertTrue(errorInfo.isError()); - Assert.assertEquals("you passed an empty traffic_type_name, traffic_type_name must be a non-empty string", errorInfo.getErrorMessage()); - } - - @Test - public void testUppercaseCharsInTrafficType() { - - Event event0 = newEventUppercase(); - Event event1 = newEventUppercase(); - Event event2 = newEventUppercase(); - Event event3 = newEventUppercase(); - - final String uppercaseMessage = "traffic_type_name should be all lowercase - converting string to lowercase"; - - event0.trafficTypeName = "custom"; - event1.trafficTypeName = "Custom"; - event2.trafficTypeName = "cUSTom"; - event3.trafficTypeName = "custoM"; - - ValidationErrorInfo errorInfo0 = validator.validate(event0, true); - ValidationErrorInfo errorInfo1 = validator.validate(event1, true); - ValidationErrorInfo errorInfo2 = validator.validate(event2, true); - ValidationErrorInfo errorInfo3 = validator.validate(event3, true); - - - Assert.assertNull(errorInfo0); - - Assert.assertNotNull(errorInfo1); - Assert.assertFalse(errorInfo1.isError()); - Assert.assertEquals(uppercaseMessage, errorInfo1.getWarnings().get(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_HAS_UPPERCASE_CHARS)); - - Assert.assertNotNull(errorInfo2); - Assert.assertFalse(errorInfo2.isError()); - Assert.assertEquals(uppercaseMessage, errorInfo2.getWarnings().get(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_HAS_UPPERCASE_CHARS)); - - Assert.assertNotNull(errorInfo3); - Assert.assertFalse(errorInfo3.isError()); - Assert.assertEquals(uppercaseMessage, errorInfo3.getWarnings().get(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_HAS_UPPERCASE_CHARS)); - } - - @Test - public void noChachedServerTrafficType() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.trafficTypeName = "nocached"; - event.key = "key1"; - - ValidationErrorInfo errorInfo = validator.validate(event, true); - - Assert.assertNotNull(errorInfo); - Assert.assertFalse(errorInfo.isError()); - Assert.assertEquals("Traffic Type nocached does not have any corresponding feature flags in this environment, " - + "make sure you’re tracking your events to a valid traffic type defined in the Split user interface", errorInfo.getWarnings().get(ValidationErrorInfo.WARNING_TRAFFIC_TYPE_WITHOUT_SPLIT_IN_ENVIRONMENT)); - } - - private Event newEventTypeName() { - Event event = new Event(); - event.trafficTypeName = "traffic1"; - event.key = "key1"; - return event; - } - - private Event newEventUppercase() { - Event event = new Event(); - event.eventTypeId = "type1"; - event.key = "key1"; - return event; - } - - private String buildEventTypeValidationMessage(String eventType) { - return "you passed " + eventType - + ", event name must adhere to the regular expression " + ValidationConfig.getInstance().getTrackEventNamePattern() - + ". This means an event name must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, " - + " underscore, period, or colon as separators of alphanumeric characters."; - } - - private Split newSplit(String name, String trafficType) { - Split split = new Split(); - split.name = name; - split.trafficTypeName = trafficType; - split.status = Status.ACTIVE; - return split; - } -} diff --git a/main/src/test/java/io/split/android/client/validators/PropertyValidatorAdapterTest.java b/main/src/test/java/io/split/android/client/validators/PropertyValidatorAdapterTest.java new file mode 100644 index 000000000..3c5bc6c4c --- /dev/null +++ b/main/src/test/java/io/split/android/client/validators/PropertyValidatorAdapterTest.java @@ -0,0 +1,83 @@ +package io.split.android.client.validators; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.tracker.TrackerPropertyValidator; + +public class PropertyValidatorAdapterTest { + + @Mock + private TrackerPropertyValidator mDelegate; + + private PropertyValidatorAdapter mAdapter; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + mAdapter = new PropertyValidatorAdapter(mDelegate); + } + + @Test + public void validateDelegatesToTrackerValidatorAndReturnsValidResult() { + Map properties = new HashMap<>(); + properties.put("key1", "value1"); + + TrackerPropertyValidator.TrackerPropertyResult delegateResult = + TrackerPropertyValidator.TrackerPropertyResult.valid(properties, 100); + when(mDelegate.validate(eq(properties), eq(0), eq("test-tag"))) + .thenReturn(delegateResult); + + PropertyValidator.Result result = mAdapter.validate(properties, "test-tag"); + + assertTrue(result.isValid()); + assertEquals(properties, result.getProperties()); + assertEquals(100, result.getSizeInBytes()); + assertNull(result.getErrorMessage()); + verify(mDelegate).validate(eq(properties), eq(0), eq("test-tag")); + } + + @Test + public void validateDelegatesToTrackerValidatorAndReturnsInvalidResult() { + Map properties = new HashMap<>(); + + TrackerPropertyValidator.TrackerPropertyResult delegateResult = + TrackerPropertyValidator.TrackerPropertyResult.invalid("Properties are too large", 50); + when(mDelegate.validate(eq(properties), eq(0), eq("test-tag"))) + .thenReturn(delegateResult); + + PropertyValidator.Result result = mAdapter.validate(properties, "test-tag"); + + assertFalse(result.isValid()); + assertNull(result.getProperties()); + assertEquals(50, result.getSizeInBytes()); + assertEquals("Properties are too large", result.getErrorMessage()); + verify(mDelegate).validate(eq(properties), eq(0), eq("test-tag")); + } + + @Test + public void validatePassesZeroAsInitialSizeInBytes() { + Map properties = new HashMap<>(); + TrackerPropertyValidator.TrackerPropertyResult delegateResult = + TrackerPropertyValidator.TrackerPropertyResult.valid(properties, 0); + when(mDelegate.validate(eq(properties), eq(0), eq("tag"))) + .thenReturn(delegateResult); + + mAdapter.validate(properties, "tag"); + + verify(mDelegate).validate(eq(properties), eq(0), eq("tag")); + } +} diff --git a/main/src/test/java/io/split/android/client/validators/TrafficTypeValidatorImplTest.java b/main/src/test/java/io/split/android/client/validators/TrafficTypeValidatorImplTest.java new file mode 100644 index 000000000..cc0d7e071 --- /dev/null +++ b/main/src/test/java/io/split/android/client/validators/TrafficTypeValidatorImplTest.java @@ -0,0 +1,47 @@ +package io.split.android.client.validators; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import io.split.android.client.storage.splits.SplitsStorage; + +public class TrafficTypeValidatorImplTest { + + @Mock + private SplitsStorage mSplitsStorage; + + private TrafficTypeValidatorImpl mValidator; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + mValidator = new TrafficTypeValidatorImpl(mSplitsStorage); + } + + @Test + public void isValidDelegatesToSplitsStorage() { + when(mSplitsStorage.isValidTrafficType("user")).thenReturn(true); + + boolean result = mValidator.isValid("user"); + + assertTrue(result); + verify(mSplitsStorage).isValidTrafficType("user"); + } + + @Test + public void isValidReturnsFalseWhenStorageReturnsFalse() { + when(mSplitsStorage.isValidTrafficType("unknown")).thenReturn(false); + + boolean result = mValidator.isValid("unknown"); + + assertFalse(result); + verify(mSplitsStorage).isValidTrafficType("unknown"); + } +} diff --git a/main/src/test/java/io/split/android/client/validators/ValidationMessageLoggerImplTest.java b/main/src/test/java/io/split/android/client/validators/ValidationMessageLoggerImplTest.java new file mode 100644 index 000000000..cd2c47670 --- /dev/null +++ b/main/src/test/java/io/split/android/client/validators/ValidationMessageLoggerImplTest.java @@ -0,0 +1,170 @@ +package io.split.android.client.validators; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.tracker.TrackerValidationError; +import io.split.android.client.utils.logger.Logger; + +public class ValidationMessageLoggerImplTest { + + private ValidationMessageLoggerImpl mLogger; + + @Before + public void setUp() { + mLogger = new ValidationMessageLoggerImpl(); + } + + @Test + public void logErrorInfoWithErrorMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(200, "error message"); + + mLogger.log(errorInfo, "test-tag"); + + // Due to parameter swap in e() method, actual output is "error message: test-tag" + loggerMock.verify(() -> Logger.e(eq("error message: test-tag"))); + } + } + + @Test + public void logErrorInfoWithWarnings() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(100, "warning 1", true); + errorInfo.addWarning(101, "warning 2"); + + mLogger.log(errorInfo, "test-tag"); + + // Due to parameter swap in w() method, actual output is "warning X: test-tag" + loggerMock.verify(() -> Logger.w(eq("warning 1: test-tag"))); + loggerMock.verify(() -> Logger.w(eq("warning 2: test-tag"))); + } + } + + @Test + public void logErrorInfoWithNullErrorMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(100, "warning message", true); + + mLogger.log(errorInfo, "test-tag"); + + loggerMock.verify(() -> Logger.w(eq("warning message: test-tag"))); + loggerMock.verify(() -> Logger.e(anyString()), never()); + } + } + + @Test + public void logErrorWithValidationErrorInfo() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(200, "error message"); + + mLogger.e(errorInfo, "test-tag"); + + loggerMock.verify(() -> Logger.e(eq("error message: test-tag"))); + } + } + + @Test + public void logWarningWithValidationErrorInfo() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + ValidationErrorInfo errorInfo = new ValidationErrorInfo(100, "first warning", true); + errorInfo.addWarning(101, "second warning"); + + mLogger.w(errorInfo, "test-tag"); + + loggerMock.verify(() -> Logger.w(eq("first warning: test-tag"))); + loggerMock.verify(() -> Logger.w(eq("second warning: test-tag"))); + } + } + + @Test + public void logErrorWithStringMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + // Note: parameter order is (message, tag) in signature, but used as (tag, message) in implementation + mLogger.e("test-tag", "error message"); + + loggerMock.verify(() -> Logger.e(eq("error message: test-tag"))); + } + } + + @Test + public void logWarningWithStringMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + // Note: parameter order is (message, tag) in signature, but used as (tag, message) in implementation + mLogger.w("test-tag", "warning message"); + + loggerMock.verify(() -> Logger.w(eq("warning message: test-tag"))); + } + } + + @Test + public void sanitizeTagWithNullTag() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + mLogger.e((String) null, "error message"); + + loggerMock.verify(() -> Logger.e(eq("error message: null"))); + } + } + + // TrackerLogger implementation tests + + @Test + public void trackerLoggerLogWithError() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + TrackerValidationError errorInfo = new TrackerValidationError(true, "tracker error"); + + mLogger.log(errorInfo, "tracker-tag"); + + loggerMock.verify(() -> Logger.e(eq("tracker-tag: tracker error"))); + } + } + + @Test + public void trackerLoggerLogWithWarnings() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + TrackerValidationError errorInfo = new TrackerValidationError( + Arrays.asList("warning 1", "warning 2", "warning 3")); + + mLogger.log(errorInfo, "tracker-tag"); + + loggerMock.verify(() -> Logger.w(eq("tracker-tag: warning 1"))); + loggerMock.verify(() -> Logger.w(eq("tracker-tag: warning 2"))); + loggerMock.verify(() -> Logger.w(eq("tracker-tag: warning 3"))); + loggerMock.verify(() -> Logger.e(anyString()), never()); + } + } + + @Test + public void trackerLoggerLogWithEmptyWarnings() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + TrackerValidationError errorInfo = new TrackerValidationError(Collections.emptyList()); + + mLogger.log(errorInfo, "tracker-tag"); + + loggerMock.verify(() -> Logger.w(anyString()), never()); + loggerMock.verify(() -> Logger.e(anyString()), never()); + } + } + + @Test + public void trackerLoggerVerboseMessage() { + try (MockedStatic loggerMock = Mockito.mockStatic(Logger.class)) { + mLogger.v("verbose message"); + + loggerMock.verify(() -> Logger.v(eq("verbose message"))); + } + } +} diff --git a/settings.gradle b/settings.gradle index f8bc17da1..4b3af1a51 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,3 +9,4 @@ include ':main' include ':events' include ':events-domain' include ':backoff' +include ':tracker' diff --git a/tracker/.gitignore b/tracker/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/tracker/.gitignore @@ -0,0 +1 @@ +/build diff --git a/tracker/README.md b/tracker/README.md new file mode 100644 index 000000000..b6cdd9b1d --- /dev/null +++ b/tracker/README.md @@ -0,0 +1,38 @@ +# tracker + +Self-contained event-tracking module. + +## Purpose + +Encapsulates the logic for validating and dispatching track events. Dependencies are injected via callbacks. + +## Public API + +| Class / Interface | Role | +|---|---| +| `Tracker` | Primary interface. `enableTracking(boolean)` / `track(...)` | +| `DefaultTracker` | Default implementation | +| `TrackerEvent` | Domain object representing a validated event (no serialization concerns) | +| `TrackerEventValidator` | Validates key, traffic type, event type, value | +| `TrackerPropertyValidator` | Validates event properties; returns `TrackerPropertyResult` | +| `TrackerLogger` | Logging abstraction (`log`, `e`, `v`) | +| `TrackerValidationError` | Simple error/warning result (`isError`, `getMessage`) | + +## Wiring (in `main/`) + +`DefaultTracker` is wired in `SplitFactoryImpl.EventsTrackerProvider`: + +```java +new DefaultTracker( + new EventValidatorImpl(keyValidator, splitsStorage), // implements TrackerEventValidator + new ValidationMessageLoggerImpl(), // implements TrackerLogger + new PropertyValidatorImpl(), // implements TrackerPropertyValidator + trackerEvent -> { + // convert TrackerEvent → Event DTO, then push + mSyncManager.pushEvent(toEvent(trackerEvent)); + }, + latencyMs -> mTelemetryStorage.recordLatency(Method.TRACK, latencyMs) +); +``` + +The `onTrackLatency` callback is optional (pass `null` to skip telemetry). diff --git a/tracker/build.gradle b/tracker/build.gradle new file mode 100644 index 000000000..0ba030834 --- /dev/null +++ b/tracker/build.gradle @@ -0,0 +1,20 @@ +plugins { + id 'com.android.library' +} + +apply from: "$projectDir/../gradle/common-android-library.gradle" + +android { + namespace 'io.split.android.client.tracker' + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +dependencies { + implementation libs.annotation + testImplementation libs.junit4 + testImplementation libs.mockitoCore +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/DefaultTracker.java b/tracker/src/main/java/io/split/android/client/tracker/DefaultTracker.java new file mode 100644 index 000000000..8e06a1e26 --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/DefaultTracker.java @@ -0,0 +1,110 @@ +package io.split.android.client.tracker; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +public class DefaultTracker implements Tracker { + + // Estimated event size in bytes without properties + private static final int ESTIMATED_EVENT_SIZE_WITHOUT_PROPS = 1024; + + /** Callback invoked with the validated event when tracking succeeds. */ + public interface OnEventPush { + void accept(TrackerEvent event); + } + + /** Callback invoked with the track latency in milliseconds. May be null to skip telemetry. */ + public interface OnTrackLatency { + void accept(long latencyMs); + } + + /** Callback invoked when an exception occurs during tracking. May be null to skip telemetry. */ + public interface OnTrackException { + void accept(); + } + + @NonNull private final TrackerEventValidator mEventValidator; + @NonNull private final TrackerLogger mTrackerLogger; + @NonNull private final TrackerPropertyValidator mPropertyValidator; + @NonNull private final OnEventPush mOnEventPush; + @Nullable private final OnTrackLatency mOnTrackLatency; + @Nullable private final OnTrackException mOnTrackException; + private final AtomicBoolean isTrackingEnabled = new AtomicBoolean(true); + + public DefaultTracker(@NonNull TrackerEventValidator eventValidator, + @NonNull TrackerLogger trackerLogger, + @NonNull TrackerPropertyValidator propertyValidator, + @NonNull OnEventPush onEventPush, + @Nullable OnTrackLatency onTrackLatency, + @Nullable OnTrackException onTrackException) { + mEventValidator = Objects.requireNonNull(eventValidator, "eventValidator must not be null"); + mTrackerLogger = Objects.requireNonNull(trackerLogger, "trackerLogger must not be null"); + mPropertyValidator = Objects.requireNonNull(propertyValidator, "propertyValidator must not be null"); + mOnEventPush = Objects.requireNonNull(onEventPush, "onEventPush must not be null"); + mOnTrackLatency = onTrackLatency; + mOnTrackException = onTrackException; + } + + @Override + public void enableTracking(boolean enable) { + isTrackingEnabled.set(enable); + } + + @Override + public boolean track(String key, String trafficType, String eventType, + double value, Map properties, boolean isSdkReady) { + if (!isTrackingEnabled.get()) { + mTrackerLogger.v("Event not tracked because tracking is disabled"); + return false; + } + + try { + final String validationTag = "track"; + + TrackerValidationError errorInfo = mEventValidator.validate( + key, trafficType, eventType, value, properties, isSdkReady); + if (errorInfo != null) { + if (errorInfo.isError()) { + mTrackerLogger.e(errorInfo.getMessage(), validationTag); + return false; + } + mTrackerLogger.log(errorInfo, validationTag); + trafficType = trafficType.toLowerCase(); + } + + TrackerPropertyValidator.TrackerPropertyResult processedProperties = + mPropertyValidator.validate(properties, ESTIMATED_EVENT_SIZE_WITHOUT_PROPS, validationTag); + if (!processedProperties.isValid()) { + return false; + } + + long startTime = System.currentTimeMillis(); + + TrackerEvent event = new TrackerEvent(); + event.eventType = eventType; + event.trafficType = trafficType; + event.key = key; + event.value = value; + event.timestamp = System.currentTimeMillis(); + event.properties = processedProperties.getProperties(); + event.sizeInBytes = processedProperties.getSizeInBytes(); + mOnEventPush.accept(event); + + if (mOnTrackLatency != null) { + mOnTrackLatency.accept(System.currentTimeMillis() - startTime); + } + + return true; + } catch (Exception exception) { + mTrackerLogger.e("Exception while tracking event: " + exception.getMessage(), "track"); + if (mOnTrackException != null) { + mOnTrackException.accept(); + } + } + return false; + } +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/Tracker.java b/tracker/src/main/java/io/split/android/client/tracker/Tracker.java new file mode 100644 index 000000000..aa2d2401f --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/Tracker.java @@ -0,0 +1,10 @@ +package io.split.android.client.tracker; + +import java.util.Map; + +public interface Tracker { + void enableTracking(boolean enable); + + boolean track(String key, String trafficType, String eventType, double value, + Map properties, boolean isSdkReady); +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerEvent.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerEvent.java new file mode 100644 index 000000000..dcdade61a --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerEvent.java @@ -0,0 +1,17 @@ +package io.split.android.client.tracker; + +import java.util.Map; + +/** + * Domain object representing a track event inside the tracker module. + * This is intentionally separate from the networking DTO (Event) used in main/. + */ +public class TrackerEvent { + public String trafficType; + public String eventType; + public String key; + public double value; + public long timestamp; + public Map properties; + public int sizeInBytes; +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerEventValidator.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerEventValidator.java new file mode 100644 index 000000000..a9d6285e3 --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerEventValidator.java @@ -0,0 +1,12 @@ +package io.split.android.client.tracker; + +import java.util.Map; + +/** + * Validates event parameters before tracking. + * Returns null if valid, or a {@link TrackerValidationError} with error/warning info. + */ +public interface TrackerEventValidator { + TrackerValidationError validate(String key, String trafficTypeName, String eventTypeId, + Double value, Map properties, boolean isSdkReady); +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerLogger.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerLogger.java new file mode 100644 index 000000000..bc8a46873 --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerLogger.java @@ -0,0 +1,15 @@ +package io.split.android.client.tracker; + +/** + * Logging abstraction for the tracker module. + */ +public interface TrackerLogger { + /** Log a validation result (error or warning) with a tag. */ + void log(TrackerValidationError errorInfo, String tag); + + /** Log an error message with a tag. */ + void e(String message, String tag); + + /** Log a verbose message. */ + void v(String message); +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerPropertyValidator.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerPropertyValidator.java new file mode 100644 index 000000000..2246109da --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerPropertyValidator.java @@ -0,0 +1,61 @@ +package io.split.android.client.tracker; + +import java.util.Map; + +/** + * Validates and processes event properties. + */ +public interface TrackerPropertyValidator { + + /** + * Validates event properties. + * + * @param properties raw properties map (may be null) + * @param initialSizeInBytes base event size in bytes (before properties), added to computed + * property size to produce the total in {@link TrackerPropertyResult#getSizeInBytes()} + * @param validationTag tag used for log messages + * @return validation result containing processed properties and total size + */ + TrackerPropertyResult validate(Map properties, int initialSizeInBytes, + String validationTag); + + class TrackerPropertyResult { + private final boolean mIsValid; + private final Map mProperties; + private final int mSizeInBytes; + private final String mErrorMessage; + + private TrackerPropertyResult(boolean isValid, Map properties, + int sizeInBytes, String errorMessage) { + mIsValid = isValid; + mProperties = properties; + mSizeInBytes = sizeInBytes; + mErrorMessage = errorMessage; + } + + public static TrackerPropertyResult valid(Map properties, int sizeInBytes) { + return new TrackerPropertyResult(true, properties, sizeInBytes, null); + } + + public static TrackerPropertyResult invalid(String errorMessage, int sizeInBytes) { + return new TrackerPropertyResult(false, null, sizeInBytes, errorMessage); + } + + public boolean isValid() { + return mIsValid; + } + + public Map getProperties() { + return mProperties; + } + + /** Total event size in bytes (initial base size + properties size). */ + public int getSizeInBytes() { + return mSizeInBytes; + } + + public String getErrorMessage() { + return mErrorMessage; + } + } +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrackerValidationError.java b/tracker/src/main/java/io/split/android/client/tracker/TrackerValidationError.java new file mode 100644 index 000000000..099a0516f --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrackerValidationError.java @@ -0,0 +1,37 @@ +package io.split.android.client.tracker; + +import java.util.Collections; +import java.util.List; + +/** + * Simple error/warning result from tracker validation. + */ +public class TrackerValidationError { + private final boolean mIsError; + private final String mMessage; + private final List mWarnings; + + public TrackerValidationError(boolean isError, String message) { + mIsError = isError; + mMessage = message; + mWarnings = Collections.emptyList(); + } + + public TrackerValidationError(List warnings) { + mIsError = false; + mMessage = null; + mWarnings = (warnings != null) ? warnings : Collections.emptyList(); + } + + public boolean isError() { + return mIsError; + } + + public String getMessage() { + return mMessage; + } + + public List getWarnings() { + return mWarnings; + } +} diff --git a/tracker/src/main/java/io/split/android/client/tracker/TrafficTypeValidator.java b/tracker/src/main/java/io/split/android/client/tracker/TrafficTypeValidator.java new file mode 100644 index 000000000..d1278947e --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/tracker/TrafficTypeValidator.java @@ -0,0 +1,14 @@ +package io.split.android.client.tracker; + +/** + * Interface for validating traffic type names. + */ +public interface TrafficTypeValidator { + /** + * Checks if the given traffic type name is valid. + * + * @param trafficTypeName the traffic type name to validate + * @return true if the traffic type is valid, false otherwise + */ + boolean isValid(String trafficTypeName); +} diff --git a/tracker/src/main/java/io/split/android/client/validators/EventValidatorImpl.java b/tracker/src/main/java/io/split/android/client/validators/EventValidatorImpl.java new file mode 100644 index 000000000..7c477e3ba --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/validators/EventValidatorImpl.java @@ -0,0 +1,73 @@ +package io.split.android.client.validators; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import io.split.android.client.tracker.TrafficTypeValidator; +import io.split.android.client.tracker.TrackerEventValidator; +import io.split.android.client.tracker.TrackerValidationError; + +/** + * Event validator implementation for the tracker module. + */ +public class EventValidatorImpl implements TrackerEventValidator { + + private final String TYPE_REGEX = ValidationConfig.getInstance().getTrackEventNamePattern(); + private final KeyValidator mKeyValidator; + private final TrafficTypeValidator mTrafficTypeValidator; + + public EventValidatorImpl(KeyValidator keyValidator, TrafficTypeValidator trafficTypeValidator) { + mKeyValidator = keyValidator; + mTrafficTypeValidator = trafficTypeValidator; + } + + @Override + public TrackerValidationError validate(String key, String trafficTypeName, String eventTypeId, + Double value, Map properties, boolean isSdkReady) { + ValidationErrorInfo errorInfo = mKeyValidator.validate(key, null); + if(errorInfo != null){ + return new TrackerValidationError(true, errorInfo.getErrorMessage()); + } + + if (trafficTypeName == null) { + return new TrackerValidationError(true, "you passed a null or undefined traffic_type_name, traffic_type_name must be a non-empty string"); + } + + if (ValidationUtils.isNullOrEmpty(trafficTypeName.trim())) { + return new TrackerValidationError(true, "you passed an empty traffic_type_name, traffic_type_name must be a non-empty string"); + } + + if (eventTypeId == null) { + return new TrackerValidationError(true, "you passed a null or undefined event_type, event_type must be a non-empty String"); + } + + if (ValidationUtils.isNullOrEmpty(eventTypeId.trim())) { + return new TrackerValidationError(true, "you passed an empty event_type, event_type must be a non-empty String"); + } + + if (!eventTypeId.matches(TYPE_REGEX)) { + return new TrackerValidationError(true, "you passed " + eventTypeId + + ", event name must adhere to the regular expression " + TYPE_REGEX + + ". This means an event name must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, " + + " underscore, period, or colon as separators of alphanumeric characters."); + } + + List warnings = new ArrayList<>(); + + if(!trafficTypeName.toLowerCase().equals(trafficTypeName)) { + warnings.add("traffic_type_name should be all lowercase - converting string to lowercase"); + } + + if (isSdkReady && !mTrafficTypeValidator.isValid(trafficTypeName)) { + String message = "Traffic Type " + trafficTypeName + " does not have any corresponding feature flags in this environment, " + + "make sure you’re tracking your events to a valid traffic type defined in the Split user interface"; + warnings.add(message); + } + + if (warnings.isEmpty()) { + return null; + } + return new TrackerValidationError(warnings); + } +} diff --git a/main/src/main/java/io/split/android/client/validators/KeyValidator.java b/tracker/src/main/java/io/split/android/client/validators/KeyValidator.java similarity index 100% rename from main/src/main/java/io/split/android/client/validators/KeyValidator.java rename to tracker/src/main/java/io/split/android/client/validators/KeyValidator.java diff --git a/main/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java b/tracker/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java similarity index 90% rename from main/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java rename to tracker/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java index c22a8daa5..fcdf0d931 100644 --- a/main/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java +++ b/tracker/src/main/java/io/split/android/client/validators/KeyValidatorImpl.java @@ -1,7 +1,5 @@ package io.split.android.client.validators; -import io.split.android.client.utils.Utils; - /** * Validates an instance of Key class. */ @@ -17,7 +15,7 @@ public ValidationErrorInfo validate(String matchingKey, String bucketingKey) { return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed a null key, matching key must be a non-empty string"); } - if (Utils.isNullOrEmpty(matchingKey.trim())) { + if (ValidationUtils.isNullOrEmpty(matchingKey.trim())) { return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME,"you passed an empty string, matching key must be a non-empty string"); } @@ -26,7 +24,7 @@ public ValidationErrorInfo validate(String matchingKey, String bucketingKey) { } if (bucketingKey != null) { - if (Utils.isNullOrEmpty(bucketingKey.trim())) { + if (ValidationUtils.isNullOrEmpty(bucketingKey.trim())) { return new ValidationErrorInfo(ValidationErrorInfo.ERROR_SOME, "you passed an empty string, bucketing key must be null or a non-empty string"); } diff --git a/tracker/src/main/java/io/split/android/client/validators/PropertyValidatorImpl.java b/tracker/src/main/java/io/split/android/client/validators/PropertyValidatorImpl.java new file mode 100644 index 000000000..343a87428 --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/validators/PropertyValidatorImpl.java @@ -0,0 +1,99 @@ +package io.split.android.client.validators; + +import java.util.HashMap; +import java.util.Map; + +import io.split.android.client.tracker.TrackerLogger; +import io.split.android.client.tracker.TrackerPropertyValidator; + + +public class PropertyValidatorImpl implements TrackerPropertyValidator { + + private final TrackerLogger mLogger; + + private final static int MAX_PROPS_COUNT = 300; + private final static int MAXIMUM_EVENT_PROPERTY_BYTES = + ValidationConfig.getInstance().getMaximumEventPropertyBytes(); + + public PropertyValidatorImpl(TrackerLogger logger) { + mLogger = logger; + } + + /** + * Internal validation logic - returns a simple result with properties and size. + */ + private InternalResult validateInternal(Map properties, String validationTag) { + if (properties == null) { + return new InternalResult(true, null, 0, null); + } + + if (properties.size() > MAX_PROPS_COUNT) { + mLogger.v(validationTag + "Event has more than " + MAX_PROPS_COUNT + + " properties. Some of them will be trimmed when processed"); + } + int sizeInBytes = 0; + Map finalProperties = new HashMap<>(properties); + + for (Map.Entry entry : properties.entrySet()) { + Object value = entry.getValue(); + String key = entry.getKey(); + + if (value != null && isInvalidValueType(value)) { + finalProperties.put(key, null); + } + sizeInBytes += calculateEventSizeInBytes(key, value); + + if (sizeInBytes > MAXIMUM_EVENT_PROPERTY_BYTES) { + mLogger.v(validationTag + + "The maximum size allowed for the " + + " properties is 32kb. Current is " + key + + ". Event not queued"); + return new InternalResult(false, null, sizeInBytes, "Event properties size is too large"); + } + } + return new InternalResult(true, finalProperties, sizeInBytes, null); + } + + private static boolean isInvalidValueType(Object value) { + return !(value instanceof Number) && + !(value instanceof Boolean) && + !(value instanceof String); + } + + private static int calculateEventSizeInBytes(String key, Object value) { + int valueSize = 0; + if(value != null && value.getClass() == String.class) { + valueSize = value.toString().getBytes().length; + } + return valueSize + key.getBytes().length; + } + + @Override + public TrackerPropertyResult validate(Map properties, int initialSizeInBytes, + String validationTag) { + InternalResult result = validateInternal(properties, validationTag); + int totalSize = initialSizeInBytes + result.sizeInBytes; + if (result.isValid) { + return TrackerPropertyResult.valid(result.properties, totalSize); + } else { + return TrackerPropertyResult.invalid(result.errorMessage, totalSize); + } + } + + /** + * Internal result class to avoid depending on main module's PropertyValidator.Result. + */ + private static class InternalResult { + final boolean isValid; + final Map properties; + final int sizeInBytes; + final String errorMessage; + + InternalResult(boolean isValid, Map properties, int sizeInBytes, String errorMessage) { + this.isValid = isValid; + this.properties = properties; + this.sizeInBytes = sizeInBytes; + this.errorMessage = errorMessage; + } + } +} diff --git a/main/src/main/java/io/split/android/client/validators/ValidationConfig.java b/tracker/src/main/java/io/split/android/client/validators/ValidationConfig.java similarity index 100% rename from main/src/main/java/io/split/android/client/validators/ValidationConfig.java rename to tracker/src/main/java/io/split/android/client/validators/ValidationConfig.java diff --git a/main/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java b/tracker/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java similarity index 91% rename from main/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java rename to tracker/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java index ef1346a1d..6e920d7e4 100644 --- a/main/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java +++ b/tracker/src/main/java/io/split/android/client/validators/ValidationErrorInfo.java @@ -19,11 +19,11 @@ public class ValidationErrorInfo { private Map mWarnings = new HashMap<>(); @SuppressWarnings("SameParameterValue") - ValidationErrorInfo(int code, String message) { + public ValidationErrorInfo(int code, String message) { this(code, message, false); } - ValidationErrorInfo(int code, String message, boolean isWarning) { + public ValidationErrorInfo(int code, String message, boolean isWarning) { if(!isWarning){ mError = code; mErrorMessage = message; diff --git a/tracker/src/main/java/io/split/android/client/validators/ValidationUtils.java b/tracker/src/main/java/io/split/android/client/validators/ValidationUtils.java new file mode 100644 index 000000000..32593df4f --- /dev/null +++ b/tracker/src/main/java/io/split/android/client/validators/ValidationUtils.java @@ -0,0 +1,23 @@ +package io.split.android.client.validators; + +import androidx.annotation.Nullable; + +/** + * Utility methods for validator implementations. + */ +public class ValidationUtils { + + /** + * Checks if a string is null or empty. + * + * @param string the string to check + * @return true if the string is null or empty, false otherwise + */ + public static boolean isNullOrEmpty(@Nullable String string) { + return string == null || string.isEmpty(); + } + + private ValidationUtils() { + // Utility class + } +} diff --git a/tracker/src/test/java/io/split/android/client/tracker/DefaultTrackerTest.java b/tracker/src/test/java/io/split/android/client/tracker/DefaultTrackerTest.java new file mode 100644 index 000000000..4c2a9fb62 --- /dev/null +++ b/tracker/src/test/java/io/split/android/client/tracker/DefaultTrackerTest.java @@ -0,0 +1,217 @@ +package io.split.android.client.tracker; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.mockito.ArgumentCaptor; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class DefaultTrackerTest { + + @Mock + private TrackerEventValidator mEventValidator; + @Mock + private TrackerLogger mTrackerLogger; + @Mock + private TrackerPropertyValidator mPropertyValidator; + @Mock + private DefaultTracker.OnEventPush mOnEventPush; + @Mock + private DefaultTracker.OnTrackLatency mOnTrackLatency; + @Mock + private DefaultTracker.OnTrackException mOnTrackException; + + private DefaultTracker mTracker; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + when(mEventValidator.validate(anyString(), anyString(), anyString(), anyDouble(), any(), anyBoolean())) + .thenReturn(null); + when(mPropertyValidator.validate(any(), anyInt(), anyString())) + .thenReturn(TrackerPropertyValidator.TrackerPropertyResult.valid(null, 0)); + + mTracker = new DefaultTracker(mEventValidator, mTrackerLogger, mPropertyValidator, + mOnEventPush, mOnTrackLatency, mOnTrackException); + } + + @Test + public void trackingEnabledByDefault() { + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertTrue(result); + verify(mOnEventPush).accept(any()); + } + + @Test + public void trackDisabledReturnsFalse() { + mTracker.enableTracking(false); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertFalse(result); + verify(mOnEventPush, never()).accept(any()); + } + + @Test + public void trackDisabledLogsVerbose() { + mTracker.enableTracking(false); + + mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + verify(mTrackerLogger).v("Event not tracked because tracking is disabled"); + } + + @Test + public void validationErrorBlocksTracking() { + when(mEventValidator.validate(anyString(), anyString(), anyString(), anyDouble(), any(), anyBoolean())) + .thenReturn(new TrackerValidationError(true, "bad event")); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertFalse(result); + verify(mTrackerLogger).e(eq("bad event"), anyString()); + verify(mOnEventPush, never()).accept(any()); + } + + @Test + public void validationWarningAllowsTracking() { + when(mEventValidator.validate(anyString(), anyString(), anyString(), anyDouble(), any(), anyBoolean())) + .thenReturn(new TrackerValidationError(Collections.singletonList("traffic type uppercase"))); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertTrue(result); + verify(mTrackerLogger).log(any(TrackerValidationError.class), anyString()); + verify(mOnEventPush).accept(any()); + } + + @Test + public void validationWarningLowercasesTrafficType() { + when(mEventValidator.validate(anyString(), anyString(), anyString(), anyDouble(), any(), anyBoolean())) + .thenReturn(new TrackerValidationError(Collections.singletonList("traffic type has uppercase chars"))); + + mTracker.track("key", "TRAFFIC", "eventType", 1.0, null, true); + + verify(mOnEventPush).accept(argThat(event -> "traffic".equals(event.trafficType))); + } + + @Test + public void propertyValidationErrorBlocksTracking() { + when(mPropertyValidator.validate(any(), anyInt(), anyString())) + .thenReturn(TrackerPropertyValidator.TrackerPropertyResult.invalid("too large", 0)); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, new HashMap<>(), true); + + assertFalse(result); + verify(mOnEventPush, never()).accept(any()); + } + + @Test + public void successfulTrackInvokesOnEventPush() { + Map props = new HashMap<>(); + props.put("k", "v"); + when(mPropertyValidator.validate(any(), anyInt(), anyString())) + .thenReturn(TrackerPropertyValidator.TrackerPropertyResult.valid(props, 1024)); + + boolean result = mTracker.track("key", "traffic", "eventType", 2.0, props, true); + + assertTrue(result); + verify(mOnEventPush).accept(any()); + } + + @Test + public void successfulTrackInvokesLatencyCallback() { + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertTrue(result); + verify(mOnTrackLatency).accept(any(Long.class)); + } + + @Test + public void nullLatencyCallbackDoesNotCrash() { + mTracker = new DefaultTracker(mEventValidator, mTrackerLogger, mPropertyValidator, + mOnEventPush, null, null); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertTrue(result); + verify(mOnEventPush).accept(any()); + } + + @Test + public void exceptionDuringTrackingInvokesOnTrackException() { + doThrow(new RuntimeException("push failed")).when(mOnEventPush).accept(any()); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertFalse(result); + verify(mOnTrackException).accept(); + } + + @Test + public void nullExceptionCallbackDoesNotCrashOnException() { + mTracker = new DefaultTracker(mEventValidator, mTrackerLogger, mPropertyValidator, + mOnEventPush, null, null); + doThrow(new RuntimeException("push failed")).when(mOnEventPush).accept(any()); + + boolean result = mTracker.track("key", "traffic", "eventType", 1.0, null, true); + + assertFalse(result); + } + + @Test + public void successfulTrackPopulatesEventFieldsCorrectly() { + Map props = new HashMap<>(); + props.put("k", "v"); + when(mPropertyValidator.validate(any(), anyInt(), anyString())) + .thenReturn(TrackerPropertyValidator.TrackerPropertyResult.valid(props, 512)); + + long beforeTrack = System.currentTimeMillis(); + mTracker.track("myKey", "myTraffic", "myEventType", 3.14, props, true); + long afterTrack = System.currentTimeMillis(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TrackerEvent.class); + verify(mOnEventPush).accept(captor.capture()); + + TrackerEvent captured = captor.getValue(); + assertNotNull(captured); + assertEquals("myKey", captured.key); + assertEquals("myTraffic", captured.trafficType); + assertEquals("myEventType", captured.eventType); + assertEquals(3.14, captured.value, 0.0001); + assertTrue(captured.timestamp >= beforeTrack && captured.timestamp <= afterTrack); + assertEquals(512, captured.sizeInBytes); + } + + // Helper matcher for verifying TrackerEvent fields + private static T argThat(ArgumentMatcherWithReturn matcher) { + return org.mockito.ArgumentMatchers.argThat(matcher::matches); + } + + @FunctionalInterface + interface ArgumentMatcherWithReturn { + boolean matches(T argument); + } +} diff --git a/main/src/test/java/io/split/android/client/validators/EventTypeNameHelper.java b/tracker/src/test/java/io/split/android/client/validators/EventTypeNameHelper.java similarity index 100% rename from main/src/test/java/io/split/android/client/validators/EventTypeNameHelper.java rename to tracker/src/test/java/io/split/android/client/validators/EventTypeNameHelper.java diff --git a/tracker/src/test/java/io/split/android/client/validators/EventValidatorTest.java b/tracker/src/test/java/io/split/android/client/validators/EventValidatorTest.java new file mode 100644 index 000000000..a1f4d5070 --- /dev/null +++ b/tracker/src/test/java/io/split/android/client/validators/EventValidatorTest.java @@ -0,0 +1,198 @@ +package io.split.android.client.validators; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import io.split.android.client.tracker.TrafficTypeValidator; +import io.split.android.client.tracker.TrackerValidationError; + +public class EventValidatorTest { + + private EventValidatorImpl validator; + + @Before + public void setUp() { + + TrafficTypeValidator trafficTypeValidator = mock(TrafficTypeValidator.class); + + when(trafficTypeValidator.isValid("traffic1")).thenReturn(true); + when(trafficTypeValidator.isValid("trafficType1")).thenReturn(true); + when(trafficTypeValidator.isValid("custom")).thenReturn(true); + + validator = new EventValidatorImpl(new KeyValidatorImpl(), trafficTypeValidator); + } + + @Test + public void testValidEventAllValues() { + TrackerValidationError error = validator.validate("pepe", "traffic1", "type1", 1.0, null, true); + Assert.assertNull(error); + } + + @Test + public void testValidEventNullValue() { + TrackerValidationError error = validator.validate("pepe", "traffic1", "type1", null, null, true); + Assert.assertNull(error); + } + + @Test + public void testNullKey() { + TrackerValidationError error = validator.validate(null, "traffic1", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed a null key, matching key must be a non-empty string", error.getMessage()); + } + + @Test + public void testEmptyKey() { + TrackerValidationError error = validator.validate("", "traffic1", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty string, matching key must be a non-empty string", error.getMessage()); + } + + @Test + public void testAllSpacesInKey() { + TrackerValidationError error = validator.validate(" ", "traffic1", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty string, matching key must be a non-empty string", error.getMessage()); + } + + @Test + public void testLongKey() { + TrackerValidationError error = validator.validate(repeat("p", 300), "traffic1", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("matching key too long - must be " + ValidationConfig.getInstance().getMaximumKeyLength() + " characters or less", error.getMessage()); + } + + @Test + public void testNullType() { + TrackerValidationError error = validator.validate("key1", "traffic1", null, null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed a null or undefined event_type, event_type must be a non-empty String", error.getMessage()); + } + + @Test + public void testEmptyType() { + TrackerValidationError error = validator.validate("key1", "traffic1", "", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty event_type, event_type must be a non-empty String", error.getMessage()); + } + + @Test + public void testAllSpacesInType() { + TrackerValidationError error = validator.validate("key1", "traffic1", " ", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty event_type, event_type must be a non-empty String", error.getMessage()); + } + + @Test + public void testTypeName() { + EventTypeNameHelper nameHelper = new EventTypeNameHelper(); + + TrackerValidationError error1 = validator.validate("key1", "traffic1", nameHelper.getValidAllValidChars(), null, null, true); + TrackerValidationError error2 = validator.validate("key1", "traffic1", nameHelper.getValidStartNumber(), null, null, true); + TrackerValidationError error3 = validator.validate("key1", "traffic1", nameHelper.getInvalidChars(), null, null, true); + TrackerValidationError error4 = validator.validate("key1", "traffic1", nameHelper.getInvalidUndercoreStart(), null, null, true); + TrackerValidationError error5 = validator.validate("key1", "traffic1", nameHelper.getInvalidHypenStart(), null, null, true); + + Assert.assertNull(error1); + Assert.assertNull(error2); + + Assert.assertNotNull(error3); + Assert.assertTrue(error3.isError()); + Assert.assertEquals(buildEventTypeValidationMessage(nameHelper.getInvalidChars()), error3.getMessage()); + + Assert.assertNotNull(error4); + Assert.assertTrue(error4.isError()); + Assert.assertEquals(buildEventTypeValidationMessage(nameHelper.getInvalidUndercoreStart()), error4.getMessage()); + + Assert.assertNotNull(error5); + Assert.assertTrue(error5.isError()); + Assert.assertEquals(buildEventTypeValidationMessage(nameHelper.getInvalidHypenStart()), error5.getMessage()); + } + + @Test + public void testNullTrafficType() { + TrackerValidationError error = validator.validate("key1", null, "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed a null or undefined traffic_type_name, traffic_type_name must be a non-empty string", error.getMessage()); + } + + @Test + public void testEmptyTrafficType() { + TrackerValidationError error = validator.validate("key1", "", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty traffic_type_name, traffic_type_name must be a non-empty string", error.getMessage()); + } + + @Test + public void testAllSpacesInTrafficType() { + TrackerValidationError error = validator.validate("key1", " ", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertTrue(error.isError()); + Assert.assertEquals("you passed an empty traffic_type_name, traffic_type_name must be a non-empty string", error.getMessage()); + } + + @Test + public void testUppercaseCharsInTrafficType() { + final String uppercaseMessage = "traffic_type_name should be all lowercase - converting string to lowercase"; + + TrackerValidationError error0 = validator.validate("key1", "custom", "type1", null, null, true); + TrackerValidationError error1 = validator.validate("key1", "Custom", "type1", null, null, true); + TrackerValidationError error2 = validator.validate("key1", "cUSTom", "type1", null, null, true); + TrackerValidationError error3 = validator.validate("key1", "custoM", "type1", null, null, true); + + Assert.assertNull(error0); + + Assert.assertNotNull(error1); + Assert.assertFalse(error1.isError()); + Assert.assertTrue(error1.getWarnings().contains(uppercaseMessage)); + + Assert.assertNotNull(error2); + Assert.assertFalse(error2.isError()); + Assert.assertTrue(error2.getWarnings().contains(uppercaseMessage)); + + Assert.assertNotNull(error3); + Assert.assertFalse(error3.isError()); + Assert.assertTrue(error3.getWarnings().contains(uppercaseMessage)); + } + + @Test + public void noChachedServerTrafficType() { + TrackerValidationError error = validator.validate("key1", "nocached", "type1", null, null, true); + Assert.assertNotNull(error); + Assert.assertFalse(error.isError()); + Assert.assertEquals(1, error.getWarnings().size()); + String actualWarning = error.getWarnings().get(0); + Assert.assertTrue("Expected warning to contain 'Traffic Type nocached'", + actualWarning.contains("Traffic Type nocached")); + Assert.assertTrue("Expected warning to contain 'does not have any corresponding feature flags'", + actualWarning.contains("does not have any corresponding feature flags")); + } + + private String buildEventTypeValidationMessage(String eventType) { + return "you passed " + eventType + + ", event name must adhere to the regular expression " + ValidationConfig.getInstance().getTrackEventNamePattern() + + ". This means an event name must be alphanumeric, cannot be more than 80 characters long, and can only include a dash, " + + " underscore, period, or colon as separators of alphanumeric characters."; + } + + private String repeat(String str, int count) { + StringBuilder builder = new StringBuilder(str.length() * count); + for (int i = 0; i < count; i++) { + builder.append(str); + } + return builder.toString(); + } +} diff --git a/main/src/test/java/io/split/android/client/validators/KeyValidatorTest.java b/tracker/src/test/java/io/split/android/client/validators/KeyValidatorTest.java similarity index 90% rename from main/src/test/java/io/split/android/client/validators/KeyValidatorTest.java rename to tracker/src/test/java/io/split/android/client/validators/KeyValidatorTest.java index f3e3cdeb7..ec538e048 100644 --- a/main/src/test/java/io/split/android/client/validators/KeyValidatorTest.java +++ b/tracker/src/test/java/io/split/android/client/validators/KeyValidatorTest.java @@ -4,8 +4,6 @@ import org.junit.Before; import org.junit.Test; -import io.split.android.client.utils.Utils; - public class KeyValidatorTest { private KeyValidator validator; @@ -60,7 +58,7 @@ public void testInvalidAllSpacesInMatchingKey() { @Test public void testInvalidLongMatchingKey() { - ValidationErrorInfo errorInfo = validator.validate(Utils.repeat("p", 256), null); + ValidationErrorInfo errorInfo = validator.validate(repeat("p", 256), null); Assert.assertNotNull(errorInfo); Assert.assertTrue(errorInfo.isError()); @@ -87,10 +85,18 @@ public void testInvalidAllSpacesInBucketingKey() { @Test public void testInvalidLongBucketingKey() { - ValidationErrorInfo errorInfo = validator.validate("key1", Utils.repeat("p", 256)); + ValidationErrorInfo errorInfo = validator.validate("key1", repeat("p", 256)); Assert.assertNotNull(errorInfo); Assert.assertTrue(errorInfo.isError()); Assert.assertEquals("bucketing key too long - must be " + ValidationConfig.getInstance().getMaximumKeyLength() + " characters or less", errorInfo.getErrorMessage()); } + + private String repeat(String str, int count) { + StringBuilder builder = new StringBuilder(str.length() * count); + for (int i = 0; i < count; i++) { + builder.append(str); + } + return builder.toString(); + } } diff --git a/main/src/test/java/io/split/android/client/events/PropertyValidatorTest.java b/tracker/src/test/java/io/split/android/client/validators/PropertyValidatorTest.java similarity index 57% rename from main/src/test/java/io/split/android/client/events/PropertyValidatorTest.java rename to tracker/src/test/java/io/split/android/client/validators/PropertyValidatorTest.java index 0841e2d0d..13927be24 100644 --- a/main/src/test/java/io/split/android/client/events/PropertyValidatorTest.java +++ b/tracker/src/test/java/io/split/android/client/validators/PropertyValidatorTest.java @@ -1,4 +1,6 @@ -package io.split.android.client.events; +package io.split.android.client.validators; + +import static org.mockito.Mockito.mock; import org.junit.Assert; import org.junit.Before; @@ -7,15 +9,12 @@ import java.util.HashMap; import java.util.Map; -import io.split.android.client.PropertyValidatorImpl; -import io.split.android.client.dtos.Split; -import io.split.android.client.utils.Utils; -import io.split.android.client.validators.PropertyValidator; -import io.split.android.client.validators.ValidationConfig; +import io.split.android.client.tracker.TrackerLogger; +import io.split.android.client.tracker.TrackerPropertyValidator; public class PropertyValidatorTest { - private final PropertyValidator processor = new PropertyValidatorImpl(); + private final TrackerPropertyValidator processor = new PropertyValidatorImpl(mock(TrackerLogger.class)); private final static long MAX_BYTES = ValidationConfig.getInstance().getMaximumEventPropertyBytes(); @Before @@ -28,24 +27,33 @@ public void sizeInBytesValidation() { int maxCount = (int) (MAX_BYTES / 1024); int count = 1; while (count <= maxCount) { - properties.put("key" + count, Utils.repeat("a", 1021)); // 1025 bytes + properties.put("key" + count, repeat("a", 1021)); // 1025 bytes count++; } - PropertyValidator.Result result = validate(properties); + TrackerPropertyValidator.TrackerPropertyResult result = validate(properties); Assert.assertFalse(result.isValid()); } + private String repeat(String str, int count) { + StringBuilder builder = new StringBuilder(str.length() * count); + for (int i = 0; i < count; i++) { + builder.append(str); + } + return builder.toString(); + } + @Test public void invalidPropertyType() { Map properties = new HashMap<>(); for (int i = 0; i < 10; i++) { properties.put("key" + i, "the value"); } + // Add invalid property types (objects that are not Number, Boolean, or String) for (int i = 0; i < 10; i++) { - properties.put("key" + i, new Split()); + properties.put("key" + i, new Object()); } - PropertyValidator.Result result = validate(properties); + TrackerPropertyValidator.TrackerPropertyResult result = validate(properties); Assert.assertTrue(result.isValid()); Assert.assertEquals(10, result.getProperties().size()); @@ -60,7 +68,7 @@ public void nullValues() { for (int i = 10; i < 20; i++) { properties.put("key" + i + 10, null); } - PropertyValidator.Result result = validate(properties); + TrackerPropertyValidator.TrackerPropertyResult result = validate(properties); Assert.assertTrue(result.isValid()); Assert.assertEquals(20, result.getProperties().size()); @@ -72,13 +80,13 @@ public void totalBytes() { for (int i = 0; i < 10; i++) { properties.put("k" + i, "10 bytes"); } - PropertyValidator.Result result = validate(properties); + TrackerPropertyValidator.TrackerPropertyResult result = validate(properties); Assert.assertTrue(result.isValid()); Assert.assertEquals(100, result.getSizeInBytes()); } - private PropertyValidator.Result validate(Map properties) { - return processor.validate(properties, "test"); + private TrackerPropertyValidator.TrackerPropertyResult validate(Map properties) { + return processor.validate(properties, 0, "test"); } }