diff --git a/src/demo/java/com/mixpanel/mixpanelapi/demo/HierarchicalFlagsDemo.java b/src/demo/java/com/mixpanel/mixpanelapi/demo/HierarchicalFlagsDemo.java new file mode 100644 index 0000000..da84f76 --- /dev/null +++ b/src/demo/java/com/mixpanel/mixpanelapi/demo/HierarchicalFlagsDemo.java @@ -0,0 +1,150 @@ +package com.mixpanel.mixpanelapi.demo; + +import com.mixpanel.mixpanelapi.MixpanelAPI; +import com.mixpanel.mixpanelapi.MixpanelIdentity; +import com.mixpanel.mixpanelapi.ServiceAccountCredential; +import com.mixpanel.mixpanelapi.featureflags.FlagContext; +import com.mixpanel.mixpanelapi.featureflags.config.LocalFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.config.RemoteFlagsConfig; +import com.mixpanel.mixpanelapi.featureflags.model.SelectedVariant; +import com.mixpanel.mixpanelapi.featureflags.provider.BaseFlagsProvider; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Evaluates one feature flag against a real Mixpanel project, once per identity shape, + * so the hierarchical groups changes can be seen end to end rather than only in tests. + * + * Reads credentials from the environment so nothing lands in the shell history: + * + *
+ *   MIXPANEL_TOKEN          required, the project token
+ *   MIXPANEL_FLAG_KEY       optional, defaults to hierchical-group-testing
+ *   MIXPANEL_COMPANY_ID     optional, defaults to acme
+ *   MIXPANEL_USER_ID        optional, defaults to user_123
+ *   MIXPANEL_FLAGS_MODE     local or remote, defaults to remote
+ *   MIXPANEL_SA_USERNAME    service account, optional for local evaluation
+ *   MIXPANEL_SA_SECRET      service account
+ *   MIXPANEL_PROJECT_ID     service account
+ * 
+ * + * With a service account it evaluates locally; with only a token it evaluates remotely. + */ +public class HierarchicalFlagsDemo { + + public static void main(String[] args) throws Exception { + String token = env("MIXPANEL_TOKEN", null); + if (token == null) { + System.err.println("Set MIXPANEL_TOKEN to the project token first."); + System.exit(2); + } + String flagKey = env("MIXPANEL_FLAG_KEY", "hierchical-group-testing"); + String companyId = env("MIXPANEL_COMPANY_ID", "acme"); + String userId = env("MIXPANEL_USER_ID", "user_123"); + boolean local = "local".equalsIgnoreCase(env("MIXPANEL_FLAGS_MODE", "remote")); + + showSdkWarnings(); + + System.out.println("flag " + flagKey); + System.out.println("evaluation " + (local ? "local" : "remote")); + System.out.println("company " + companyId); + System.out.println("user " + userId); + System.out.println(); + + MixpanelAPI mixpanel = build(token, local); + BaseFlagsProvider flags = local ? mixpanel.getLocalFlags() : mixpanel.getRemoteFlags(); + if (local) { + mixpanel.getLocalFlags().startPollingForDefinitions(); + Thread.sleep(3000); // let the first definition fetch land + } + + evaluate(flags, flagKey, "hierarchical: user + company", + FlagContext.builder() + .identity(MixpanelIdentity.builder().userId(userId).companyId(companyId).build()) + .build()); + + evaluate(flags, flagKey, "hierarchical: company only", + FlagContext.builder() + .identity(MixpanelIdentity.builder().companyId(companyId).build()) + .build()); + + evaluate(flags, flagKey, "anonymous: device only", + FlagContext.builder() + .identity(MixpanelIdentity.builder().deviceId("device_abc").build()) + .build()); + + evaluate(flags, flagKey, "combined distinct id", + FlagContext.builder() + .identity(MixpanelIdentity.builder() + .distinctId("$company:" + companyId + "|$user:" + userId).build()) + .build()); + + evaluate(flags, flagKey, "combined distinct id + hierarchical keys", + FlagContext.builder() + .identity(MixpanelIdentity.builder() + .distinctId("$company:" + companyId + "|$user:" + userId) + .userId(userId).companyId(companyId).build()) + .build()); + + mixpanel.close(); + } + + private static MixpanelAPI build(String token, boolean local) { + if (!local) { + return new MixpanelAPI(RemoteFlagsConfig.builder() + .projectToken(token) + .hierarchicalGroups(true) + .build()); + } + LocalFlagsConfig config = LocalFlagsConfig.builder() + .projectToken(token) + .hierarchicalGroups(true) + .enablePolling(false) + .build(); + MixpanelAPI.Builder builder = new MixpanelAPI.Builder().flagsConfig(config); + // The definitions endpoint accepts the project token, so a service account is optional. + if (env("MIXPANEL_SA_USERNAME", null) != null) { + builder.credentials(new ServiceAccountCredential( + Long.parseLong(env("MIXPANEL_PROJECT_ID", "0")), + env("MIXPANEL_SA_USERNAME", null), + env("MIXPANEL_SA_SECRET", null))); + } + return builder.build(); + } + + private static void evaluate(BaseFlagsProvider flags, String flagKey, String label, + Map context) { + System.out.println(label); + System.out.println(" context " + new LinkedHashMap<>(context)); + try { + SelectedVariant variant = + flags.getVariant(flagKey, new SelectedVariant((Object) false), + context); + System.out.println(" variant " + variant.getVariantKey() + + " value=" + variant.getVariantValue() + + " source=" + variant.getSource()); + } catch (Throwable t) { + System.out.println(" THREW " + t.getClass().getSimpleName() + ": " + t.getMessage()); + } + System.out.println(); + } + + /** The SDK reports dropped exposures and missing context through java.util.logging. */ + private static void showSdkWarnings() { + Logger root = Logger.getLogger("com.mixpanel"); + ConsoleHandler handler = new ConsoleHandler(); + handler.setLevel(Level.ALL); + root.addHandler(handler); + root.setLevel(Level.ALL); + root.setUseParentHandlers(false); + } + + private static String env(String name, String fallback) { + String value = System.getenv(name); + return value == null || value.isEmpty() ? fallback : value; + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java b/src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java index 6ba467e..bdda682 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java +++ b/src/main/java/com/mixpanel/mixpanelapi/ClientDelivery.java @@ -71,7 +71,7 @@ else if (!messageType.equals("event") && !messageType.equals("people") && !messa ret = false; } } - } catch (JSONException e) { + } catch (Exception e) { ret = false; } diff --git a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java index 45ed07d..9b507c6 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java +++ b/src/main/java/com/mixpanel/mixpanelapi/MessageBuilder.java @@ -2,10 +2,14 @@ import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import java.util.TimeZone; import java.util.UUID; @@ -23,11 +27,84 @@ public class MessageBuilder { private static final String ENGAGE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"; + private static final String USER_ID_GROUP_KEY = "$user_id"; + // /groups has no $add, $append or $merge + private static final Set GROUP_OPERATIONS = Collections.unmodifiableSet(new LinkedHashSet<>( + Arrays.asList("$set", "$set_once", "$unset", "$union", "$remove", "$delete"))); + // Keys of a /engage or /groups message that are not operations + private static final Set NON_OPERATION_KEYS = Collections.unmodifiableSet(new LinkedHashSet<>( + Arrays.asList("$token", "$distinct_id", "$group_key", "$group_id", "$company_id", "$time", "$ip", + "$ignore_time", "$ignore_alias", "$mp_metadata", "$latitude", "$longitude"))); private final String mToken; + private final boolean mHierarchicalGroups; + private final String mCompanyId; public MessageBuilder(String token) { + this(token, false); + } + + /** + * Creates a MessageBuilder for a project that may use hierarchical groups. + * + * When hierarchicalGroups is true, Mixpanel builds the distinct id from {@code $user_id} and + * {@code $company_id}. Prefer the {@link MixpanelIdentity} overloads, such as + * {@link #event(MixpanelIdentity, String, JSONObject)}, which send those properties. + * {@link #event(String, String, JSONObject)} still works, and Mixpanel accepts a distinct id + * it built itself, but a distinct id that is not in that form names a different user that + * nothing reconciles later. See {@link MixpanelIdentity.Builder#distinctId(String)}. + * + * User profiles are group profiles keyed by {@code $user_id}, so they support only what + * {@code /groups} supports. {@link #increment}, {@link #append} and {@link #trackCharge} have + * no equivalent there and throw {@link UnsupportedOperationException}; set, setOnce, unset, + * union, remove and delete work, and {@link #set(MixpanelIdentity, JSONObject)} and its + * siblings address a profile without needing a distinct id. No user profile update goes to + * {@code /engage}: the distinct id overloads, such as {@link #set(String, JSONObject)}, accept + * only a distinct id of the form {@code $company:{company}|$user:{user}}, send it to + * {@code /groups} as that user's profile within the company, and throw + * {@link IllegalArgumentException} for any other distinct id. Group profile messages require a + * known company, either through {@link #forCompany(String)} or a {@code $company_id} modifier. + * + * @param token the Mixpanel project token + * @param hierarchicalGroups true if the project uses hierarchical groups + */ + public MessageBuilder(String token, boolean hierarchicalGroups) { + this(token, hierarchicalGroups, null); + } + + private MessageBuilder(String token, boolean hierarchicalGroups, String companyId) { mToken = token; + mHierarchicalGroups = hierarchicalGroups; + mCompanyId = companyId; + } + + /** + * Returns a MessageBuilder that adds {@code $company_id} to every group profile + * message it builds. Hierarchical group projects require {@code $company_id} on + * every {@code /groups} write, including the company's own profile and user profiles. The + * company methods, such as {@link #companySet(JSONObject)}, build messages for the bound + * company's own profile: + *
+     * {@code
+     *     MessageBuilder acme = messageBuilder.forCompany("acme");
+     *     acme.companySet(companyProperties);
+     *     acme.groupSet("$user_id", "user_123", userProperties);
+     *     acme.groupSet("department", "finance", departmentProperties);
+     * }
+     * 
+ * + * User profile updates made with a {@link MixpanelIdentity} use the identity's own company and do + * not need a bound company; a bound builder throws {@link IllegalArgumentException} for a user + * from another company, and updates a user-only identity's profile within the bound company. + * + * @param companyId the id of the top-level company + * @return a new MessageBuilder bound to the given company + */ + public MessageBuilder forCompany(String companyId) { + if (companyId == null || companyId.isEmpty()) { + throw new IllegalArgumentException("companyId must be non-empty"); + } + return new MessageBuilder(mToken, mHierarchicalGroups, companyId); } /*** @@ -40,7 +117,10 @@ public MessageBuilder(String token) { * to get the most out of your metrics. * * @param distinctId a string uniquely identifying the individual cause associated with this event - * (for example, the user id of a signing-in user, or the hostname of a server) + * (for example, the user id of a signing-in user, or the hostname of a server). + * On a hierarchical groups project this must be a distinct id Mixpanel built, of the + * form {@code $company:{company}|$user:{user}}, which ingestion parses back into + * {@code $company_id} and {@code $user_id}. Any other value names a separate user. * @param eventName a human readable name for the event, for example "Purchase", or "Threw Exception" * @param properties a JSONObject associating properties with the event. These are useful * for reporting and segmentation of events. It is often useful not only to include @@ -84,6 +164,68 @@ public JSONObject event(String distinctId, String eventName, JSONObject properti } } + /** + * Creates a message tracking an event for the given identity. In hierarchical + * group mode the message carries {@code $user_id} and {@code $company_id} instead + * of {@code distinct_id}; Mixpanel constructs the distinct id from them. + * Each of {@code distinct_id}, {@code $user_id}, {@code $company_id} and + * {@code $device_id} that the identity sets replaces any value for that key in + * properties; a key the identity leaves unset is kept as given in properties. + * + * @param identity who the event belongs to + * @param eventName a human readable name for the event + * @param properties a JSONObject associating properties with the event, or null + * @return event message for consumption by MixpanelAPI + * @see #event(String, String, JSONObject) + */ + public JSONObject event(MixpanelIdentity identity, String eventName, JSONObject properties) { + requireIdentityMatchesProject(identity); + return event(identity.getDistinctId(), eventName, withIdentity(identity, properties)); + } + + /** + * Creates a message importing a historical event for the given identity. + * The identity's keys replace those in properties as in + * {@link #event(MixpanelIdentity, String, JSONObject)}; a key the identity leaves + * unset is kept as given in properties. + * + * @param identity who the event belongs to + * @param eventName a human readable name for the event + * @param properties a JSONObject associating properties with the event, or null + * @return import event message for consumption by MixpanelAPI + * @see #importEvent(String, String, JSONObject) + * @see #event(MixpanelIdentity, String, JSONObject) + */ + public JSONObject importEvent(MixpanelIdentity identity, String eventName, JSONObject properties) { + requireIdentityMatchesProject(identity); + return importEvent(identity.getDistinctId(), eventName, withIdentity(identity, properties)); + } + + /** + * Hierarchical groups is a project setting, so a hierarchical identity is only valid on a + * builder created for a hierarchical groups project. Ingestion accepts the keys on any + * project but ignores them, so a mismatch would be silently mis-attributed. + */ + private void requireIdentityMatchesProject(MixpanelIdentity identity) { + if (identity == null) { + throw new IllegalArgumentException("identity must be non-null"); + } + if (identity.isHierarchical() && !mHierarchicalGroups) { + throw new IllegalArgumentException(identity + " requires a hierarchical groups MessageBuilder: " + + "new MessageBuilder(token, true)"); + } + } + + private static JSONObject withIdentity(MixpanelIdentity identity, JSONObject properties) { + try { + JSONObject merged = properties == null ? new JSONObject() : new JSONObject(properties.toString()); + identity.applyTo(merged); + return merged; + } catch (JSONException e) { + throw new RuntimeException("Can't construct a Mixpanel message", e); + } + } + /** * Creates a message for importing historical events (events older than 5 days) to Mixpanel via the /import endpoint. * This method is similar to event(), but is designed for the import endpoint which requires: @@ -465,6 +607,152 @@ public JSONObject unset(String distinctId, Collection propertyNames, JSO return peopleMessage(distinctId, "$unset", propNamesArray, modifiers); } + /** + * Sets properties on the user profile for the given identity. For a hierarchical + * groups identity the update is sent to {@code /groups} as the {@code $user_id} + * group profile within the identity's company; for a distinct id identity it is + * the same as {@link #set(String, JSONObject)}. + * + *
+     * {@code
+     *     JSONObject message = messageBuilder.set(MixpanelIdentity.builder().userId("user_123").companyId("acme").build(),
+     *             userProperties);
+     *     mixpanelApi.sendMessage(message);
+     * }
+     * 
+ * + * Hierarchical groups projects support set, setOnce, unset, union, remove and delete. + * {@code $add} and {@code $append} have no group profile equivalent, so increment, + * append and trackCharge have no identity overload. + * + * @param identity whose profile to change + * @param properties a collection of properties to set on the profile + * @return profile set message for consumption by MixpanelAPI + * + * @throws IllegalArgumentException if the identity cannot name a user profile in this project, + * for example a company-only identity, or on a hierarchical groups builder a distinct id + * not of the form {@code $company:{company}|$user:{user}} + */ + public JSONObject set(MixpanelIdentity identity, JSONObject properties) { + return set(identity, properties, null); + } + + /** + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject set(MixpanelIdentity identity, JSONObject properties, JSONObject modifiers) { + return profileMessage(identity, "$set", properties, modifiers); + } + + /** + * Sets properties on the user profile for the given identity only if they are not already set. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject setOnce(MixpanelIdentity identity, JSONObject properties) { + return setOnce(identity, properties, null); + } + + /** + * @see #setOnce(MixpanelIdentity, JSONObject) + */ + public JSONObject setOnce(MixpanelIdentity identity, JSONObject properties, JSONObject modifiers) { + return profileMessage(identity, "$set_once", properties, modifiers); + } + + /** + * Deletes the user profile for the given identity. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject delete(MixpanelIdentity identity) { + return delete(identity, null); + } + + /** + * @see #delete(MixpanelIdentity) + */ + public JSONObject delete(MixpanelIdentity identity, JSONObject modifiers) { + return profileMessage(identity, "$delete", new JSONObject(), modifiers); + } + + /** + * Removes values from list properties on the user profile for the given identity. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject remove(MixpanelIdentity identity, JSONObject properties) { + return remove(identity, properties, null); + } + + /** + * @see #remove(MixpanelIdentity, JSONObject) + */ + public JSONObject remove(MixpanelIdentity identity, JSONObject properties, JSONObject modifiers) { + return profileMessage(identity, "$remove", properties, modifiers); + } + + /** + * Merges values into list properties on the user profile for the given identity, + * ignoring values already present. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject union(MixpanelIdentity identity, Map properties) { + return union(identity, properties, null); + } + + /** + * @see #union(MixpanelIdentity, Map) + */ + public JSONObject union(MixpanelIdentity identity, Map properties, JSONObject modifiers) { + return profileMessage(identity, "$union", new JSONObject(properties), modifiers); + } + + /** + * Removes properties from the user profile for the given identity. + * + * @see #set(MixpanelIdentity, JSONObject) + */ + public JSONObject unset(MixpanelIdentity identity, Collection propertyNames) { + return unset(identity, propertyNames, null); + } + + /** + * @see #unset(MixpanelIdentity, Collection) + */ + public JSONObject unset(MixpanelIdentity identity, Collection propertyNames, JSONObject modifiers) { + return profileMessage(identity, "$unset", new JSONArray(propertyNames), modifiers); + } + + private JSONObject profileMessage(MixpanelIdentity identity, String actionType, Object properties, + JSONObject modifiers) { + requireIdentityMatchesProject(identity); + if (identity.getDistinctId() != null) { + // On a hierarchical groups builder peopleMessage parses a distinct id Mixpanel built into + // its company and user, so this reaches the same /groups profile as the hierarchical form. + return peopleMessage(identity.getDistinctId(), actionType, properties, modifiers); + } + if (!mHierarchicalGroups) { + throw new IllegalArgumentException(identity + " cannot identify a profile on this project. " + + "User profiles are addressed by distinct id unless the project uses hierarchical groups."); + } + if (identity.getUserId() == null) { + if (identity.getCompanyId() == null) { + throw new IllegalArgumentException(identity + " names no user, so it cannot identify a profile. " + + "Device ids identify anonymous events, not profiles."); + } + throw new IllegalArgumentException("User profile updates require a user id, and " + identity + + " has none. Use forCompany(companyId).companySet(properties) to update the company's " + + "profile."); + } + requireBoundCompany(identity.getCompanyId(), identity.toString()); + // A user-only identity takes the bound company, which then wins over a $company_id modifier. + String companyId = identity.getCompanyId() != null ? identity.getCompanyId() : mCompanyId; + return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, modifiers, + companyId); + } + /** * Tracks revenue associated with the given distinctId. * @@ -525,8 +813,15 @@ public JSONObject trackCharge(String distinctId, double amount, JSONObject prope * The underlying API is documented at https://developer.mixpanel.com/docs/http * * @param distinctId a string uniquely identifying the individual cause associated with this event - * (for example, the user id of a signing-in user, or the hostname of a server) - * @param actionType a string associated in the HTTP api with the operation (for example, $set or $add) + * (for example, the user id of a signing-in user, or the hostname of a server). + * On a hierarchical groups builder this must be of the form + * {@code $company:{company}|$user:{user}}, and the message is a {@code /groups} update of + * the {@code $user_id} profile within that company, without {@code $distinct_id}. A + * {@code $distinct_id} modifier replaces this argument and is held to the same rule. + * @param actionType a string associated in the HTTP api with the operation (for example, $set or $add). + * On a hierarchical groups builder user profiles are group profiles, so only $set, $set_once, + * $unset, $union, $remove and $delete are supported; $add and $append throw + * UnsupportedOperationException. * @param properties a payload of the operation. Will be converted to JSON, and should be of types * Boolean, Double, Integer, Long, String, JSONArray, JSONObject, the JSONObject.NULL object, or null. * NaN and negative/positive infinity will throw an IllegalArgumentException @@ -534,13 +829,29 @@ public JSONObject trackCharge(String distinctId, double amount, JSONObject prope * be merged as modifiers associated with the update message (for example, "$time" or "$ignore_time") * @return generic user profile message for consumption by MixpanelAPI * - * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property + * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property, or on a + * hierarchical groups builder if the distinct id does not name both a company and a user + * @throws UnsupportedOperationException on a hierarchical groups builder, for $add, $append or any other + * operation /groups does not support, whether as the actionType or as a modifier key * * @see MessageBuilder#set(String distinctId, JSONObject properties) * @see MessageBuilder#delete(String distinctId) * @see MessageBuilder#append(String distinctId, JSONObject properties, JSONObject modifiers) */ public JSONObject peopleMessage(String distinctId, String actionType, Object properties, JSONObject modifiers) { + if (mHierarchicalGroups && !GROUP_OPERATIONS.contains(actionType)) { + // A hierarchical groups user profile is a group profile, so it supports what /groups + // supports. $add and $append have no equivalent there, and no other call reaches them. + throw new UnsupportedOperationException(actionType + " is not supported for hierarchical groups " + + "projects, whose user profiles are group profiles keyed by $user_id. Supported operations " + + "are " + GROUP_OPERATIONS + ", so increment, append and trackCharge have no hierarchical " + + "groups equivalent."); + } + if (mHierarchicalGroups) { + // Checked before the distinct id, as the actionType is. + requireSupportedModifiers(modifiers); + return hierarchicalUserProfileMessage(distinctId, actionType, properties, modifiers); + } JSONObject dataObj = new JSONObject(); if (null == properties) { throw new IllegalArgumentException("Cannot send null properties, use JSONObject.NULL instead"); @@ -575,6 +886,45 @@ public JSONObject peopleMessage(String distinctId, String actionType, Object pro } } + /** + * A hierarchical groups user profile is the {@code $user_id} group profile within the user's + * company, so a user profile update never goes to {@code /engage}. The message's distinct id + * (a {@code $distinct_id} modifier, if given, replaces the argument just as it would in the + * message) must be one Mixpanel built, which names both the user and the company. + */ + private JSONObject hierarchicalUserProfileMessage(String distinctId, String actionType, Object properties, + JSONObject modifiers) { + JSONObject groupModifiers = modifiers; + if (modifiers != null && modifiers.has("$distinct_id")) { + Object override = modifiers.opt("$distinct_id"); + distinctId = override instanceof String ? (String) override : null; + groupModifiers = new JSONObject(modifiers, JSONObject.getNames(modifiers)); + groupModifiers.remove("$distinct_id"); + } + MixpanelIdentity identity = MixpanelIdentity.fromCombinedDistinctId(distinctId); + if (identity == null) { + throw new IllegalArgumentException("User profile updates on hierarchical groups projects need a user " + + "and a company, and the distinct id \"" + distinctId + "\" does not name both. Pass " + + "MixpanelIdentity.builder().userId(userId).companyId(companyId).build(), or a distinct id " + + "of the form $company:{company}|$user:{user}."); + } + requireBoundCompany(identity.getCompanyId(), "\"" + distinctId + "\""); + return buildGroupMessage(USER_ID_GROUP_KEY, identity.getUserId(), actionType, properties, groupModifiers, + identity.getCompanyId()); + } + + /** + * A builder bound with {@link #forCompany(String)} refuses a user from another company rather + * than silently building an update of that company's user profile. + */ + private void requireBoundCompany(String companyId, String described) { + if (companyId != null && mCompanyId != null && !companyId.equals(mCompanyId)) { + throw new IllegalArgumentException("This builder is bound to company \"" + mCompanyId + "\", but " + + described + " belongs to company \"" + companyId + "\". Update the user through a builder " + + "for that company, or one without forCompany."); + } + } + /** * Sets properties on the group profile identified by the given groupKey * and groupId, creating the profile if needed. Existing values for the @@ -797,6 +1147,194 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection return groupMessage(groupKey, groupId, "$unset", propNamesArray, modifiers); } + /** + * Sets properties on the profile of the company bound with {@link #forCompany(String)}, + * creating the profile if needed. Existing values for the given properties are replaced. + * Builds the same message as {@code groupSet("$company_id", companyId, properties)} on this builder. + * + * @param properties a collection of properties to set on the company profile. Each key + * in the properties argument will be updated on the profile. + * @return company profile set message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companySet(JSONObject properties) { + return companySet(properties, null); + } + + /** + * Sets properties on the profile of the company bound with {@link #forCompany(String)}, + * creating the profile if needed. Existing values for the given properties are replaced. + * Builds the same message as {@code groupSet("$company_id", companyId, properties, modifiers)} + * on this builder. + * + * @param properties a collection of properties to set on the company profile. Each key + * in the properties argument will be updated on the profile. + * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time"). + * this can be null- if non-null, the keys and values in the modifiers + * object will be associated directly with the update. + * @return company profile set message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companySet(JSONObject properties, JSONObject modifiers) { + return groupSet("$company_id", requireBoundCompanyId("companySet"), properties, modifiers); + } + + /** + * Sets properties if they do not already exist on the profile of the company bound with + * {@link #forCompany(String)}. + * Builds the same message as {@code groupSetOnce("$company_id", companyId, properties)} on this builder. + * + * @param properties a collection of properties to set on the company profile. Each key + * in the properties argument will be updated on the profile. + * @return company profile setOnce message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companySetOnce(JSONObject properties) { + return companySetOnce(properties, null); + } + + /** + * Sets properties if they do not already exist on the profile of the company bound with + * {@link #forCompany(String)}. + * Builds the same message as {@code groupSetOnce("$company_id", companyId, properties, modifiers)} + * on this builder. + * + * @param properties a collection of properties to set on the company profile. Each key + * in the properties argument will be updated on the profile. + * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time"). + * this can be null- if non-null, the keys and values in the modifiers + * object will be associated directly with the update. + * @return company profile setOnce message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companySetOnce(JSONObject properties, JSONObject modifiers) { + return groupSetOnce("$company_id", requireBoundCompanyId("companySetOnce"), properties, modifiers); + } + + /** + * Deletes the profile of the company bound with {@link #forCompany(String)}. + * Builds the same message as {@code groupDelete("$company_id", companyId)} on this builder. + * + * @return company profile delete message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companyDelete() { + return companyDelete(null); + } + + /** + * Deletes the profile of the company bound with {@link #forCompany(String)}. + * Builds the same message as {@code groupDelete("$company_id", companyId, modifiers)} on this builder. + * + * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time"). + * this can be null- if non-null, the keys and values in the modifiers + * object will be associated directly with the update. + * @return company profile delete message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companyDelete(JSONObject modifiers) { + return groupDelete("$company_id", requireBoundCompanyId("companyDelete"), modifiers); + } + + /** + * For each key and value in the properties argument, attempts to remove that value from a list + * associated with the key in the profile of the company bound with {@link #forCompany(String)}. + * Builds the same message as {@code groupRemove("$company_id", companyId, properties)} on this builder. + * + * @param properties properties for the remove operation + * @return company profile remove message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companyRemove(JSONObject properties) { + return companyRemove(properties, null); + } + + /** + * For each key and value in the properties argument, attempts to remove that value from a list + * associated with the key in the profile of the company bound with {@link #forCompany(String)}. + * Builds the same message as {@code groupRemove("$company_id", companyId, properties, modifiers)} + * on this builder. + * + * @param properties properties for the remove operation + * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time"). + * this can be null- if non-null, the keys and values in the modifiers + * object will be associated directly with the update. + * @return company profile remove message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companyRemove(JSONObject properties, JSONObject modifiers) { + return groupRemove("$company_id", requireBoundCompanyId("companyRemove"), properties, modifiers); + } + + /** + * Merges list-valued properties into the profile of the company bound with + * {@link #forCompany(String)}, ignoring duplicate list values. + * Builds the same message as {@code groupUnion("$company_id", companyId, properties)} on this builder. + * + * @param properties properties for the union operation + * @return company profile union message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companyUnion(Map properties) { + return companyUnion(properties, null); + } + + /** + * Merges list-valued properties into the profile of the company bound with + * {@link #forCompany(String)}, ignoring duplicate list values. + * Builds the same message as {@code groupUnion("$company_id", companyId, properties, modifiers)} + * on this builder. + * + * @param properties properties for the union operation + * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time"). + * this can be null- if non-null, the keys and values in the modifiers + * object will be associated directly with the update. + * @return company profile union message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companyUnion(Map properties, JSONObject modifiers) { + return groupUnion("$company_id", requireBoundCompanyId("companyUnion"), properties, modifiers); + } + + /** + * Removes the properties named in propertyNames from the profile of the company bound with + * {@link #forCompany(String)}. + * Builds the same message as {@code groupUnset("$company_id", companyId, propertyNames)} on this builder. + * + * @param propertyNames properties for the unset operation + * @return company profile unset message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companyUnset(Collection propertyNames) { + return companyUnset(propertyNames, null); + } + + /** + * Removes the properties named in propertyNames from the profile of the company bound with + * {@link #forCompany(String)}. + * Builds the same message as {@code groupUnset("$company_id", companyId, propertyNames, modifiers)} + * on this builder. + * + * @param propertyNames properties for the unset operation + * @param modifiers Modifiers associated with the update message. (for example "$time" or "$ignore_time"). + * this can be null- if non-null, the keys and values in the modifiers + * object will be associated directly with the update. + * @return company profile unset message for consumption by MixpanelAPI + * @throws IllegalStateException if this builder has no company bound with {@link #forCompany(String)} + */ + public JSONObject companyUnset(Collection propertyNames, JSONObject modifiers) { + return groupUnset("$company_id", requireBoundCompanyId("companyUnset"), propertyNames, modifiers); + } + + private String requireBoundCompanyId(String method) { + if (mCompanyId == null) { + throw new IllegalStateException(method + " builds a message for the profile of the company bound with " + + "forCompany, and this builder has none. Use forCompany(companyId)." + method + + ". Nothing to send"); + } + return mCompanyId; + } + /** * Formats a generic group profile message. * Use of this method requires familiarity with the underlying Mixpanel HTTP API, @@ -808,7 +1346,8 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection * * @param groupKey string identifier for the type of group, e.g. 'Company' * @param groupId unique string identifier for the group, e.g. 'Acme Inc.' - * @param actionType a string associated in the HTTP api with the operation (for example, $set or $add) + * @param actionType a string associated in the HTTP api with the operation (for example, $set or $union). + * /groups has no $add, $append or $merge; a hierarchical groups builder rejects them. * @param properties a payload of the operation. Will be converted to JSON, and should be of types * Boolean, Double, Integer, Long, String, JSONArray, JSONObject, the JSONObject.NULL object, or null. * NaN and negative/positive infinity will throw an IllegalArgumentException @@ -817,6 +1356,10 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection * @return generic group profile message for consumption by MixpanelAPI * * @throws IllegalArgumentException if properties is not intelligible as a JSONObject property + * @throws IllegalStateException for hierarchical groups projects when no company is known + * @throws UnsupportedOperationException on a hierarchical groups builder, for an operation /groups lacks + * (anything but $set, $set_once, $unset, $union, $remove or $delete), whether as the actionType + * or as a modifier key * * @see MessageBuilder#groupSet(String groupKey, String groupId, JSONObject properties) * @see MessageBuilder#groupSetOnce(String groupKey, String groupId, JSONObject properties) @@ -825,6 +1368,24 @@ public JSONObject groupUnset(String groupKey, String groupId, Collection */ public JSONObject groupMessage(String groupKey, String groupId, String actionType, Object properties, JSONObject modifiers) { + // Only the generic entry point can carry an arbitrary actionType; the groupSet family passes constants. + if (mHierarchicalGroups && !GROUP_OPERATIONS.contains(actionType)) { + throw new UnsupportedOperationException(actionType + " is not supported for group profiles on " + + "hierarchical groups projects. Supported operations are " + GROUP_OPERATIONS + "."); + } + return buildGroupMessage(groupKey, groupId, actionType, properties, modifiers, mCompanyId); + } + + private JSONObject buildGroupMessage(String groupKey, String groupId, String actionType, Object properties, + JSONObject modifiers, String companyId) { + if (mHierarchicalGroups) { + requireSupportedModifiers(modifiers); + } + if (mHierarchicalGroups && companyId == null && !hasCompanyIdModifier(modifiers)) { + throw new IllegalStateException("Group profile updates for hierarchical groups projects require a " + + "company. Use forCompany(companyId) to build them. Nothing to send for group " + + groupKey + "=" + groupId); + } JSONObject dataObj = new JSONObject(); if (null == properties) { throw new IllegalArgumentException("Cannot send null properties, use JSONObject.NULL instead"); @@ -850,6 +1411,10 @@ public JSONObject groupMessage(String groupKey, String groupId, String actionTyp } } } + // $company_id must be top-level, not inside the operation payload + if (companyId != null) { + dataObj.put("$company_id", companyId); + } JSONObject envelope = new JSONObject(); envelope.put("envelope_version", 1); envelope.put("message_type", "group"); @@ -860,4 +1425,38 @@ public JSONObject groupMessage(String groupKey, String groupId, String actionTyp } } + /** + * Modifiers are merged into the message, so on a hierarchical groups builder a {@code $}-prefixed + * modifier key is an operation unless it is one of the non-operation keys such as {@code $time}, + * and an operation /groups lacks throws just as it does as the actionType. + */ + private static void requireSupportedModifiers(JSONObject modifiers) { + if (modifiers == null) { + return; + } + final String[] keys = JSONObject.getNames(modifiers); + if (keys == null) { + return; + } + for (String key : keys) { + if (key.startsWith("$") && !GROUP_OPERATIONS.contains(key) && !NON_OPERATION_KEYS.contains(key)) { + throw new UnsupportedOperationException(key + " is not supported for group profiles on " + + "hierarchical groups projects, whose user profiles are group profiles keyed by $user_id. " + + "Supported operations are " + GROUP_OPERATIONS + "."); + } + } + } + + /** + * An empty or JSONObject.NULL {@code $company_id} modifier names no company, just as + * {@link #forCompany(String)} rejects a null or empty id. + */ + private static boolean hasCompanyIdModifier(JSONObject modifiers) { + if (modifiers == null) { + return false; + } + Object companyId = modifiers.opt("$company_id"); + return companyId != null && !JSONObject.NULL.equals(companyId) && !"".equals(companyId); + } + } diff --git a/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java new file mode 100644 index 0000000..a9d79c3 --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/MixpanelIdentity.java @@ -0,0 +1,202 @@ +package com.mixpanel.mixpanelapi; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Identifies who an event or profile update belongs to. + * + * Mixpanel resolves an identity from whichever of {@code distinct_id}, {@code $user_id}, + * {@code $company_id} and {@code $device_id} an event carries, so this type sets any + * combination of them and leaves the rest unset. + * + *
+ * {@code
+ *     MixpanelIdentity.builder().distinctId("user_123").build();               // a plain distinct id
+ *     MixpanelIdentity.builder().userId("user_123").deviceId("device_abc").build();  // simplified id merge
+ *     MixpanelIdentity.builder().userId("user_123").companyId("acme").build();  // hierarchical groups
+ *     MixpanelIdentity.builder().deviceId("device_abc").build();                // anonymous, no user yet
+ *     MixpanelIdentity.builder().companyId("acme").build();                     // a company, no user
+ * }
+ * 
+ * + * Which combinations are valid depends on the project, so {@link MessageBuilder} rather than + * this type decides. On a hierarchical groups project the company is part of the user's + * identity: a {@code $user_id} sent without a {@code $company_id} resolves to a different user + * that nothing reconciles later, so include the company whenever the user has one. Events still + * send a user id without one as given; user profile updates need the company. A user who + * belongs to two companies is two users, by design. + * + * Instances are immutable. + */ +public final class MixpanelIdentity { + + private final String mDistinctId; + private final String mUserId; + private final String mCompanyId; + private final String mDeviceId; + + private MixpanelIdentity(Builder builder) { + mDistinctId = builder.mDistinctId; + mUserId = builder.mUserId; + mCompanyId = builder.mCompanyId; + mDeviceId = builder.mDeviceId; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String mDistinctId; + private String mUserId; + private String mCompanyId; + private String mDeviceId; + + private Builder() { + } + + /** + * A distinct id Mixpanel resolves directly, or one it built itself. + * + * On a hierarchical groups project Mixpanel builds the distinct id from the user and + * company, so a raw distinct id names a different, unrelated user. The two are counted + * separately, never appear in the same company report, and cannot be merged afterwards: + * correcting it means re-importing the events. Set one here only when it is one Mixpanel + * itself built, of the form {@code $company:{company}|$user:{user}}. User profile updates + * on a hierarchical groups project accept only that form, and send it to {@code /groups} + * as the user's profile within the company. + */ + public Builder distinctId(String distinctId) { + mDistinctId = requireNonEmptyOrNull(distinctId, "distinctId"); + return this; + } + + /** The user's id. On a hierarchical groups project it only needs to be unique within the company. */ + public Builder userId(String userId) { + mUserId = requireNonEmptyOrNull(userId, "userId"); + return this; + } + + /** The id of the top-level company, on a hierarchical groups project. */ + public Builder companyId(String companyId) { + mCompanyId = requireNonEmptyOrNull(companyId, "companyId"); + return this; + } + + /** + * The client's device id. Events carrying one are attributed to the device until a + * later event names the user, at which point that earlier activity follows the user. + */ + public Builder deviceId(String deviceId) { + mDeviceId = requireNonEmptyOrNull(deviceId, "deviceId"); + return this; + } + + public MixpanelIdentity build() { + if (mDistinctId == null && mUserId == null && mCompanyId == null && mDeviceId == null) { + throw new IllegalArgumentException("An identity needs at least one of distinctId, userId, " + + "companyId or deviceId"); + } + return new MixpanelIdentity(this); + } + + /** Null leaves the value unset; an empty string is a caller error rather than an absent value. */ + private static String requireNonEmptyOrNull(String value, String name) { + if (value != null && value.isEmpty()) { + throw new IllegalArgumentException(name + " must be non-empty"); + } + return value; + } + } + + private static final String COMBINED_COMPANY_PREFIX = "$company:"; + private static final String COMBINED_USER_SEPARATOR = "|$user:"; + + /** + * Parses a distinct id Mixpanel built for a hierarchical groups user, of the form + * {@code $company:{company}|$user:{user}}. The company is the text before the first + * {@code |$user:}; the user is everything after it. + * + * @return an identity with the parsed user and company, or null if the distinct id is not + * in that form or either part is empty + */ + /* package */ static MixpanelIdentity fromCombinedDistinctId(String distinctId) { + if (distinctId == null || !distinctId.startsWith(COMBINED_COMPANY_PREFIX)) { + return null; + } + int separator = distinctId.indexOf(COMBINED_USER_SEPARATOR, COMBINED_COMPANY_PREFIX.length()); + if (separator < 0) { + return null; + } + String companyId = distinctId.substring(COMBINED_COMPANY_PREFIX.length(), separator); + String userId = distinctId.substring(separator + COMBINED_USER_SEPARATOR.length()); + if (companyId.isEmpty() || userId.isEmpty()) { + return null; + } + return builder().userId(userId).companyId(companyId).build(); + } + + public String getDistinctId() { + return mDistinctId; + } + + public String getUserId() { + return mUserId; + } + + public String getCompanyId() { + return mCompanyId; + } + + public String getDeviceId() { + return mDeviceId; + } + + /** + * @return true if this identity names a company, which is what sends it down Mixpanel's + * hierarchical groups resolution + */ + public boolean isHierarchical() { + return mCompanyId != null; + } + + /** + * Writes the keys this identity sets into the given event properties, replacing any + * caller-supplied values for those keys. A key this identity leaves unset is left as + * the caller gave it. + */ + /* package */ void applyTo(JSONObject properties) throws JSONException { + putIfSet(properties, "distinct_id", mDistinctId); + putIfSet(properties, "$user_id", mUserId); + putIfSet(properties, "$company_id", mCompanyId); + putIfSet(properties, "$device_id", mDeviceId); + } + + private static void putIfSet(JSONObject properties, String key, String value) throws JSONException { + if (value != null) { + properties.put(key, value); + } + } + + @Override + public String toString() { + StringBuilder out = new StringBuilder("MixpanelIdentity{"); + append(out, "distinctId", mDistinctId); + append(out, "userId", mUserId); + append(out, "companyId", mCompanyId); + append(out, "deviceId", mDeviceId); + return out.append("}").toString(); + } + + private static void append(StringBuilder out, String name, String value) { + if (value == null) { + return; + } + if (out.charAt(out.length() - 1) != '{') { + out.append(", "); + } + out.append(name).append("=").append(value); + } +} diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java new file mode 100644 index 0000000..2f9d0dd --- /dev/null +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/FlagContext.java @@ -0,0 +1,107 @@ +package com.mixpanel.mixpanelapi.featureflags; + +import com.mixpanel.mixpanelapi.MixpanelIdentity; + +import java.util.HashMap; +import java.util.Map; + +/** + * Builds a flag evaluation context with the keys Mixpanel expects, so callers do not + * hand-write reserved keys such as {@code $company_id}. + * + *
+ * {@code
+ *     Map context = FlagContext.builder()
+ *         .identity(MixpanelIdentity.builder().userId("user_123").companyId("acme").build())
+ *         .group("department", "finance")
+ *         .customProperty("plan", "enterprise")
+ *         .build();
+ *     boolean enabled = mixpanel.getLocalFlags().isEnabled("new-checkout", context);
+ * }
+ * 
+ */ +public final class FlagContext { + + private FlagContext() { + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private final Map mContext = new HashMap<>(); + // Identity keys are applied last in build(), so they win over group() and + // customProperty() whatever the call order; among them the later call wins. + private final Map mIdentity = new HashMap<>(); + private final Map 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 }