mCustomProperties = new HashMap<>();
+
+ private Builder() {
+ }
+
+ /**
+ * Sets the identity keys from a {@link MixpanelIdentity}: {@code distinct_id} for a
+ * legacy identity, or {@code $user_id}, {@code $company_id} and {@code device_id}
+ * for a hierarchical groups identity.
+ *
+ * Identity keys, whether set here or with {@link #distinctId}, {@link #userId},
+ * {@link #companyId} or {@link #deviceId}, win over the same keys set with
+ * {@link #group} or {@link #customProperty}, whatever the call order. Among the
+ * identity setters, the later call wins.
+ */
+ public Builder identity(MixpanelIdentity identity) {
+ if (identity.getDistinctId() != null) distinctId(identity.getDistinctId());
+ if (identity.getUserId() != null) userId(identity.getUserId());
+ if (identity.getCompanyId() != null) companyId(identity.getCompanyId());
+ if (identity.getDeviceId() != null) deviceId(identity.getDeviceId());
+ return this;
+ }
+
+ public Builder distinctId(String distinctId) {
+ mIdentity.put("distinct_id", distinctId);
+ return this;
+ }
+
+ /** Sets the device id, used as the assignment key by flags that assign on {@code device_id}. */
+ public Builder deviceId(String deviceId) {
+ mIdentity.put("device_id", deviceId);
+ return this;
+ }
+
+ public Builder userId(String userId) {
+ mIdentity.put("$user_id", userId);
+ return this;
+ }
+
+ public Builder companyId(String companyId) {
+ mIdentity.put("$company_id", companyId);
+ return this;
+ }
+
+ /**
+ * Sets a group the evaluation is for. Flags whose assignment key is this group
+ * key are assigned on the key their server-published {@code context_value_template}
+ * builds from the context (for example {@code $company:{$company_id}|department:{department}}),
+ * or on the raw group value when the template is absent or cannot be filled.
+ */
+ public Builder group(String groupKey, Object groupValue) {
+ mContext.put(groupKey, groupValue);
+ return this;
+ }
+
+ /** Adds a property used by runtime targeting rules. */
+ public Builder customProperty(String name, Object value) {
+ mCustomProperties.put(name, value);
+ return this;
+ }
+
+ public Map build() {
+ Map context = new HashMap<>(mContext);
+ if (!mCustomProperties.isEmpty()) {
+ context.put("custom_properties", new HashMap<>(mCustomProperties));
+ }
+ context.putAll(mIdentity);
+ return context;
+ }
+ }
+}
diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java
index 9d3aa9f..0edd292 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/BaseFlagsConfig.java
@@ -13,6 +13,7 @@ public class BaseFlagsConfig {
private final String apiHost;
private final int requestTimeoutSeconds;
private final Executor exposureExecutor;
+ private final boolean hierarchicalGroups;
/**
* Creates a new BaseFlagsConfig with specified settings.
@@ -23,10 +24,25 @@ public class BaseFlagsConfig {
* @param exposureExecutor executor used to dispatch exposure event HTTP sends; may be null
*/
protected BaseFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor) {
+ this(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, false);
+ }
+
+ /**
+ * Creates a new BaseFlagsConfig with specified settings.
+ *
+ * @param projectToken the Mixpanel project token
+ * @param apiHost the API endpoint host
+ * @param requestTimeoutSeconds HTTP request timeout in seconds
+ * @param exposureExecutor executor used to dispatch exposure event HTTP sends; may be null
+ * @param hierarchicalGroups true if the project uses hierarchical groups
+ */
+ protected BaseFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor,
+ boolean hierarchicalGroups) {
this.projectToken = projectToken;
this.apiHost = apiHost;
this.requestTimeoutSeconds = requestTimeoutSeconds;
this.exposureExecutor = exposureExecutor;
+ this.hierarchicalGroups = hierarchicalGroups;
}
/**
@@ -57,6 +73,13 @@ public Executor getExposureExecutor() {
return exposureExecutor;
}
+ /**
+ * @return true if the project uses hierarchical groups
+ */
+ public boolean isHierarchicalGroups() {
+ return hierarchicalGroups;
+ }
+
/**
* Builder for BaseFlagsConfig.
*
@@ -68,6 +91,7 @@ public static class Builder> {
protected String apiHost = "api.mixpanel.com";
protected int requestTimeoutSeconds = 10;
protected Executor exposureExecutor;
+ protected boolean hierarchicalGroups;
/**
* Sets the project token.
@@ -123,13 +147,29 @@ public T exposureExecutor(Executor exposureExecutor) {
return (T) this;
}
+ /**
+ * Sets whether the project uses hierarchical groups. When true, exposure events are
+ * attributed with {@code $user_id} and {@code $company_id} instead of
+ * {@code distinct_id}. Assignment keys do not depend on this setting: locally
+ * evaluated flags build them from the {@code context_value_template} the server
+ * publishes for each flag, falling back to the raw context value when the template
+ * is absent or the context lacks one of its placeholders.
+ *
+ * @param hierarchicalGroups true if the project uses hierarchical groups
+ * @return this builder
+ */
+ public T hierarchicalGroups(boolean hierarchicalGroups) {
+ this.hierarchicalGroups = hierarchicalGroups;
+ return (T) this;
+ }
+
/**
* Builds the BaseFlagsConfig instance.
*
* @return a new BaseFlagsConfig
*/
public BaseFlagsConfig build() {
- return new BaseFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor);
+ return new BaseFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups);
}
}
diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java
index f139dce..a1bcc93 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/LocalFlagsConfig.java
@@ -23,8 +23,8 @@ public final class LocalFlagsConfig extends BaseFlagsConfig {
* @param enablePolling whether to periodically refresh flag definitions
* @param pollingIntervalSeconds time between refresh cycles in seconds
*/
- private LocalFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor, boolean enablePolling, int pollingIntervalSeconds) {
- super(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor);
+ private LocalFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor, boolean hierarchicalGroups, boolean enablePolling, int pollingIntervalSeconds) {
+ super(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups);
this.enablePolling = enablePolling;
this.pollingIntervalSeconds = pollingIntervalSeconds;
}
@@ -79,7 +79,7 @@ public Builder pollingIntervalSeconds(int pollingIntervalSeconds) {
*/
@Override
public LocalFlagsConfig build() {
- return new LocalFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, enablePolling, pollingIntervalSeconds);
+ return new LocalFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups, enablePolling, pollingIntervalSeconds);
}
}
diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java
index a454e89..2c50e08 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/config/RemoteFlagsConfig.java
@@ -19,8 +19,8 @@ public final class RemoteFlagsConfig extends BaseFlagsConfig {
* @param requestTimeoutSeconds HTTP request timeout in seconds
* @param exposureExecutor executor used to dispatch exposure event HTTP sends; may be null
*/
- private RemoteFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor) {
- super(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor);
+ private RemoteFlagsConfig(String projectToken, String apiHost, int requestTimeoutSeconds, Executor exposureExecutor, boolean hierarchicalGroups) {
+ super(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups);
}
/**
@@ -35,7 +35,7 @@ public static final class Builder extends BaseFlagsConfig.Builder {
*/
@Override
public RemoteFlagsConfig build() {
- return new RemoteFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor);
+ return new RemoteFlagsConfig(projectToken, apiHost, requestTimeoutSeconds, exposureExecutor, hierarchicalGroups);
}
}
diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java
index 1a796ae..78bf871 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/model/ExperimentationFlag.java
@@ -23,6 +23,7 @@ public final class ExperimentationFlag {
private final UUID experimentId;
private final Boolean isExperimentActive;
private final String hashSalt;
+ private final String contextValueTemplate;
/**
* Creates a new ExperimentationFlag.
@@ -39,6 +40,11 @@ public final class ExperimentationFlag {
* @param hashSalt the hash salt for this flag (may be null for legacy flags)
*/
public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive, String hashSalt) {
+ this(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt, "");
+ }
+
+ public ExperimentationFlag(String id, String name, String key, String status, int projectId, RuleSet ruleset, String context, UUID experimentId, Boolean isExperimentActive, String hashSalt, String contextValueTemplate) {
+ this.contextValueTemplate = contextValueTemplate == null ? "" : contextValueTemplate;
this.id = id;
this.name = name;
this.key = key;
@@ -51,6 +57,18 @@ public ExperimentationFlag(String id, String name, String key, String status, in
this.hashSalt = hashSalt;
}
+ /**
+ * The template the service publishes for building this flag's assignment key from the
+ * evaluation context, for example {@code $company:{$company_id}|$user:{$user_id}}. Each
+ * {@code {key}} placeholder is replaced with that context value; when the template is
+ * empty or a placeholder is missing, the raw {@link #getContext()} value is used instead.
+ *
+ * @return the template, or an empty string when the service sends none
+ */
+ public String getContextValueTemplate() {
+ return contextValueTemplate;
+ }
+
/**
* @return the unique identifier for this flag
*/
diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java
index 89f05c4..8fa988a 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/BaseFlagsProvider.java
@@ -207,13 +207,48 @@ public boolean isEnabled(String flagKey, Map context) {
// #region Exposure Tracking
/**
- * Common helper method for tracking exposure events.
+ * Returns true if an exposure event for this context can be attributed: by distinct_id,
+ * or on a hierarchical groups project by $company_id as well (plus $user_id when present),
+ * from which Mixpanel constructs the distinct id.
*/
- protected void trackExposure(String distinctId, String flagKey, String variantKey,
+ protected boolean canAttributeExposure(Map context) {
+ if (!config.isHierarchicalGroups()) {
+ return context.get("distinct_id") != null;
+ }
+ // A distinct id attributes on any project, including one Mixpanel built from a
+ // company and user, which ingestion parses back into its parts.
+ return isPresent(context.get("$company_id")) || isPresent(context.get("distinct_id"));
+ }
+
+ private static boolean isPresent(Object value) {
+ return value != null && !value.toString().isEmpty();
+ }
+
+ /**
+ * Common helper method for tracking exposure events. For hierarchical groups the
+ * event carries $user_id and $company_id instead of distinct_id.
+ */
+ protected void trackExposure(Map context, String flagKey, String variantKey,
String evaluationMode, Consumer addTimingProperties,
UUID experimentId, Boolean isExperimentActive, Boolean isQaTester) {
try {
JSONObject properties = new JSONObject();
+ if (config.isHierarchicalGroups()) {
+ // Carry whichever identity keys the caller sent; Mixpanel resolves the user
+ // from the pair, or from a distinct id it built itself.
+ Object userId = context.get("$user_id");
+ if (isPresent(userId)) {
+ properties.put("$user_id", userId.toString());
+ }
+ Object companyId = context.get("$company_id");
+ if (isPresent(companyId)) {
+ properties.put("$company_id", companyId.toString());
+ }
+ }
+ Object distinctIdObj = context.get("distinct_id");
+ // Without hierarchical groups any distinct id is sent as given, as before
+ boolean hasDistinctId = config.isHierarchicalGroups() ? isPresent(distinctIdObj) : distinctIdObj != null;
+ String distinctId = hasDistinctId ? distinctIdObj.toString() : null;
properties.put("Experiment name", flagKey);
properties.put("Variant name", variantKey);
properties.put("$experiment_type", "feature_flag");
diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java
index f6c1aff..d8a2d69 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java
@@ -18,6 +18,8 @@
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.logging.Logger;
@@ -230,6 +232,34 @@ private Map parseDefinitions(String jsonResponse) {
return definitions;
}
+ /** A {@code {key}} placeholder in a context value template. */
+ private static final Pattern CONTEXT_PLACEHOLDER = Pattern.compile("\\{([^{}]+)\\}");
+
+ /**
+ * Fills a flag's {@code context_value_template} with values from the evaluation context.
+ * Each {@code {key}} placeholder is replaced with the string value of {@code key}; text
+ * outside the braces is literal.
+ *
+ * @return the filled template, or null when there is no template or any placeholder key
+ * is missing (absent or null) from the context
+ */
+ private static String applyContextValueTemplate(String template, Map context) {
+ if (template == null || template.isEmpty()) {
+ return null;
+ }
+ StringBuffer out = new StringBuffer();
+ Matcher matcher = CONTEXT_PLACEHOLDER.matcher(template);
+ while (matcher.find()) {
+ Object value = context.get(matcher.group(1));
+ if (value == null) {
+ return null;
+ }
+ matcher.appendReplacement(out, Matcher.quoteReplacement(value.toString()));
+ }
+ matcher.appendTail(out);
+ return out.toString();
+ }
+
/**
* Parses a single flag from JSON.
*/
@@ -240,6 +270,7 @@ private ExperimentationFlag parseFlag(JSONObject json) {
String status = json.optString("status", "");
int projectId = json.optInt("project_id", 0);
String context = json.optString("context", "distinct_id");
+ String contextValueTemplate = json.optString("context_value_template", "");
// Parse experiment metadata
UUID experimentId = null;
@@ -262,7 +293,7 @@ private ExperimentationFlag parseFlag(JSONObject json) {
RuleSet ruleset = parseRuleSet(json.optJSONObject("ruleset"));
- return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt);
+ return new ExperimentationFlag(id, name, key, status, projectId, ruleset, context, experimentId, isExperimentActive, hashSalt, contextValueTemplate);
}
/**
@@ -390,19 +421,27 @@ public SelectedVariant getVariant(String flagKey, SelectedVariant fall
return fallback.withSource(Source.fallback(Source.Fallback.Reason.FLAG_NOT_FOUND));
}
- // Extract context value
+ // Resolve the assignment key: the flag's context_value_template when every
+ // placeholder resolves, otherwise the raw context value, as the server does.
String contextProperty = flag.getContext();
- Object contextValueObj = context.get(contextProperty);
- if (contextValueObj == null) {
- logger.log(Level.WARNING, "Variant assignment key property '" + contextProperty + "' not found for flag: " + flagKey);
- return fallback.withSource(Source.fallback(Source.Fallback.Reason.MISSING_CONTEXT_KEY));
+ String contextValue = applyContextValueTemplate(flag.getContextValueTemplate(), context);
+ if (contextValue == null) {
+ Object contextValueObj = context.get(contextProperty);
+ if (contextValueObj == null) {
+ logger.log(Level.WARNING, "Variant assignment key property '" + contextProperty + "' not found for flag: " + flagKey);
+ return fallback.withSource(Source.fallback(Source.Fallback.Reason.MISSING_CONTEXT_KEY));
+ }
+ contextValue = contextValueObj.toString();
}
- String contextValue = contextValueObj.toString();
// Check test user overrides
RuleSet ruleset = flag.getRuleset();
if (ruleset.hasTestUserOverrides()) {
- String distinctId = context.get("distinct_id") != null ? context.get("distinct_id").toString() : null;
+ // Overrides are keyed by distinct id, which is the resolved assignment key
+ // when the flag assigns on distinct_id.
+ String distinctId = "distinct_id".equals(contextProperty)
+ ? contextValue
+ : (context.get("distinct_id") != null ? context.get("distinct_id").toString() : null);
if (distinctId != null) {
String testVariantKey = ruleset.getTestUserOverrides().get(distinctId);
if (testVariantKey != null) {
@@ -713,20 +752,25 @@ private void trackLocalExposure(Map context, String flagKey, Str
return;
}
- Object distinctIdObj = context.get("distinct_id");
- if (distinctIdObj == null) {
+ if (!canAttributeExposure(context)) {
// Local eval succeeds when the flag's Variant Assignment Key is
// something other than distinct_id (e.g., device_id), but the
- // exposure event still needs distinct_id to attribute the user.
- // Surface the drop instead of silently returning so callers can
- // see they need to include distinct_id in the context.
- logger.log(Level.WARNING,
- "Cannot track exposure event for flag ''{0}'' without a distinct_id in the context",
- flagKey);
+ // exposure event still needs distinct_id ($company_id for hierarchical
+ // groups) to attribute the user. Surface the drop instead of silently
+ // returning so callers can see what to include in the context.
+ if (config.isHierarchicalGroups()) {
+ logger.log(Level.WARNING,
+ "Cannot track exposure event for flag ''{0}'' without a {1} in the context",
+ new Object[] {flagKey, "$company_id"});
+ } else {
+ logger.log(Level.WARNING,
+ "Cannot track exposure event for flag ''{0}'' without a distinct_id in the context",
+ flagKey);
+ }
return;
}
- trackExposure(distinctIdObj.toString(), flagKey, variantKey, "local", properties -> {
+ trackExposure(context, flagKey, variantKey, "local", properties -> {
properties.put("Variant fetch latency (ms)", latencyMs);
}, experimentId, isExperimentActive, isQaTester);
}
diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java
index d2a84b9..e7fbce2 100644
--- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java
+++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProvider.java
@@ -185,12 +185,11 @@ private void trackRemoteExposure(Map context, String flagKey, St
return;
}
- Object distinctIdObj = context.get("distinct_id");
- if (distinctIdObj == null) {
+ if (!canAttributeExposure(context)) {
return;
}
- trackExposure(distinctIdObj.toString(), flagKey, variantKey, "remote", properties -> {
+ trackExposure(context, flagKey, variantKey, "remote", properties -> {
properties.put("Variant fetch start time", startTime);
properties.put("Variant fetch complete time", completeTime);
}, experimentId, isExperimentActive, isQaTester);
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java
new file mode 100644
index 0000000..2f7b200
--- /dev/null
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsScenariosTest.java
@@ -0,0 +1,645 @@
+package com.mixpanel.mixpanelapi;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+
+/**
+ * One test per hierarchical groups scenario. Each builds its messages, delivers them through
+ * {@link ClientDelivery} and {@link MixpanelAPI}, and asserts the endpoint and exact body sent.
+ */
+public class HierarchicalGroupsScenariosTest {
+
+ private static final String TOKEN = "test-token";
+ private static final String USER_ID = "my_user_id";
+ private static final String COMPANY_ID = "my_company_id";
+ private static final String TEAM_ID = "my_team_id";
+ private static final String DEVICE_ID = "my_device_id";
+ private static final String COMBINED_DISTINCT_ID = "$company:" + COMPANY_ID + "|$user:" + USER_ID;
+
+ private static final String TRACK = "/track";
+ private static final String ENGAGE = "/engage";
+ private static final String GROUPS = "/groups";
+ private static final String IMPORT = "/import";
+
+ private static final Set EVENT_KEYS = setOf("event", "properties");
+ private static final Set EVENT_PROPERTY_KEYS = setOf("token", "time", "mp_lib");
+ private static final Set IMPORT_PROPERTY_KEYS = setOf("token", "time", "mp_lib", "$insert_id");
+ private static final Set GROUP_MESSAGE_KEYS = setOf("$token", "$group_key", "$group_id", "$time",
+ "$company_id");
+
+ private static final MixpanelIdentity IDENTIFIED =
+ MixpanelIdentity.builder().userId(USER_ID).companyId(COMPANY_ID).build();
+ private static final MixpanelIdentity IDENTIFIED_WITH_DEVICE =
+ MixpanelIdentity.builder().userId(USER_ID).companyId(COMPANY_ID).deviceId(DEVICE_ID).build();
+
+ private static final class Sent {
+ final String endpoint;
+ final JSONObject body;
+
+ Sent(String endpoint, JSONObject body) {
+ this.endpoint = endpoint;
+ this.body = body;
+ }
+ }
+
+ private final List mSent = new ArrayList<>();
+ private MixpanelAPI mApi;
+ private MessageBuilder mBuilder;
+ private MessageBuilder mCompany;
+ private MessageBuilder mLegacyBuilder;
+
+ @Before
+ public void setUp() {
+ mSent.clear();
+ mApi = new MixpanelAPI() {
+ @Override
+ boolean sendData(String dataString, String endpointUrl) {
+ record(dataString, endpointUrl);
+ return true;
+ }
+
+ @Override
+ boolean sendImportData(String dataString, String endpointUrl, String token) {
+ record(dataString, endpointUrl);
+ return true;
+ }
+ };
+ mBuilder = new MessageBuilder(TOKEN, true);
+ mCompany = mBuilder.forCompany(COMPANY_ID);
+ mLegacyBuilder = new MessageBuilder(TOKEN);
+ }
+
+ private void record(String dataString, String endpointUrl) {
+ String path = endpointUrl.substring(Config.BASE_ENDPOINT.length(), endpointUrl.indexOf('?'));
+ JSONArray messages = new JSONArray(dataString);
+ for (int i = 0; i < messages.length(); i++) {
+ mSent.add(new Sent(path, messages.getJSONObject(i)));
+ }
+ }
+
+ private void send(JSONObject... messages) {
+ ClientDelivery delivery = new ClientDelivery();
+ for (JSONObject message : messages) {
+ delivery.addMessage(message);
+ }
+ try {
+ mApi.deliver(delivery);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void assertNothingSent() {
+ send();
+ assertEquals("Expected no requests", 0, mSent.size());
+ }
+
+ private Sent onlySent(String endpoint) {
+ assertEquals("Expected exactly one request", 1, mSent.size());
+ Sent sent = mSent.get(0);
+ assertEquals(endpoint, sent.endpoint);
+ return sent;
+ }
+
+ // Asserts the single event request and returns its properties, checking their exact key set
+ private JSONObject onlyEvent(String endpoint, String eventName, Set baseKeys, String... extraKeys) {
+ JSONObject event = onlySent(endpoint).body;
+ assertEquals(EVENT_KEYS, keys(event));
+ assertEquals(eventName, event.getString("event"));
+ JSONObject props = event.getJSONObject("properties");
+ Set expected = new HashSet<>(baseKeys);
+ expected.addAll(Arrays.asList(extraKeys));
+ assertEquals(expected, keys(props));
+ assertEquals(TOKEN, props.getString("token"));
+ return props;
+ }
+
+ private JSONObject onlyTrack(String eventName, String... extraKeys) {
+ return onlyEvent(TRACK, eventName, EVENT_PROPERTY_KEYS, extraKeys);
+ }
+
+ // Asserts the single /groups request and returns the value of its operation
+ private Object onlyGroupsUpdate(String groupKey, String groupId, String operation) {
+ JSONObject message = onlySent(GROUPS).body;
+ Set expected = new HashSet<>(GROUP_MESSAGE_KEYS);
+ expected.add(operation);
+ assertEquals(expected, keys(message));
+ assertEquals(TOKEN, message.getString("$token"));
+ assertEquals(groupKey, message.getString("$group_key"));
+ assertEquals(groupId, message.getString("$group_id"));
+ assertEquals(COMPANY_ID, message.getString("$company_id"));
+ return message.get(operation);
+ }
+
+ private Object onlyUserUpdate(String operation) {
+ return onlyGroupsUpdate("$user_id", USER_ID, operation);
+ }
+
+ private Object onlyCompanyUpdate(String operation) {
+ return onlyGroupsUpdate("$company_id", COMPANY_ID, operation);
+ }
+
+ private Object onlyCustomGroupUpdate(String operation) {
+ return onlyGroupsUpdate("team_id", TEAM_ID, operation);
+ }
+
+ private static Set keys(JSONObject object) {
+ Set keys = new HashSet<>();
+ for (Iterator iter = object.keys(); iter.hasNext(); ) {
+ keys.add(iter.next());
+ }
+ return keys;
+ }
+
+ private static Set setOf(String... values) {
+ return Collections.unmodifiableSet(new HashSet<>(Arrays.asList(values)));
+ }
+
+ private static JSONObject json(Object... keyValues) {
+ JSONObject object = new JSONObject();
+ for (int i = 0; i < keyValues.length; i += 2) {
+ object.put((String) keyValues[i], keyValues[i + 1]);
+ }
+ return object;
+ }
+
+ private static Map unionOf(String key, Object... values) {
+ Map union = new HashMap<>();
+ union.put(key, new JSONArray(Arrays.asList(values)));
+ return union;
+ }
+
+ private static void assertJsonEquals(Object expected, Object actual) {
+ assertEquals(expected.toString(), actual.toString());
+ }
+
+ // Track (Anonymous)
+
+ @Test
+ public void testTrackAnonymous() {
+ send(mBuilder.event(MixpanelIdentity.builder().deviceId(DEVICE_ID).build(), "Page View", null));
+
+ JSONObject props = onlyTrack("Page View", "$device_id");
+ assertEquals(DEVICE_ID, props.getString("$device_id"));
+ }
+
+ // Track (Identified)
+
+ @Test
+ public void testTrackIdentified() {
+ send(mBuilder.event(IDENTIFIED_WITH_DEVICE, "Signup", null));
+
+ JSONObject props = onlyTrack("Signup", "$user_id", "$company_id", "$device_id");
+ assertEquals(USER_ID, props.getString("$user_id"));
+ assertEquals(COMPANY_ID, props.getString("$company_id"));
+ assertEquals(DEVICE_ID, props.getString("$device_id"));
+ }
+
+ // Track (Identified Without Device)
+
+ @Test
+ public void testTrackIdentifiedWithoutDevice() {
+ send(mBuilder.event(IDENTIFIED, "Signup", null));
+
+ JSONObject props = onlyTrack("Signup", "$user_id", "$company_id");
+ assertEquals(USER_ID, props.getString("$user_id"));
+ assertEquals(COMPANY_ID, props.getString("$company_id"));
+ }
+
+ // Track With Custom Group
+
+ @Test
+ public void testTrackWithCustomGroup() {
+ send(mBuilder.event(IDENTIFIED, "Signup", json("team_id", TEAM_ID)));
+
+ JSONObject props = onlyTrack("Signup", "$user_id", "$company_id", "team_id");
+ assertEquals(TEAM_ID, props.get("team_id"));
+ }
+
+ // Import (Identified)
+
+ @Test
+ public void testImportIdentified() {
+ send(mBuilder.importEvent(IDENTIFIED_WITH_DEVICE, "Signup", null));
+
+ JSONObject props = onlyEvent(IMPORT, "Signup", IMPORT_PROPERTY_KEYS, "$user_id", "$company_id", "$device_id");
+ assertEquals(USER_ID, props.getString("$user_id"));
+ assertEquals(COMPANY_ID, props.getString("$company_id"));
+ assertEquals(DEVICE_ID, props.getString("$device_id"));
+ }
+
+ // Identity Overrides Properties
+
+ @Test
+ public void testIdentityOverridesProperties() {
+ JSONObject caller = json("$user_id", "other_user", "$company_id", "other_company", "plan", "pro");
+ send(mBuilder.event(IDENTIFIED, "Signup", caller));
+
+ JSONObject props = onlyTrack("Signup", "$user_id", "$company_id", "plan");
+ assertEquals(USER_ID, props.getString("$user_id"));
+ assertEquals(COMPANY_ID, props.getString("$company_id"));
+ assertEquals("pro", props.getString("plan"));
+ }
+
+ // Track With Combined Distinct ID
+
+ @Test
+ public void testTrackWithCombinedDistinctId() {
+ send(mBuilder.event(COMBINED_DISTINCT_ID, "Signup", null));
+
+ JSONObject props = onlyTrack("Signup", "distinct_id");
+ assertEquals(COMBINED_DISTINCT_ID, props.getString("distinct_id"));
+ }
+
+ // Track With Combined Distinct ID Identity
+
+ @Test
+ public void testTrackWithCombinedDistinctIdIdentity() {
+ MixpanelIdentity identity = MixpanelIdentity.builder().distinctId(COMBINED_DISTINCT_ID).build();
+ send(mBuilder.event(identity, "Signup", null));
+
+ JSONObject props = onlyTrack("Signup", "distinct_id");
+ assertEquals(COMBINED_DISTINCT_ID, props.getString("distinct_id"));
+ }
+
+ // Set User Property
+
+ @Test
+ public void testSetUserProperty() {
+ send(mBuilder.set(IDENTIFIED, json("plan", "pro")));
+
+ assertJsonEquals(json("plan", "pro"), onlyUserUpdate("$set"));
+ }
+
+ // Set User Property With Combined Distinct ID
+
+ @Test
+ public void testSetUserPropertyWithCombinedDistinctId() {
+ send(mBuilder.set(COMBINED_DISTINCT_ID, json("plan", "pro")));
+
+ // $group_id and $company_id are parsed from the combined distinct id
+ assertJsonEquals(json("plan", "pro"), onlyUserUpdate("$set"));
+ }
+
+ // Set User Property With Combined Distinct ID Identity
+
+ @Test
+ public void testSetUserPropertyWithCombinedDistinctIdIdentity() {
+ MixpanelIdentity identity = MixpanelIdentity.builder().distinctId(COMBINED_DISTINCT_ID).build();
+ send(mBuilder.set(identity, json("plan", "pro")));
+
+ assertJsonEquals(json("plan", "pro"), onlyUserUpdate("$set"));
+ }
+
+ // Set User Property With Distinct ID (Unsupported)
+
+ @Test
+ public void testSetUserPropertyWithDistinctIdIsUnsupported() {
+ assertThrows(IllegalArgumentException.class, () -> mBuilder.set(USER_ID, json("plan", "pro")));
+ assertNothingSent();
+ }
+
+ // Set User Properties
+
+ @Test
+ public void testSetUserProperties() {
+ send(mBuilder.set(IDENTIFIED, json("plan", "pro", "seats", 5)));
+
+ JSONObject set = (JSONObject) onlyUserUpdate("$set");
+ assertEquals(setOf("plan", "seats"), keys(set));
+ assertEquals("pro", set.getString("plan"));
+ assertEquals(5, set.getInt("seats"));
+ }
+
+ // Set Once User Property
+
+ @Test
+ public void testSetOnceUserProperty() {
+ send(mBuilder.setOnce(IDENTIFIED, json("first_seen", "today")));
+
+ assertJsonEquals(json("first_seen", "today"), onlyUserUpdate("$set_once"));
+ }
+
+ // Union User Property
+
+ @Test
+ public void testUnionUserProperty() {
+ send(mBuilder.union(IDENTIFIED, unionOf("roles", "admin")));
+
+ assertJsonEquals(json("roles", new JSONArray().put("admin")), onlyUserUpdate("$union"));
+ }
+
+ // Remove User Property
+
+ @Test
+ public void testRemoveUserProperty() {
+ send(mBuilder.remove(IDENTIFIED, json("roles", "admin")));
+
+ assertJsonEquals(json("roles", "admin"), onlyUserUpdate("$remove"));
+ }
+
+ // Unset User Property
+
+ @Test
+ public void testUnsetUserProperty() {
+ send(mBuilder.unset(IDENTIFIED, Collections.singletonList("plan")));
+
+ assertJsonEquals(new JSONArray().put("plan"), onlyUserUpdate("$unset"));
+ }
+
+ // Delete User
+
+ @Test
+ public void testDeleteUser() {
+ send(mBuilder.delete(IDENTIFIED));
+
+ assertJsonEquals(new JSONObject(), onlyUserUpdate("$delete"));
+ }
+
+ // Increment User Property (Unsupported)
+
+ @Test
+ public void testIncrementUserPropertyIsUnsupported() {
+ Map increment = new HashMap<>();
+ increment.put("logins", 1L);
+
+ assertThrows(UnsupportedOperationException.class, () -> mBuilder.increment(USER_ID, increment));
+ assertNothingSent();
+ }
+
+ // Append User Property (Unsupported)
+
+ @Test
+ public void testAppendUserPropertyIsUnsupported() {
+ assertThrows(UnsupportedOperationException.class,
+ () -> mBuilder.append(USER_ID, json("roles", "admin")));
+ assertNothingSent();
+ }
+
+ // Track Charge (Unsupported)
+
+ @Test
+ public void testTrackChargeIsUnsupported() {
+ assertThrows(UnsupportedOperationException.class, () -> mBuilder.trackCharge(USER_ID, 9.99, null));
+ assertNothingSent();
+ }
+
+ // Set Company Property
+
+ @Test
+ public void testSetCompanyProperty() {
+ send(mCompany.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise")));
+
+ assertJsonEquals(json("tier", "enterprise"), onlyCompanyUpdate("$set"));
+ }
+
+ // Set Company Properties
+
+ @Test
+ public void testSetCompanyProperties() {
+ send(mCompany.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise", "seats", 50)));
+
+ JSONObject set = (JSONObject) onlyCompanyUpdate("$set");
+ assertEquals(setOf("tier", "seats"), keys(set));
+ assertEquals("enterprise", set.getString("tier"));
+ assertEquals(50, set.getInt("seats"));
+ }
+
+ // Set Once Company Property
+
+ @Test
+ public void testSetOnceCompanyProperty() {
+ send(mCompany.groupSetOnce("$company_id", COMPANY_ID, json("founded", "2020")));
+
+ assertJsonEquals(json("founded", "2020"), onlyCompanyUpdate("$set_once"));
+ }
+
+ // Union Company Property
+
+ @Test
+ public void testUnionCompanyProperty() {
+ send(mCompany.groupUnion("$company_id", COMPANY_ID, unionOf("regions", "us")));
+
+ assertJsonEquals(json("regions", new JSONArray().put("us")), onlyCompanyUpdate("$union"));
+ }
+
+ // Remove Company Property
+
+ @Test
+ public void testRemoveCompanyProperty() {
+ send(mCompany.groupRemove("$company_id", COMPANY_ID, json("regions", "us")));
+
+ assertJsonEquals(json("regions", "us"), onlyCompanyUpdate("$remove"));
+ }
+
+ // Unset Company Property
+
+ @Test
+ public void testUnsetCompanyProperty() {
+ send(mCompany.groupUnset("$company_id", COMPANY_ID, Collections.singletonList("tier")));
+
+ assertJsonEquals(new JSONArray().put("tier"), onlyCompanyUpdate("$unset"));
+ }
+
+ // Set User's Custom Group
+
+ @Test
+ public void testSetUsersCustomGroup() {
+ send(mCompany.set(IDENTIFIED, json("team_id", TEAM_ID)));
+
+ JSONObject set = (JSONObject) onlyUserUpdate("$set");
+ assertEquals(setOf("team_id"), keys(set));
+ assertEquals(TEAM_ID, set.get("team_id"));
+ }
+
+ // Remove User's Custom Group
+
+ @Test
+ public void testRemoveUsersCustomGroup() {
+ send(mCompany.unset(IDENTIFIED, Collections.singletonList("team_id")));
+
+ assertJsonEquals(new JSONArray().put("team_id"), onlyUserUpdate("$unset"));
+ }
+
+ // Set Custom Group Property
+
+ @Test
+ public void testSetCustomGroupProperty() {
+ send(mCompany.groupSet("team_id", TEAM_ID, json("name", "Platform")));
+
+ assertJsonEquals(json("name", "Platform"), onlyCustomGroupUpdate("$set"));
+ }
+
+ // Set Custom Group Properties
+
+ @Test
+ public void testSetCustomGroupProperties() {
+ send(mCompany.groupSet("team_id", TEAM_ID, json("name", "Platform", "size", 8)));
+
+ JSONObject set = (JSONObject) onlyCustomGroupUpdate("$set");
+ assertEquals(setOf("name", "size"), keys(set));
+ assertEquals("Platform", set.getString("name"));
+ assertEquals(8, set.getInt("size"));
+ }
+
+ // Set Once Custom Group Property
+
+ @Test
+ public void testSetOnceCustomGroupProperty() {
+ send(mCompany.groupSetOnce("team_id", TEAM_ID, json("created", "today")));
+
+ assertJsonEquals(json("created", "today"), onlyCustomGroupUpdate("$set_once"));
+ }
+
+ // Union Custom Group Property
+
+ @Test
+ public void testUnionCustomGroupProperty() {
+ send(mCompany.groupUnion("team_id", TEAM_ID, unionOf("tools", "jira")));
+
+ assertJsonEquals(json("tools", new JSONArray().put("jira")), onlyCustomGroupUpdate("$union"));
+ }
+
+ // Remove Custom Group Property
+
+ @Test
+ public void testRemoveCustomGroupProperty() {
+ send(mCompany.groupRemove("team_id", TEAM_ID, json("tools", "jira")));
+
+ assertJsonEquals(json("tools", "jira"), onlyCustomGroupUpdate("$remove"));
+ }
+
+ // Unset Custom Group Property
+
+ @Test
+ public void testUnsetCustomGroupProperty() {
+ send(mCompany.groupUnset("team_id", TEAM_ID, Collections.singletonList("name")));
+
+ assertJsonEquals(new JSONArray().put("name"), onlyCustomGroupUpdate("$unset"));
+ }
+
+ // Delete Company
+
+ @Test
+ public void testDeleteCompany() {
+ send(mCompany.groupDelete("$company_id", COMPANY_ID));
+
+ assertJsonEquals(new JSONObject(), onlyCompanyUpdate("$delete"));
+ }
+
+ // Delete Custom Group
+
+ @Test
+ public void testDeleteCustomGroup() {
+ send(mCompany.groupDelete("team_id", TEAM_ID));
+
+ assertJsonEquals(new JSONObject(), onlyCustomGroupUpdate("$delete"));
+ }
+
+ // Identify Without Company
+
+ @Test
+ public void testIdentifyWithoutCompany() {
+ MixpanelIdentity userOnly = MixpanelIdentity.builder().userId(USER_ID).build();
+
+ // The event is sent as given; a user profile needs the company.
+ assertThrows(IllegalStateException.class, () -> mBuilder.set(userOnly, json("plan", "pro")));
+ assertNothingSent();
+
+ send(mBuilder.event(userOnly, "Signup", null));
+ JSONObject props = onlyTrack("Signup", "$user_id");
+ assertEquals(USER_ID, props.getString("$user_id"));
+ }
+
+ // Set User Property Without User (Unsupported)
+
+ @Test
+ public void testSetUserPropertyWithoutUserIsUnsupported() {
+ MixpanelIdentity companyOnly = MixpanelIdentity.builder().companyId(COMPANY_ID).build();
+
+ assertThrows(IllegalArgumentException.class, () -> mBuilder.set(companyOnly, json("plan", "pro")));
+ assertNothingSent();
+ }
+
+ // Set Company Property Without Company (Unsupported)
+
+ @Test
+ public void testSetCompanyPropertyWithoutCompanyIsUnsupported() {
+ assertThrows(IllegalStateException.class,
+ () -> mBuilder.groupSet("$company_id", COMPANY_ID, json("tier", "enterprise")));
+ assertNothingSent();
+ }
+
+ // Set Custom Group Property Without Company (Unsupported)
+
+ @Test
+ public void testSetCustomGroupPropertyWithoutCompanyIsUnsupported() {
+ assertThrows(IllegalStateException.class,
+ () -> mBuilder.groupSet("team_id", TEAM_ID, json("name", "Platform")));
+ assertNothingSent();
+ }
+
+ // Company Identity Without Hierarchical Groups (Unsupported)
+
+ @Test
+ public void testCompanyIdentityWithoutHierarchicalGroupsIsUnsupported() {
+ assertThrows(IllegalArgumentException.class, () -> mLegacyBuilder.event(IDENTIFIED, "Signup", null));
+ assertThrows(IllegalArgumentException.class, () -> mLegacyBuilder.importEvent(IDENTIFIED, "Signup", null));
+ assertThrows(IllegalArgumentException.class, () -> mLegacyBuilder.set(IDENTIFIED, json("plan", "pro")));
+ assertNothingSent();
+ }
+
+ // Track With Distinct ID (without hierarchical groups)
+
+ @Test
+ public void testTrackWithDistinctId() {
+ send(mLegacyBuilder.event(USER_ID, "Signup", null));
+
+ JSONObject props = onlyTrack("Signup", "distinct_id");
+ assertEquals(USER_ID, props.getString("distinct_id"));
+ }
+
+ // Set User Property With Distinct ID (without hierarchical groups)
+
+ @Test
+ public void testSetUserPropertyWithDistinctId() {
+ send(mLegacyBuilder.set(USER_ID, json("plan", "pro")));
+
+ JSONObject message = onlySent(ENGAGE).body;
+ assertEquals(setOf("$token", "$distinct_id", "$time", "$set"), keys(message));
+ assertEquals(TOKEN, message.getString("$token"));
+ assertEquals(USER_ID, message.getString("$distinct_id"));
+ assertJsonEquals(json("plan", "pro"), message.get("$set"));
+ }
+
+ // Set Group Property (without hierarchical groups)
+
+ @Test
+ public void testSetGroupProperty() {
+ send(mLegacyBuilder.groupSet("team_id", TEAM_ID, json("name", "Platform")));
+
+ JSONObject message = onlySent(GROUPS).body;
+ assertEquals(setOf("$token", "$group_key", "$group_id", "$time", "$set"), keys(message));
+ assertEquals("team_id", message.getString("$group_key"));
+ assertEquals(TEAM_ID, message.getString("$group_id"));
+ assertFalse(message.has("$company_id"));
+ assertJsonEquals(json("name", "Platform"), message.get("$set"));
+ }
+}
diff --git a/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
new file mode 100644
index 0000000..a2db8ce
--- /dev/null
+++ b/src/test/java/com/mixpanel/mixpanelapi/HierarchicalGroupsTest.java
@@ -0,0 +1,954 @@
+package com.mixpanel.mixpanelapi;
+
+import com.mixpanel.mixpanelapi.featureflags.FlagContext;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import static org.junit.Assert.*;
+
+public class HierarchicalGroupsTest {
+
+ private static final String TOKEN = "test-token";
+
+ private MessageBuilder hgBuilder;
+ private MessageBuilder legacyBuilder;
+
+ @Before
+ public void setUp() {
+ hgBuilder = new MessageBuilder(TOKEN, true);
+ legacyBuilder = new MessageBuilder(TOKEN);
+ }
+
+ private static JSONObject eventProperties(JSONObject envelope) {
+ return envelope.getJSONObject("message").getJSONObject("properties");
+ }
+
+ // #region Events
+
+ @Test
+ public void testHierarchicalEventSendsUserAndCompanyWithoutDistinctId() {
+ JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Signup", null));
+
+ assertEquals("user_123", props.getString("$user_id"));
+ assertEquals("acme", props.getString("$company_id"));
+ assertFalse(props.has("distinct_id"));
+ assertFalse(props.has("$device_id"));
+ assertEquals(TOKEN, props.getString("token"));
+ }
+
+ @Test
+ public void testHierarchicalEventWithDeviceId() {
+ JSONObject props = eventProperties(
+ hgBuilder.event(MixpanelIdentity.builder().userId("user_123").companyId("acme").deviceId("device_abc").build(), "Signup", null));
+
+ assertEquals("device_abc", props.getString("$device_id"));
+ assertFalse(props.has("distinct_id"));
+ }
+
+ @Test
+ public void testCompanyOnlyEvent() {
+ JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().companyId("acme").build(), "Renewed", null));
+
+ assertEquals("acme", props.getString("$company_id"));
+ assertFalse(props.has("$user_id"));
+ assertFalse(props.has("distinct_id"));
+ }
+
+ @Test
+ public void testLegacyIdentitySendsDistinctId() {
+ JSONObject props = eventProperties(legacyBuilder.event(MixpanelIdentity.builder().distinctId("user_123").build(), "Signup", null));
+
+ assertEquals("user_123", props.getString("distinct_id"));
+ assertFalse(props.has("$user_id"));
+ assertFalse(props.has("$company_id"));
+ }
+
+ @Test
+ public void testIdentityOverridesCallerSuppliedKeysAndKeepsOtherProperties() {
+ JSONObject caller = new JSONObject();
+ caller.put("$company_id", "wrong");
+ caller.put("plan", "enterprise");
+
+ JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Signup", caller));
+
+ assertEquals("acme", props.getString("$company_id"));
+ assertEquals("enterprise", props.getString("plan"));
+ assertEquals("wrong", caller.getString("$company_id"));
+ }
+
+ @Test
+ public void testIdentityKeepsCallerSuppliedKeysItLeavesUnset() {
+ JSONObject caller = new JSONObject();
+ caller.put("distinct_id", "caller");
+ caller.put("$device_id", "caller_device");
+ caller.put("$user_id", "caller_user");
+ caller.put("$company_id", "wrong");
+ caller.put("plan", "enterprise");
+
+ JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().companyId("acme").build(), "Renewed", caller));
+
+ assertEquals("acme", props.getString("$company_id"));
+ assertEquals("caller", props.getString("distinct_id"));
+ assertEquals("caller_device", props.getString("$device_id"));
+ assertEquals("caller_user", props.getString("$user_id"));
+ assertEquals("enterprise", props.getString("plan"));
+ assertEquals("wrong", caller.getString("$company_id"));
+
+ JSONObject imported = eventProperties(hgBuilder.importEvent(
+ MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Renewed",
+ new JSONObject().put("distinct_id", "caller")));
+ assertEquals("caller", imported.getString("distinct_id"));
+ assertEquals("user_123", imported.getString("$user_id"));
+ }
+
+ @Test
+ public void testHierarchicalImportEvent() {
+ JSONObject envelope = hgBuilder.importEvent(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), "Signup", null);
+
+ assertEquals("import", envelope.getString("message_type"));
+ JSONObject props = eventProperties(envelope);
+ assertEquals("user_123", props.getString("$user_id"));
+ assertEquals("acme", props.getString("$company_id"));
+ assertFalse(props.has("distinct_id"));
+ assertTrue(props.has("$insert_id"));
+ }
+
+ @Test
+ public void testIdentityRejectsEmptyValues() {
+ // An empty string is a caller error; null simply leaves the value unset.
+ assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().userId(""));
+ assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().companyId(""));
+ assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().deviceId(""));
+ assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().distinctId("").build());
+ }
+
+ @Test
+ public void testIdentityNeedsAtLeastOneValue() {
+ assertThrows(IllegalArgumentException.class, () -> MixpanelIdentity.builder().build());
+ assertThrows(IllegalArgumentException.class,
+ () -> MixpanelIdentity.builder().userId(null).companyId(null).build());
+ }
+
+ @Test
+ public void testNullLeavesAValueUnset() {
+ MixpanelIdentity identity = MixpanelIdentity.builder().userId("user_123").deviceId(null).build();
+
+ assertEquals("user_123", identity.getUserId());
+ assertNull(identity.getDeviceId());
+ }
+
+ @Test
+ public void testUserAndDeviceWithoutCompanyIsValidForSimplifiedIdMerge() {
+ // $user_id with $device_id is the standard non-hierarchical server-side identity.
+ JSONObject props = eventProperties(legacyBuilder.event(
+ MixpanelIdentity.builder().userId("user_123").deviceId("device_abc").build(), "Signup", null));
+
+ assertEquals("user_123", props.getString("$user_id"));
+ assertEquals("device_abc", props.getString("$device_id"));
+ assertFalse(props.has("distinct_id"));
+ assertFalse(props.has("$company_id"));
+ }
+
+ @Test
+ public void testUserWithoutCompanyIsSentAsGivenForHierarchicalGroups() {
+ JSONObject props = eventProperties(hgBuilder.event(
+ MixpanelIdentity.builder().userId("user_123").build(), "Signup", null));
+
+ assertEquals("user_123", props.getString("$user_id"));
+ assertFalse(props.has("$company_id"));
+ }
+
+ @Test
+ public void testUserWithDeviceButNoCompanyIsAllowedForHierarchicalGroups() {
+ // Mixpanel ignores the user id and keeps the event under the device, which later links to
+ // the company-scoped user. Recoverable, so the library does not refuse it.
+ JSONObject props = eventProperties(hgBuilder.event(
+ MixpanelIdentity.builder().userId("user_123").deviceId("device_abc").build(), "Signup", null));
+
+ assertEquals("device_abc", props.getString("$device_id"));
+ assertFalse(props.has("$company_id"));
+ }
+
+ @Test
+ public void testAnonymousDeviceEventIsValidForHierarchicalGroups() {
+ JSONObject props = eventProperties(hgBuilder.event(
+ MixpanelIdentity.builder().deviceId("device_abc").build(), "Page View", null));
+
+ assertEquals("device_abc", props.getString("$device_id"));
+ assertFalse(props.has("distinct_id"));
+ assertFalse(props.has("$user_id"));
+ }
+
+ @Test
+ public void testCombinedDistinctIdEventPassesThrough() {
+ // Ingestion parses $company:{company}|$user:{user} back into $company_id and $user_id,
+ // so a pipeline that can only carry one id resolves to the same user as the identity form.
+ String combined = "$company:acme|$user:user_123";
+
+ for (JSONObject envelope : Arrays.asList(
+ hgBuilder.event(combined, "Signup", null),
+ hgBuilder.event(MixpanelIdentity.builder().distinctId(combined).build(), "Signup", null))) {
+ JSONObject props = eventProperties(envelope);
+ assertEquals(combined, props.getString("distinct_id"));
+ assertFalse(props.has("$user_id"));
+ }
+ }
+
+ @Test
+ public void testRawDistinctIdEventsAreNotRefused() {
+ // Not the recommended form, but it is valid data that Mixpanel accepts, so the library
+ // documents the consequence rather than refusing to build the message.
+ assertEquals("user_123", eventProperties(hgBuilder.event("user_123", "Signup", null))
+ .getString("distinct_id"));
+ assertEquals("user_123", eventProperties(hgBuilder.importEvent("user_123", "Signup", null))
+ .getString("distinct_id"));
+ assertEquals("user_123", eventProperties(legacyBuilder.event("user_123", "Signup", null))
+ .getString("distinct_id"));
+ }
+
+ @Test
+ public void testAnonymousDeviceEvent() {
+ JSONObject props = eventProperties(hgBuilder.event(MixpanelIdentity.builder().deviceId("device_abc").build(), "Page View", null));
+
+ assertEquals("device_abc", props.getString("$device_id"));
+ assertFalse(props.has("distinct_id"));
+ assertFalse(props.has("$user_id"));
+ assertFalse(props.has("$company_id"));
+ }
+
+ @Test
+ public void testAnonymousDeviceEventWorksForLegacyProjects() {
+ JSONObject props = eventProperties(legacyBuilder.event(MixpanelIdentity.builder().deviceId("device_abc").build(), "Page View", null));
+
+ assertEquals("device_abc", props.getString("$device_id"));
+ assertFalse(props.has("distinct_id"));
+ }
+
+ @Test
+ public void testDeviceIdentityCannotIdentifyAProfile() {
+ JSONObject props = new JSONObject().put("plan", "pro");
+
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.set(MixpanelIdentity.builder().deviceId("device_abc").build(), props));
+ assertThrows(IllegalArgumentException.class,
+ () -> legacyBuilder.set(MixpanelIdentity.builder().deviceId("device_abc").build(), props));
+ }
+
+ // #endregion
+ // #region User profiles
+
+ @Test
+ public void testAddAndAppendThrowForHierarchicalGroups() {
+ // A hierarchical groups user profile is a group profile, and /groups has no $add or $append,
+ // so these have no valid path at all.
+ JSONObject props = new JSONObject().put("plan", "pro");
+ Class expected = UnsupportedOperationException.class;
+
+ assertThrows(expected, () -> hgBuilder.increment("user_123", Collections.singletonMap("logins", 1L)));
+ assertThrows(expected, () -> hgBuilder.append("user_123", props));
+ assertThrows(expected, () -> hgBuilder.trackCharge("user_123", 9.99, null));
+ assertThrows(expected, () -> hgBuilder.peopleMessage("user_123", "$add", props, null));
+ assertThrows(expected, () -> hgBuilder.peopleMessage("user_123", "$append", props, null));
+ }
+
+ @Test
+ public void testSupportedPeopleMethodsWorkForHierarchicalGroups() {
+ // A combined distinct id is parsed into its company and user and sent to /groups.
+ JSONObject props = new JSONObject().put("plan", "pro");
+ String id = "$company:acme|$user:user_123";
+
+ JSONObject[] envelopes = {
+ hgBuilder.set(id, props),
+ hgBuilder.setOnce(id, props),
+ hgBuilder.delete(id),
+ hgBuilder.remove(id, props),
+ hgBuilder.union(id, Collections.singletonMap("tags", new JSONArray().put("a"))),
+ hgBuilder.unset(id, Arrays.asList("plan")),
+ };
+ String[] operations = {"$set", "$set_once", "$delete", "$remove", "$union", "$unset"};
+ for (int i = 0; i < envelopes.length; i++) {
+ assertUserGroupMessage(envelopes[i], "acme", "user_123");
+ assertTrue(operations[i], envelopes[i].getJSONObject("message").has(operations[i]));
+ }
+ }
+
+ private static void assertUserGroupMessage(JSONObject envelope, String companyId, String userId) {
+ assertEquals("group", envelope.getString("message_type"));
+ JSONObject message = envelope.getJSONObject("message");
+ assertEquals(TOKEN, message.getString("$token"));
+ assertEquals("$user_id", message.getString("$group_key"));
+ assertEquals(userId, message.getString("$group_id"));
+ assertEquals(companyId, message.getString("$company_id"));
+ assertFalse(message.has("$distinct_id"));
+ }
+
+ @Test
+ public void testRawDistinctIdProfileUpdateIsRejectedForHierarchicalGroups() {
+ JSONObject props = new JSONObject().put("plan", "pro");
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> hgBuilder.set("user_123", props));
+ assertTrue(e.getMessage(), e.getMessage().contains("$company:{company}|$user:{user}"));
+ assertTrue(e.getMessage(), e.getMessage().contains("MixpanelIdentity"));
+
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.setOnce("user_123", props));
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.delete("user_123"));
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.remove("user_123", props));
+ assertThrows(IllegalArgumentException.class,
+ () -> hgBuilder.union("user_123", Collections.singletonMap("tags", new JSONArray().put("a"))));
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.unset("user_123", Arrays.asList("plan")));
+ assertThrows(IllegalArgumentException.class,
+ () -> hgBuilder.set(MixpanelIdentity.builder().distinctId("user_123").build(), props));
+ assertThrows(IllegalArgumentException.class,
+ () -> hgBuilder.forCompany("acme").set("user_123", props));
+ }
+
+ @Test
+ public void testMalformedCombinedDistinctIdIsRejected() {
+ JSONObject props = new JSONObject().put("plan", "pro");
+ String[] malformed = {
+ "$company:acme|$user:", // missing user
+ "$company:|$user:user_123", // missing company
+ "$company:acme", // no user part
+ "$org:acme|$user:user_123", // wrong prefix
+ "company:acme|$user:user_123",
+ "$user:user_123|$company:acme",
+ "",
+ };
+ for (String id : malformed) {
+ assertThrows(id, IllegalArgumentException.class, () -> hgBuilder.set(id, props));
+ assertThrows(id, IllegalArgumentException.class,
+ () -> hgBuilder.peopleMessage(id, "$set", props, null));
+ }
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.set((String) null, props));
+ }
+
+ @Test
+ public void testCombinedDistinctIdSplitsOnFirstUserSeparator() {
+ JSONObject envelope = hgBuilder.set("$company:acme|$user:user|$user:123", new JSONObject().put("a", 1));
+
+ assertUserGroupMessage(envelope, "acme", "user|$user:123");
+ }
+
+ @Test
+ public void testForCompanyRefusesACombinedDistinctIdFromAnotherCompany() {
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> hgBuilder.forCompany("other").set("$company:acme|$user:user_123", new JSONObject().put("a", 1)));
+
+ assertTrue(e.getMessage().contains("belongs to company \"acme\""));
+ }
+
+ @Test
+ public void testGenericPeopleMessageRewritesCombinedDistinctId() {
+ JSONObject modifiers = new JSONObject().put("$ip", "1.2.3.4").put("$ignore_time", true);
+ JSONObject envelope = hgBuilder.peopleMessage("$company:acme|$user:user_123", "$set",
+ new JSONObject().put("plan", "pro"), modifiers);
+
+ assertUserGroupMessage(envelope, "acme", "user_123");
+ JSONObject message = envelope.getJSONObject("message");
+ assertEquals("pro", message.getJSONObject("$set").getString("plan"));
+ assertEquals("1.2.3.4", message.getString("$ip"));
+ assertTrue(message.getBoolean("$ignore_time"));
+ assertTrue(message.has("$time"));
+ }
+
+ @Test
+ public void testGenericPeopleMessageAppliesRuleToDistinctIdModifier() {
+ JSONObject props = new JSONObject().put("plan", "pro");
+ JSONObject modifiers = new JSONObject().put("$distinct_id", "$company:acme|$user:user_123");
+
+ JSONObject envelope = hgBuilder.peopleMessage("ignored", "$set", props, modifiers);
+ assertUserGroupMessage(envelope, "acme", "user_123");
+ // The caller's modifiers are not changed
+ assertEquals("$company:acme|$user:user_123", modifiers.getString("$distinct_id"));
+
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.peopleMessage("$company:acme|$user:user_123",
+ "$set", props, new JSONObject().put("$distinct_id", "user_123")));
+ }
+
+ @Test
+ public void testUnsupportedOperationIsReportedBeforeDistinctId() {
+ JSONObject props = new JSONObject().put("plan", "pro");
+
+ assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.peopleMessage("$company:acme|$user:user_123", "$add", props, null));
+ assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.peopleMessage("not_combined", "$append", props, null));
+ }
+
+ @Test
+ public void testUnsupportedModifierOperationThrowsOnHierarchicalUserProfile() {
+ JSONObject props = new JSONObject().put("plan", "pro");
+ for (String operation : Arrays.asList("$add", "$append", "$merge", "$foo")) {
+ JSONObject modifiers = new JSONObject().put(operation, new JSONObject().put("logins", 1));
+ UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.peopleMessage("$company:acme|$user:user_123", "$set", props, modifiers));
+ assertTrue(e.getMessage(), e.getMessage().startsWith(operation + " is not supported"));
+ // Before the distinct id is checked, as for the actionType
+ assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.peopleMessage("not_combined", "$set", props, modifiers));
+ assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(),
+ props, modifiers));
+ }
+ }
+
+ @Test
+ public void testNonOperationAndSupportedModifiersBuildOnHierarchicalUserProfile() {
+ JSONObject modifiers = new JSONObject()
+ .put("$token", TOKEN).put("$time", 1L).put("$ip", "1.2.3.4").put("$ignore_time", true)
+ .put("$ignore_alias", true).put("$mp_metadata", new JSONObject()).put("$company_id", "acme")
+ .put("$group_key", "$user_id").put("$group_id", "user_123")
+ .put("$unset", new JSONArray().put("old")).put("custom", 1);
+ JSONObject envelope = hgBuilder.peopleMessage("$company:acme|$user:user_123", "$set",
+ new JSONObject().put("plan", "pro"), modifiers);
+
+ assertUserGroupMessage(envelope, "acme", "user_123");
+ JSONObject message = envelope.getJSONObject("message");
+ assertEquals("pro", message.getJSONObject("$set").getString("plan"));
+ assertEquals("old", message.getJSONArray("$unset").getString(0));
+ }
+
+ @Test
+ public void testLegacyPeopleMessageCopiesAnyModifier() {
+ JSONObject envelope = legacyBuilder.peopleMessage("user_123", "$set", new JSONObject().put("plan", "pro"),
+ new JSONObject().put("$add", new JSONObject().put("logins", 1)).put("$foo", 1));
+
+ JSONObject message = envelope.getJSONObject("message");
+ assertEquals("people", envelope.getString("message_type"));
+ assertEquals(1, message.getJSONObject("$add").getInt("logins"));
+ assertEquals(1, message.getInt("$foo"));
+ }
+
+ @Test
+ public void testLegacyGenericPeopleMessageKeepsDistinctId() {
+ JSONObject envelope = legacyBuilder.peopleMessage("$company:acme|$user:user_123", "$set",
+ new JSONObject().put("plan", "pro"), null);
+
+ assertEquals("people", envelope.getString("message_type"));
+ JSONObject message = envelope.getJSONObject("message");
+ assertEquals("$company:acme|$user:user_123", message.getString("$distinct_id"));
+ assertFalse(message.has("$group_key"));
+ assertFalse(message.has("$company_id"));
+ }
+
+ @Test
+ public void testAddAndAppendErrorNamesWhatIsSupported() {
+ UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.increment("user_123", Collections.singletonMap("logins", 1L)));
+
+ // The whole point of throwing is that the message reaches whoever wrote the call.
+ assertTrue(e.getMessage(), e.getMessage().contains("$add"));
+ assertTrue(e.getMessage(), e.getMessage().contains("increment, append and trackCharge"));
+ }
+
+ @Test
+ public void testPeopleMethodsUnchangedForLegacy() {
+ JSONObject envelope = legacyBuilder.set("user_123", new JSONObject().put("plan", "pro"));
+
+ assertNotNull(envelope);
+ assertEquals("people", envelope.getString("message_type"));
+ assertEquals("user_123", envelope.getJSONObject("message").getString("$distinct_id"));
+ }
+
+ @Test
+ public void testDeliveryRejectsNullMessages() {
+ ClientDelivery delivery = new ClientDelivery();
+
+ assertFalse(delivery.isValidMessage(null));
+ assertThrows(MixpanelMessageException.class, () -> delivery.addMessage(null));
+
+ assertTrue(delivery.getPeopleMessages().isEmpty());
+ assertTrue(delivery.getEventsMessages().isEmpty());
+ }
+
+ @Test
+ public void testIdentitySetReroutesToUserGroupProfile() {
+ JSONObject envelope = hgBuilder.set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), new JSONObject().put("role", "admin"));
+
+ assertEquals("group", envelope.getString("message_type"));
+ JSONObject message = envelope.getJSONObject("message");
+ assertEquals("$user_id", message.getString("$group_key"));
+ assertEquals("user_123", message.getString("$group_id"));
+ assertEquals("acme", message.getString("$company_id"));
+ assertEquals("admin", message.getJSONObject("$set").getString("role"));
+ assertFalse(message.has("$distinct_id"));
+ }
+
+ @Test
+ public void testEveryIdentityProfileOperationReroutes() {
+ MixpanelIdentity identity = MixpanelIdentity.builder().userId("user_123").companyId("acme").build();
+ JSONObject props = new JSONObject().put("plan", "pro");
+
+ JSONObject[] envelopes = {
+ hgBuilder.set(identity, props),
+ hgBuilder.setOnce(identity, props),
+ hgBuilder.delete(identity),
+ hgBuilder.remove(identity, props),
+ hgBuilder.union(identity, Collections.singletonMap("tags", new JSONArray().put("a"))),
+ hgBuilder.unset(identity, Arrays.asList("plan")),
+ };
+ String[] operations = {"$set", "$set_once", "$delete", "$remove", "$union", "$unset"};
+ for (int i = 0; i < envelopes.length; i++) {
+ assertEquals("group", envelopes[i].getString("message_type"));
+ JSONObject message = envelopes[i].getJSONObject("message");
+ assertTrue(operations[i], message.has(operations[i]));
+ assertEquals("acme", message.getString("$company_id"));
+ assertEquals("user_123", message.getString("$group_id"));
+ }
+ }
+
+ @Test
+ public void testHierarchicalIdentityRequiresHierarchicalBuilder() {
+ MixpanelIdentity identity = MixpanelIdentity.builder().userId("user_123").companyId("acme").build();
+ JSONObject props = new JSONObject().put("role", "admin");
+
+ assertThrows(IllegalArgumentException.class, () -> legacyBuilder.set(identity, props));
+ assertThrows(IllegalArgumentException.class, () -> legacyBuilder.event(identity, "Signup", null));
+ assertThrows(IllegalArgumentException.class, () -> legacyBuilder.importEvent(identity, "Signup", null));
+ assertThrows(IllegalArgumentException.class,
+ () -> legacyBuilder.event(MixpanelIdentity.builder().companyId("acme").build(), "Renewed", null));
+ }
+
+ @Test
+ public void testForCompanyAcceptsAUserFromTheBoundCompany() {
+ JSONObject envelope = hgBuilder.forCompany("acme")
+ .set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(), new JSONObject().put("role", "admin"));
+
+ assertEquals("acme", envelope.getJSONObject("message").getString("$company_id"));
+ }
+
+ @Test
+ public void testForCompanyRefusesAUserFromAnotherCompany() {
+ MixpanelIdentity user = MixpanelIdentity.builder().userId("user_123").companyId("acme").build();
+
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> hgBuilder.forCompany("other").set(user, new JSONObject().put("role", "admin")));
+
+ assertTrue(e.getMessage().contains("bound to company \"other\""));
+ }
+
+ @Test
+ public void testForCompanyUpdatesAUserOnlyIdentityWithinTheBoundCompany() {
+ MixpanelIdentity userOnly = MixpanelIdentity.builder().userId("user_123").build();
+ MessageBuilder acme = hgBuilder.forCompany("acme");
+ JSONObject props = new JSONObject().put("role", "admin");
+
+ assertUserGroupMessage(acme.set(userOnly, props), "acme", "user_123");
+ assertUserGroupMessage(acme.set(userOnly, props, new JSONObject().put("$company_id", "globex")),
+ "acme", "user_123");
+ }
+
+ @Test
+ public void testUserOnlyIdentityUsesCompanyIdModifierWithoutBoundCompany() {
+ MixpanelIdentity userOnly = MixpanelIdentity.builder().userId("user_123").build();
+ JSONObject props = new JSONObject().put("role", "admin");
+
+ assertUserGroupMessage(hgBuilder.set(userOnly, props, new JSONObject().put("$company_id", "acme")),
+ "acme", "user_123");
+ assertThrows(IllegalStateException.class, () -> hgBuilder.set(userOnly, props));
+ }
+
+ @Test
+ public void testIdentityCompanyWinsOverCompanyIdModifier() {
+ MixpanelIdentity user = MixpanelIdentity.builder().userId("user_123").companyId("acme").build();
+
+ assertUserGroupMessage(hgBuilder.set(user, new JSONObject().put("role", "admin"),
+ new JSONObject().put("$company_id", "globex")), "acme", "user_123");
+ }
+
+ @Test
+ public void testEmptyCompanyIdModifierIsNotACompany() {
+ MixpanelIdentity userOnly = MixpanelIdentity.builder().userId("user_123").build();
+ JSONObject props = new JSONObject().put("role", "admin");
+
+ for (Object empty : Arrays.asList("", JSONObject.NULL)) {
+ JSONObject modifiers = new JSONObject().put("$company_id", empty);
+ assertThrows(IllegalStateException.class, () -> hgBuilder.set(userOnly, props, modifiers));
+ assertThrows(IllegalStateException.class, () -> hgBuilder.groupSet("department", "finance", props, modifiers));
+ }
+ }
+
+ @Test
+ public void testLegacyIdentityUsesEngage() {
+ JSONObject envelope = legacyBuilder.set(MixpanelIdentity.builder().distinctId("user_123").build(), new JSONObject().put("role", "admin"));
+
+ assertEquals("people", envelope.getString("message_type"));
+ assertEquals("user_123", envelope.getJSONObject("message").getString("$distinct_id"));
+ }
+
+ @Test
+ public void testCombinedDistinctIdIdentityProfileUpdateGoesToGroups() {
+ JSONObject envelope = hgBuilder.set(MixpanelIdentity.builder().distinctId("$company:acme|$user:user_123").build(),
+ new JSONObject().put("role", "admin"));
+
+ assertUserGroupMessage(envelope, "acme", "user_123");
+ assertEquals("admin", envelope.getJSONObject("message").getJSONObject("$set").getString("role"));
+ }
+
+ @Test
+ public void testCompanyOnlyIdentityProfileUpdateIsRejected() {
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+ () -> hgBuilder.set(MixpanelIdentity.builder().companyId("acme").build(), new JSONObject().put("tier", "ent")));
+
+ assertTrue(e.getMessage(), e.getMessage().contains("companySet"));
+ }
+
+ @Test
+ public void testIdentityProfileUpdateRejectsNullIdentity() {
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.set((MixpanelIdentity) null, new JSONObject()));
+ }
+
+ // #endregion
+ // #region Group profiles
+
+ @Test
+ public void testForCompanyAddsTopLevelCompanyId() {
+ JSONObject message = hgBuilder.forCompany("acme")
+ .groupSet("department", "finance", new JSONObject().put("plan", "enterprise"))
+ .getJSONObject("message");
+
+ assertEquals("acme", message.getString("$company_id"));
+ assertEquals("department", message.getString("$group_key"));
+ assertEquals("finance", message.getString("$group_id"));
+ assertFalse(message.getJSONObject("$set").has("$company_id"));
+ }
+
+ @Test
+ public void testUserAndCompanyProfilesHaveBareGroupIds() {
+ MessageBuilder acme = hgBuilder.forCompany("acme");
+
+ JSONObject user = acme.groupSet("$user_id", "user_123", new JSONObject().put("role", "admin"))
+ .getJSONObject("message");
+ assertEquals("$user_id", user.getString("$group_key"));
+ assertEquals("user_123", user.getString("$group_id"));
+ assertEquals("acme", user.getString("$company_id"));
+
+ JSONObject company = acme.groupSet("$company_id", "acme", new JSONObject().put("tier", "ent"))
+ .getJSONObject("message");
+ assertEquals("$company_id", company.getString("$group_key"));
+ assertEquals("acme", company.getString("$group_id"));
+ assertEquals("acme", company.getString("$company_id"));
+ }
+
+ @Test
+ public void testEveryGroupOperationCarriesCompany() {
+ MessageBuilder acme = hgBuilder.forCompany("acme");
+ JSONObject props = new JSONObject().put("plan", "enterprise");
+
+ JSONObject[] messages = {
+ acme.groupSet("department", "finance", props),
+ acme.groupSetOnce("department", "finance", props),
+ acme.groupDelete("department", "finance"),
+ acme.groupRemove("department", "finance", props),
+ acme.groupUnion("department", "finance", Collections.singletonMap("tags", new JSONArray().put("a"))),
+ acme.groupUnset("department", "finance", Arrays.asList("plan")),
+ };
+ for (JSONObject envelope : messages) {
+ assertEquals("acme", envelope.getJSONObject("message").getString("$company_id"));
+ }
+ }
+
+ @Test
+ public void testHierarchicalGroupWithoutCompanyIsRejected() {
+ IllegalStateException e = assertThrows(IllegalStateException.class,
+ () -> hgBuilder.groupSet("department", "finance", new JSONObject().put("plan", "enterprise")));
+
+ assertTrue(e.getMessage(), e.getMessage().contains("forCompany"));
+ }
+
+ @Test
+ public void testCompanyIdModifierIsAccepted() {
+ JSONObject modifiers = new JSONObject().put("$company_id", "acme");
+ JSONObject envelope = hgBuilder.groupSet("department", "finance", new JSONObject().put("plan", "x"), modifiers);
+
+ assertNotNull(envelope);
+ assertEquals("acme", envelope.getJSONObject("message").getString("$company_id"));
+ }
+
+ @Test
+ public void testForCompanyWinsOverModifier() {
+ JSONObject modifiers = new JSONObject().put("$company_id", "other");
+ JSONObject envelope = hgBuilder.forCompany("acme")
+ .groupSet("department", "finance", new JSONObject().put("plan", "x"), modifiers);
+
+ assertEquals("acme", envelope.getJSONObject("message").getString("$company_id"));
+ }
+
+ @Test
+ public void testLegacyGroupUnchanged() {
+ JSONObject message = legacyBuilder.groupSet("company", "Acme Inc.", new JSONObject().put("plan", "x"))
+ .getJSONObject("message");
+
+ assertFalse(message.has("$company_id"));
+ assertEquals("company", message.getString("$group_key"));
+ }
+
+ @Test
+ public void testUnsupportedGroupOperationStillBuildsMessage() {
+ JSONObject envelope = legacyBuilder.groupMessage("company", "Acme Inc.", "$add",
+ new JSONObject().put("seats", 1), null);
+
+ assertNotNull(envelope);
+ assertTrue(envelope.getJSONObject("message").has("$add"));
+ }
+
+ @Test
+ public void testForCompanyRejectsEmpty() {
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.forCompany(""));
+ assertThrows(IllegalArgumentException.class, () -> hgBuilder.forCompany(null));
+ }
+
+ @Test
+ public void testForCompanyKeepsAddAndAppendRejected() {
+ assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.forCompany("acme").increment("user_123", Collections.singletonMap("logins", 1L)));
+ }
+
+ @Test
+ public void testUnsupportedGroupOperationThrows() {
+ MessageBuilder acme = hgBuilder.forCompany("acme");
+ for (String operation : Arrays.asList("$add", "$append", "$merge")) {
+ UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class,
+ () -> acme.groupMessage("company", "acme", operation, new JSONObject().put("seats", 1), null));
+ assertTrue(e.getMessage(), e.getMessage().startsWith(operation + " is not supported"));
+ }
+ }
+
+ @Test
+ public void testUnsupportedGroupModifierOperationThrows() {
+ MessageBuilder acme = hgBuilder.forCompany("acme");
+ JSONObject properties = new JSONObject().put("seats", 1);
+ for (String operation : Arrays.asList("$add", "$append", "$merge", "$foo")) {
+ JSONObject modifiers = new JSONObject().put(operation, properties);
+ UnsupportedOperationException e = assertThrows(UnsupportedOperationException.class,
+ () -> acme.groupMessage("company", "acme", "$set", properties, modifiers));
+ assertTrue(e.getMessage(), e.getMessage().startsWith(operation + " is not supported"));
+ assertThrows(UnsupportedOperationException.class,
+ () -> acme.groupSet("company", "acme", properties, modifiers));
+ // Before the company check, as for the actionType
+ assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.groupMessage("company", "acme", "$set", properties, modifiers));
+ }
+ }
+
+ @Test
+ public void testNonOperationGroupModifiersBuild() {
+ JSONObject modifiers = new JSONObject()
+ .put("$token", TOKEN).put("$time", 1L).put("$ip", "1.2.3.4").put("$ignore_time", true)
+ .put("$ignore_alias", true).put("$mp_metadata", new JSONObject()).put("$distinct_id", "x")
+ .put("$company_id", "acme").put("$remove", new JSONObject().put("tags", "a"));
+ JSONObject envelope = hgBuilder.groupMessage("company", "acme", "$set", new JSONObject().put("seats", 1),
+ modifiers);
+
+ JSONObject message = envelope.getJSONObject("message");
+ assertEquals("acme", message.getString("$company_id"));
+ assertEquals(1, message.getJSONObject("$set").getInt("seats"));
+ assertEquals("a", message.getJSONObject("$remove").getString("tags"));
+ }
+
+ @Test
+ public void testLegacyGroupMessageCopiesAnyModifier() {
+ JSONObject envelope = legacyBuilder.groupMessage("company", "acme", "$set", new JSONObject().put("seats", 1),
+ new JSONObject().put("$add", new JSONObject().put("seats", 1)).put("$foo", 1));
+
+ JSONObject message = envelope.getJSONObject("message");
+ assertEquals(1, message.getJSONObject("$add").getInt("seats"));
+ assertEquals(1, message.getInt("$foo"));
+ }
+
+ @Test
+ public void testUnsupportedGroupOperationThrowsBeforeCompanyCheck() {
+ assertThrows(UnsupportedOperationException.class,
+ () -> hgBuilder.groupMessage("company", "acme", "$add", new JSONObject().put("seats", 1), null));
+ }
+
+ @Test
+ public void testSupportedGroupOperationsBuild() {
+ JSONObject properties = new JSONObject().put("seats", 1);
+ MessageBuilder acme = hgBuilder.forCompany("acme");
+
+ assertNotNull(acme.groupSet("department", "finance", properties));
+ assertNotNull(acme.groupSetOnce("department", "finance", properties));
+ assertNotNull(acme.groupUnset("department", "finance", Arrays.asList("seats")));
+ assertNotNull(acme.groupUnion("department", "finance",
+ Collections.singletonMap("tags", new JSONArray().put("a"))));
+ assertNotNull(acme.groupRemove("department", "finance", properties));
+ assertNotNull(acme.groupDelete("department", "finance"));
+ }
+
+ /** A company method call, followed by the equivalent group method call with the company named twice. */
+ private static List> companyAndGroupCalls(JSONObject modifiers) {
+ JSONObject props = new JSONObject().put("plan", "enterprise");
+ Map union = Collections.singletonMap("tags", new JSONArray().put("a"));
+ List unset = Arrays.asList("plan");
+ if (modifiers == null) {
+ return Arrays.asList(
+ b -> b.companySet(props), b -> b.groupSet("$company_id", "acme", props),
+ b -> b.companySetOnce(props), b -> b.groupSetOnce("$company_id", "acme", props),
+ b -> b.companyDelete(), b -> b.groupDelete("$company_id", "acme"),
+ b -> b.companyRemove(props), b -> b.groupRemove("$company_id", "acme", props),
+ b -> b.companyUnion(union), b -> b.groupUnion("$company_id", "acme", union),
+ b -> b.companyUnset(unset), b -> b.groupUnset("$company_id", "acme", unset));
+ }
+ return Arrays.asList(
+ b -> b.companySet(props, modifiers), b -> b.groupSet("$company_id", "acme", props, modifiers),
+ b -> b.companySetOnce(props, modifiers), b -> b.groupSetOnce("$company_id", "acme", props, modifiers),
+ b -> b.companyDelete(modifiers), b -> b.groupDelete("$company_id", "acme", modifiers),
+ b -> b.companyRemove(props, modifiers), b -> b.groupRemove("$company_id", "acme", props, modifiers),
+ b -> b.companyUnion(union, modifiers), b -> b.groupUnion("$company_id", "acme", union, modifiers),
+ b -> b.companyUnset(unset, modifiers), b -> b.groupUnset("$company_id", "acme", unset, modifiers));
+ }
+
+ private static void assertCompanyMethodsMatchGroupMethods(MessageBuilder acme, JSONObject modifiers) {
+ List> calls = companyAndGroupCalls(modifiers);
+ for (int i = 0; i < calls.size(); i += 2) {
+ JSONObject company = calls.get(i).apply(acme);
+ JSONObject group = calls.get(i + 1).apply(acme);
+ if (modifiers == null) {
+ // $time is the build time, so it can differ between the two calls.
+ company.getJSONObject("message").remove("$time");
+ group.getJSONObject("message").remove("$time");
+ }
+ assertEquals(group.toString(), company.toString());
+
+ JSONObject message = company.getJSONObject("message");
+ assertEquals("$company_id", message.getString("$group_key"));
+ assertEquals("acme", message.getString("$group_id"));
+ assertEquals("acme", message.getString("$company_id"));
+ }
+ }
+
+ @Test
+ public void testCompanyMethodsMatchGroupMethodsWithBoundCompany() {
+ assertCompanyMethodsMatchGroupMethods(hgBuilder.forCompany("acme"), null);
+ assertCompanyMethodsMatchGroupMethods(legacyBuilder.forCompany("acme"), null);
+ }
+
+ @Test
+ public void testCompanyMethodsWithModifiersMatchGroupMethodsWithBoundCompany() {
+ JSONObject modifiers = new JSONObject().put("$time", 1234567890L).put("$ignore_time", true);
+ assertCompanyMethodsMatchGroupMethods(hgBuilder.forCompany("acme"), modifiers);
+ assertCompanyMethodsMatchGroupMethods(legacyBuilder.forCompany("acme"), modifiers);
+ }
+
+ @Test
+ public void testCompanyMethodsWithoutBoundCompanyAreRejected() {
+ String[] names = {"companySet", "companySetOnce", "companyDelete", "companyRemove", "companyUnion",
+ "companyUnset"};
+ for (JSONObject modifiers : Arrays.asList(null, new JSONObject().put("$company_id", "acme"))) {
+ List> calls = companyAndGroupCalls(modifiers);
+ for (MessageBuilder unbound : Arrays.asList(hgBuilder, legacyBuilder)) {
+ for (int i = 0; i < calls.size(); i += 2) {
+ Function call = calls.get(i);
+ IllegalStateException e = assertThrows(IllegalStateException.class, () -> call.apply(unbound));
+ assertTrue(e.getMessage(), e.getMessage().contains("forCompany"));
+ assertTrue(e.getMessage(), e.getMessage().startsWith(names[i / 2]));
+ }
+ }
+ }
+ }
+
+ // #endregion
+ // #region Flag context
+
+ @Test
+ public void testFlagContextFromHierarchicalIdentity() {
+ Map context = FlagContext.builder()
+ .identity(MixpanelIdentity.builder().userId("user_123").companyId("acme").deviceId("device_abc").build())
+ .group("department", "finance")
+ .customProperty("plan", "enterprise")
+ .build();
+
+ assertEquals("user_123", context.get("$user_id"));
+ assertEquals("acme", context.get("$company_id"));
+ assertEquals("device_abc", context.get("device_id"));
+ assertEquals("finance", context.get("department"));
+ assertFalse(context.containsKey("distinct_id"));
+ assertEquals("enterprise", ((Map, ?>) context.get("custom_properties")).get("plan"));
+ }
+
+ @Test
+ public void testFlagContextIdentityWinsOverGroupsWhateverTheOrder() {
+ MixpanelIdentity identity = MixpanelIdentity.builder().distinctId("d1").userId("user_123")
+ .companyId("acme").deviceId("device_abc").build();
+
+ Map groupsFirst = FlagContext.builder()
+ .group("distinct_id", "g").group("$user_id", "g").group("$company_id", "g").group("device_id", "g")
+ .group("custom_properties", "g")
+ .customProperty("plan", "enterprise")
+ .identity(identity)
+ .build();
+ Map identityFirst = FlagContext.builder()
+ .identity(identity)
+ .customProperty("plan", "enterprise")
+ .group("distinct_id", "g").group("$user_id", "g").group("$company_id", "g").group("device_id", "g")
+ .build();
+
+ for (Map context : Arrays.asList(groupsFirst, identityFirst)) {
+ assertEquals("d1", context.get("distinct_id"));
+ assertEquals("user_123", context.get("$user_id"));
+ assertEquals("acme", context.get("$company_id"));
+ assertEquals("device_abc", context.get("device_id"));
+ assertEquals("enterprise", ((Map, ?>) context.get("custom_properties")).get("plan"));
+ }
+ }
+
+ @Test
+ public void testFlagContextExplicitSettersWinOverGroupsWhateverTheOrder() {
+ Map settersFirst = FlagContext.builder()
+ .distinctId("d1").userId("user_123").companyId("acme").deviceId("device_abc")
+ .group("distinct_id", "g").group("$user_id", "g").group("$company_id", "g").group("device_id", "g")
+ .build();
+ Map groupsFirst = FlagContext.builder()
+ .group("distinct_id", "g").group("$user_id", "g").group("$company_id", "g").group("device_id", "g")
+ .distinctId("d1").userId("user_123").companyId("acme").deviceId("device_abc")
+ .build();
+
+ for (Map context : Arrays.asList(settersFirst, groupsFirst)) {
+ assertEquals("d1", context.get("distinct_id"));
+ assertEquals("user_123", context.get("$user_id"));
+ assertEquals("acme", context.get("$company_id"));
+ assertEquals("device_abc", context.get("device_id"));
+ }
+ }
+
+ @Test
+ public void testFlagContextLaterIdentitySourceWins() {
+ Map setterLast = FlagContext.builder()
+ .identity(MixpanelIdentity.builder().userId("from_identity").companyId("acme").build())
+ .userId("from_setter")
+ .build();
+ assertEquals("from_setter", setterLast.get("$user_id"));
+ assertEquals("acme", setterLast.get("$company_id"));
+
+ Map identityLast = FlagContext.builder()
+ .userId("from_setter")
+ .companyId("other")
+ .identity(MixpanelIdentity.builder().userId("from_identity").companyId("acme").build())
+ .build();
+ assertEquals("from_identity", identityLast.get("$user_id"));
+ assertEquals("acme", identityLast.get("$company_id"));
+ }
+
+ @Test
+ public void testFlagContextFromLegacyIdentity() {
+ Map context = FlagContext.builder()
+ .identity(MixpanelIdentity.builder().distinctId("user_123").build())
+ .build();
+
+ assertEquals("user_123", context.get("distinct_id"));
+ assertFalse(context.containsKey("$company_id"));
+ assertFalse(context.containsKey("custom_properties"));
+ }
+
+ // #endregion
+}
diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
index 4b747f7..c189f09 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java
@@ -1026,6 +1026,23 @@ public void close() {}
}
}
+ @Test
+ public void testTracksExposureForEmptyDistinctIdWithoutHierarchicalGroups() throws Exception {
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ List rollouts = Arrays.asList(new Rollout(1.0f));
+ String response = buildFlagsResponse("device-flag", "device_id", variants, rollouts, null);
+
+ provider = createProviderWithResponse(response);
+ provider.startPollingForDefinitions();
+
+ Map context = new HashMap<>();
+ context.put("device_id", "device-abc");
+ context.put("distinct_id", "");
+
+ assertEquals("value-a", provider.getVariantValue("device-flag", fallbackVariantValue, context));
+ assertEquals(1, eventSender.getEvents().size());
+ }
+
// #endregion
// #region Polling Lifecycle Thread Safety (SDK-81)
@@ -1864,4 +1881,372 @@ public void testLegacyFlagsWithoutHashSaltUseVariantSalt() {
// #endregion
// #endregion
+
+ // #region Hierarchical Groups
+
+ private void useHierarchicalGroupsConfig() {
+ config = LocalFlagsConfig.builder()
+ .projectToken(TEST_TOKEN)
+ .enablePolling(false)
+ .hierarchicalGroups(true)
+ .build();
+ }
+
+ private Map hierarchicalContext(String userId, String companyId) {
+ Map context = new HashMap<>();
+ context.put("$user_id", userId);
+ context.put("$company_id", companyId);
+ return context;
+ }
+
+ private String hashedContextValue(TestableHashingLocalFlagsProvider hashingProvider) {
+ for (TestableHashingLocalFlagsProvider.HashCall call : hashingProvider.getHashCalls()) {
+ if ("rollout".equals(call.type)) {
+ return call.contextValue;
+ }
+ }
+ return null;
+ }
+
+ @Test
+ public void testHierarchicalContextWithoutDistinctIdCannotEvaluateLocally() {
+ // Mixpanel derives a hierarchical distinct id service side, so a context carrying only
+ // $user_id and $company_id has nothing for a distinct_id keyed flag to hash on.
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ Object result = hashingProvider.getVariantValue(flagKey, fallbackVariantValue, hierarchicalContext("user-123", "acme"));
+
+ assertEquals(fallbackVariantValue, result);
+ assertNull(hashedContextValue(hashingProvider));
+ }
+
+ private String buildTemplatedFlagsResponse(String flagKey, String context, List variants,
+ Map testUsers, String contextValueTemplate) {
+ return buildFlagsResponse(flagKey, context, variants, Arrays.asList(new Rollout(1.0f)), testUsers)
+ .replace("\"context\":", "\"context_value_template\":" + JSONObject.quote(contextValueTemplate) + ",\"context\":");
+ }
+
+ /** The evaluation context from the spec's examples. */
+ private Map specContext() {
+ Map context = new HashMap<>();
+ context.put("distinct_id", "user_123");
+ context.put("device_id", "device_abc");
+ context.put("$user_id", "user_123");
+ context.put("$company_id", "company_123");
+ context.put("department", "finance");
+ return context;
+ }
+
+ private String hashedAssignmentKey(String response, Map context) {
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+ assertEquals("value-a", hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context));
+ return hashedContextValue(hashingProvider);
+ }
+
+ private String hashedTemplatedAssignmentKey(String contextKey, String template, Map context) {
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ return hashedAssignmentKey(buildTemplatedFlagsResponse(flagKey, contextKey, variants, null, template), context);
+ }
+
+ @Test
+ public void testContextValueTemplateForDistinctIdFlag() {
+ useHierarchicalGroupsConfig();
+ assertEquals("$company:company_123|$user:user_123",
+ hashedTemplatedAssignmentKey("distinct_id", "$company:{$company_id}|$user:{$user_id}", specContext()));
+ }
+
+ @Test
+ public void testContextValueTemplateForDeviceIdFlag() {
+ useHierarchicalGroupsConfig();
+ assertEquals("device_abc",
+ hashedTemplatedAssignmentKey("device_id", "{device_id}", specContext()));
+ }
+
+ @Test
+ public void testContextValueTemplateForUserIdFlag() {
+ useHierarchicalGroupsConfig();
+ assertEquals("$company:company_123|$user:user_123",
+ hashedTemplatedAssignmentKey("$user_id", "$company:{$company_id}|$user:{$user_id}", specContext()));
+ }
+
+ @Test
+ public void testContextValueTemplateForCompanyIdFlag() {
+ useHierarchicalGroupsConfig();
+ assertEquals("$company:company_123",
+ hashedTemplatedAssignmentKey("$company_id", "$company:{$company_id}", specContext()));
+ }
+
+ @Test
+ public void testContextValueTemplateForGroupFlag() {
+ useHierarchicalGroupsConfig();
+ assertEquals("$company:company_123|department:finance",
+ hashedTemplatedAssignmentKey("department", "$company:{$company_id}|department:{department}", specContext()));
+ }
+
+ @Test
+ public void testContextValueTemplateKeepsLiteralTextOutsideBraces() {
+ useHierarchicalGroupsConfig();
+ assertEquals("org=company_123;dept=finance!",
+ hashedTemplatedAssignmentKey("department", "org={$company_id};dept={department}!", specContext()));
+ }
+
+ @Test
+ public void testMissingPlaceholderFallsBackToRawContextValue() {
+ useHierarchicalGroupsConfig();
+ Map context = specContext();
+ context.remove("$company_id");
+
+ assertEquals("finance",
+ hashedTemplatedAssignmentKey("department", "$company:{$company_id}|department:{department}", context));
+ assertEquals("user_123",
+ hashedTemplatedAssignmentKey("distinct_id", "$company:{$company_id}|$user:{$user_id}", context));
+ }
+
+ @Test
+ public void testNullPlaceholderValueFallsBackToRawContextValue() {
+ useHierarchicalGroupsConfig();
+ Map context = specContext();
+ context.put("$company_id", null);
+
+ assertEquals("finance",
+ hashedTemplatedAssignmentKey("department", "$company:{$company_id}|department:{department}", context));
+ }
+
+ @Test
+ public void testNoTemplateFallsBackToRawContextValue() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildFlagsResponse(flagKey, "department", variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ assertEquals("finance", hashedAssignmentKey(response, specContext()));
+ }
+
+ @Test
+ public void testMissingPlaceholderAndMissingRawValueReturnsFallback() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildTemplatedFlagsResponse(flagKey, "department", variants, null,
+ "$company:{$company_id}|department:{department}");
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+ Map context = specContext();
+ context.remove("department");
+ SelectedVariant result = hashingProvider.getVariant(flagKey, new SelectedVariant<>(fallbackVariantValue), context, true);
+
+ assertEquals(fallbackVariantValue, result.getVariantValue());
+ assertEquals(Source.Fallback.Reason.MISSING_CONTEXT_KEY, ((Source.Fallback) result.getSource()).reason);
+ assertNull(hashedContextValue(hashingProvider));
+ }
+
+ @Test
+ public void testTestUserOverrideUsesResolvedKeyForDistinctIdFlag() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(
+ new Variant("control", "control-value", false, 1.0f),
+ new Variant("treatment", "treatment-value", false, 0.0f)
+ );
+ Map resolvedKeyUsers = new HashMap<>();
+ resolvedKeyUsers.put("$company:company_123|$user:user_123", "treatment");
+ provider = createProviderWithResponse(buildTemplatedFlagsResponse(flagKey, "distinct_id", variants, resolvedKeyUsers,
+ "$company:{$company_id}|$user:{$user_id}"));
+ provider.startPollingForDefinitions();
+ assertEquals("treatment-value", provider.getVariantValue(flagKey, fallbackVariantValue, specContext()));
+
+ // The raw distinct_id is not the override key once the template resolves.
+ Map rawKeyUsers = new HashMap<>();
+ rawKeyUsers.put("user_123", "treatment");
+ provider = createProviderWithResponse(buildTemplatedFlagsResponse(flagKey, "distinct_id", variants, rawKeyUsers,
+ "$company:{$company_id}|$user:{$user_id}"));
+ provider.startPollingForDefinitions();
+ assertEquals("control-value", provider.getVariantValue(flagKey, fallbackVariantValue, specContext()));
+ }
+
+ @Test
+ public void testTestUserOverrideUsesRawDistinctIdForOtherFlags() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(
+ new Variant("control", "control-value", false, 1.0f),
+ new Variant("treatment", "treatment-value", false, 0.0f)
+ );
+ Map testUsers = new HashMap<>();
+ testUsers.put("user_123", "treatment");
+ provider = createProviderWithResponse(buildTemplatedFlagsResponse(flagKey, "department", variants, testUsers,
+ "$company:{$company_id}|department:{department}"));
+ provider.startPollingForDefinitions();
+
+ assertEquals("treatment-value", provider.getVariantValue(flagKey, fallbackVariantValue, specContext()));
+ }
+
+ @Test
+ public void testNonHierarchicalContextMatchesMasterForTemplatedFlag() {
+ // Without $company_id the template cannot resolve, so the flag assigns on the raw
+ // distinct_id exactly as a flag without a template does.
+ List variants = Arrays.asList(
+ new Variant("control", "control-value", false, 0.5f),
+ new Variant("treatment", "treatment-value", false, 0.5f)
+ );
+ String templated = buildTemplatedFlagsResponse(flagKey, "distinct_id", variants, null,
+ "$company:{$company_id}|$user:{$user_id}");
+ String untemplated = buildFlagsResponse(flagKey, "distinct_id", variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ TestableHashingLocalFlagsProvider templatedProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ templatedProvider.setMockResponse("/flags/definitions", templated);
+ templatedProvider.startPollingForDefinitions();
+ LocalFlagsProvider untemplatedProvider = createProviderWithResponse(untemplated);
+ untemplatedProvider.startPollingForDefinitions();
+
+ Set seen = new HashSet<>();
+ for (int i = 0; i < 50; i++) {
+ Map context = new HashMap<>();
+ context.put("distinct_id", "user-" + i);
+ context.put("$user_id", "user-" + i);
+ Object expected = untemplatedProvider.getVariantValue(flagKey, fallbackVariantValue, context);
+ assertEquals(expected, templatedProvider.getVariantValue(flagKey, fallbackVariantValue, context));
+ seen.add(expected);
+ }
+ for (TestableHashingLocalFlagsProvider.HashCall call : templatedProvider.getHashCalls()) {
+ assertTrue(call.contextValue.startsWith("user-"));
+ }
+ // Both variants were exercised, so the comparison covers the split.
+ assertEquals(2, seen.size());
+ }
+
+ @Test
+ public void testHierarchicalDistinctIdIsHashedAsSent() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ Map context = hierarchicalContext("user-123", "acme");
+ context.put("distinct_id", "$company:acme|$user:user-123");
+ Object result = hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context);
+
+ assertEquals("value-a", result);
+ assertEquals("$company:acme|$user:user-123", hashedContextValue(hashingProvider));
+ }
+
+ @Test
+ public void testHierarchicalGroupFlagHashesOnRawGroupValue() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildFlagsResponse(flagKey, "department", variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ Map context = hierarchicalContext("user-123", "acme");
+ context.put("department", "finance");
+ hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context);
+
+ assertEquals("finance", hashedContextValue(hashingProvider));
+ }
+
+ @Test
+ public void testHierarchicalDeviceFlagHashesOnRawDeviceId() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildFlagsResponse(flagKey, "device_id", variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ Map context = hierarchicalContext("user-123", "acme");
+ context.put("device_id", "device-abc");
+ hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context);
+
+ assertEquals("device-abc", hashedContextValue(hashingProvider));
+ }
+
+ @Test
+ public void testHierarchicalExposureCarriesUserAndCompanyWithoutDistinctId() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ provider = createProviderWithResponse(response);
+ provider.startPollingForDefinitions();
+ Map context = hierarchicalContext("user-123", "acme");
+ context.put("distinct_id", "$company:acme|$user:user-123");
+ provider.getVariantValue(flagKey, fallbackVariantValue, context);
+
+ assertEquals(1, eventSender.getEvents().size());
+ MockEventSender.ExposureEvent event = eventSender.getEvents().get(0);
+ assertEquals("user-123", event.properties.getString("$user_id"));
+ assertEquals("acme", event.properties.getString("$company_id"));
+ }
+
+ @Test
+ public void testHierarchicalTestUserOverrideMatchesDistinctIdAsSent() {
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(
+ new Variant("control", "control-value", false, 1.0f),
+ new Variant("treatment", "treatment-value", false, 0.0f)
+ );
+ Map testUsers = new HashMap<>();
+ testUsers.put("$company:acme|$user:user-123", "treatment");
+ String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), testUsers);
+
+ provider = createProviderWithResponse(response);
+ provider.startPollingForDefinitions();
+ Map context = hierarchicalContext("user-123", "acme");
+ context.put("distinct_id", "$company:acme|$user:user-123");
+ Object result = provider.getVariantValue(flagKey, fallbackVariantValue, context);
+
+ assertEquals("treatment-value", result);
+ }
+
+ @Test
+ public void testHierarchicalProjectEvaluatesOnDistinctIdAlone() {
+ // The company no longer changes what is hashed, so a distinct id on its own evaluates
+ // the same way it does on any other project.
+ useHierarchicalGroupsConfig();
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ provider = createProviderWithResponse(response);
+ provider.startPollingForDefinitions();
+ Map context = new HashMap<>();
+ context.put("distinct_id", "user-123");
+ Object result = provider.getVariantValue(flagKey, fallbackVariantValue, context);
+
+ assertEquals("value-a", result);
+ }
+
+ @Test
+ public void testLegacyProjectIgnoresCompanyIdInContext() {
+ List variants = Arrays.asList(new Variant("variant-a", "value-a", false, 1.0f));
+ String response = buildFlagsResponse(flagKey, distinctIdContextKey, variants, Arrays.asList(new Rollout(1.0f)), null);
+
+ TestableHashingLocalFlagsProvider hashingProvider = new TestableHashingLocalFlagsProvider(config, SDK_VERSION, eventSender);
+ hashingProvider.setMockResponse("/flags/definitions", response);
+ hashingProvider.startPollingForDefinitions();
+
+ Map context = hierarchicalContext("user-123", "acme");
+ context.put("distinct_id", "user-123");
+ hashingProvider.getVariantValue(flagKey, fallbackVariantValue, context);
+
+ assertEquals("user-123", hashedContextValue(hashingProvider));
+ MockEventSender.ExposureEvent event = eventSender.getEvents().get(0);
+ assertEquals("user-123", event.distinctId);
+ assertFalse(event.properties.has("$company_id"));
+ assertFalse(event.properties.has("$user_id"));
+ }
+
+ // #endregion
}
diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java
index bc41ccf..1d0c3b8 100644
--- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java
+++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/RemoteFlagsProviderTest.java
@@ -287,5 +287,34 @@ public void testIsEnabledReturnsFalseForBooleanFalseVariant() {
}
// #endregion
+
+ // #region Hierarchical Groups
+
+ @Test
+ public void testHierarchicalContextIsForwardedAndExposureCarriesUserAndCompany() {
+ config = RemoteFlagsConfig.builder()
+ .projectToken(TEST_TOKEN)
+ .requestTimeoutSeconds(5)
+ .hierarchicalGroups(true)
+ .build();
+ String response = buildRemoteFlagsResponse("test-flag", "variant-a", "value-a");
+ provider = new TestableRemoteFlagsProvider(config, SDK_VERSION, eventSender);
+ // Only respond when the serialized context carries $company_id
+ provider.setMockResponse("%22%24company_id%22%3A%22acme%22", response);
+
+ Map context = new HashMap<>();
+ context.put("$user_id", "user-123");
+ context.put("$company_id", "acme");
+ String result = provider.getVariantValue("test-flag", "fallback", context);
+
+ assertEquals("value-a", result);
+ assertEquals(1, eventSender.getEvents().size());
+ MockEventSender.ExposureEvent event = eventSender.getEvents().get(0);
+ assertNull(event.distinctId);
+ assertEquals("user-123", event.properties.getString("$user_id"));
+ assertEquals("acme", event.properties.getString("$company_id"));
+ }
+
+ // #endregion
}