From fb198e7243604638f51763bfa252c8a27b450e63 Mon Sep 17 00:00:00 2001 From: Hans Li Date: Thu, 13 Nov 2025 13:34:43 -0800 Subject: [PATCH 01/23] Initial formatting --- .prettierignore | 1 + example.js | 97 +-- lib/flags/flags.d.ts | 71 +- lib/flags/flags.js | 269 ++++---- lib/flags/index.js | 8 +- lib/flags/local_flags.d.ts | 126 ++-- lib/flags/local_flags.js | 573 ++++++++-------- lib/flags/remote_flags.d.ts | 116 ++-- lib/flags/remote_flags.js | 258 +++---- lib/flags/types.d.ts | 128 ++-- lib/flags/utils.js | 60 +- lib/groups.js | 72 +- lib/mixpanel-node.d.ts | 378 ++++++++-- lib/mixpanel-node.js | 741 ++++++++++---------- lib/people.js | 379 +++++----- lib/profile_helpers.js | 434 ++++++------ lib/utils.js | 82 +-- package-lock.json | 243 +++++++ package.json | 7 +- test/alias.js | 64 +- test/config.js | 80 +-- test/flags/local_flags.js | 1008 +++++++++++++++------------ test/flags/remote_flags.js | 884 ++++++++++++------------ test/flags/utils.js | 180 ++--- test/groups.js | 745 ++++++++++---------- test/import.js | 613 +++++++++-------- test/logger.js | 206 +++--- test/people.js | 1289 ++++++++++++++++++----------------- test/send_request.js | 599 ++++++++-------- test/track.js | 666 +++++++++--------- test/utils.js | 83 +-- 31 files changed, 5710 insertions(+), 4750 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e3f5c35 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +history.md diff --git a/example.js b/example.js index 87f9396..f20a3b2 100644 --- a/example.js +++ b/example.js @@ -1,43 +1,51 @@ // grab the Mixpanel factory -var Mixpanel = require('./lib/mixpanel-node'); +var Mixpanel = require("./lib/mixpanel-node"); // create an instance of the mixpanel client -var mixpanel = Mixpanel.init('962dbca1bbc54701d402c94d65b4a20e'); +var mixpanel = Mixpanel.init("962dbca1bbc54701d402c94d65b4a20e"); mixpanel.set_config({ debug: true }); // track an event with optional properties mixpanel.track("my event", { - distinct_id: "some unique client id", - as: "many", - properties: "as", - you: "want" + distinct_id: "some unique client id", + as: "many", + properties: "as", + you: "want", }); mixpanel.track("played_game"); // create or update a user in Mixpanel Engage mixpanel.people.set("billybob", { - $first_name: "Billy", - $last_name: "Bob", - $created: (new Date('jan 1 2013')).toISOString(), - plan: "premium", - games_played: 1, - points: 0 + $first_name: "Billy", + $last_name: "Bob", + $created: new Date("jan 1 2013").toISOString(), + plan: "premium", + games_played: 1, + points: 0, }); // create or update a user in Mixpanel Engage without altering $last_seen // - pass option `$ignore_time: true` to prevent the $last_seen property from being updated -mixpanel.people.set("billybob", { +mixpanel.people.set( + "billybob", + { plan: "premium", - games_played: 1 -}, { - $ignore_time: true -}); + games_played: 1, + }, + { + $ignore_time: true, + }, +); // set a single property on a user mixpanel.people.set("billybob", "plan", "free"); // set a single property on a user, don't override -mixpanel.people.set_once("billybob", "first_game_play", (new Date('jan 1 2013')).toISOString()); +mixpanel.people.set_once( + "billybob", + "first_game_play", + new Date("jan 1 2013").toISOString(), +); // increment a numeric property mixpanel.people.increment("billybob", "games_played"); @@ -46,13 +54,16 @@ mixpanel.people.increment("billybob", "games_played"); mixpanel.people.increment("billybob", "points", 15); // increment multiple properties -mixpanel.people.increment("billybob", {"points": 10, "games_played": 1}); +mixpanel.people.increment("billybob", { points: 10, games_played: 1 }); // append value to a list mixpanel.people.append("billybob", "awards", "Great Player"); // append multiple values to a list -mixpanel.people.append("billybob", {"awards": "Great Player", "levels_finished": "Level 4"}); +mixpanel.people.append("billybob", { + awards: "Great Player", + levels_finished: "Level 4", +}); // record a transaction for revenue analytics mixpanel.people.track_charge("billybob", 39.99); @@ -65,38 +76,42 @@ mixpanel.people.delete_user("billybob"); // all functions that send data to mixpanel take an optional // callback as the last argument -mixpanel.track("test", function(err) { if (err) { throw err; } }); +mixpanel.track("test", function (err) { + if (err) { + throw err; + } +}); // import an old event -var mixpanel_importer = Mixpanel.init('valid mixpanel token', { - secret: "valid api secret for project" +var mixpanel_importer = Mixpanel.init("valid mixpanel token", { + secret: "valid api secret for project", }); mixpanel_importer.set_config({ debug: true }); // needs to be in the system once for it to show up in the interface -mixpanel_importer.track('old event', { gender: '' }); +mixpanel_importer.track("old event", { gender: "" }); mixpanel_importer.import("old event", new Date(2012, 4, 20, 12, 34, 56), { - distinct_id: 'billybob', - gender: 'male' + distinct_id: "billybob", + gender: "male", }); // import multiple events at once mixpanel_importer.import_batch([ - { - event: 'old event', - properties: { - time: new Date(2012, 4, 20, 12, 34, 56), - distinct_id: 'billybob', - gender: 'male' - } + { + event: "old event", + properties: { + time: new Date(2012, 4, 20, 12, 34, 56), + distinct_id: "billybob", + gender: "male", + }, + }, + { + event: "another old event", + properties: { + time: new Date(2012, 4, 21, 11, 33, 55), + distinct_id: "billybob", + color: "red", }, - { - event: 'another old event', - properties: { - time: new Date(2012, 4, 21, 11, 33, 55), - distinct_id: 'billybob', - color: 'red' - } - } + }, ]); diff --git a/lib/flags/flags.d.ts b/lib/flags/flags.d.ts index 8302667..9ac591d 100644 --- a/lib/flags/flags.d.ts +++ b/lib/flags/flags.d.ts @@ -2,16 +2,16 @@ * TypeScript type definitions for Base Feature Flags Provider */ -import { CustomLogger } from '../mixpanel-node'; -import { SelectedVariant, FlagContext } from './types'; +import { CustomLogger } from "../mixpanel-node"; +import { SelectedVariant, FlagContext } from "./types"; /** * Configuration for feature flags API requests */ export interface FeatureFlagsConfig { - token: string; - api_host: string; - request_timeout_in_seconds: number; + token: string; + api_host: string; + request_timeout_in_seconds: number; } /** @@ -19,32 +19,43 @@ export interface FeatureFlagsConfig { * Contains common methods for feature flag evaluation */ export class FeatureFlagsProvider { - providerConfig: FeatureFlagsConfig; - endpoint: string; - logger: CustomLogger | null; + providerConfig: FeatureFlagsConfig; + endpoint: string; + logger: CustomLogger | null; - /** - * @param config - Common configuration for feature flag providers - * @param endpoint - API endpoint path (i.e., '/flags' or '/flags/definitions') - * @param logger - Logger instance - */ - constructor(config: FeatureFlagsConfig, endpoint: string, logger: CustomLogger | null); + /** + * @param config - Common configuration for feature flag providers + * @param endpoint - API endpoint path (i.e., '/flags' or '/flags/definitions') + * @param logger - Logger instance + */ + constructor( + config: FeatureFlagsConfig, + endpoint: string, + logger: CustomLogger | null, + ); - /** - * Common HTTP request handler for flags API endpoints - * @param additionalParams - Additional query parameters to append - * @returns Parsed JSON response - */ - callFlagsEndpoint(additionalParams?: Record | null): Promise; + /** + * Common HTTP request handler for flags API endpoints + * @param additionalParams - Additional query parameters to append + * @returns Parsed JSON response + */ + callFlagsEndpoint( + additionalParams?: Record | null, + ): Promise; - /** - * Manually tracks a feature flag exposure event to Mixpanel - * This provides flexibility for reporting individual exposure events when using getAllVariants - * If using getVariantValue or getVariant, exposure events are tracked automatically by default. - * @param {string} flagKey - The key of the feature flag - * @param {SelectedVariant} variant - The selected variant for the feature flag - * @param {FlagContext} context - The user context used to evaluate the feature flag - * @param {number|null} latencyMs - Optionally included latency in milliseconds that assignment took. - */ - trackExposureEvent(flagKey: string, variant: SelectedVariant, context: FlagContext, latencyMs?: number | null): void; + /** + * Manually tracks a feature flag exposure event to Mixpanel + * This provides flexibility for reporting individual exposure events when using getAllVariants + * If using getVariantValue or getVariant, exposure events are tracked automatically by default. + * @param {string} flagKey - The key of the feature flag + * @param {SelectedVariant} variant - The selected variant for the feature flag + * @param {FlagContext} context - The user context used to evaluate the feature flag + * @param {number|null} latencyMs - Optionally included latency in milliseconds that assignment took. + */ + trackExposureEvent( + flagKey: string, + variant: SelectedVariant, + context: FlagContext, + latencyMs?: number | null, + ): void; } diff --git a/lib/flags/flags.js b/lib/flags/flags.js index 3cb1a62..4dc7f1c 100644 --- a/lib/flags/flags.js +++ b/lib/flags/flags.js @@ -3,145 +3,166 @@ * Contains common methods for feature flag evaluation */ -const https = require('https'); -const packageInfo = require('../../package.json'); -const { prepareCommonQueryParams, generateTraceparent, EXPOSURE_EVENT, REQUEST_HEADERS } = require('./utils'); +const https = require("https"); +const packageInfo = require("../../package.json"); +const { + prepareCommonQueryParams, + generateTraceparent, + EXPOSURE_EVENT, + REQUEST_HEADERS, +} = require("./utils"); /** * @typedef {import('./types').SelectedVariant} SelectedVariant * @typedef {import('./types').FlagContext} FlagContext */ class FeatureFlagsProvider { - /** - * @param {Object} providerConfig - Configuration object with token, api_host, request_timeout_in_seconds - * @param {string} endpoint - API endpoint path (e.g., '/flags' or '/flags/definitions') - * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) - * @param {string} evaluationMode - The feature flag evaluation mode - * @param {CustomLogger} logger - Logger instance - */ - constructor(providerConfig, endpoint, tracker, evaluationMode, logger) { - this.providerConfig = providerConfig; - this.endpoint = endpoint; - this.tracker = tracker; - this.evaluationMode = evaluationMode; - this.logger = logger; - } + /** + * @param {Object} providerConfig - Configuration object with token, api_host, request_timeout_in_seconds + * @param {string} endpoint - API endpoint path (e.g., '/flags' or '/flags/definitions') + * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) + * @param {string} evaluationMode - The feature flag evaluation mode + * @param {CustomLogger} logger - Logger instance + */ + constructor(providerConfig, endpoint, tracker, evaluationMode, logger) { + this.providerConfig = providerConfig; + this.endpoint = endpoint; + this.tracker = tracker; + this.evaluationMode = evaluationMode; + this.logger = logger; + } + + /** + * Common HTTP request handler for flags API endpoints + * @param {Object} additionalParams - Additional query parameters to append + * @returns {Promise} - Parsed JSON response + */ + async callFlagsEndpoint(additionalParams = null) { + return new Promise((resolve, reject) => { + const commonParams = prepareCommonQueryParams( + this.providerConfig.token, + packageInfo.version, + ); + const params = new URLSearchParams(commonParams); + + if (additionalParams) { + for (const [key, value] of Object.entries(additionalParams)) { + params.append(key, value); + } + } + + const path = `${this.endpoint}?${params.toString()}`; + + const requestOptions = { + host: this.providerConfig.api_host, + port: 443, + path: path, + method: "GET", + headers: { + ...REQUEST_HEADERS, + Authorization: + "Basic " + + Buffer.from(this.providerConfig.token + ":").toString("base64"), + traceparent: generateTraceparent(), + }, + timeout: this.providerConfig.request_timeout_in_seconds * 1000, + }; + + const request = https.request(requestOptions, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); - /** - * Common HTTP request handler for flags API endpoints - * @param {Object} additionalParams - Additional query parameters to append - * @returns {Promise} - Parsed JSON response - */ - async callFlagsEndpoint(additionalParams = null) { - return new Promise((resolve, reject) => { - const commonParams = prepareCommonQueryParams(this.providerConfig.token, packageInfo.version); - const params = new URLSearchParams(commonParams); - - if (additionalParams) { - for (const [key, value] of Object.entries(additionalParams)) { - params.append(key, value); - } - } - - const path = `${this.endpoint}?${params.toString()}`; - - const requestOptions = { - host: this.providerConfig.api_host, - port: 443, - path: path, - method: 'GET', - headers: { - ...REQUEST_HEADERS, - 'Authorization': 'Basic ' + Buffer.from(this.providerConfig.token + ':').toString('base64'), - 'traceparent': generateTraceparent(), - }, - timeout: this.providerConfig.request_timeout_in_seconds * 1000, - }; - - const request = https.request(requestOptions, (res) => { - let data = ''; - - res.on('data', (chunk) => { - data += chunk; - }); - - res.on('end', () => { - if (res.statusCode !== 200) { - this.logger?.error(`HTTP ${res.statusCode} error calling flags endpoint: ${data}`); - return reject(new Error(`HTTP ${res.statusCode}: ${data}`)); - } - - try { - const result = JSON.parse(data); - resolve(result); - } catch (parseErr) { - this.logger?.error(`Failed to parse JSON response: ${parseErr.message}`); - reject(parseErr); - } - }); - }); - - request.on('error', (err) => { - this.logger?.error(`Network error calling flags endpoint: ${err.message}`); - reject(err); - }); - - request.on('timeout', () => { - this.logger?.error(`Request timeout calling flags endpoint`); - request.destroy(); - reject(new Error('Request timeout')); - }); - - request.end(); + res.on("end", () => { + if (res.statusCode !== 200) { + this.logger?.error( + `HTTP ${res.statusCode} error calling flags endpoint: ${data}`, + ); + return reject(new Error(`HTTP ${res.statusCode}: ${data}`)); + } + + try { + const result = JSON.parse(data); + resolve(result); + } catch (parseErr) { + this.logger?.error( + `Failed to parse JSON response: ${parseErr.message}`, + ); + reject(parseErr); + } }); + }); + + request.on("error", (err) => { + this.logger?.error( + `Network error calling flags endpoint: ${err.message}`, + ); + reject(err); + }); + + request.on("timeout", () => { + this.logger?.error(`Request timeout calling flags endpoint`); + request.destroy(); + reject(new Error("Request timeout")); + }); + + request.end(); + }); + } + + /** + * Manually tracks a feature flag exposure event to Mixpanel + * This provides flexibility for reporting individual exposure events when using getAllVariants + * If using getVariantValue or getVariant, exposure events are tracked automatically by default. + * @param {string} flagKey - The key of the feature flag + * @param {SelectedVariant} variant - The selected variant for the feature flag + * @param {FlagContext} context - The user context used to evaluate the feature flag + * @param {number|null} latencyMs - Optionally included latency in milliseconds that assignment took. + */ + trackExposureEvent(flagKey, selectedVariant, context, latencyMs = null) { + if (!context.distinct_id) { + this.logger?.error( + "Cannot track exposure event without a distinct_id in the context", + ); + return; } - /** - * Manually tracks a feature flag exposure event to Mixpanel - * This provides flexibility for reporting individual exposure events when using getAllVariants - * If using getVariantValue or getVariant, exposure events are tracked automatically by default. - * @param {string} flagKey - The key of the feature flag - * @param {SelectedVariant} variant - The selected variant for the feature flag - * @param {FlagContext} context - The user context used to evaluate the feature flag - * @param {number|null} latencyMs - Optionally included latency in milliseconds that assignment took. - */ - trackExposureEvent(flagKey, selectedVariant, context, latencyMs=null) { - if (!context.distinct_id) { - this.logger?.error('Cannot track exposure event without a distinct_id in the context'); - return; - } - - const properties = { - 'distinct_id': context.distinct_id, - 'Experiment name': flagKey, - 'Variant name': selectedVariant.variant_key, - '$experiment_type': 'feature_flag', - 'Flag evaluation mode': this.evaluationMode - }; - - if (latencyMs !== null && latencyMs !== undefined) { - properties['Variant fetch latency (ms)'] = latencyMs; - } + const properties = { + distinct_id: context.distinct_id, + "Experiment name": flagKey, + "Variant name": selectedVariant.variant_key, + $experiment_type: "feature_flag", + "Flag evaluation mode": this.evaluationMode, + }; - if (selectedVariant.experiment_id !== undefined) { - properties['$experiment_id'] = selectedVariant.experiment_id; - } + if (latencyMs !== null && latencyMs !== undefined) { + properties["Variant fetch latency (ms)"] = latencyMs; + } - if (selectedVariant.is_experiment_active !== undefined) { - properties['$is_experiment_active'] = selectedVariant.is_experiment_active; - } + if (selectedVariant.experiment_id !== undefined) { + properties["$experiment_id"] = selectedVariant.experiment_id; + } - if (selectedVariant.is_qa_tester !== undefined) { - properties['$is_qa_tester'] = selectedVariant.is_qa_tester; - } + if (selectedVariant.is_experiment_active !== undefined) { + properties["$is_experiment_active"] = + selectedVariant.is_experiment_active; + } - // Use the tracker function provided (bound to the main mixpanel instance) - this.tracker(EXPOSURE_EVENT, properties, (err) => { - if (err) { - this.logger?.error(`[flags]Failed to track exposure event for flag '${flagKey}': ${err.message}`); - } - }); + if (selectedVariant.is_qa_tester !== undefined) { + properties["$is_qa_tester"] = selectedVariant.is_qa_tester; } + + // Use the tracker function provided (bound to the main mixpanel instance) + this.tracker(EXPOSURE_EVENT, properties, (err) => { + if (err) { + this.logger?.error( + `[flags]Failed to track exposure event for flag '${flagKey}': ${err.message}`, + ); + } + }); + } } module.exports = FeatureFlagsProvider; diff --git a/lib/flags/index.js b/lib/flags/index.js index f6dfd3a..c1a9574 100644 --- a/lib/flags/index.js +++ b/lib/flags/index.js @@ -3,10 +3,10 @@ * Exports for local and remote feature flag evaluation */ -const LocalFeatureFlagsProvider = require('./local_flags'); -const RemoteFeatureFlagsProvider = require('./remote_flags'); +const LocalFeatureFlagsProvider = require("./local_flags"); +const RemoteFeatureFlagsProvider = require("./remote_flags"); module.exports = { - LocalFeatureFlagsProvider, - RemoteFeatureFlagsProvider, + LocalFeatureFlagsProvider, + RemoteFeatureFlagsProvider, }; diff --git a/lib/flags/local_flags.d.ts b/lib/flags/local_flags.d.ts index 728e45a..104ff36 100644 --- a/lib/flags/local_flags.d.ts +++ b/lib/flags/local_flags.d.ts @@ -2,79 +2,79 @@ * TypeScript definitions for Local Feature Flags Provider */ -import { LocalFlagsConfig, FlagContext, SelectedVariant } from './types'; -import { CustomLogger } from '../mixpanel-node'; +import { LocalFlagsConfig, FlagContext, SelectedVariant } from "./types"; +import { CustomLogger } from "../mixpanel-node"; /** * Local Feature Flags Provider * Evaluates feature flags client-side using locally cached definitions */ export default class LocalFeatureFlagsProvider { - constructor( - token: string, - config: LocalFlagsConfig, - tracker: (distinct_id: string, event: string, properties: object, callback: (err?: Error) => void) => void, - logger: CustomLogger - ); + constructor( + token: string, + config: LocalFlagsConfig, + tracker: ( + distinct_id: string, + event: string, + properties: object, + callback: (err?: Error) => void, + ) => void, + logger: CustomLogger, + ); - /** - * Start polling for flag definitions - * Fetches immediately and then at regular intervals if polling is enabled - */ - startPollingForDefinitions(): Promise; + /** + * Start polling for flag definitions + * Fetches immediately and then at regular intervals if polling is enabled + */ + startPollingForDefinitions(): Promise; - /** - * Stop polling for flag definitions - */ - stopPollingForDefinitions(): void; + /** + * Stop polling for flag definitions + */ + stopPollingForDefinitions(): void; - /** - * Get the variant value for a feature flag - * @param flagKey - Feature flag key - * @param fallbackValue - Value to return if flag evaluation fails - * @param context - Evaluation context (must include distinct_id) - * @param reportExposure - Whether to track exposure event (default: true) - */ - getVariantValue( - flagKey: string, - fallbackValue: T, - context: FlagContext, - reportExposure?: boolean - ): T; + /** + * Get the variant value for a feature flag + * @param flagKey - Feature flag key + * @param fallbackValue - Value to return if flag evaluation fails + * @param context - Evaluation context (must include distinct_id) + * @param reportExposure - Whether to track exposure event (default: true) + */ + getVariantValue( + flagKey: string, + fallbackValue: T, + context: FlagContext, + reportExposure?: boolean, + ): T; - /** - * Get the complete variant information for a feature flag - * @param flagKey - Feature flag key - * @param fallbackVariant - Variant to return if flag evaluation fails - * @param context - Evaluation context (must include distinct_id) - * @param reportExposure - Whether to track exposure event (default: true) - */ - getVariant( - flagKey: string, - fallbackVariant: SelectedVariant, - context: FlagContext, - reportExposure?: boolean - ): SelectedVariant; + /** + * Get the complete variant information for a feature flag + * @param flagKey - Feature flag key + * @param fallbackVariant - Variant to return if flag evaluation fails + * @param context - Evaluation context (must include distinct_id) + * @param reportExposure - Whether to track exposure event (default: true) + */ + getVariant( + flagKey: string, + fallbackVariant: SelectedVariant, + context: FlagContext, + reportExposure?: boolean, + ): SelectedVariant; - /** - * Check if a feature flag is enabled - * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) - * This checks that the variant value of a selected variant is concretely the boolean 'true' - * It does not coerce other truthy values. - * @param flagKey - Feature flag key - * @param context - Evaluation context (must include distinct_id) - */ - isEnabled( - flagKey: string, - context: FlagContext - ): boolean; + /** + * Check if a feature flag is enabled + * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) + * This checks that the variant value of a selected variant is concretely the boolean 'true' + * It does not coerce other truthy values. + * @param flagKey - Feature flag key + * @param context - Evaluation context (must include distinct_id) + */ + isEnabled(flagKey: string, context: FlagContext): boolean; - /** - * Get all feature flag variants for the current user context - * Exposure events are not automatically tracked when this method is used - * @param context - Evaluation context (must include distinct_id) - */ - getAllVariants( - context: FlagContext - ): {[key: string]: SelectedVariant}; + /** + * Get all feature flag variants for the current user context + * Exposure events are not automatically tracked when this method is used + * @param context - Evaluation context (must include distinct_id) + */ + getAllVariants(context: FlagContext): { [key: string]: SelectedVariant }; } diff --git a/lib/flags/local_flags.js b/lib/flags/local_flags.js index 1b35788..b3961cc 100644 --- a/lib/flags/local_flags.js +++ b/lib/flags/local_flags.js @@ -11,309 +11,338 @@ * @typedef {import('./types').LocalFlagsResponse} LocalFlagsResponse * */ -const FeatureFlagsProvider = require('./flags'); -const { normalizedHash } = require('./utils'); +const FeatureFlagsProvider = require("./flags"); +const { normalizedHash } = require("./utils"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { - /** - * @param {string} token - Mixpanel project token - * @param {LocalFlagsConfig} config - Local flags configuration - * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) - * @param {CustomLogger} logger - Logger - */ - constructor(token, config, tracker, logger) { - const mergedConfig = { - api_host: 'api.mixpanel.com', - request_timeout_in_seconds: 10, - enable_polling: true, - polling_interval_in_seconds: 60, - ...config, - }; - - const providerConfig = { - token: token, - api_host: mergedConfig.api_host, - request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, - }; - - super(providerConfig, '/flags/definitions', tracker, 'local', logger); - - this.config = mergedConfig; - this.flagDefinitions = new Map(); - this.pollingInterval = null; - } - - /** - * Start polling for flag definitions. - * Fetches immediately and then at regular intervals if polling is enabled - * @returns {Promise} - */ - async startPollingForDefinitions() { - try { + /** + * @param {string} token - Mixpanel project token + * @param {LocalFlagsConfig} config - Local flags configuration + * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) + * @param {CustomLogger} logger - Logger + */ + constructor(token, config, tracker, logger) { + const mergedConfig = { + api_host: "api.mixpanel.com", + request_timeout_in_seconds: 10, + enable_polling: true, + polling_interval_in_seconds: 60, + ...config, + }; + + const providerConfig = { + token: token, + api_host: mergedConfig.api_host, + request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, + }; + + super(providerConfig, "/flags/definitions", tracker, "local", logger); + + this.config = mergedConfig; + this.flagDefinitions = new Map(); + this.pollingInterval = null; + } + + /** + * Start polling for flag definitions. + * Fetches immediately and then at regular intervals if polling is enabled + * @returns {Promise} + */ + async startPollingForDefinitions() { + try { + await this._fetchFlagDefinitions(); + + if (this.config.enable_polling && !this.pollingInterval) { + this.pollingInterval = setInterval(async () => { + try { await this._fetchFlagDefinitions(); - - if (this.config.enable_polling && !this.pollingInterval) { - this.pollingInterval = setInterval(async () => { - try { - await this._fetchFlagDefinitions(); - } catch (err) { - this.logger?.error(`Error polling for flag definition: ${err.message}`); - } - }, this.config.polling_interval_in_seconds * 1000); - } - } catch (err) { - this.logger?.error(`Initial flag definitions fetch failed: ${err.message}`); - } + } catch (err) { + this.logger?.error( + `Error polling for flag definition: ${err.message}`, + ); + } + }, this.config.polling_interval_in_seconds * 1000); + } + } catch (err) { + this.logger?.error( + `Initial flag definitions fetch failed: ${err.message}`, + ); } - - /** - * Stop polling for flag definitions - */ - stopPollingForDefinitions() { - if (this.pollingInterval) { - clearInterval(this.pollingInterval); - this.pollingInterval = null; - } else { - this.logger?.warn('stopPollingForDefinitions called but polling was not active'); - } + } + + /** + * Stop polling for flag definitions + */ + stopPollingForDefinitions() { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } else { + this.logger?.warn( + "stopPollingForDefinitions called but polling was not active", + ); } - - /** - * Check if a feature flag is enabled - * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) - * This checks that the variant value of a selected variant is concretely the boolean 'true' - * It does not coerce other truthy values. - * @param {string} flagKey - Feature flag key - * @param {FlagContext} context - Evaluation context (must include distinct_id) - * @returns {boolean} - */ - isEnabled(flagKey, context) { - const value = this.getVariantValue(flagKey, false, context); - return value === true; + } + + /** + * Check if a feature flag is enabled + * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) + * This checks that the variant value of a selected variant is concretely the boolean 'true' + * It does not coerce other truthy values. + * @param {string} flagKey - Feature flag key + * @param {FlagContext} context - Evaluation context (must include distinct_id) + * @returns {boolean} + */ + isEnabled(flagKey, context) { + const value = this.getVariantValue(flagKey, false, context); + return value === true; + } + + /** + * Get the variant value for a feature flag + * @param {string} flagKey - Feature flag key + * @param {*} fallbackValue - Value to return if the user context is not in the rollout for a flag or if evaluation fails + * @param {FlagContext} context - Evaluation context + * @param {boolean} [reportExposure=true] - Whether to track exposure event + * @returns {*} The variant value + */ + getVariantValue(flagKey, fallbackValue, context, reportExposure = true) { + const result = this.getVariant( + flagKey, + { variant_value: fallbackValue }, + context, + reportExposure, + ); + return result.variant_value; + } + + /** + * Get the complete variant information for a feature flag + * @param {string} flagKey - Feature flag key + * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails + * @param {FlagContext} context - Evaluation context (must include distinct_id) + * @param {boolean} [reportExposure=true] - Whether to track exposure event + * @returns {SelectedVariant} + */ + getVariant(flagKey, fallbackVariant, context, reportExposure = true) { + const flag = this.flagDefinitions.get(flagKey); + + if (!flag) { + this.logger?.warn(`Cannot find flag definition for key: '${flagKey}`); + return fallbackVariant; } - /** - * Get the variant value for a feature flag - * @param {string} flagKey - Feature flag key - * @param {*} fallbackValue - Value to return if the user context is not in the rollout for a flag or if evaluation fails - * @param {FlagContext} context - Evaluation context - * @param {boolean} [reportExposure=true] - Whether to track exposure event - * @returns {*} The variant value - */ - getVariantValue(flagKey, fallbackValue, context, reportExposure = true) { - const result = this.getVariant(flagKey, { variant_value: fallbackValue }, context, reportExposure); - return result.variant_value; + if (!Object.hasOwn(context, flag.context)) { + this.logger?.warn( + `The variant assignment key, '${flag.context}' for flag, '${flagKey}' is not present in the supplied user context dictionary`, + ); + return fallbackVariant; } - /** - * Get the complete variant information for a feature flag - * @param {string} flagKey - Feature flag key - * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails - * @param {FlagContext} context - Evaluation context (must include distinct_id) - * @param {boolean} [reportExposure=true] - Whether to track exposure event - * @returns {SelectedVariant} - */ - getVariant(flagKey, fallbackVariant, context, reportExposure = true) { - const flag = this.flagDefinitions.get(flagKey); - - if (!flag) { - this.logger?.warn(`Cannot find flag definition for key: '${flagKey}`); - return fallbackVariant; - } - - if (!Object.hasOwn(context, flag.context)) { - this.logger?.warn( - `The variant assignment key, '${flag.context}' for flag, '${flagKey}' is not present in the supplied user context dictionary` - ); - return fallbackVariant; - } - - const contextValue = context[flag.context]; - - let selectedVariant = null; - - const testUserVariant = this._getVariantOverrideForTestUser(flag, context); - if (testUserVariant) { - selectedVariant = testUserVariant; - } else { - const rollout = this._getAssignedRollout(flag, contextValue, context); - if (rollout) { - selectedVariant = this._getAssignedVariant(flag, contextValue, flagKey, rollout); - } - } - - if (selectedVariant) { - if (reportExposure) { - this.trackExposureEvent(flagKey, selectedVariant, context); - } - return selectedVariant; - } - - return fallbackVariant; + const contextValue = context[flag.context]; + + let selectedVariant = null; + + const testUserVariant = this._getVariantOverrideForTestUser(flag, context); + if (testUserVariant) { + selectedVariant = testUserVariant; + } else { + const rollout = this._getAssignedRollout(flag, contextValue, context); + if (rollout) { + selectedVariant = this._getAssignedVariant( + flag, + contextValue, + flagKey, + rollout, + ); + } } - /** - * Get all feature flag variants for the current user context - * Exposure events are not automatically tracked when this method is used - * @param {FlagContext} context - Evaluation context (must include distinct_id) - * @returns {{[key: string]: SelectedVariant}} - */ - getAllVariants(context) { - const variants = {}; - - for (const flagKey of this.flagDefinitions.keys()) { - const variant = this.getVariant(flagKey, null, context, false); - if (variant !== null) { - variants[flagKey] = variant; - } - } - - return variants; + if (selectedVariant) { + if (reportExposure) { + this.trackExposureEvent(flagKey, selectedVariant, context); + } + return selectedVariant; } - /** - * Fetch flag definitions from API. - * @returns {Promise} - */ - async _fetchFlagDefinitions() { - const response = await this.callFlagsEndpoint(); - - const newDefinitions = new Map(); - response.flags.forEach(flag => { - newDefinitions.set(flag.key, flag); - }); - - this.flagDefinitions = newDefinitions; - - return response; + return fallbackVariant; + } + + /** + * Get all feature flag variants for the current user context + * Exposure events are not automatically tracked when this method is used + * @param {FlagContext} context - Evaluation context (must include distinct_id) + * @returns {{[key: string]: SelectedVariant}} + */ + getAllVariants(context) { + const variants = {}; + + for (const flagKey of this.flagDefinitions.keys()) { + const variant = this.getVariant(flagKey, null, context, false); + if (variant !== null) { + variants[flagKey] = variant; + } } - /** - * Find a variant by key (case-insensitive) and return complete SelectedVariant - * @param {string} variantKey - Variant key to find - * @param {ExperimentationFlag} flag - Flag definition - * @returns {SelectedVariant|null} - */ - _getMatchingVariant(variantKey, flag) { - for (const variant of flag.ruleset.variants) { - if (variantKey.toLowerCase() === variant.key.toLowerCase()) { - return { - variant_key: variant.key, - variant_value: variant.value, - experiment_id: flag.experiment_id, - is_experiment_active: flag.is_experiment_active, - }; - } - } - return null; + return variants; + } + + /** + * Fetch flag definitions from API. + * @returns {Promise} + */ + async _fetchFlagDefinitions() { + const response = await this.callFlagsEndpoint(); + + const newDefinitions = new Map(); + response.flags.forEach((flag) => { + newDefinitions.set(flag.key, flag); + }); + + this.flagDefinitions = newDefinitions; + + return response; + } + + /** + * Find a variant by key (case-insensitive) and return complete SelectedVariant + * @param {string} variantKey - Variant key to find + * @param {ExperimentationFlag} flag - Flag definition + * @returns {SelectedVariant|null} + */ + _getMatchingVariant(variantKey, flag) { + for (const variant of flag.ruleset.variants) { + if (variantKey.toLowerCase() === variant.key.toLowerCase()) { + return { + variant_key: variant.key, + variant_value: variant.value, + experiment_id: flag.experiment_id, + is_experiment_active: flag.is_experiment_active, + }; + } } + return null; + } - _getVariantOverrideForTestUser(flag, context) { - if (!flag.ruleset.test?.users) { - return null; - } - - const distinctId = context.distinct_id; - if (!distinctId) { - return null; - } - - const variantKey = flag.ruleset.test.users[distinctId]; - if (!variantKey) { - return null; - } - - let selected_variant = this._getMatchingVariant(variantKey, flag); - if (selected_variant) { - selected_variant.is_qa_tester = true; - } - return selected_variant; + _getVariantOverrideForTestUser(flag, context) { + if (!flag.ruleset.test?.users) { + return null; } - _getAssignedRollout(flag, contextValue, context) { - for (let index = 0; index < flag.ruleset.rollout.length; index++) { - const rollout = flag.ruleset.rollout[index]; - - let salt; - if (flag.hash_salt !== null && flag.hash_salt !== undefined) { - salt = flag.key + flag.hash_salt + index.toString(); - } else { - salt = flag.key + "rollout"; - } - - const rolloutHash = normalizedHash(String(contextValue), salt); - - if (rolloutHash < rollout.rollout_percentage && - this._isRuntimeEvaluationSatisfied(rollout, context)) { - return rollout; - } - } + const distinctId = context.distinct_id; + if (!distinctId) { + return null; + } - return null; + const variantKey = flag.ruleset.test.users[distinctId]; + if (!variantKey) { + return null; } - _getAssignedVariant(flag, contextValue, flagKey, rollout) { - if (rollout.variant_override) { - const variant = this._getMatchingVariant(rollout.variant_override.key, flag); - if (variant) { - return { ...variant, is_qa_tester: false }; - } - } + let selected_variant = this._getMatchingVariant(variantKey, flag); + if (selected_variant) { + selected_variant.is_qa_tester = true; + } + return selected_variant; + } + + _getAssignedRollout(flag, contextValue, context) { + for (let index = 0; index < flag.ruleset.rollout.length; index++) { + const rollout = flag.ruleset.rollout[index]; + + let salt; + if (flag.hash_salt !== null && flag.hash_salt !== undefined) { + salt = flag.key + flag.hash_salt + index.toString(); + } else { + salt = flag.key + "rollout"; + } + + const rolloutHash = normalizedHash(String(contextValue), salt); + + if ( + rolloutHash < rollout.rollout_percentage && + this._isRuntimeEvaluationSatisfied(rollout, context) + ) { + return rollout; + } + } - const storedSalt = flag.hash_salt !== null && flag.hash_salt !== undefined ? flag.hash_salt : ""; - const salt = flagKey + storedSalt + "variant"; - const variantHash = normalizedHash(String(contextValue), salt); - - const variants = flag.ruleset.variants.map(v => ({ ...v })); - if (rollout.variant_splits) { - for (const variant of variants) { - if (variant.key in rollout.variant_splits) { - variant.split = rollout.variant_splits[variant.key]; - } - } - } + return null; + } + + _getAssignedVariant(flag, contextValue, flagKey, rollout) { + if (rollout.variant_override) { + const variant = this._getMatchingVariant( + rollout.variant_override.key, + flag, + ); + if (variant) { + return { ...variant, is_qa_tester: false }; + } + } - let selected = variants[0]; - let cumulative = 0.0; - for (const variant of variants) { - selected = variant; - cumulative += variant.split || 0.0; - if (variantHash < cumulative) { - break; - } + const storedSalt = + flag.hash_salt !== null && flag.hash_salt !== undefined + ? flag.hash_salt + : ""; + const salt = flagKey + storedSalt + "variant"; + const variantHash = normalizedHash(String(contextValue), salt); + + const variants = flag.ruleset.variants.map((v) => ({ ...v })); + if (rollout.variant_splits) { + for (const variant of variants) { + if (variant.key in rollout.variant_splits) { + variant.split = rollout.variant_splits[variant.key]; } - - return { - variant_key: selected.key, - variant_value: selected.value, - experiment_id: flag.experiment_id, - is_experiment_active: flag.is_experiment_active, - is_qa_tester: false, - }; + } } - _isRuntimeEvaluationSatisfied(rollout, context) { - if (!rollout.runtime_evaluation_definition) { - return true; - } + let selected = variants[0]; + let cumulative = 0.0; + for (const variant of variants) { + selected = variant; + cumulative += variant.split || 0.0; + if (variantHash < cumulative) { + break; + } + } - const customProperties = context.custom_properties; - if (!customProperties || typeof customProperties !== 'object') { - return false; - } + return { + variant_key: selected.key, + variant_value: selected.value, + experiment_id: flag.experiment_id, + is_experiment_active: flag.is_experiment_active, + is_qa_tester: false, + }; + } + + _isRuntimeEvaluationSatisfied(rollout, context) { + if (!rollout.runtime_evaluation_definition) { + return true; + } - for (const [key, expectedValue] of Object.entries(rollout.runtime_evaluation_definition)) { - if (!(key in customProperties)) { - return false; - } + const customProperties = context.custom_properties; + if (!customProperties || typeof customProperties !== "object") { + return false; + } - const actualValue = customProperties[key]; - if (String(actualValue).toLowerCase() !== String(expectedValue).toLowerCase()) { - return false; - } - } - return true; + for (const [key, expectedValue] of Object.entries( + rollout.runtime_evaluation_definition, + )) { + if (!(key in customProperties)) { + return false; + } + + const actualValue = customProperties[key]; + if ( + String(actualValue).toLowerCase() !== + String(expectedValue).toLowerCase() + ) { + return false; + } } + return true; + } } module.exports = LocalFeatureFlagsProvider; diff --git a/lib/flags/remote_flags.d.ts b/lib/flags/remote_flags.d.ts index 5652736..878c7f6 100644 --- a/lib/flags/remote_flags.d.ts +++ b/lib/flags/remote_flags.d.ts @@ -2,71 +2,73 @@ * TypeScript definitions for Remote Feature Flags Provider */ -import { CustomLogger } from '../mixpanel-node' -import { RemoteFlagsConfig, FlagContext, SelectedVariant } from './types'; +import { CustomLogger } from "../mixpanel-node"; +import { RemoteFlagsConfig, FlagContext, SelectedVariant } from "./types"; /** * Remote Feature Flags Provider * Evaluates feature flags via server-side API requests */ export default class RemoteFeatureFlagsProvider { - constructor( - token: string, - config: RemoteFlagsConfig, - logger: CustomLogger, - tracker: (distinct_id: string, event: string, properties: object, callback: (err?: Error) => void) => void - ); + constructor( + token: string, + config: RemoteFlagsConfig, + logger: CustomLogger, + tracker: ( + distinct_id: string, + event: string, + properties: object, + callback: (err?: Error) => void, + ) => void, + ); - /** - * Get the variant value for a feature flag - * @param flagKey - Feature flag key - * @param fallbackValue - Value to return if flag evaluation fails - * @param context - Evaluation context (must include distinct_id) - * @param reportExposure - Whether to track exposure event (default: true) - * @returns Promise resolving to variant value - */ - getVariantValue( - flagKey: string, - fallbackValue: T, - context: FlagContext, - reportExposure?: boolean - ): Promise; + /** + * Get the variant value for a feature flag + * @param flagKey - Feature flag key + * @param fallbackValue - Value to return if flag evaluation fails + * @param context - Evaluation context (must include distinct_id) + * @param reportExposure - Whether to track exposure event (default: true) + * @returns Promise resolving to variant value + */ + getVariantValue( + flagKey: string, + fallbackValue: T, + context: FlagContext, + reportExposure?: boolean, + ): Promise; - /** - * Get the complete variant information for a feature flag - * @param flagKey - Feature flag key - * @param fallbackVariant - Variant to return if flag evaluation fails - * @param context - Evaluation context (must include distinct_id) - * @param reportExposure - Whether to track exposure event (default: true) - * @returns Promise resolving to selected variant - */ - getVariant( - flagKey: string, - fallbackVariant: SelectedVariant, - context: FlagContext, - reportExposure?: boolean - ): Promise; + /** + * Get the complete variant information for a feature flag + * @param flagKey - Feature flag key + * @param fallbackVariant - Variant to return if flag evaluation fails + * @param context - Evaluation context (must include distinct_id) + * @param reportExposure - Whether to track exposure event (default: true) + * @returns Promise resolving to selected variant + */ + getVariant( + flagKey: string, + fallbackVariant: SelectedVariant, + context: FlagContext, + reportExposure?: boolean, + ): Promise; - /** - * Check if a feature flag is enabled. - * This checks that the variant value of a selected variant is concretely the boolean 'true', which will be the case for flags setup as FeatureGates - * It does not coerce other truthy values. - * @param flagKey - Feature flag key - * @param context - Evaluation context (must include distinct_id) - * @returns Promise resolving to whether the flag is enabled - */ - isEnabled( - flagKey: string, - context: FlagContext - ): Promise; + /** + * Check if a feature flag is enabled. + * This checks that the variant value of a selected variant is concretely the boolean 'true', which will be the case for flags setup as FeatureGates + * It does not coerce other truthy values. + * @param flagKey - Feature flag key + * @param context - Evaluation context (must include distinct_id) + * @returns Promise resolving to whether the flag is enabled + */ + isEnabled(flagKey: string, context: FlagContext): Promise; - /** - * Get all feature flag variants for the current user context from remote server - * Exposure events are not automatically tracked when this method is used - * @param context - Evaluation context (must include distinct_id) - * @returns Promise resolving to dictionary mapping flag keys to variants, or null if the call fails - */ - getAllVariants( - context: FlagContext - ): Promise<{[key: string]: SelectedVariant} | null>; + /** + * Get all feature flag variants for the current user context from remote server + * Exposure events are not automatically tracked when this method is used + * @param context - Evaluation context (must include distinct_id) + * @returns Promise resolving to dictionary mapping flag keys to variants, or null if the call fails + */ + getAllVariants( + context: FlagContext, + ): Promise<{ [key: string]: SelectedVariant } | null>; } diff --git a/lib/flags/remote_flags.js b/lib/flags/remote_flags.js index d4c3e36..5566916 100644 --- a/lib/flags/remote_flags.js +++ b/lib/flags/remote_flags.js @@ -10,135 +10,151 @@ * @typedef {import('./types').RemoteFlagsResponse} RemoteFlagsResponse */ -const FeatureFlagsProvider = require('./flags'); +const FeatureFlagsProvider = require("./flags"); class RemoteFeatureFlagsProvider extends FeatureFlagsProvider { - /** - * @param {string} token - Mixpanel project token - * @param {RemoteFlagsConfig} config - Remote flags configuration - * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) - * @param {CustomLogger} logger - Logger instance - */ - constructor(token, config, tracker, logger) { - const mergedConfig = { - api_host: 'api.mixpanel.com', - request_timeout_in_seconds: 10, - ...config, - }; - - const providerConfig = { - token: token, - api_host: mergedConfig.api_host, - request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, - }; - - super(providerConfig, '/flags', tracker, 'remote', logger); + /** + * @param {string} token - Mixpanel project token + * @param {RemoteFlagsConfig} config - Remote flags configuration + * @param {Function} tracker - Function to track events (signature: track(distinct_id, event, properties, callback)) + * @param {CustomLogger} logger - Logger instance + */ + constructor(token, config, tracker, logger) { + const mergedConfig = { + api_host: "api.mixpanel.com", + request_timeout_in_seconds: 10, + ...config, + }; + + const providerConfig = { + token: token, + api_host: mergedConfig.api_host, + request_timeout_in_seconds: mergedConfig.request_timeout_in_seconds, + }; + + super(providerConfig, "/flags", tracker, "remote", logger); + } + + /** + * Get the variant value for a feature flag + * If the user context is eligible for the rollout, one of the flag variants will be selected and an exposure event will be tracked to Mixpanel. + * If the user context is not eligible, the fallback value is returned. + * @param {string} flagKey - Feature flag key + * @param {*} fallbackValue - Value to return if flag evaluation fails + * @param {FlagContext} context - Evaluation context + * @param {boolean} reportExposure - Whether to track exposure event + * @returns {Promise<*>} - Variant value + */ + async getVariantValue( + flagKey, + fallbackValue, + context, + reportExposure = true, + ) { + try { + const selectedVariant = await this.getVariant( + flagKey, + { variant_value: fallbackValue }, + context, + reportExposure, + ); + return selectedVariant.variant_value; + } catch (err) { + this.logger?.error( + `Failed to get variant value for flag '${flagKey}': ${err.message}`, + ); + return fallbackValue; } - - /** - * Get the variant value for a feature flag - * If the user context is eligible for the rollout, one of the flag variants will be selected and an exposure event will be tracked to Mixpanel. - * If the user context is not eligible, the fallback value is returned. - * @param {string} flagKey - Feature flag key - * @param {*} fallbackValue - Value to return if flag evaluation fails - * @param {FlagContext} context - Evaluation context - * @param {boolean} reportExposure - Whether to track exposure event - * @returns {Promise<*>} - Variant value - */ - async getVariantValue(flagKey, fallbackValue, context, reportExposure = true) { - try { - const selectedVariant = await this.getVariant(flagKey, { variant_value: fallbackValue }, context, reportExposure); - return selectedVariant.variant_value; - } catch (err) { - this.logger?.error(`Failed to get variant value for flag '${flagKey}': ${err.message}`); - return fallbackValue; - } + } + + /** + * Get the complete variant information for a feature flag + * If the user context is eligible for the rollout, one of the flag variants will be selected and an exposure event will be tracked to Mixpanel. + * If the user context is not eligible, the fallback value is returned. + * @param {string} flagKey - Feature flag key + * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails + * @param {FlagContext} context - Evaluation context + * @param {boolean} reportExposure - Whether to track exposure event in the event that the user context is eligible for the rollout. + * @returns {Promise} - Selected variant + */ + async getVariant(flagKey, fallbackVariant, context, reportExposure = true) { + try { + const startTime = Date.now(); + const response = await this._fetchFlags(context, flagKey); + const latencyMs = Date.now() - startTime; + + const flags = response.flags || {}; + const selectedVariant = flags[flagKey]; + if (!selectedVariant) { + return fallbackVariant; + } + + if (reportExposure) { + this.trackExposureEvent(flagKey, selectedVariant, context, latencyMs); + } + + return selectedVariant; + } catch (err) { + this.logger?.error( + `Failed to get variant for flag '${flagKey}': ${err.message}`, + ); + return fallbackVariant; } - - /** - * Get the complete variant information for a feature flag - * If the user context is eligible for the rollout, one of the flag variants will be selected and an exposure event will be tracked to Mixpanel. - * If the user context is not eligible, the fallback value is returned. - * @param {string} flagKey - Feature flag key - * @param {SelectedVariant} fallbackVariant - Variant to return if flag evaluation fails - * @param {FlagContext} context - Evaluation context - * @param {boolean} reportExposure - Whether to track exposure event in the event that the user context is eligible for the rollout. - * @returns {Promise} - Selected variant - */ - async getVariant(flagKey, fallbackVariant, context, reportExposure = true) { - try { - const startTime = Date.now(); - const response = await this._fetchFlags(context, flagKey); - const latencyMs = Date.now() - startTime; - - const flags = response.flags || {}; - const selectedVariant = flags[flagKey]; - if (!selectedVariant) { - return fallbackVariant; - } - - if (reportExposure) { - this.trackExposureEvent(flagKey, selectedVariant, context, latencyMs); - } - - return selectedVariant; - } catch (err) { - this.logger?.error(`Failed to get variant for flag '${flagKey}': ${err.message}`); - return fallbackVariant; - } + } + + /** + * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) + * This checks that the variant value of a selected variant is concretely the boolean 'true' + * It does not coerce other truthy values. + * @param {string} flagKey - Feature flag key + * @param {FlagContext} context - User's evaluation context + * @returns {Promise} - Whether the flag is enabled + */ + async isEnabled(flagKey, context) { + try { + const value = await this.getVariantValue(flagKey, false, context); + return value === true; + } catch (err) { + this.logger?.error( + `Failed to check if flag '${flagKey}' is enabled: ${err.message}`, + ); + return false; } - - /** - * This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags) - * This checks that the variant value of a selected variant is concretely the boolean 'true' - * It does not coerce other truthy values. - * @param {string} flagKey - Feature flag key - * @param {FlagContext} context - User's evaluation context - * @returns {Promise} - Whether the flag is enabled - */ - async isEnabled(flagKey, context) { - try { - const value = await this.getVariantValue(flagKey, false, context); - return value === true; - } catch (err) { - this.logger?.error(`Failed to check if flag '${flagKey}' is enabled: ${err.message}`); - return false; - } + } + + /** + * Get all feature flag variants for the current user context from remote server + * Exposure events are not automatically tracked when this method is used + * @param {FlagContext} context - User's evaluation context + * @returns {Promise<{[key: string]: SelectedVariant}|null>} - Dictionary mapping flag keys to variants, or null if the call fails + */ + async getAllVariants(context) { + try { + const response = await this._fetchFlags(context); + return response.flags || {}; + } catch (err) { + this.logger?.error(`Failed to get all remote variants: ${err.message}`); + return null; } - - /** - * Get all feature flag variants for the current user context from remote server - * Exposure events are not automatically tracked when this method is used - * @param {FlagContext} context - User's evaluation context - * @returns {Promise<{[key: string]: SelectedVariant}|null>} - Dictionary mapping flag keys to variants, or null if the call fails - */ - async getAllVariants(context) { - try { - const response = await this._fetchFlags(context); - return response.flags || {}; - } catch (err) { - this.logger?.error(`Failed to get all remote variants: ${err.message}`); - return null; - } + } + + /** + * Fetch flags from remote flags evaluation API + * @param {FlagContext} context - Evaluation context + * @param {string} [flagKey] - Optional flag key (if omitted, fetches all flags) + * @returns {Promise} - API response containing flags dictionary + */ + _fetchFlags(context, flagKey = null) { + const additionalParams = { + context: JSON.stringify(context), + }; + + if (flagKey !== null) { + additionalParams.flag_key = flagKey; } - /** - * Fetch flags from remote flags evaluation API - * @param {FlagContext} context - Evaluation context - * @param {string} [flagKey] - Optional flag key (if omitted, fetches all flags) - * @returns {Promise} - API response containing flags dictionary - */ - _fetchFlags(context, flagKey = null) { - const additionalParams = { - context: JSON.stringify(context), - }; - - if (flagKey !== null) { - additionalParams.flag_key = flagKey; - } - - return this.callFlagsEndpoint(additionalParams); - } + return this.callFlagsEndpoint(additionalParams); + } } module.exports = RemoteFeatureFlagsProvider; diff --git a/lib/flags/types.d.ts b/lib/flags/types.d.ts index 0e0b549..09a491d 100644 --- a/lib/flags/types.d.ts +++ b/lib/flags/types.d.ts @@ -6,20 +6,20 @@ * Base configuration for feature flags */ export interface FlagsConfig { - /** API host for Mixpanel (default: 'api.mixpanel.com') */ - api_host?: string; - /** Request timeout in seconds (default: 10) */ - request_timeout_in_seconds?: number; + /** API host for Mixpanel (default: 'api.mixpanel.com') */ + api_host?: string; + /** Request timeout in seconds (default: 10) */ + request_timeout_in_seconds?: number; } /** * Configuration for local feature flags (client-side evaluation) */ export interface LocalFlagsConfig extends FlagsConfig { - /** Enable automatic polling for flag definition updates (default: true) */ - enable_polling?: boolean; - /** Polling interval in seconds (default: 60) */ - polling_interval_in_seconds?: number; + /** Enable automatic polling for flag definition updates (default: true) */ + enable_polling?: boolean; + /** Polling interval in seconds (default: 60) */ + polling_interval_in_seconds?: number; } /** @@ -31,102 +31,102 @@ export interface RemoteFlagsConfig extends FlagsConfig {} * Represents a variant in a feature flag */ export interface Variant { - /** Variant key/name */ - key: string; - /** Variant value (can be any type) */ - value: any; - /** Whether this is the control variant */ - is_control: boolean; - /** Percentage split for this variant (0.0-1.0) */ - split?: number; + /** Variant key/name */ + key: string; + /** Variant value (can be any type) */ + value: any; + /** Whether this is the control variant */ + is_control: boolean; + /** Percentage split for this variant (0.0-1.0) */ + split?: number; } /** * Variant override configuration */ export interface VariantOverride { - /** Key of the variant to override to */ - key: string; + /** Key of the variant to override to */ + key: string; } /** * Rollout configuration for a feature flag */ export interface Rollout { - /** Percentage of users to include in this rollout (0.0-1.0) */ - rollout_percentage: number; - /** Runtime evaluation conditions (property-based targeting) */ - runtime_evaluation_definition?: Record; - /** Variant override for this rollout */ - variant_override?: VariantOverride; - /** Variant split percentages (variant_key -> percentage) */ - variant_splits?: Record; + /** Percentage of users to include in this rollout (0.0-1.0) */ + rollout_percentage: number; + /** Runtime evaluation conditions (property-based targeting) */ + runtime_evaluation_definition?: Record; + /** Variant override for this rollout */ + variant_override?: VariantOverride; + /** Variant split percentages (variant_key -> percentage) */ + variant_splits?: Record; } /** * Test users configuration for a feature flag */ export interface FlagTestUsers { - /** Map of distinct_id to variant_key */ - users: Record; + /** Map of distinct_id to variant_key */ + users: Record; } /** * Rule set for a feature flag */ export interface RuleSet { - /** Available variants for this flag */ - variants: Variant[]; - /** Rollout configurations */ - rollout: Rollout[]; - /** Test users configuration */ - test?: FlagTestUsers; + /** Available variants for this flag */ + variants: Variant[]; + /** Rollout configurations */ + rollout: Rollout[]; + /** Test users configuration */ + test?: FlagTestUsers; } /** * Complete feature flag definition */ export interface ExperimentationFlag { - /** Flag ID */ - id: string; - /** Flag name */ - name: string; - /** Flag key (used for lookups) */ - key: string; - /** Flag status */ - status: string; - /** Project ID */ - project_id: number; - /** Rule set for this flag */ - ruleset: RuleSet; - /** Context type (e.g., 'user', 'group') */ - context: string; - /** Associated experiment ID */ - experiment_id?: string; - /** Whether the associated experiment is active */ - is_experiment_active?: boolean; - /** Hash salt for variant assignment */ - hash_salt?: string; + /** Flag ID */ + id: string; + /** Flag name */ + name: string; + /** Flag key (used for lookups) */ + key: string; + /** Flag status */ + status: string; + /** Project ID */ + project_id: number; + /** Rule set for this flag */ + ruleset: RuleSet; + /** Context type (e.g., 'user', 'group') */ + context: string; + /** Associated experiment ID */ + experiment_id?: string; + /** Whether the associated experiment is active */ + is_experiment_active?: boolean; + /** Hash salt for variant assignment */ + hash_salt?: string; } export interface SelectedVariant { - variant_key?: string | null; - variant_value: any; - experiment_id?: string; - is_experiment_active?: boolean; - is_qa_tester?: boolean; + variant_key?: string | null; + variant_value: any; + experiment_id?: string; + is_experiment_active?: boolean; + is_qa_tester?: boolean; } export interface RemoteFlagsResponse { - code: number; - flags: Record; + code: number; + flags: Record; } export interface LocalFlagsResponse { - flags: ExperimentationFlag[]; + flags: ExperimentationFlag[]; } export interface FlagContext { - distinct_id: string; - [key: string]: any; + distinct_id: string; + [key: string]: any; } diff --git a/lib/flags/utils.js b/lib/flags/utils.js index 2f9f6c9..ed9511d 100644 --- a/lib/flags/utils.js +++ b/lib/flags/utils.js @@ -1,32 +1,32 @@ /** * Utility functions for Mixpanel feature flags */ -const crypto = require('crypto'); +const crypto = require("crypto"); // Constants -const EXPOSURE_EVENT = '$experiment_started'; +const EXPOSURE_EVENT = "$experiment_started"; const REQUEST_HEADERS = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }; /** * FNV-1a 64-bit hash function used for consistent variant assignment - * https://www.ietf.org/archive/id/draft-eastlake-fnv-21.html#section-6.1.2 + * https://www.ietf.org/archive/id/draft-eastlake-fnv-21.html#section-6.1.2 * @param {Buffer} data - Data to hash * @returns {BigInt} - Hash value as BigInt */ function _fnv1a64(data) { - const FNV_PRIME = BigInt('0x100000001B3'); - let hash = BigInt('0xCBF29CE484222325'); + const FNV_PRIME = BigInt("0x100000001B3"); + let hash = BigInt("0xCBF29CE484222325"); - for (let i = 0; i < data.length; i++) { - hash ^= BigInt(data[i]); - hash *= FNV_PRIME; - hash &= BigInt('0xFFFFFFFFFFFFFFFF'); - } + for (let i = 0; i < data.length; i++) { + hash ^= BigInt(data[i]); + hash *= FNV_PRIME; + hash &= BigInt("0xFFFFFFFFFFFFFFFF"); + } - return hash; + return hash; } /** @@ -37,9 +37,9 @@ function _fnv1a64(data) { * @returns {number} - Hash value normalized to the non-inclusive range, [0.0, 1.0) */ function normalizedHash(key, salt) { - const combined = Buffer.from(key + salt, 'utf-8'); - const hashValue = _fnv1a64(combined); - return Number(hashValue % BigInt(100)) / 100.0; + const combined = Buffer.from(key + salt, "utf-8"); + const hashValue = _fnv1a64(combined); + return Number(hashValue % BigInt(100)) / 100.0; } /** @@ -49,11 +49,11 @@ function normalizedHash(key, salt) { * @returns {Object} - Query parameters object */ function prepareCommonQueryParams(token, sdkVersion) { - return { - mp_lib: 'node', - $lib_version: sdkVersion, - token: token, - }; + return { + mp_lib: "node", + $lib_version: sdkVersion, + token: token, + }; } /** @@ -62,18 +62,18 @@ function prepareCommonQueryParams(token, sdkVersion) { * @returns {string} - traceparent header value */ function generateTraceparent() { - const version = '00'; - const traceId = crypto.randomBytes(16).toString('hex'); - const parentId = crypto.randomBytes(8).toString('hex'); - const traceFlags = '01'; // sampled + const version = "00"; + const traceId = crypto.randomBytes(16).toString("hex"); + const parentId = crypto.randomBytes(8).toString("hex"); + const traceFlags = "01"; // sampled - return `${version}-${traceId}-${parentId}-${traceFlags}`; + return `${version}-${traceId}-${parentId}-${traceFlags}`; } module.exports = { - EXPOSURE_EVENT, - REQUEST_HEADERS, - normalizedHash, - prepareCommonQueryParams, - generateTraceparent, + EXPOSURE_EVENT, + REQUEST_HEADERS, + normalizedHash, + prepareCommonQueryParams, + generateTraceparent, }; diff --git a/lib/groups.js b/lib/groups.js index 628bac8..8e82205 100644 --- a/lib/groups.js +++ b/lib/groups.js @@ -2,25 +2,25 @@ * Group profile methods. Learn more: https://help.mixpanel.com/hc/en-us/articles/360025333632 */ -const {ProfileHelpers} = require('./profile_helpers'); +const { ProfileHelpers } = require("./profile_helpers"); class MixpanelGroups extends ProfileHelpers() { - constructor(mp_instance) { - super(); - this.mixpanel = mp_instance; - this.endpoint = '/groups'; - } + constructor(mp_instance) { + super(); + this.mixpanel = mp_instance; + this.endpoint = "/groups"; + } - /** groups.set_once(group_key, group_id, prop, to, modifiers, callback) + /** groups.set_once(group_key, group_id, prop, to, modifiers, callback) --- The same as groups.set, but adds a property value to a group only if it has not been set before. */ - set_once(group_key, group_id, prop, to, modifiers, callback) { - const identifiers = {$group_key: group_key, $group_id: group_id}; - this._set(prop, to, modifiers, callback, {identifiers, set_once: true}); - } + set_once(group_key, group_id, prop, to, modifiers, callback) { + const identifiers = { $group_key: group_key, $group_id: group_id }; + this._set(prop, to, modifiers, callback, { identifiers, set_once: true }); + } - /** + /** groups.set(group_key, group_id, prop, to, modifiers, callback) --- set properties on a group profile @@ -34,12 +34,12 @@ class MixpanelGroups extends ProfileHelpers() { '$name': 'Acme Inc.', }); */ - set(group_key, group_id, prop, to, modifiers, callback) { - const identifiers = {$group_key: group_key, $group_id: group_id}; - this._set(prop, to, modifiers, callback, {identifiers}); - } + set(group_key, group_id, prop, to, modifiers, callback) { + const identifiers = { $group_key: group_key, $group_id: group_id }; + this._set(prop, to, modifiers, callback, { identifiers }); + } - /** + /** groups.delete_group(group_key, group_id, modifiers, callback) --- delete a group profile permanently @@ -48,12 +48,12 @@ class MixpanelGroups extends ProfileHelpers() { mixpanel.groups.delete_group('company', 'Acme Inc.'); */ - delete_group(group_key, group_id, modifiers, callback) { - const identifiers = {$group_key: group_key, $group_id: group_id}; - this._delete_profile({identifiers, modifiers, callback}); - } + delete_group(group_key, group_id, modifiers, callback) { + const identifiers = { $group_key: group_key, $group_id: group_id }; + this._delete_profile({ identifiers, modifiers, callback }); + } - /** + /** groups.remove(group_key, group_id, data, modifiers, callback) --- remove a value from a list-valued group profile property. @@ -67,12 +67,12 @@ class MixpanelGroups extends ProfileHelpers() { 'customer segments': 'coyotes' }); */ - remove(group_key, group_id, data, modifiers, callback) { - const identifiers = {$group_key: group_key, $group_id: group_id}; - this._remove({identifiers, data, modifiers, callback}); - } + remove(group_key, group_id, data, modifiers, callback) { + const identifiers = { $group_key: group_key, $group_id: group_id }; + this._remove({ identifiers, data, modifiers, callback }); + } - /** + /** groups.union(group_key, group_id, data, modifiers, callback) --- merge value(s) into a list-valued group profile property. @@ -83,12 +83,12 @@ class MixpanelGroups extends ProfileHelpers() { mixpanel.groups.union('company', 'Acme Inc.', {'products': ['anvil'], 'customer segments': ['coyotes']}); */ - union(group_key, group_id, data, modifiers, callback) { - const identifiers = {$group_key: group_key, $group_id: group_id}; - this._union({identifiers, data, modifiers, callback}) - } + union(group_key, group_id, data, modifiers, callback) { + const identifiers = { $group_key: group_key, $group_id: group_id }; + this._union({ identifiers, data, modifiers, callback }); + } - /** + /** groups.unset(group_key, group_id, prop, modifiers, callback) --- delete a property on a group profile @@ -99,10 +99,10 @@ class MixpanelGroups extends ProfileHelpers() { mixpanel.groups.unset('company', 'Acme Inc.', ['products', 'customer segments']); */ - unset(group_key, group_id, prop, modifiers, callback) { - const identifiers = {$group_key: group_key, $group_id: group_id}; - this._unset({identifiers, prop, modifiers, callback}) - } + unset(group_key, group_id, prop, modifiers, callback) { + const identifiers = { $group_key: group_key, $group_id: group_id }; + this._unset({ identifiers, prop, modifiers, callback }); + } } exports.MixpanelGroups = MixpanelGroups; diff --git a/lib/mixpanel-node.d.ts b/lib/mixpanel-node.d.ts index 164d4c1..c70a8ef 100644 --- a/lib/mixpanel-node.d.ts +++ b/lib/mixpanel-node.d.ts @@ -1,6 +1,6 @@ -import LocalFeatureFlagsProvider from './flags/local_flags'; -import RemoteFeatureFlagsProvider from './flags/remote_flags'; -import { LocalFlagsConfig, RemoteFlagsConfig } from './flags/types'; +import LocalFeatureFlagsProvider from "./flags/local_flags"; +import RemoteFeatureFlagsProvider from "./flags/remote_flags"; +import { LocalFlagsConfig, RemoteFlagsConfig } from "./flags/types"; declare const mixpanel: mixpanel.Mixpanel; @@ -64,24 +64,45 @@ declare namespace mixpanel { } export interface RemoveData { - [key: string]: string | number + [key: string]: string | number; } interface Mixpanel { init(mixpanelToken: string, config?: Partial): Mixpanel; track(eventName: string, callback?: Callback): void; - track(eventName: string, properties: PropertyDict, callback?: Callback): void; - - track_batch(events: Event[], options?: BatchOptions, callback?: BatchCallback): void; + track( + eventName: string, + properties: PropertyDict, + callback?: Callback, + ): void; + + track_batch( + events: Event[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; track_batch(events: Event[], callback: BatchCallback): void; - track_batch(eventNames: string[], options?: BatchOptions, callback?: BatchCallback): void; + track_batch( + eventNames: string[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; track_batch(eventNames: string[], callback?: BatchCallback): void; - import(eventName: string, time: Date | number, properties?: PropertyDict, callback?: Callback): void; + import( + eventName: string, + time: Date | number, + properties?: PropertyDict, + callback?: Callback, + ): void; import(eventName: string, time: Date | number, callback: Callback): void; - import_batch(eventNames: string[], options?: BatchOptions, callback?: BatchCallback): void; + import_batch( + eventNames: string[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; import_batch(eventNames: string[], callback?: BatchCallback): void; import_batch(events: Event[], callback?: BatchCallback): void; @@ -97,76 +118,301 @@ declare namespace mixpanel { } interface People { - set(distinctId: string, properties: PropertyDict, callback?: Callback): void; - set(distinctId: string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; - set(distinctId: string, propertyName: string, value: string | number, modifiers: Modifiers): void; - set(distinctId: string, propertyName: string, value: string | number, callback?: Callback): void; - set(distinctId: string, propertyName: string, value: string | number, modifiers: Modifiers, callback: Callback): void; - - unset(distinctId: string, propertyName: string | string[], callback?: Callback): void; - unset(distinctId: string, propertyName: string | string[], modifiers?: Modifiers, callback?: Callback): void; - - set_once(distinctId: string, propertyName: string, value: string, callback?: Callback): void; - set_once(distinctId: string, propertyName: string, value: string, modifiers: Modifiers, callback?: Callback): void; - set_once(distinctId: string, properties: PropertyDict, callback?: Callback): void; - set_once(distinctId: string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; - - increment(distinctId: string, propertyName: string, modifiers?: Modifiers, callback?: Callback): void; - increment(distinctId: string, propertyName: string, incrementBy: number, modifiers: Modifiers, callback?: Callback): void; - increment(distinctId: string, propertyName: string, incrementBy: number, callback?: Callback): void; - increment(distinctId: string, properties: NumberMap, modifiers: Modifiers, callback?: Callback): void; - increment(distinctId: string, properties: NumberMap, callback?: Callback): void; - - append(distinctId: string, propertyName: string, value: any, modifiers: Modifiers, callback?: Callback): void; - append(distinctId: string, propertyName: string, value: any, callback?: Callback): void; - append(distinctId: string, properties: PropertyDict, callback?: Callback): void; - append(distinctId: string, properties: PropertyDict, modifiers: Modifiers, callback?: Callback): void; - - union(distinctId: string, data: UnionData, modifiers?: Modifiers, callback?: Callback): void; + set( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set( + distinctId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + callback?: Callback, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + callback: Callback, + ): void; + + unset( + distinctId: string, + propertyName: string | string[], + callback?: Callback, + ): void; + unset( + distinctId: string, + propertyName: string | string[], + modifiers?: Modifiers, + callback?: Callback, + ): void; + + set_once( + distinctId: string, + propertyName: string, + value: string, + callback?: Callback, + ): void; + set_once( + distinctId: string, + propertyName: string, + value: string, + modifiers: Modifiers, + callback?: Callback, + ): void; + set_once( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set_once( + distinctId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + increment( + distinctId: string, + propertyName: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + propertyName: string, + incrementBy: number, + modifiers: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + propertyName: string, + incrementBy: number, + callback?: Callback, + ): void; + increment( + distinctId: string, + properties: NumberMap, + modifiers: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + properties: NumberMap, + callback?: Callback, + ): void; + + append( + distinctId: string, + propertyName: string, + value: any, + modifiers: Modifiers, + callback?: Callback, + ): void; + append( + distinctId: string, + propertyName: string, + value: any, + callback?: Callback, + ): void; + append( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + append( + distinctId: string, + properties: PropertyDict, + modifiers: Modifiers, + callback?: Callback, + ): void; + + union( + distinctId: string, + data: UnionData, + modifiers?: Modifiers, + callback?: Callback, + ): void; union(distinctId: string, data: UnionData, callback: Callback): void; - remove(distinctId: string, data: RemoveData, modifiers?: Modifiers, callback?: Callback): void; + remove( + distinctId: string, + data: RemoveData, + modifiers?: Modifiers, + callback?: Callback, + ): void; remove(distinctId: string, data: RemoveData, callback: Callback): void; - track_charge(distinctId: string, amount: number | string, properties?: PropertyDict, callback?: Callback): void; - track_charge(distinctId: string, amount: number | string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; - - clear_charges(distinctId: string, modifiers?: Modifiers, callback?: Callback): void; + track_charge( + distinctId: string, + amount: number | string, + properties?: PropertyDict, + callback?: Callback, + ): void; + track_charge( + distinctId: string, + amount: number | string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + clear_charges( + distinctId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; clear_charges(distinctId: string, callback: Callback): void; - delete_user(distinctId: string, modifiers?: Modifiers, callback?: Callback): void; + delete_user( + distinctId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; delete_user(distinctId: string, callback: Callback): void; } interface Groups { - set(groupKey: string, groupId: string, properties: PropertyDict, callback?: Callback): void; - set(groupKey: string, groupId: string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; - set(groupKey: string, groupId: string, propertyName: string, value: string | number, modifiers: Modifiers): void; - set(groupKey: string, groupId: string, propertyName: string, value: string | number, callback?: Callback): void; - set(groupKey: string, groupId: string, propertyName: string, value: string | number, modifiers: Modifiers, callback: Callback): void; - - unset(groupKey: string, groupId: string, propertyName: string | string[], callback?: Callback): void; - unset(groupKey: string, groupId: string, propertyName: string | string[], modifiers?: Modifiers, callback?: Callback): void; - - set_once(groupKey: string, groupId: string, propertyName: string, value: string, callback?: Callback): void; - set_once( groupKey: string, groupId: string, propertyName: string, value: string, modifiers: Modifiers, callback?: Callback): void; - set_once(groupKey: string, groupId: string, properties: PropertyDict, callback?: Callback): void; - set_once(groupKey: string, groupId: string, properties: PropertyDict, modifiers?: Modifiers, callback?: Callback): void; - - union(groupKey: string, groupId: string, data: UnionData, modifiers?: Modifiers, callback?: Callback): void; - union(groupKey: string, groupId: string, data: UnionData, callback: Callback): void; - - remove(groupKey: string, groupId: string, data: RemoveData, modifiers?: Modifiers, callback?: Callback): void; - remove(groupKey: string, groupId: string, data: RemoveData, callback: Callback): void; - - delete_group(groupKey: string, groupId: string, modifiers?: Modifiers, callback?: Callback): void; + set( + groupKey: string, + groupId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + callback: Callback, + ): void; + + unset( + groupKey: string, + groupId: string, + propertyName: string | string[], + callback?: Callback, + ): void; + unset( + groupKey: string, + groupId: string, + propertyName: string | string[], + modifiers?: Modifiers, + callback?: Callback, + ): void; + + set_once( + groupKey: string, + groupId: string, + propertyName: string, + value: string, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + propertyName: string, + value: string, + modifiers: Modifiers, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + union( + groupKey: string, + groupId: string, + data: UnionData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + union( + groupKey: string, + groupId: string, + data: UnionData, + callback: Callback, + ): void; + + remove( + groupKey: string, + groupId: string, + data: RemoveData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + remove( + groupKey: string, + groupId: string, + data: RemoveData, + callback: Callback, + ): void; + + delete_group( + groupKey: string, + groupId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; delete_group(groupKey: string, groupId: string, callback: Callback): void; } // Export feature flags types for convenience - export { LocalFlagsConfig, RemoteFlagsConfig, FlagContext, SelectedVariant } from './flags/types'; - export { default as LocalFeatureFlagsProvider } from './flags/local_flags'; - export { default as RemoteFeatureFlagsProvider } from './flags/remote_flags'; + export { + LocalFlagsConfig, + RemoteFlagsConfig, + FlagContext, + SelectedVariant, + } from "./flags/types"; + export { default as LocalFeatureFlagsProvider } from "./flags/local_flags"; + export { default as RemoteFeatureFlagsProvider } from "./flags/remote_flags"; } export = mixpanel; diff --git a/lib/mixpanel-node.js b/lib/mixpanel-node.js index aaa169b..fc8c4b1 100644 --- a/lib/mixpanel-node.js +++ b/lib/mixpanel-node.js @@ -7,279 +7,322 @@ Released under the MIT license. */ -const querystring = require('querystring'); -const Buffer = require('buffer').Buffer; -const http = require('http'); -const https = require('https'); -const HttpsProxyAgent = require('https-proxy-agent'); -const url = require('url'); -const packageInfo = require('../package.json') - -const {async_all, ensure_timestamp, assert_logger} = require('./utils'); -const {MixpanelGroups} = require('./groups'); -const {MixpanelPeople} = require('./people'); -const {LocalFeatureFlagsProvider, RemoteFeatureFlagsProvider} = require('./flags'); +const querystring = require("querystring"); +const Buffer = require("buffer").Buffer; +const http = require("http"); +const https = require("https"); +const HttpsProxyAgent = require("https-proxy-agent"); +const url = require("url"); +const packageInfo = require("../package.json"); + +const { async_all, ensure_timestamp, assert_logger } = require("./utils"); +const { MixpanelGroups } = require("./groups"); +const { MixpanelPeople } = require("./people"); +const { + LocalFeatureFlagsProvider, + RemoteFeatureFlagsProvider, +} = require("./flags"); const DEFAULT_CONFIG = { - test: false, - debug: false, - verbose: false, - host: 'api.mixpanel.com', - protocol: 'https', - path: '', - keepAlive: true, - // set this to true to automatically geolocate based on the client's ip. - // e.g., when running under electron - geolocate: false, - logger: console, + test: false, + debug: false, + verbose: false, + host: "api.mixpanel.com", + protocol: "https", + path: "", + keepAlive: true, + // set this to true to automatically geolocate based on the client's ip. + // e.g., when running under electron + geolocate: false, + logger: console, }; -var create_client = function(token, config) { - if (!token) { - throw new Error("The Mixpanel Client needs a Mixpanel token: `init(token)`"); - } - - const metrics = { - token, - config: {...DEFAULT_CONFIG}, +var create_client = function (token, config) { + if (!token) { + throw new Error( + "The Mixpanel Client needs a Mixpanel token: `init(token)`", + ); + } + + const metrics = { + token, + config: { ...DEFAULT_CONFIG }, + }; + const { keepAlive } = metrics.config; + + // mixpanel constants + const MAX_BATCH_SIZE = 50; + const REQUEST_LIBS = { http, https }; + const REQUEST_AGENTS = { + http: new http.Agent({ keepAlive }), + https: new https.Agent({ keepAlive }), + }; + const proxyPath = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + const proxyAgent = proxyPath + ? new HttpsProxyAgent( + Object.assign(url.parse(proxyPath), { + keepAlive, + }), + ) + : null; + + /** + * sends an async GET or POST request to mixpanel + * for batch processes data must be send in the body of a POST + * @param {object} options + * @param {string} options.endpoint + * @param {object} options.data the data to send in the request + * @param {string} [options.method] e.g. `get` or `post`, defaults to `get` + * @param {function} callback called on request completion or error + */ + metrics.send_request = function (options, callback) { + callback = callback || function () {}; + + let content = Buffer.from(JSON.stringify(options.data)).toString("base64"); + const endpoint = options.endpoint; + const method = (options.method || "GET").toUpperCase(); + let query_params = { + ip: metrics.config.geolocate ? 1 : 0, + verbose: metrics.config.verbose ? 1 : 0, }; - const {keepAlive} = metrics.config; - - // mixpanel constants - const MAX_BATCH_SIZE = 50; - const REQUEST_LIBS = {http, https}; - const REQUEST_AGENTS = { - http: new http.Agent({keepAlive}), - https: new https.Agent({keepAlive}), + const key = metrics.config.key; + const secret = metrics.config.secret; + const request_lib = REQUEST_LIBS[metrics.config.protocol]; + let request_options = { + host: metrics.config.host, + port: metrics.config.port, + headers: {}, + method: method, }; - const proxyPath = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; - const proxyAgent = proxyPath ? new HttpsProxyAgent(Object.assign(url.parse(proxyPath), { - keepAlive, - })) : null; + let request; + + if (!request_lib) { + throw new Error( + "Mixpanel Initialization Error: Unsupported protocol " + + metrics.config.protocol + + ". " + + "Supported protocols are: " + + Object.keys(REQUEST_LIBS), + ); + } - /** - * sends an async GET or POST request to mixpanel - * for batch processes data must be send in the body of a POST - * @param {object} options - * @param {string} options.endpoint - * @param {object} options.data the data to send in the request - * @param {string} [options.method] e.g. `get` or `post`, defaults to `get` - * @param {function} callback called on request completion or error - */ - metrics.send_request = function(options, callback) { - callback = callback || function() {}; - - let content = Buffer.from(JSON.stringify(options.data)).toString('base64'); - const endpoint = options.endpoint; - const method = (options.method || 'GET').toUpperCase(); - let query_params = { - 'ip': metrics.config.geolocate ? 1 : 0, - 'verbose': metrics.config.verbose ? 1 : 0 - }; - const key = metrics.config.key; - const secret = metrics.config.secret; - const request_lib = REQUEST_LIBS[metrics.config.protocol]; - let request_options = { - host: metrics.config.host, - port: metrics.config.port, - headers: {}, - method: method - }; - let request; + if (method === "POST") { + content = "data=" + content; + request_options.headers["Content-Type"] = + "application/x-www-form-urlencoded"; + request_options.headers["Content-Length"] = Buffer.byteLength(content); + } else if (method === "GET") { + query_params.data = content; + } - if (!request_lib) { - throw new Error( - "Mixpanel Initialization Error: Unsupported protocol " + metrics.config.protocol + ". " + - "Supported protocols are: " + Object.keys(REQUEST_LIBS) - ); - } + // add auth params + if (secret) { + if (request_lib !== https) { + throw new Error("Must use HTTPS if authenticating with API Secret"); + } + const encoded = Buffer.from(secret + ":").toString("base64"); + request_options.headers["Authorization"] = "Basic " + encoded; + } else if (key) { + query_params.api_key = key; + } else if (endpoint === "/import") { + throw new Error( + "The Mixpanel Client needs a Mixpanel API Secret when importing old events: `init(token, { secret: ... })`", + ); + } + request_options.agent = + proxyAgent || REQUEST_AGENTS[metrics.config.protocol]; - if (method === 'POST') { - content = 'data=' + content; - request_options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; - request_options.headers['Content-Length'] = Buffer.byteLength(content); - } else if (method === 'GET') { - query_params.data = content; - } - + if (metrics.config.test) { + query_params.test = 1; + } - // add auth params - if (secret) { - if (request_lib !== https) { - throw new Error("Must use HTTPS if authenticating with API Secret"); + request_options.path = + metrics.config.path + + endpoint + + "?" + + querystring.stringify(query_params); + + request = request_lib.request(request_options, function (res) { + var data = ""; + res.on("data", function (chunk) { + data += chunk; + }); + + res.on("end", function () { + var e; + if (metrics.config.verbose) { + try { + var result = JSON.parse(data); + if (result.status != 1) { + e = new Error("Mixpanel Server Error: " + result.error); } - const encoded = Buffer.from(secret + ':').toString('base64'); - request_options.headers['Authorization'] = 'Basic ' + encoded; - } else if (key) { - query_params.api_key = key; - } else if (endpoint === '/import') { - throw new Error("The Mixpanel Client needs a Mixpanel API Secret when importing old events: `init(token, { secret: ... })`"); - } - - request_options.agent = proxyAgent || REQUEST_AGENTS[metrics.config.protocol]; - - if (metrics.config.test) { - query_params.test = 1; + } catch (ex) { + e = new Error("Could not parse response from Mixpanel"); + } + } else { + e = + data !== "1" + ? new Error("Mixpanel Server Error: " + data) + : undefined; } - request_options.path = metrics.config.path + endpoint + "?" + querystring.stringify(query_params); - - request = request_lib.request(request_options, function(res) { - var data = ""; - res.on('data', function(chunk) { - data += chunk; - }); - - res.on('end', function() { - var e; - if (metrics.config.verbose) { - try { - var result = JSON.parse(data); - if(result.status != 1) { - e = new Error("Mixpanel Server Error: " + result.error); - } - } - catch(ex) { - e = new Error("Could not parse response from Mixpanel"); - } - } - else { - e = (data !== '1') ? new Error("Mixpanel Server Error: " + data) : undefined; - } + callback(e); + }); + }); - callback(e); - }); - }); + request.on("error", function (e) { + if (metrics.config.debug) { + metrics.config.logger.error("Got Error: " + e.message); + } + callback(e); + }); - request.on('error', function(e) { - if (metrics.config.debug) { - metrics.config.logger.error("Got Error: " + e.message); - } - callback(e); - }); - - if (method === 'POST') { - request.write(content); - } - request.end(); + if (method === "POST") { + request.write(content); + } + request.end(); + }; + + /** + * Send an event to Mixpanel, using the specified endpoint (e.g., track/import) + * @param {string} endpoint - API endpoint name + * @param {string} event - event name + * @param {object} properties - event properties + * @param {Function} [callback] - callback for request completion/error + */ + metrics.send_event_request = function ( + endpoint, + event, + properties, + callback, + ) { + properties.token = metrics.token; + properties.mp_lib = "node"; + properties.$lib_version = packageInfo.version; + + var data = { + event: event, + properties: properties, }; - /** - * Send an event to Mixpanel, using the specified endpoint (e.g., track/import) - * @param {string} endpoint - API endpoint name - * @param {string} event - event name - * @param {object} properties - event properties - * @param {Function} [callback] - callback for request completion/error - */ - metrics.send_event_request = function(endpoint, event, properties, callback) { - properties.token = metrics.token; - properties.mp_lib = "node"; - properties.$lib_version = packageInfo.version; - - var data = { - event: event, - properties: properties - }; - - if (metrics.config.debug) { - metrics.config.logger.debug("Sending the following event to Mixpanel", { data }); - } + if (metrics.config.debug) { + metrics.config.logger.debug("Sending the following event to Mixpanel", { + data, + }); + } - metrics.send_request({ method: "GET", endpoint: endpoint, data: data }, callback); - }; + metrics.send_request( + { method: "GET", endpoint: endpoint, data: data }, + callback, + ); + }; + + /** + * breaks array into equal-sized chunks, with the last chunk being the remainder + * @param {Array} arr + * @param {number} size + * @returns {Array} + */ + var chunk = function (arr, size) { + var chunks = [], + i = 0, + total = arr.length; + + while (i < total) { + chunks.push(arr.slice(i, (i += size))); + } + return chunks; + }; + + /** + * sends events in batches + * @param {object} options + * @param {[{}]} options.event_list array of event objects + * @param {string} options.endpoint e.g. `/track` or `/import` + * @param {number} [options.max_concurrent_requests] limits concurrent async requests over the network + * @param {number} [options.max_batch_size] limits number of events sent to mixpanel per request + * @param {Function} [callback] callback receives array of errors if any + * + */ + var send_batch_requests = function (options, callback) { + var event_list = options.event_list, + endpoint = options.endpoint, + max_batch_size = options.max_batch_size + ? Math.min(MAX_BATCH_SIZE, options.max_batch_size) + : MAX_BATCH_SIZE, + // to maintain original intention of max_batch_size; if max_batch_size is greater than 50, we assume the user is trying to set max_concurrent_requests + max_concurrent_requests = + options.max_concurrent_requests || + (options.max_batch_size > MAX_BATCH_SIZE && + Math.ceil(options.max_batch_size / MAX_BATCH_SIZE)), + event_batches = chunk(event_list, max_batch_size), + request_batches = max_concurrent_requests + ? chunk(event_batches, max_concurrent_requests) + : [event_batches], + total_event_batches = event_batches.length, + total_request_batches = request_batches.length; /** - * breaks array into equal-sized chunks, with the last chunk being the remainder - * @param {Array} arr - * @param {number} size - * @returns {Array} + * sends a batch of events to mixpanel through http api + * @param {Array} batch + * @param {Function} cb */ - var chunk = function(arr, size) { - var chunks = [], - i = 0, - total = arr.length; + function send_event_batch(batch, cb) { + if (batch.length > 0) { + batch = batch.map(function (event) { + var properties = event.properties; + if (endpoint === "/import" || event.properties.time) { + // usually there will be a time property, but not required for `/track` endpoint + event.properties.time = ensure_timestamp(event.properties.time); + } + event.properties.token = event.properties.token || metrics.token; + return event; + }); - while (i < total) { - chunks.push(arr.slice(i, i += size)); - } - return chunks; - }; + // must be a POST + metrics.send_request( + { method: "POST", endpoint: endpoint, data: batch }, + cb, + ); + } + } /** - * sends events in batches - * @param {object} options - * @param {[{}]} options.event_list array of event objects - * @param {string} options.endpoint e.g. `/track` or `/import` - * @param {number} [options.max_concurrent_requests] limits concurrent async requests over the network - * @param {number} [options.max_batch_size] limits number of events sent to mixpanel per request - * @param {Function} [callback] callback receives array of errors if any - * + * Asynchronously sends batches of requests + * @param {number} index */ - var send_batch_requests = function(options, callback) { - var event_list = options.event_list, - endpoint = options.endpoint, - max_batch_size = options.max_batch_size ? Math.min(MAX_BATCH_SIZE, options.max_batch_size) : MAX_BATCH_SIZE, - // to maintain original intention of max_batch_size; if max_batch_size is greater than 50, we assume the user is trying to set max_concurrent_requests - max_concurrent_requests = options.max_concurrent_requests || (options.max_batch_size > MAX_BATCH_SIZE && Math.ceil(options.max_batch_size / MAX_BATCH_SIZE)), - event_batches = chunk(event_list, max_batch_size), - request_batches = max_concurrent_requests ? chunk(event_batches, max_concurrent_requests) : [event_batches], - total_event_batches = event_batches.length, - total_request_batches = request_batches.length; - - /** - * sends a batch of events to mixpanel through http api - * @param {Array} batch - * @param {Function} cb - */ - function send_event_batch(batch, cb) { - if (batch.length > 0) { - batch = batch.map(function (event) { - var properties = event.properties; - if (endpoint === '/import' || event.properties.time) { - // usually there will be a time property, but not required for `/track` endpoint - event.properties.time = ensure_timestamp(event.properties.time); - } - event.properties.token = event.properties.token || metrics.token; - return event; - }); - - // must be a POST - metrics.send_request({ method: "POST", endpoint: endpoint, data: batch }, cb); - } - } - - /** - * Asynchronously sends batches of requests - * @param {number} index - */ - function send_next_request_batch(index) { - var request_batch = request_batches[index], - cb = function (errors, results) { - index += 1; - if (index === total_request_batches) { - callback && callback(errors, results); - } else { - send_next_request_batch(index); - } - }; - - async_all(request_batch, send_event_batch, cb); - } + function send_next_request_batch(index) { + var request_batch = request_batches[index], + cb = function (errors, results) { + index += 1; + if (index === total_request_batches) { + callback && callback(errors, results); + } else { + send_next_request_batch(index); + } + }; - // init recursive function - send_next_request_batch(0); + async_all(request_batch, send_event_batch, cb); + } - if (metrics.config.debug) { - metrics.config.logger.debug( - "Sending " + event_list.length + " events to Mixpanel in " + - total_event_batches + " batches of events and " + - total_request_batches + " batches of requests" - ); - } - }; + // init recursive function + send_next_request_batch(0); + + if (metrics.config.debug) { + metrics.config.logger.debug( + "Sending " + + event_list.length + + " events to Mixpanel in " + + total_event_batches + + " batches of events and " + + total_request_batches + + " batches of requests", + ); + } + }; - /** + /** track(event, properties, callback) --- this function sends an event to mixpanel. @@ -289,45 +332,45 @@ var create_client = function(token, config) { callback:function(err:Error) callback is called when the request is finished or an error occurs */ - metrics.track = function(event, properties, callback) { - if (!properties || typeof properties === "function") { - callback = properties; - properties = {}; - } + metrics.track = function (event, properties, callback) { + if (!properties || typeof properties === "function") { + callback = properties; + properties = {}; + } - // time is optional for `track` - if (properties.time) { - properties.time = ensure_timestamp(properties.time); - } + // time is optional for `track` + if (properties.time) { + properties.time = ensure_timestamp(properties.time); + } - metrics.send_event_request("/track", event, properties, callback); + metrics.send_event_request("/track", event, properties, callback); + }; + + /** + * send a batch of events to mixpanel `track` endpoint: this should only be used if events are less than 5 days old + * @param {Array} event_list array of event objects to track + * @param {object} [options] + * @param {number} [options.max_concurrent_requests] number of concurrent http requests that can be made to mixpanel + * @param {number} [options.max_batch_size] number of events that can be sent to mixpanel per request + * @param {Function} [callback] callback receives array of errors if any + */ + metrics.track_batch = function (event_list, options, callback) { + options = options || {}; + if (typeof options === "function") { + callback = options; + options = {}; + } + var batch_options = { + event_list: event_list, + endpoint: "/track", + max_concurrent_requests: options.max_concurrent_requests, + max_batch_size: options.max_batch_size, }; - /** - * send a batch of events to mixpanel `track` endpoint: this should only be used if events are less than 5 days old - * @param {Array} event_list array of event objects to track - * @param {object} [options] - * @param {number} [options.max_concurrent_requests] number of concurrent http requests that can be made to mixpanel - * @param {number} [options.max_batch_size] number of events that can be sent to mixpanel per request - * @param {Function} [callback] callback receives array of errors if any - */ - metrics.track_batch = function(event_list, options, callback) { - options = options || {}; - if (typeof options === 'function') { - callback = options; - options = {}; - } - var batch_options = { - event_list: event_list, - endpoint: "/track", - max_concurrent_requests: options.max_concurrent_requests, - max_batch_size: options.max_batch_size - }; - - send_batch_requests(batch_options, callback); - }; + send_batch_requests(batch_options, callback); + }; - /** + /** import(event, time, properties, callback) --- This function sends an event to mixpanel using the import @@ -347,18 +390,18 @@ var create_client = function(token, config) { callback:function(err:Error) callback is called when the request is finished or an error occurs */ - metrics.import = function(event, time, properties, callback) { - if (!properties || typeof properties === "function") { - callback = properties; - properties = {}; - } + metrics.import = function (event, time, properties, callback) { + if (!properties || typeof properties === "function") { + callback = properties; + properties = {}; + } - properties.time = ensure_timestamp(time); + properties.time = ensure_timestamp(time); - metrics.send_event_request("/import", event, properties, callback); - }; + metrics.send_event_request("/import", event, properties, callback); + }; - /** + /** import_batch(event_list, options, callback) --- This function sends a list of events to mixpanel using the import @@ -400,23 +443,23 @@ var create_client = function(token, config) { callback:function(error_list:array) callback is called when the request is finished or an error occurs */ - metrics.import_batch = function(event_list, options, callback) { - var batch_options; + metrics.import_batch = function (event_list, options, callback) { + var batch_options; - if (typeof(options) === "function" || !options) { - callback = options; - options = {}; - } - batch_options = { - event_list: event_list, - endpoint: "/import", - max_concurrent_requests: options.max_concurrent_requests, - max_batch_size: options.max_batch_size - }; - send_batch_requests(batch_options, callback); + if (typeof options === "function" || !options) { + callback = options; + options = {}; + } + batch_options = { + event_list: event_list, + endpoint: "/import", + max_concurrent_requests: options.max_concurrent_requests, + max_batch_size: options.max_batch_size, }; + send_batch_requests(batch_options, callback); + }; - /** + /** alias(distinct_id, alias) --- This function creates an alias for distinct_id @@ -427,19 +470,19 @@ var create_client = function(token, config) { distinct_id:string the current identifier alias:string the future alias */ - metrics.alias = function(distinct_id, alias, callback) { - var properties = { - distinct_id: distinct_id, - alias: alias - }; - - metrics.track('$create_alias', properties, callback); + metrics.alias = function (distinct_id, alias, callback) { + var properties = { + distinct_id: distinct_id, + alias: alias, }; - metrics.groups = new MixpanelGroups(metrics); - metrics.people = new MixpanelPeople(metrics); + metrics.track("$create_alias", properties, callback); + }; - /** + metrics.groups = new MixpanelGroups(metrics); + metrics.people = new MixpanelPeople(metrics); + + /** set_config(config) --- Modifies the mixpanel config @@ -447,48 +490,48 @@ var create_client = function(token, config) { config:object an object with properties to override in the mixpanel client config */ - metrics.set_config = function(config) { - if (config && config.logger !== undefined) { - assert_logger(config.logger); - } - Object.assign(metrics.config, config); - if (config.host) { - // Split host into host and port - const [host, port] = config.host.split(':'); - metrics.config.host = host; - if (port) { - metrics.config.port = Number(port); - } - } - }; - - if (config) { - metrics.set_config(config); - } - - // Initialize feature flags providers if configs are provided - if (config && config.local_flags_config) { - metrics.local_flags = new LocalFeatureFlagsProvider( - token, - config.local_flags_config, - metrics.track.bind(metrics), - config.logger - ); + metrics.set_config = function (config) { + if (config && config.logger !== undefined) { + assert_logger(config.logger); } - - if (config && config.remote_flags_config) { - metrics.remote_flags = new RemoteFeatureFlagsProvider( - token, - config.remote_flags_config, - metrics.track.bind(metrics), - config.logger - ); + Object.assign(metrics.config, config); + if (config.host) { + // Split host into host and port + const [host, port] = config.host.split(":"); + metrics.config.host = host; + if (port) { + metrics.config.port = Number(port); + } } - - return metrics; + }; + + if (config) { + metrics.set_config(config); + } + + // Initialize feature flags providers if configs are provided + if (config && config.local_flags_config) { + metrics.local_flags = new LocalFeatureFlagsProvider( + token, + config.local_flags_config, + metrics.track.bind(metrics), + config.logger, + ); + } + + if (config && config.remote_flags_config) { + metrics.remote_flags = new RemoteFeatureFlagsProvider( + token, + config.remote_flags_config, + metrics.track.bind(metrics), + config.logger, + ); + } + + return metrics; }; // module exporting module.exports = { - init: create_client, + init: create_client, }; diff --git a/lib/people.js b/lib/people.js index 0ffd95b..b35827d 100644 --- a/lib/people.js +++ b/lib/people.js @@ -1,13 +1,13 @@ -const {merge_modifiers, ProfileHelpers} = require('./profile_helpers'); +const { merge_modifiers, ProfileHelpers } = require("./profile_helpers"); class MixpanelPeople extends ProfileHelpers() { - constructor(mp_instance) { - super(); - this.mixpanel = mp_instance; - this.endpoint = '/engage'; - } + constructor(mp_instance) { + super(); + this.mixpanel = mp_instance; + this.endpoint = "/engage"; + } - /** people.set_once(distinct_id, prop, to, modifiers, callback) + /** people.set_once(distinct_id, prop, to, modifiers, callback) --- The same as people.set but in the words of mixpanel: mixpanel.people.set_once @@ -19,12 +19,12 @@ class MixpanelPeople extends ProfileHelpers() { website for the first time. " */ - set_once(distinct_id, prop, to, modifiers, callback) { - const identifiers = {$distinct_id: distinct_id}; - this._set(prop, to, modifiers, callback, {identifiers, set_once: true}); - } + set_once(distinct_id, prop, to, modifiers, callback) { + const identifiers = { $distinct_id: distinct_id }; + this._set(prop, to, modifiers, callback, { identifiers, set_once: true }); + } - /** + /** people.set(distinct_id, prop, to, modifiers, callback) --- set properties on an user record in engage @@ -38,12 +38,12 @@ class MixpanelPeople extends ProfileHelpers() { 'plan': 'premium' }); */ - set(distinct_id, prop, to, modifiers, callback) { - const identifiers = {$distinct_id: distinct_id}; - this._set(prop, to, modifiers, callback, {identifiers}); - } + set(distinct_id, prop, to, modifiers, callback) { + const identifiers = { $distinct_id: distinct_id }; + this._set(prop, to, modifiers, callback, { identifiers }); + } - /** + /** people.increment(distinct_id, prop, by, modifiers, callback) --- increment/decrement properties on an user record in engage @@ -66,63 +66,69 @@ class MixpanelPeople extends ProfileHelpers() { counter3: -2 }); */ - increment(distinct_id, prop, by, modifiers, callback) { - // TODO extract to ProfileHelpers - - var $add = {}; - - if (typeof(prop) === 'object') { - if (typeof(by) === 'object') { - callback = modifiers; - modifiers = by; - } else { - callback = by; - } - for (const [key, val] of Object.entries(prop)) { - if (isNaN(parseFloat(val))) { - if (this.mixpanel.config.debug) { - this.mixpanel.config.logger.error( - "Invalid increment value passed to mixpanel.people.increment - must be a number", - {key, value: val} - ); - } - } else { - $add[key] = val; - } - }; + increment(distinct_id, prop, by, modifiers, callback) { + // TODO extract to ProfileHelpers + + var $add = {}; + + if (typeof prop === "object") { + if (typeof by === "object") { + callback = modifiers; + modifiers = by; + } else { + callback = by; + } + for (const [key, val] of Object.entries(prop)) { + if (isNaN(parseFloat(val))) { + if (this.mixpanel.config.debug) { + this.mixpanel.config.logger.error( + "Invalid increment value passed to mixpanel.people.increment - must be a number", + { key, value: val }, + ); + } } else { - if (typeof(by) === 'number' || !by) { - by = by || 1; - $add[prop] = by; - if (typeof(modifiers) === 'function') { - callback = modifiers; - } - } else if (typeof(by) === 'function') { - callback = by; - $add[prop] = 1; - } else { - callback = modifiers; - modifiers = (typeof(by) === 'object') ? by : {}; - $add[prop] = 1; - } + $add[key] = val; } + } + } else { + if (typeof by === "number" || !by) { + by = by || 1; + $add[prop] = by; + if (typeof modifiers === "function") { + callback = modifiers; + } + } else if (typeof by === "function") { + callback = by; + $add[prop] = 1; + } else { + callback = modifiers; + modifiers = typeof by === "object" ? by : {}; + $add[prop] = 1; + } + } - var data = { - '$add': $add, - '$token': this.mixpanel.token, - '$distinct_id': distinct_id - }; - - data = merge_modifiers(data, modifiers); + var data = { + $add: $add, + $token: this.mixpanel.token, + $distinct_id: distinct_id, + }; - if (this.mixpanel.config.debug) { - this.mixpanel.config.logger.debug("Sending the following data to Mixpanel (Engage)", { data }); - } + data = merge_modifiers(data, modifiers); - this.mixpanel.send_request({ method: "GET", endpoint: "/engage", data: data }, callback); + if (this.mixpanel.config.debug) { + this.mixpanel.config.logger.debug( + "Sending the following data to Mixpanel (Engage)", + { data }, + ); } - /** + this.mixpanel.send_request( + { method: "GET", endpoint: "/engage", data: data }, + callback, + ); + } + + /** people.append(distinct_id, prop, value, modifiers, callback) --- Append a value to a list-valued people analytics property. @@ -138,44 +144,50 @@ class MixpanelPeople extends ProfileHelpers() { list2: 123 }); */ - append(distinct_id, prop, value, modifiers, callback) { - // TODO extract to ProfileHelpers - - var $append = {}; - - if (typeof(prop) === 'object') { - if (typeof(value) === 'object') { - callback = modifiers; - modifiers = value; - } else { - callback = value; - } - Object.keys(prop).forEach(function(key) { - $append[key] = prop[key]; - }); - } else { - $append[prop] = value; - if (typeof(modifiers) === 'function') { - callback = modifiers; - } - } - - var data = { - '$append': $append, - '$token': this.mixpanel.token, - '$distinct_id': distinct_id - }; + append(distinct_id, prop, value, modifiers, callback) { + // TODO extract to ProfileHelpers + + var $append = {}; + + if (typeof prop === "object") { + if (typeof value === "object") { + callback = modifiers; + modifiers = value; + } else { + callback = value; + } + Object.keys(prop).forEach(function (key) { + $append[key] = prop[key]; + }); + } else { + $append[prop] = value; + if (typeof modifiers === "function") { + callback = modifiers; + } + } - data = merge_modifiers(data, modifiers); + var data = { + $append: $append, + $token: this.mixpanel.token, + $distinct_id: distinct_id, + }; - if (this.mixpanel.config.debug) { - this.mixpanel.config.logger.debug("Sending the following data to Mixpanel (Engage)", { data }); - } + data = merge_modifiers(data, modifiers); - this.mixpanel.send_request({ method: "GET", endpoint: "/engage", data: data }, callback); + if (this.mixpanel.config.debug) { + this.mixpanel.config.logger.debug( + "Sending the following data to Mixpanel (Engage)", + { data }, + ); } - /** + this.mixpanel.send_request( + { method: "GET", endpoint: "/engage", data: data }, + callback, + ); + } + + /** people.track_charge(distinct_id, amount, properties, modifiers, callback) --- Record that you have charged the current user a certain @@ -189,56 +201,64 @@ class MixpanelPeople extends ProfileHelpers() { // charge a user $19 on the 1st of february mixpanel.people.track_charge('bob', 19, { '$time': new Date('feb 1 2012') }); */ - track_charge(distinct_id, amount, properties, modifiers, callback) { - if (typeof(properties) === 'function' || !properties) { - callback = properties || undefined; - properties = {}; - } else { - if (typeof(modifiers) === 'function' || !modifiers) { - callback = modifiers || undefined; - if (properties.$ignore_time || properties.hasOwnProperty("$ip")) { - modifiers = {}; - Object.keys(properties).forEach(function(key) { - modifiers[key] = properties[key]; - delete properties[key]; - }); - } - } - } - - if (typeof(amount) !== 'number') { - amount = parseFloat(amount); - if (isNaN(amount)) { - this.mixpanel.config.logger.error("Invalid value passed to mixpanel.people.track_charge - must be a number"); - return; - } + track_charge(distinct_id, amount, properties, modifiers, callback) { + if (typeof properties === "function" || !properties) { + callback = properties || undefined; + properties = {}; + } else { + if (typeof modifiers === "function" || !modifiers) { + callback = modifiers || undefined; + if (properties.$ignore_time || properties.hasOwnProperty("$ip")) { + modifiers = {}; + Object.keys(properties).forEach(function (key) { + modifiers[key] = properties[key]; + delete properties[key]; + }); } + } + } - properties.$amount = amount; + if (typeof amount !== "number") { + amount = parseFloat(amount); + if (isNaN(amount)) { + this.mixpanel.config.logger.error( + "Invalid value passed to mixpanel.people.track_charge - must be a number", + ); + return; + } + } - if (properties.hasOwnProperty('$time')) { - var time = properties.$time; - if (Object.prototype.toString.call(time) === '[object Date]') { - properties.$time = time.toISOString(); - } - } + properties.$amount = amount; - var data = { - '$append': { '$transactions': properties }, - '$token': this.mixpanel.token, - '$distinct_id': distinct_id - }; + if (properties.hasOwnProperty("$time")) { + var time = properties.$time; + if (Object.prototype.toString.call(time) === "[object Date]") { + properties.$time = time.toISOString(); + } + } - data = merge_modifiers(data, modifiers); + var data = { + $append: { $transactions: properties }, + $token: this.mixpanel.token, + $distinct_id: distinct_id, + }; - if (this.mixpanel.config.debug) { - this.mixpanel.config.logger.debug("Sending the following data to Mixpanel (Engage)", { data }); - } + data = merge_modifiers(data, modifiers); - this.mixpanel.send_request({ method: "GET", endpoint: "/engage", data: data }, callback); + if (this.mixpanel.config.debug) { + this.mixpanel.config.logger.debug( + "Sending the following data to Mixpanel (Engage)", + { data }, + ); } - /** + this.mixpanel.send_request( + { method: "GET", endpoint: "/engage", data: data }, + callback, + ); + } + + /** people.clear_charges(distinct_id, modifiers, callback) --- Clear all the current user's transactions. @@ -247,25 +267,32 @@ class MixpanelPeople extends ProfileHelpers() { mixpanel.people.clear_charges('bob'); */ - clear_charges(distinct_id, modifiers, callback) { - var data = { - '$set': { '$transactions': [] }, - '$token': this.mixpanel.token, - '$distinct_id': distinct_id - }; - - if (typeof(modifiers) === 'function') { callback = modifiers; } - - data = merge_modifiers(data, modifiers); + clear_charges(distinct_id, modifiers, callback) { + var data = { + $set: { $transactions: [] }, + $token: this.mixpanel.token, + $distinct_id: distinct_id, + }; + + if (typeof modifiers === "function") { + callback = modifiers; + } - if (this.mixpanel.config.debug) { - this.mixpanel.config.logger.debug("Clearing this user's charges", { '$distinct_id': distinct_id }); - } + data = merge_modifiers(data, modifiers); - this.mixpanel.send_request({ method: "GET", endpoint: "/engage", data: data }, callback); + if (this.mixpanel.config.debug) { + this.mixpanel.config.logger.debug("Clearing this user's charges", { + $distinct_id: distinct_id, + }); } - /** + this.mixpanel.send_request( + { method: "GET", endpoint: "/engage", data: data }, + callback, + ); + } + + /** people.delete_user(distinct_id, modifiers, callback) --- delete an user record in engage @@ -274,12 +301,12 @@ class MixpanelPeople extends ProfileHelpers() { mixpanel.people.delete_user('bob'); */ - delete_user(distinct_id, modifiers, callback) { - const identifiers = {$distinct_id: distinct_id}; - this._delete_profile({identifiers, modifiers, callback}); - } + delete_user(distinct_id, modifiers, callback) { + const identifiers = { $distinct_id: distinct_id }; + this._delete_profile({ identifiers, modifiers, callback }); + } - /** + /** people.remove(distinct_id, data, modifiers, callback) --- remove a value from a list-valued user profile property. @@ -290,12 +317,12 @@ class MixpanelPeople extends ProfileHelpers() { mixpanel.people.remove('bob', {'browsers': 'chrome', 'os': 'linux'}); */ - remove(distinct_id, data, modifiers, callback) { - const identifiers = {'$distinct_id': distinct_id}; - this._remove({identifiers, data, modifiers, callback}) - } + remove(distinct_id, data, modifiers, callback) { + const identifiers = { $distinct_id: distinct_id }; + this._remove({ identifiers, data, modifiers, callback }); + } - /** + /** people.union(distinct_id, data, modifiers, callback) --- merge value(s) into a list-valued people analytics property. @@ -306,12 +333,12 @@ class MixpanelPeople extends ProfileHelpers() { mixpanel.people.union('bob', {'browsers': ['chrome'], os: ['linux']}); */ - union(distinct_id, data, modifiers, callback) { - const identifiers = {$distinct_id: distinct_id}; - this._union({identifiers, data, modifiers, callback}); - } + union(distinct_id, data, modifiers, callback) { + const identifiers = { $distinct_id: distinct_id }; + this._union({ identifiers, data, modifiers, callback }); + } - /** + /** people.unset(distinct_id, prop, modifiers, callback) --- delete a property on an user record in engage @@ -322,10 +349,10 @@ class MixpanelPeople extends ProfileHelpers() { mixpanel.people.unset('bob', ['page_views', 'last_login']); */ - unset(distinct_id, prop, modifiers, callback) { - const identifiers = {$distinct_id: distinct_id}; - this._unset({identifiers, prop, modifiers, callback}); - } -}; + unset(distinct_id, prop, modifiers, callback) { + const identifiers = { $distinct_id: distinct_id }; + this._unset({ identifiers, prop, modifiers, callback }); + } +} exports.MixpanelPeople = MixpanelPeople; diff --git a/lib/profile_helpers.js b/lib/profile_helpers.js index 0465c87..0b729b3 100644 --- a/lib/profile_helpers.js +++ b/lib/profile_helpers.js @@ -2,245 +2,273 @@ * Mixin with profile-related helpers (for people and groups) */ -const {ensure_timestamp} = require('./utils'); +const { ensure_timestamp } = require("./utils"); function merge_modifiers(data, modifiers) { - if (modifiers) { - if (modifiers.$ignore_alias) { - data.$ignore_alias = modifiers.$ignore_alias; - } - if (modifiers.$ignore_time) { - data.$ignore_time = modifiers.$ignore_time; - } - if (modifiers.hasOwnProperty("$ip")) { - data.$ip = modifiers.$ip; - } - if (modifiers.hasOwnProperty("$time")) { - data.$time = ensure_timestamp(modifiers.$time); - } - if (modifiers.hasOwnProperty("$latitude") && modifiers.hasOwnProperty('$longitude')) { - data.$latitude = modifiers.$latitude; - data.$longitude = modifiers.$longitude; - } + if (modifiers) { + if (modifiers.$ignore_alias) { + data.$ignore_alias = modifiers.$ignore_alias; + } + if (modifiers.$ignore_time) { + data.$ignore_time = modifiers.$ignore_time; + } + if (modifiers.hasOwnProperty("$ip")) { + data.$ip = modifiers.$ip; } - return data; -}; + if (modifiers.hasOwnProperty("$time")) { + data.$time = ensure_timestamp(modifiers.$time); + } + if ( + modifiers.hasOwnProperty("$latitude") && + modifiers.hasOwnProperty("$longitude") + ) { + data.$latitude = modifiers.$latitude; + data.$longitude = modifiers.$longitude; + } + } + return data; +} exports.merge_modifiers = merge_modifiers; -exports.ProfileHelpers = (Base = Object) => class extends Base { +exports.ProfileHelpers = (Base = Object) => + class extends Base { get token() { - return this.mixpanel.token; + return this.mixpanel.token; } get config() { - return this.mixpanel.config; + return this.mixpanel.config; } - _set(prop, to, modifiers, callback, {identifiers, set_once = false}) { - let $set = {}; - - if (typeof(prop) === 'object') { - if (typeof(to) === 'object') { - callback = modifiers; - modifiers = to; - } else { - callback = to; - } - $set = prop; - } else { - $set[prop] = to; - if (typeof(modifiers) === 'function' || !modifiers) { - callback = modifiers; - } - } - - let data = { - '$token': this.token, - ...identifiers, - }; - - const set_key = set_once ? "$set_once" : "$set"; - data[set_key] = $set; - - if ('ip' in $set) { - data.$ip = $set.ip; - delete $set.ip; - } + _set(prop, to, modifiers, callback, { identifiers, set_once = false }) { + let $set = {}; - if ($set.$ignore_time) { - data.$ignore_time = $set.$ignore_time; - delete $set.$ignore_time; + if (typeof prop === "object") { + if (typeof to === "object") { + callback = modifiers; + modifiers = to; + } else { + callback = to; } - - data = merge_modifiers(data, modifiers); - - if (this.config.debug) { - this.mixpanel.config.logger.debug(`Sending the following data to Mixpanel (${this.endpoint})`, { data }); + $set = prop; + } else { + $set[prop] = to; + if (typeof modifiers === "function" || !modifiers) { + callback = modifiers; } - - this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); + } + + let data = { + $token: this.token, + ...identifiers, + }; + + const set_key = set_once ? "$set_once" : "$set"; + data[set_key] = $set; + + if ("ip" in $set) { + data.$ip = $set.ip; + delete $set.ip; + } + + if ($set.$ignore_time) { + data.$ignore_time = $set.$ignore_time; + delete $set.$ignore_time; + } + + data = merge_modifiers(data, modifiers); + + if (this.config.debug) { + this.mixpanel.config.logger.debug( + `Sending the following data to Mixpanel (${this.endpoint})`, + { data }, + ); + } + + this.mixpanel.send_request( + { method: "GET", endpoint: this.endpoint, data }, + callback, + ); } - _delete_profile({identifiers, modifiers, callback}){ - let data = { - '$delete': '', - '$token': this.token, - ...identifiers, - }; + _delete_profile({ identifiers, modifiers, callback }) { + let data = { + $delete: "", + $token: this.token, + ...identifiers, + }; - if (typeof(modifiers) === 'function') { callback = modifiers; } + if (typeof modifiers === "function") { + callback = modifiers; + } - data = merge_modifiers(data, modifiers); + data = merge_modifiers(data, modifiers); - if (this.config.debug) { - this.mixpanel.config.logger.debug('Deleting profile', { identifiers }); - } + if (this.config.debug) { + this.mixpanel.config.logger.debug("Deleting profile", { identifiers }); + } - this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); + this.mixpanel.send_request( + { method: "GET", endpoint: this.endpoint, data }, + callback, + ); } - _remove({identifiers, data, modifiers, callback}) { - let $remove = {}; - - if (typeof(data) !== 'object' || Array.isArray(data)) { - if (this.config.debug) { - this.mixpanel.config.logger.error("Invalid value passed to #remove - data must be an object with scalar values"); - } - return; - } - - for (const [key, val] of Object.entries(data)) { - if (typeof(val) === 'string' || typeof(val) === 'number') { - $remove[key] = val; - } else { - if (this.config.debug) { - this.mixpanel.config.logger.error( - "Invalid argument passed to #remove - values must be scalar", - { key, value: val } - ); - } - return; - } - } - - if (Object.keys($remove).length === 0) { - return; - } - - data = { - '$remove': $remove, - '$token': this.token, - ...identifiers - }; + _remove({ identifiers, data, modifiers, callback }) { + let $remove = {}; - if (typeof(modifiers) === 'function') { - callback = modifiers; + if (typeof data !== "object" || Array.isArray(data)) { + if (this.config.debug) { + this.mixpanel.config.logger.error( + "Invalid value passed to #remove - data must be an object with scalar values", + ); } + return; + } - data = merge_modifiers(data, modifiers); - - if (this.config.debug) { - this.mixpanel.config.logger.debug( - `Sending the following data to Mixpanel (${this.endpoint})`, - { data } + for (const [key, val] of Object.entries(data)) { + if (typeof val === "string" || typeof val === "number") { + $remove[key] = val; + } else { + if (this.config.debug) { + this.mixpanel.config.logger.error( + "Invalid argument passed to #remove - values must be scalar", + { key, value: val }, ); + } + return; } - - this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); + } + + if (Object.keys($remove).length === 0) { + return; + } + + data = { + $remove: $remove, + $token: this.token, + ...identifiers, + }; + + if (typeof modifiers === "function") { + callback = modifiers; + } + + data = merge_modifiers(data, modifiers); + + if (this.config.debug) { + this.mixpanel.config.logger.debug( + `Sending the following data to Mixpanel (${this.endpoint})`, + { data }, + ); + } + + this.mixpanel.send_request( + { method: "GET", endpoint: this.endpoint, data }, + callback, + ); } - _union({identifiers, data, modifiers, callback}) { - let $union = {}; - - if (typeof(data) !== 'object' || Array.isArray(data)) { - if (this.config.debug) { - this.mixpanel.config.logger.error("Invalid value passed to #union - data must be an object with scalar or array values"); - } - return; - } - - for (const [key, val] of Object.entries(data)) { - if (Array.isArray(val)) { - var merge_values = val.filter(function(v) { - return typeof(v) === 'string' || typeof(v) === 'number'; - }); - if (merge_values.length > 0) { - $union[key] = merge_values; - } - } else if (typeof(val) === 'string' || typeof(val) === 'number') { - $union[key] = [val]; - } else { - if (this.config.debug) { - this.mixpanel.config.logger.error( - "Invalid argument passed to #union - values must be a scalar value or array", - { key, value: val } - ); - } - } - } - - if (Object.keys($union).length === 0) { - return; - } - - data = { - '$union': $union, - '$token': this.token, - ...identifiers, - }; - - if (typeof(modifiers) === 'function') { - callback = modifiers; - } - - data = merge_modifiers(data, modifiers); + _union({ identifiers, data, modifiers, callback }) { + let $union = {}; + if (typeof data !== "object" || Array.isArray(data)) { if (this.config.debug) { - this.mixpanel.config.logger.debug( - `Sending the following data to Mixpanel (${this.endpoint})`, - { data } - ); + this.mixpanel.config.logger.error( + "Invalid value passed to #union - data must be an object with scalar or array values", + ); } - - this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); - } - - _unset({identifiers, prop, modifiers, callback}){ - let $unset = []; - - if (Array.isArray(prop)) { - $unset = prop; - } else if (typeof(prop) === 'string') { - $unset = [prop]; + return; + } + + for (const [key, val] of Object.entries(data)) { + if (Array.isArray(val)) { + var merge_values = val.filter(function (v) { + return typeof v === "string" || typeof v === "number"; + }); + if (merge_values.length > 0) { + $union[key] = merge_values; + } + } else if (typeof val === "string" || typeof val === "number") { + $union[key] = [val]; } else { - if (this.config.debug) { - this.mixpanel.config.logger.error( - "Invalid argument passed to #unset - must be a string or array", - { prop } - ); - } - return; - } - - let data = { - '$unset': $unset, - '$token': this.token, - ...identifiers, - }; - - if (typeof(modifiers) === 'function') { - callback = modifiers; + if (this.config.debug) { + this.mixpanel.config.logger.error( + "Invalid argument passed to #union - values must be a scalar value or array", + { key, value: val }, + ); + } } + } + + if (Object.keys($union).length === 0) { + return; + } + + data = { + $union: $union, + $token: this.token, + ...identifiers, + }; + + if (typeof modifiers === "function") { + callback = modifiers; + } + + data = merge_modifiers(data, modifiers); + + if (this.config.debug) { + this.mixpanel.config.logger.debug( + `Sending the following data to Mixpanel (${this.endpoint})`, + { data }, + ); + } + + this.mixpanel.send_request( + { method: "GET", endpoint: this.endpoint, data }, + callback, + ); + } - data = merge_modifiers(data, modifiers); + _unset({ identifiers, prop, modifiers, callback }) { + let $unset = []; + if (Array.isArray(prop)) { + $unset = prop; + } else if (typeof prop === "string") { + $unset = [prop]; + } else { if (this.config.debug) { - this.mixpanel.config.logger.debug( - `Sending the following data to Mixpanel (${this.endpoint})`, - { data } - ); + this.mixpanel.config.logger.error( + "Invalid argument passed to #unset - must be a string or array", + { prop }, + ); } - - this.mixpanel.send_request({ method: "GET", endpoint: this.endpoint, data }, callback); + return; + } + + let data = { + $unset: $unset, + $token: this.token, + ...identifiers, + }; + + if (typeof modifiers === "function") { + callback = modifiers; + } + + data = merge_modifiers(data, modifiers); + + if (this.config.debug) { + this.mixpanel.config.logger.debug( + `Sending the following data to Mixpanel (${this.endpoint})`, + { data }, + ); + } + + this.mixpanel.send_request( + { method: "GET", endpoint: this.endpoint, data }, + callback, + ); } -}; + }; diff --git a/lib/utils.js b/lib/utils.js index 71b1019..0ccc6be 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -5,29 +5,29 @@ * @param {Function} handler * @param {Function} callback */ -exports.async_all = function(requests, handler, callback) { - var total = requests.length, - errors = null, - results = [], - done = function (err, result) { - if (err) { - // errors are `null` unless there is an error, which allows for promisification - errors = errors || []; - errors.push(err); - } - results.push(result); - if (--total === 0) { - callback(errors, results) - } - }; - - if (total === 0) { +exports.async_all = function (requests, handler, callback) { + var total = requests.length, + errors = null, + results = [], + done = function (err, result) { + if (err) { + // errors are `null` unless there is an error, which allows for promisification + errors = errors || []; + errors.push(err); + } + results.push(result); + if (--total === 0) { callback(errors, results); - } else { - for(var i = 0, l = requests.length; i < l; i++) { - handler(requests[i], done); - } + } + }; + + if (total === 0) { + callback(errors, results); + } else { + for (var i = 0, l = requests.length; i < l; i++) { + handler(requests[i], done); } + } }; /** @@ -35,27 +35,29 @@ exports.async_all = function(requests, handler, callback) { * @param {Date|number} time - value to check * @returns {number} Unix timestamp */ -exports.ensure_timestamp = function(time) { - if (!(time instanceof Date || typeof time === "number")) { - throw new Error("`time` property must be a Date or Unix timestamp and is only required for `import` endpoint"); - } - return time instanceof Date ? time.getTime() : time; +exports.ensure_timestamp = function (time) { + if (!(time instanceof Date || typeof time === "number")) { + throw new Error( + "`time` property must be a Date or Unix timestamp and is only required for `import` endpoint", + ); + } + return time instanceof Date ? time.getTime() : time; }; /** -* Asserts that the provided logger object is valid -* @param {CustomLogger} logger - The logger object to be validated -* @throws {TypeError} If the logger object is not a valid Logger object or -* if it is missing any of the required methods -*/ -exports.assert_logger = function(logger) { - if (typeof logger !== 'object') { - throw new TypeError(`"logger" must be a valid Logger object`); + * Asserts that the provided logger object is valid + * @param {CustomLogger} logger - The logger object to be validated + * @throws {TypeError} If the logger object is not a valid Logger object or + * if it is missing any of the required methods + */ +exports.assert_logger = function (logger) { + if (typeof logger !== "object") { + throw new TypeError(`"logger" must be a valid Logger object`); + } + + ["trace", "debug", "info", "warn", "error"].forEach((method) => { + if (typeof logger[method] !== "function") { + throw new TypeError(`Logger object missing "${method}" method`); } - - ['trace', 'debug', 'info', 'warn', 'error'].forEach((method) => { - if (typeof logger[method] !== 'function') { - throw new TypeError(`Logger object missing "${method}" method`); - } - }); + }); }; diff --git a/package-lock.json b/package-lock.json index abcc8da..0f8cb1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "devDependencies": { "@vitest/coverage-v8": "^4.0.8", "nock": "^14.0.10", + "oxlint": "^1.16.0", + "prettier": "^3.6.2", "proxyquire": "^2.1.3", "vitest": "^4.0.8" }, @@ -594,6 +596,118 @@ "dev": true, "license": "MIT" }, + "node_modules/@oxlint/darwin-arm64": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.16.0.tgz", + "integrity": "sha512-t9sBjbcG15Jgwgw2wY+rtfKEazdkKM/YhcdyjmGYeSjBXaczLfp/gZe03taC2qUHK+t6cxSYNkOLXRLWxaf3tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/darwin-x64": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-1.16.0.tgz", + "integrity": "sha512-c9aeLQATeu27TK8gR/p8GfRBsuakx0zs+6UHFq/s8Kux+8tYb3pH1pql/XWUPbxubv48F2MpnD5zgjOrShAgag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxlint/linux-arm64-gnu": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-1.16.0.tgz", + "integrity": "sha512-ZoBtxtRHhftbiKKeScpgUKIg4cu9s7rsBPCkjfMCY0uLjhKqm6ShPEaIuP8515+/Csouciz1ViZhbrya5ligAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-arm64-musl": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-1.16.0.tgz", + "integrity": "sha512-a/Dys7CTyj1eZIkD59k9Y3lp5YsHBUeZXR7qHTplKb41H+Ivm5OQPf+rfbCBSLMfCPZCeKQPW36GXOSYLNE1uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-gnu": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-1.16.0.tgz", + "integrity": "sha512-rsfv90ytLhl+s7aa8eE8gGwB1XGbiUA2oyUee/RhGRyeoZoe9/hHNtIcE2XndMYlJToROKmGyrTN4MD2c0xxLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/linux-x64-musl": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-1.16.0.tgz", + "integrity": "sha512-djwSL4harw46kdCwaORUvApyE9Y6JSnJ7pF5PHcQlJ7S1IusfjzYljXky4hONPO0otvXWdKq1GpJqhmtM0/xbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxlint/win32-arm64": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-1.16.0.tgz", + "integrity": "sha512-lQBfW4hBiQ47P12UAFXyX3RVHlWCSYp6I89YhG+0zoLipxAfyB37P8G8N43T/fkUaleb8lvt0jyNG6jQTkCmhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxlint/win32-x64": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-1.16.0.tgz", + "integrity": "sha512-B5se3JnM4Xu6uHF78hAY9wdk/sdLFib1YwFsLY6rkQKEMFyi+vMZZlDaAS+s+Dt9q7q881U2OhNznZenJZdPdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", @@ -1497,6 +1611,41 @@ "dev": true, "license": "MIT" }, + "node_modules/oxlint": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.16.0.tgz", + "integrity": "sha512-o6z8s6QVw/d7QuxQ7QFfqDMrIcmHyU3J/MewxjqduJmy4vHt/s7OZISk8zEXjHXZzTWrcFakIrLqU/b9IKTcjg==", + "dev": true, + "license": "MIT", + "bin": { + "oxc_language_server": "bin/oxc_language_server", + "oxlint": "bin/oxlint" + }, + "engines": { + "node": ">=8.*" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/darwin-arm64": "1.16.0", + "@oxlint/darwin-x64": "1.16.0", + "@oxlint/linux-arm64-gnu": "1.16.0", + "@oxlint/linux-arm64-musl": "1.16.0", + "@oxlint/linux-x64-gnu": "1.16.0", + "@oxlint/linux-x64-musl": "1.16.0", + "@oxlint/win32-arm64": "1.16.0", + "@oxlint/win32-x64": "1.16.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.2.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1560,6 +1709,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/propagate": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", @@ -2212,6 +2377,62 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "dev": true }, + "@oxlint/darwin-arm64": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.16.0.tgz", + "integrity": "sha512-t9sBjbcG15Jgwgw2wY+rtfKEazdkKM/YhcdyjmGYeSjBXaczLfp/gZe03taC2qUHK+t6cxSYNkOLXRLWxaf3tw==", + "dev": true, + "optional": true + }, + "@oxlint/darwin-x64": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/darwin-x64/-/darwin-x64-1.16.0.tgz", + "integrity": "sha512-c9aeLQATeu27TK8gR/p8GfRBsuakx0zs+6UHFq/s8Kux+8tYb3pH1pql/XWUPbxubv48F2MpnD5zgjOrShAgag==", + "dev": true, + "optional": true + }, + "@oxlint/linux-arm64-gnu": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-gnu/-/linux-arm64-gnu-1.16.0.tgz", + "integrity": "sha512-ZoBtxtRHhftbiKKeScpgUKIg4cu9s7rsBPCkjfMCY0uLjhKqm6ShPEaIuP8515+/Csouciz1ViZhbrya5ligAg==", + "dev": true, + "optional": true + }, + "@oxlint/linux-arm64-musl": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-arm64-musl/-/linux-arm64-musl-1.16.0.tgz", + "integrity": "sha512-a/Dys7CTyj1eZIkD59k9Y3lp5YsHBUeZXR7qHTplKb41H+Ivm5OQPf+rfbCBSLMfCPZCeKQPW36GXOSYLNE1uw==", + "dev": true, + "optional": true + }, + "@oxlint/linux-x64-gnu": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-gnu/-/linux-x64-gnu-1.16.0.tgz", + "integrity": "sha512-rsfv90ytLhl+s7aa8eE8gGwB1XGbiUA2oyUee/RhGRyeoZoe9/hHNtIcE2XndMYlJToROKmGyrTN4MD2c0xxLQ==", + "dev": true, + "optional": true + }, + "@oxlint/linux-x64-musl": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/linux-x64-musl/-/linux-x64-musl-1.16.0.tgz", + "integrity": "sha512-djwSL4harw46kdCwaORUvApyE9Y6JSnJ7pF5PHcQlJ7S1IusfjzYljXky4hONPO0otvXWdKq1GpJqhmtM0/xbg==", + "dev": true, + "optional": true + }, + "@oxlint/win32-arm64": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-arm64/-/win32-arm64-1.16.0.tgz", + "integrity": "sha512-lQBfW4hBiQ47P12UAFXyX3RVHlWCSYp6I89YhG+0zoLipxAfyB37P8G8N43T/fkUaleb8lvt0jyNG6jQTkCmhg==", + "dev": true, + "optional": true + }, + "@oxlint/win32-x64": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@oxlint/win32-x64/-/win32-x64-1.16.0.tgz", + "integrity": "sha512-B5se3JnM4Xu6uHF78hAY9wdk/sdLFib1YwFsLY6rkQKEMFyi+vMZZlDaAS+s+Dt9q7q881U2OhNznZenJZdPdQ==", + "dev": true, + "optional": true + }, "@rollup/rollup-android-arm-eabi": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", @@ -2775,6 +2996,22 @@ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", "dev": true }, + "oxlint": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.16.0.tgz", + "integrity": "sha512-o6z8s6QVw/d7QuxQ7QFfqDMrIcmHyU3J/MewxjqduJmy4vHt/s7OZISk8zEXjHXZzTWrcFakIrLqU/b9IKTcjg==", + "dev": true, + "requires": { + "@oxlint/darwin-arm64": "1.16.0", + "@oxlint/darwin-x64": "1.16.0", + "@oxlint/linux-arm64-gnu": "1.16.0", + "@oxlint/linux-arm64-musl": "1.16.0", + "@oxlint/linux-x64-gnu": "1.16.0", + "@oxlint/linux-x64-musl": "1.16.0", + "@oxlint/win32-arm64": "1.16.0", + "@oxlint/win32-x64": "1.16.0" + } + }, "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2810,6 +3047,12 @@ "source-map-js": "^1.2.1" } }, + "prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true + }, "propagate": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", diff --git a/package.json b/package.json index 428cd26..8850a07 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,17 @@ "node": ">=10.0" }, "scripts": { - "test": "vitest" + "test": "vitest", + "format": "prettier --write '**/*.{js,ts}'", + "check-format": "prettier --check '**/*.{js,ts}'", + "lint": "oxlint '**/*.{js,ts}'" }, "types": "./lib/mixpanel-node.d.ts", "devDependencies": { "@vitest/coverage-v8": "^4.0.8", "nock": "^14.0.10", + "oxlint": "^1.16.0", + "prettier": "^3.6.2", "proxyquire": "^2.1.3", "vitest": "^4.0.8" }, diff --git a/test/alias.js b/test/alias.js index 0971059..66ea539 100644 --- a/test/alias.js +++ b/test/alias.js @@ -1,37 +1,37 @@ -const Mixpanel = require('../lib/mixpanel-node'); +const Mixpanel = require("../lib/mixpanel-node"); -describe('alias', () => { - let mixpanel; - let sendRequestMock; - beforeEach(() => { - mixpanel = Mixpanel.init('token', { key: 'key' }); - vi.spyOn(mixpanel, 'send_request'); - return () => { - mixpanel.send_request.mockRestore(); - }; - }); +describe("alias", () => { + let mixpanel; + let sendRequestMock; + beforeEach(() => { + mixpanel = Mixpanel.init("token", { key: "key" }); + vi.spyOn(mixpanel, "send_request"); + return () => { + mixpanel.send_request.mockRestore(); + }; + }); - it("calls send_request with correct endpoint and data", () => { - var alias = "test", - distinct_id = "old_id", - expected_endpoint = "/track", - expected_data = { - event: '$create_alias', - properties: expect.objectContaining({ - distinct_id: distinct_id, - alias: alias, - token: 'token', - }), - }; + it("calls send_request with correct endpoint and data", () => { + var alias = "test", + distinct_id = "old_id", + expected_endpoint = "/track", + expected_data = { + event: "$create_alias", + properties: expect.objectContaining({ + distinct_id: distinct_id, + alias: alias, + token: "token", + }), + }; - mixpanel.alias(distinct_id, alias); + mixpanel.alias(distinct_id, alias); - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data, - }), - undefined, - ); - }); + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); }); diff --git a/test/config.js b/test/config.js index a536899..e27773f 100644 --- a/test/config.js +++ b/test/config.js @@ -1,47 +1,51 @@ -const Mixpanel = require('../lib/mixpanel-node'); - -describe('config', () => { - let mixpanel; - beforeEach(() => { - mixpanel = Mixpanel.init('asjdf'); - }) - it("is set to correct defaults", () => { - expect(mixpanel.config).toEqual({ - test: false, - debug: false, - verbose: false, - host: 'api.mixpanel.com', - protocol: 'https', - path: '', - keepAlive: true, - geolocate: false, - logger: console, - }); +const Mixpanel = require("../lib/mixpanel-node"); + +describe("config", () => { + let mixpanel; + beforeEach(() => { + mixpanel = Mixpanel.init("asjdf"); + }); + it("is set to correct defaults", () => { + expect(mixpanel.config).toEqual({ + test: false, + debug: false, + verbose: false, + host: "api.mixpanel.com", + protocol: "https", + path: "", + keepAlive: true, + geolocate: false, + logger: console, }); + }); - it("is modified by set_config", () => { - expect(mixpanel.config.test).toBe(false); + it("is modified by set_config", () => { + expect(mixpanel.config.test).toBe(false); - mixpanel.set_config({ test: true }); + mixpanel.set_config({ test: true }); - expect(mixpanel.config.test).toBe(true); - }); + expect(mixpanel.config.test).toBe(true); + }); - it("can be set during init", () => { - var mp = Mixpanel.init('token', { test: true }); + it("can be set during init", () => { + var mp = Mixpanel.init("token", { test: true }); - expect(mp.config.test).toBe(true); - }); + expect(mp.config.test).toBe(true); + }); - it("host config is split into host and port", () => { - const exampleHost = 'api.example.com'; - const examplePort = 70; - const hostWithoutPortConfig = Mixpanel.init('token', {host: exampleHost}).config; - expect(hostWithoutPortConfig.port).toEqual(undefined); - expect(hostWithoutPortConfig.host).toEqual(exampleHost); + it("host config is split into host and port", () => { + const exampleHost = "api.example.com"; + const examplePort = 70; + const hostWithoutPortConfig = Mixpanel.init("token", { + host: exampleHost, + }).config; + expect(hostWithoutPortConfig.port).toEqual(undefined); + expect(hostWithoutPortConfig.host).toEqual(exampleHost); - const hostWithPortConfig = Mixpanel.init('token', {host: `${exampleHost}:${examplePort}`}).config; - expect(hostWithPortConfig.port).toBe(examplePort); - expect(hostWithPortConfig.host).toBe(exampleHost); - }); + const hostWithPortConfig = Mixpanel.init("token", { + host: `${exampleHost}:${examplePort}`, + }).config; + expect(hostWithPortConfig.port).toBe(examplePort); + expect(hostWithPortConfig.host).toBe(exampleHost); + }); }); diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index a9c62a5..03dd2e3 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -1,502 +1,632 @@ -const nock = require('nock'); -const LocalFeatureFlagsProvider = require('../../lib/flags/local_flags'); +const nock = require("nock"); +const LocalFeatureFlagsProvider = require("../../lib/flags/local_flags"); const mockFlagDefinitionsResponse = (flags) => { - const response = { - code: 200, - flags: flags, - }; - - nock('https://localhost') - .get('/flags/definitions') - .query(true) - .reply(200, response); + const response = { + code: 200, + flags: flags, + }; + + nock("https://localhost") + .get("/flags/definitions") + .query(true) + .reply(200, response); }; const mockFailedFlagDefinitionsResponse = (statusCode) => { - nock('https://localhost') - .get('/flags/definitions') - .query(true) - .reply(statusCode); + nock("https://localhost") + .get("/flags/definitions") + .query(true) + .reply(statusCode); }; const createTestFlag = ({ - flagKey = 'test_flag', - context = 'distinct_id', - variants = null, - variantOverride = null, - rolloutPercentage = 100.0, - runtimeEvaluation = null, - testUsers = null, - experimentId = null, - isExperimentActive = null, - variantSplits = null, - hashSalt = null + flagKey = "test_flag", + context = "distinct_id", + variants = null, + variantOverride = null, + rolloutPercentage = 100.0, + runtimeEvaluation = null, + testUsers = null, + experimentId = null, + isExperimentActive = null, + variantSplits = null, + hashSalt = null, } = {}) => { - const defaultVariants = [ - { key: 'control', value: 'control', is_control: true, split: 50.0 }, - { key: 'treatment', value: 'treatment', is_control: false, split: 50.0 } - ]; - - const rollout = [{ - rollout_percentage: rolloutPercentage, - runtime_evaluation_definition: runtimeEvaluation, - variant_override: variantOverride, - variant_splits: variantSplits - }]; - - const testConfig = testUsers ? { users: testUsers } : null; - - return { - id: 'test-id', - name: 'Test Flag', - key: flagKey, - status: 'active', - project_id: 123, - context: context, - experiment_id: experimentId, - is_experiment_active: isExperimentActive, - hash_salt: hashSalt, - ruleset: { - variants: variants || defaultVariants, - rollout: rollout, - test: testConfig - } - }; + const defaultVariants = [ + { key: "control", value: "control", is_control: true, split: 50.0 }, + { key: "treatment", value: "treatment", is_control: false, split: 50.0 }, + ]; + + const rollout = [ + { + rollout_percentage: rolloutPercentage, + runtime_evaluation_definition: runtimeEvaluation, + variant_override: variantOverride, + variant_splits: variantSplits, + }, + ]; + + const testConfig = testUsers ? { users: testUsers } : null; + + return { + id: "test-id", + name: "Test Flag", + key: flagKey, + status: "active", + project_id: 123, + context: context, + experiment_id: experimentId, + is_experiment_active: isExperimentActive, + hash_salt: hashSalt, + ruleset: { + variants: variants || defaultVariants, + rollout: rollout, + test: testConfig, + }, + }; }; -describe('LocalFeatureFlagsProvider', () => { - const TEST_TOKEN = 'test-token'; - const TEST_CONTEXT = { - distinct_id: 'test-user', +describe("LocalFeatureFlagsProvider", () => { + const TEST_TOKEN = "test-token"; + const TEST_CONTEXT = { + distinct_id: "test-user", + }; + + let mockTracker; + let mockLogger; + + beforeEach(() => { + mockTracker = vi.fn(); + + mockLogger = { + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), }; + }); - let mockTracker; - let mockLogger; + afterEach(() => { + vi.restoreAllMocks(); + nock.cleanAll(); + }); - beforeEach(() => { - mockTracker = vi.fn(); + describe("getVariant", () => { + let provider; - mockLogger = { - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn() - }; + beforeEach(() => { + const config = { + api_host: "localhost", + enable_polling: false, + }; + + provider = new LocalFeatureFlagsProvider( + TEST_TOKEN, + config, + mockTracker, + mockLogger, + ); }); afterEach(() => { - vi.restoreAllMocks(); - nock.cleanAll(); - }); - - describe('getVariant', () => { - let provider; - - beforeEach(() => { - const config = { - api_host: 'localhost', - enable_polling: false, - }; - - provider = new LocalFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); - }); - - afterEach(() => { - provider.stopPollingForDefinitions(); - }); - - it('should return fallback when no flag definitions', async () => { - mockFlagDefinitionsResponse([]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('nonexistent_flag', { variant_value: 'control' }, TEST_CONTEXT); - expect(result.variant_value).toBe('control'); - expect(mockTracker).not.toHaveBeenCalled(); - }); - - it('should return fallback if flag definition call fails', async () => { - mockFailedFlagDefinitionsResponse(500); - - await provider.startPollingForDefinitions(); - const result = provider.getVariant('nonexistent_flag', { variant_value: 'control' }, TEST_CONTEXT); - expect(result.variant_value).toBe('control'); - }); - - it('should return fallback when flag does not exist', async () => { - const otherFlag = createTestFlag({ flagKey: 'other_flag' }); - mockFlagDefinitionsResponse([otherFlag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('nonexistent_flag', { variant_value: 'control' }, TEST_CONTEXT); - expect(result.variant_value).toBe('control'); - }); - - it('should return fallback when no context', async () => { - const flag = createTestFlag({ context: 'distinct_id' }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, {}); - expect(result.variant_value).toBe('fallback'); - }); - - it('should return fallback when wrong context key', async () => { - const flag = createTestFlag({ context: 'user_id' }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, { distinct_id: 'user123' }); - expect(result.variant_value).toBe('fallback'); - }); - - it('should return test user variant when configured', async () => { - const variants = [ - { key: 'control', value: 'false', is_control: true, split: 50.0 }, - { key: 'treatment', value: 'true', is_control: false, split: 50.0 } - ]; - const flag = createTestFlag({ - variants: variants, - testUsers: { 'test_user': 'treatment' } - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'control' }, { distinct_id: 'test_user' }); - expect(result.variant_value).toBe('true'); - }); - - it('should return correct variant when test user variant not configured', async () => { - const variants = [ - { key: 'control', value: 'false', is_control: true, split: 50.0 }, - { key: 'treatment', value: 'true', is_control: false, split: 50.0 } - ]; - const flag = createTestFlag({ - variants: variants, - testUsers: { 'test_user': 'nonexistent_variant' } - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, { distinct_id: 'test_user' }); - expect(['false', 'true']).toContain(result.variant_value); - }); - - it('should return fallback when rollout percentage zero', async () => { - const flag = createTestFlag({ rolloutPercentage: 0.0 }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); - expect(result.variant_value).toBe('fallback'); - }); - - it('should return variant when rollout percentage hundred', async () => { - const flag = createTestFlag({ rolloutPercentage: 100.0 }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); - expect(result.variant_value).not.toBe('fallback'); - expect(['control', 'treatment']).toContain(result.variant_value); - }); - - it('should respect runtime evaluation when satisfied', async () => { - const runtimeEval = { plan: 'premium', region: 'US' }; - const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const context = { - distinct_id: 'user123', - custom_properties: { - plan: 'premium', - region: 'US' - } - }; - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, context); - expect(result.variant_value).not.toBe('fallback'); - }); - - it('should return fallback when runtime evaluation not satisfied', async () => { - const runtimeEval = { plan: 'premium', region: 'US' }; - const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const context = { - distinct_id: 'user123', - custom_properties: { - plan: 'basic', - region: 'US' - } - }; - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, context); - expect(result.variant_value).toBe('fallback'); - }); - - it('should pick correct variant with hundred percent split', async () => { - const variants = [ - { key: 'A', value: 'variant_a', is_control: false, split: 100.0 }, - { key: 'B', value: 'variant_b', is_control: false, split: 0.0 }, - { key: 'C', value: 'variant_c', is_control: false, split: 0.0 } - ]; - const flag = createTestFlag({ variants: variants, rolloutPercentage: 100.0 }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); - expect(result.variant_value).toBe('variant_a'); - }); - - it('should pick correct variant with half migrated group splits', async () => { - const variants = [ - { key: 'A', value: 'variant_a', is_control: false, split: 100.0 }, - { key: 'B', value: 'variant_b', is_control: false, split: 0.0 }, - { key: 'C', value: 'variant_c', is_control: false, split: 0.0 } - ]; - const variantSplits = { A: 0.0, B: 100.0, C: 0.0 }; - const flag = createTestFlag({ - variants: variants, - rolloutPercentage: 100.0, - variantSplits: variantSplits - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); - expect(result.variant_value).toBe('variant_b'); - }); - - it('should pick correct variant with full migrated group splits', async () => { - const variants = [ - { key: 'A', value: 'variant_a', is_control: false }, - { key: 'B', value: 'variant_b', is_control: false }, - { key: 'C', value: 'variant_c', is_control: false } - ]; - const variantSplits = { A: 0.0, B: 0.0, C: 100.0 }; - const flag = createTestFlag({ - variants: variants, - rolloutPercentage: 100.0, - variantSplits: variantSplits - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); - expect(result.variant_value).toBe('variant_c'); - }); - - it('should pick overridden variant', async () => { - const variants = [ - { key: 'A', value: 'variant_a', is_control: false, split: 100.0 }, - { key: 'B', value: 'variant_b', is_control: false, split: 0.0 } - ]; - const flag = createTestFlag({ - variants: variants, - variantOverride: { key: 'B' } - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - const result = provider.getVariant('test_flag', { variant_value: 'control' }, TEST_CONTEXT); - expect(result.variant_value).toBe('variant_b'); - }); - - it('should track exposure when variant selected', async () => { - const flag = createTestFlag(); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - provider.getVariant('test_flag', { variant_value: 'fallback' }, TEST_CONTEXT); - expect(mockTracker).toHaveBeenCalledTimes(1); - }); - - it('should track exposure with correct properties', async () => { - const flag = createTestFlag({ - experimentId: 'exp-123', - isExperimentActive: true, - testUsers: { 'qa_user': 'treatment' } - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); - - provider.getVariant('test_flag', { variant_value: 'fallback' }, { distinct_id: 'qa_user' }); - - expect(mockTracker).toHaveBeenCalledTimes(1); - - const call = mockTracker.mock.calls[0]; - const properties = call[1]; - - expect(properties['$experiment_id']).toBe('exp-123'); - expect(properties['$is_experiment_active']).toBe(true); - expect(properties['$is_qa_tester']).toBe(true); - }); - - it('should not track exposure on fallback', async () => { - mockFlagDefinitionsResponse([]); - await provider.startPollingForDefinitions(); - - provider.getVariant('nonexistent_flag', { variant_value: 'fallback' }, TEST_CONTEXT); - expect(mockTracker).not.toHaveBeenCalled(); - }); + provider.stopPollingForDefinitions(); + }); + + it("should return fallback when no flag definitions", async () => { + mockFlagDefinitionsResponse([]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "nonexistent_flag", + { variant_value: "control" }, + TEST_CONTEXT, + ); + expect(result.variant_value).toBe("control"); + expect(mockTracker).not.toHaveBeenCalled(); + }); - it('should not track exposure without distinct_id', async () => { - const flag = createTestFlag({ context: 'company' }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + it("should return fallback if flag definition call fails", async () => { + mockFailedFlagDefinitionsResponse(500); - provider.getVariant('test_flag', { variant_value: 'fallback' }, { company_id: 'company123' }); - expect(mockTracker).not.toHaveBeenCalled(); - }); + await provider.startPollingForDefinitions(); + const result = provider.getVariant( + "nonexistent_flag", + { variant_value: "control" }, + TEST_CONTEXT, + ); + expect(result.variant_value).toBe("control"); }); - describe('getAllVariants', () => { - let provider; + it("should return fallback when flag does not exist", async () => { + const otherFlag = createTestFlag({ flagKey: "other_flag" }); + mockFlagDefinitionsResponse([otherFlag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "nonexistent_flag", + { variant_value: "control" }, + TEST_CONTEXT, + ); + expect(result.variant_value).toBe("control"); + }); - beforeEach(() => { - const config = { - api_host: 'localhost', - enable_polling: false, - }; + it("should return fallback when no context", async () => { + const flag = createTestFlag({ context: "distinct_id" }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + {}, + ); + expect(result.variant_value).toBe("fallback"); + }); - provider = new LocalFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); - }); + it("should return fallback when wrong context key", async () => { + const flag = createTestFlag({ context: "user_id" }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + { distinct_id: "user123" }, + ); + expect(result.variant_value).toBe("fallback"); + }); - afterEach(() => { - provider.stopPollingForDefinitions(); - }); + it("should return test user variant when configured", async () => { + const variants = [ + { key: "control", value: "false", is_control: true, split: 50.0 }, + { key: "treatment", value: "true", is_control: false, split: 50.0 }, + ]; + const flag = createTestFlag({ + variants: variants, + testUsers: { test_user: "treatment" }, + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "control" }, + { distinct_id: "test_user" }, + ); + expect(result.variant_value).toBe("true"); + }); - it('should return empty object when no flag definitions', async () => { - mockFlagDefinitionsResponse([]); - await provider.startPollingForDefinitions(); + it("should return correct variant when test user variant not configured", async () => { + const variants = [ + { key: "control", value: "false", is_control: true, split: 50.0 }, + { key: "treatment", value: "true", is_control: false, split: 50.0 }, + ]; + const flag = createTestFlag({ + variants: variants, + testUsers: { test_user: "nonexistent_variant" }, + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + { distinct_id: "test_user" }, + ); + expect(["false", "true"]).toContain(result.variant_value); + }); - const result = provider.getAllVariants(TEST_CONTEXT); + it("should return fallback when rollout percentage zero", async () => { + const flag = createTestFlag({ rolloutPercentage: 0.0 }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + TEST_CONTEXT, + ); + expect(result.variant_value).toBe("fallback"); + }); - expect(result).toEqual({}); - }); + it("should return variant when rollout percentage hundred", async () => { + const flag = createTestFlag({ rolloutPercentage: 100.0 }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + TEST_CONTEXT, + ); + expect(result.variant_value).not.toBe("fallback"); + expect(["control", "treatment"]).toContain(result.variant_value); + }); - it('should return all variants when two flags have 100% rollout', async () => { - const flag1 = createTestFlag({ flagKey: 'flag1', rolloutPercentage: 100.0 }); - const flag2 = createTestFlag({ flagKey: 'flag2', rolloutPercentage: 100.0 }); + it("should respect runtime evaluation when satisfied", async () => { + const runtimeEval = { plan: "premium", region: "US" }; + const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const context = { + distinct_id: "user123", + custom_properties: { + plan: "premium", + region: "US", + }, + }; + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + context, + ); + expect(result.variant_value).not.toBe("fallback"); + }); - mockFlagDefinitionsResponse([flag1, flag2]); - await provider.startPollingForDefinitions(); + it("should return fallback when runtime evaluation not satisfied", async () => { + const runtimeEval = { plan: "premium", region: "US" }; + const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const context = { + distinct_id: "user123", + custom_properties: { + plan: "basic", + region: "US", + }, + }; + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + context, + ); + expect(result.variant_value).toBe("fallback"); + }); - const result = provider.getAllVariants(TEST_CONTEXT); + it("should pick correct variant with hundred percent split", async () => { + const variants = [ + { key: "A", value: "variant_a", is_control: false, split: 100.0 }, + { key: "B", value: "variant_b", is_control: false, split: 0.0 }, + { key: "C", value: "variant_c", is_control: false, split: 0.0 }, + ]; + const flag = createTestFlag({ + variants: variants, + rolloutPercentage: 100.0, + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + TEST_CONTEXT, + ); + expect(result.variant_value).toBe("variant_a"); + }); - expect(Object.keys(result).length).toBe(2); - expect(result).toHaveProperty('flag1'); - expect(result).toHaveProperty('flag2'); - }); + it("should pick correct variant with half migrated group splits", async () => { + const variants = [ + { key: "A", value: "variant_a", is_control: false, split: 100.0 }, + { key: "B", value: "variant_b", is_control: false, split: 0.0 }, + { key: "C", value: "variant_c", is_control: false, split: 0.0 }, + ]; + const variantSplits = { A: 0.0, B: 100.0, C: 0.0 }; + const flag = createTestFlag({ + variants: variants, + rolloutPercentage: 100.0, + variantSplits: variantSplits, + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + TEST_CONTEXT, + ); + expect(result.variant_value).toBe("variant_b"); + }); - it('should return partial results when one flag has 0% rollout', async () => { - const flag1 = createTestFlag({ flagKey: 'flag1', rolloutPercentage: 100.0 }); - const flag2 = createTestFlag({ flagKey: 'flag2', rolloutPercentage: 0.0 }); + it("should pick correct variant with full migrated group splits", async () => { + const variants = [ + { key: "A", value: "variant_a", is_control: false }, + { key: "B", value: "variant_b", is_control: false }, + { key: "C", value: "variant_c", is_control: false }, + ]; + const variantSplits = { A: 0.0, B: 0.0, C: 100.0 }; + const flag = createTestFlag({ + variants: variants, + rolloutPercentage: 100.0, + variantSplits: variantSplits, + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + TEST_CONTEXT, + ); + expect(result.variant_value).toBe("variant_c"); + }); - mockFlagDefinitionsResponse([flag1, flag2]); - await provider.startPollingForDefinitions(); + it("should pick overridden variant", async () => { + const variants = [ + { key: "A", value: "variant_a", is_control: false, split: 100.0 }, + { key: "B", value: "variant_b", is_control: false, split: 0.0 }, + ]; + const flag = createTestFlag({ + variants: variants, + variantOverride: { key: "B" }, + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariant( + "test_flag", + { variant_value: "control" }, + TEST_CONTEXT, + ); + expect(result.variant_value).toBe("variant_b"); + }); - const result = provider.getAllVariants(TEST_CONTEXT); + it("should track exposure when variant selected", async () => { + const flag = createTestFlag(); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + TEST_CONTEXT, + ); + expect(mockTracker).toHaveBeenCalledTimes(1); + }); + + it("should track exposure with correct properties", async () => { + const flag = createTestFlag({ + experimentId: "exp-123", + isExperimentActive: true, + testUsers: { qa_user: "treatment" }, + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + { distinct_id: "qa_user" }, + ); - expect(Object.keys(result).length).toBe(1); - expect(result).toHaveProperty('flag1'); - expect(result).not.toHaveProperty('flag2'); - }); + expect(mockTracker).toHaveBeenCalledTimes(1); + + const call = mockTracker.mock.calls[0]; + const properties = call[1]; + + expect(properties["$experiment_id"]).toBe("exp-123"); + expect(properties["$is_experiment_active"]).toBe(true); + expect(properties["$is_qa_tester"]).toBe(true); }); - describe('getVariantValue', () => { - let provider; + it("should not track exposure on fallback", async () => { + mockFlagDefinitionsResponse([]); + await provider.startPollingForDefinitions(); - beforeEach(() => { - const config = { - api_host: 'localhost', - enable_polling: false, - }; + provider.getVariant( + "nonexistent_flag", + { variant_value: "fallback" }, + TEST_CONTEXT, + ); + expect(mockTracker).not.toHaveBeenCalled(); + }); - provider = new LocalFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); - }); + it("should not track exposure without distinct_id", async () => { + const flag = createTestFlag({ context: "company" }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + provider.getVariant( + "test_flag", + { variant_value: "fallback" }, + { company_id: "company123" }, + ); + expect(mockTracker).not.toHaveBeenCalled(); + }); + }); + + describe("getAllVariants", () => { + let provider; + + beforeEach(() => { + const config = { + api_host: "localhost", + enable_polling: false, + }; + + provider = new LocalFeatureFlagsProvider( + TEST_TOKEN, + config, + mockTracker, + mockLogger, + ); + }); + + afterEach(() => { + provider.stopPollingForDefinitions(); + }); + + it("should return empty object when no flag definitions", async () => { + mockFlagDefinitionsResponse([]); + await provider.startPollingForDefinitions(); + + const result = provider.getAllVariants(TEST_CONTEXT); + + expect(result).toEqual({}); + }); + + it("should return all variants when two flags have 100% rollout", async () => { + const flag1 = createTestFlag({ + flagKey: "flag1", + rolloutPercentage: 100.0, + }); + const flag2 = createTestFlag({ + flagKey: "flag2", + rolloutPercentage: 100.0, + }); + + mockFlagDefinitionsResponse([flag1, flag2]); + await provider.startPollingForDefinitions(); + + const result = provider.getAllVariants(TEST_CONTEXT); - afterEach(() => { - provider.stopPollingForDefinitions(); - }); + expect(Object.keys(result).length).toBe(2); + expect(result).toHaveProperty("flag1"); + expect(result).toHaveProperty("flag2"); + }); - it('should return variant value when flag exists', async () => { - const variants = [ - { key: 'treatment', value: 'blue', is_control: false, split: 100.0 } - ]; - const flag = createTestFlag({ variants: variants, rolloutPercentage: 100.0 }); + it("should return partial results when one flag has 0% rollout", async () => { + const flag1 = createTestFlag({ + flagKey: "flag1", + rolloutPercentage: 100.0, + }); + const flag2 = createTestFlag({ + flagKey: "flag2", + rolloutPercentage: 0.0, + }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + mockFlagDefinitionsResponse([flag1, flag2]); + await provider.startPollingForDefinitions(); - const result = provider.getVariantValue('test_flag', 'default', TEST_CONTEXT); + const result = provider.getAllVariants(TEST_CONTEXT); - expect(result).toBe('blue'); - }); + expect(Object.keys(result).length).toBe(1); + expect(result).toHaveProperty("flag1"); + expect(result).not.toHaveProperty("flag2"); + }); + }); - it('should return fallback value when flag doesn\'t exist', async () => { - mockFlagDefinitionsResponse([]); - await provider.startPollingForDefinitions(); + describe("getVariantValue", () => { + let provider; - const result = provider.getVariantValue('nonexistent_flag', 'default_value', TEST_CONTEXT); + beforeEach(() => { + const config = { + api_host: "localhost", + enable_polling: false, + }; + + provider = new LocalFeatureFlagsProvider( + TEST_TOKEN, + config, + mockTracker, + mockLogger, + ); + }); - expect(result).toBe('default_value'); - }); + afterEach(() => { + provider.stopPollingForDefinitions(); }); - describe('isEnabled', () => { - let provider; + it("should return variant value when flag exists", async () => { + const variants = [ + { key: "treatment", value: "blue", is_control: false, split: 100.0 }, + ]; + const flag = createTestFlag({ + variants: variants, + rolloutPercentage: 100.0, + }); + + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.getVariantValue( + "test_flag", + "default", + TEST_CONTEXT, + ); + + expect(result).toBe("blue"); + }); - beforeEach(() => { - const config = { - api_host: 'localhost', - enable_polling: false, - }; + it("should return fallback value when flag doesn't exist", async () => { + mockFlagDefinitionsResponse([]); + await provider.startPollingForDefinitions(); - provider = new LocalFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); - }); + const result = provider.getVariantValue( + "nonexistent_flag", + "default_value", + TEST_CONTEXT, + ); - afterEach(() => { - provider.stopPollingForDefinitions(); - }); + expect(result).toBe("default_value"); + }); + }); - it('should return true when variant value is boolean true', async () => { - const variants = [ - { key: 'treatment', value: true, is_control: false, split: 100.0 } - ]; - const flag = createTestFlag({ variants: variants, rolloutPercentage: 100.0 }); + describe("isEnabled", () => { + let provider; - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + beforeEach(() => { + const config = { + api_host: "localhost", + enable_polling: false, + }; + + provider = new LocalFeatureFlagsProvider( + TEST_TOKEN, + config, + mockTracker, + mockLogger, + ); + }); + + afterEach(() => { + provider.stopPollingForDefinitions(); + }); - const result = provider.isEnabled('test_flag', TEST_CONTEXT); + it("should return true when variant value is boolean true", async () => { + const variants = [ + { key: "treatment", value: true, is_control: false, split: 100.0 }, + ]; + const flag = createTestFlag({ + variants: variants, + rolloutPercentage: 100.0, + }); - expect(result).toBe(true); - }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); + + const result = provider.isEnabled("test_flag", TEST_CONTEXT); + + expect(result).toBe(true); + }); - it('should return false when variant value is boolean false', async () => { - const variants = [ - { key: 'control', value: false, is_control: true, split: 100.0 } - ]; - const flag = createTestFlag({ variants: variants, rolloutPercentage: 100.0 }); + it("should return false when variant value is boolean false", async () => { + const variants = [ + { key: "control", value: false, is_control: true, split: 100.0 }, + ]; + const flag = createTestFlag({ + variants: variants, + rolloutPercentage: 100.0, + }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); - const result = provider.isEnabled('test_flag', TEST_CONTEXT); + const result = provider.isEnabled("test_flag", TEST_CONTEXT); - expect(result).toBe(false); - }); + expect(result).toBe(false); }); + }); }); diff --git a/test/flags/remote_flags.js b/test/flags/remote_flags.js index 32acb1e..0f0d763 100644 --- a/test/flags/remote_flags.js +++ b/test/flags/remote_flags.js @@ -1,443 +1,489 @@ -const nock = require('nock'); -const RemoteFeatureFlagsProvider = require('../../lib/flags/remote_flags'); +const nock = require("nock"); +const RemoteFeatureFlagsProvider = require("../../lib/flags/remote_flags"); const mockSuccessResponse = (flags_with_selected_variant) => { - const remote_response = { - code: 200, - flags: flags_with_selected_variant, + const remote_response = { + code: 200, + flags: flags_with_selected_variant, + }; + + nock("https://localhost") + .get("/flags") + .query(true) + .reply(200, remote_response); +}; + +describe("RemoteFeatureFlagProvider", () => { + const flagsEndpointHostName = "localhost"; + const TEST_TOKEN = "test-token"; + + const TEST_CONTEXT = { + distinct_id: "test-user", + }; + + let provider; + let mockTracker; + + beforeEach(() => { + mockTracker = vi.fn(); + + let mockLogger = { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }; + + let config = { + api_host: flagsEndpointHostName, }; - nock('https://localhost') - .get('/flags') + provider = new RemoteFeatureFlagsProvider( + TEST_TOKEN, + config, + mockTracker, + mockLogger, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + nock.cleanAll(); + }); + + describe("getVariant", () => { + it("should return variant when served", async () => { + mockSuccessResponse({ + "new-feature": { + variant_key: "on", + variant_value: true, + }, + }); + + const expectedVariant = { + variant_key: "on", + variant_value: true, + }; + + const result = await provider.getVariant( + "new-feature", + null, + TEST_CONTEXT, + ); + + expect(result).toEqual(expectedVariant); + }); + + it("should select fallback variant when no flags are served", async () => { + nock("https://localhost") + .get("/flags") .query(true) - .reply(200, remote_response); -}; + .reply(200, { code: 200, flags: {} }); -describe('RemoteFeatureFlagProvider', () => { - const flagsEndpointHostName = "localhost"; - const TEST_TOKEN = 'test-token'; + const fallbackVariant = { + variant_key: "control", + variant_value: false, + }; - const TEST_CONTEXT = { - distinct_id: 'test-user', - }; + const result = await provider.getVariant( + "any-flag", + fallbackVariant, + TEST_CONTEXT, + ); - let provider; - let mockTracker; + expect(result).toEqual(fallbackVariant); + expect(mockTracker).not.toHaveBeenCalled(); + }); + + it("should select fallback variant if flag does not exist in served flags", async () => { + mockSuccessResponse({ + "different-flag": { + variant_key: "on", + variant_value: true, + }, + }); + + const fallbackVariant = { + variant_key: "control", + variant_value: false, + }; + + const result = await provider.getVariant( + "missing-flag", + fallbackVariant, + TEST_CONTEXT, + ); + + expect(result).toEqual(fallbackVariant); + expect(mockTracker).not.toHaveBeenCalled(); + }); - beforeEach(() => { - mockTracker = vi.fn(); + it("No exposure events are tracked when fallback variant is selected", async () => { + nock("https://localhost") + .get("/flags") + .query(true) + .reply(200, { code: 200, flags: {} }); - let mockLogger = { - error: vi.fn(), - warn: vi.fn(), - info: vi.fn() - }; + const fallbackVariant = { + variant_key: "control", + variant_value: false, + }; - let config = { - api_host: flagsEndpointHostName, - }; + await provider.getVariant("any-flag", fallbackVariant, TEST_CONTEXT); - provider = new RemoteFeatureFlagsProvider(TEST_TOKEN, config, mockTracker, mockLogger); + expect(mockTracker).not.toHaveBeenCalled(); }); - afterEach(() => { - vi.restoreAllMocks(); - nock.cleanAll(); + it("Exposure event is tracked when a variant is selected", async () => { + mockSuccessResponse({ + "test-flag": { + variant_key: "treatment", + variant_value: true, + }, + }); + + const fallbackVariant = { + variant_key: "control", + variant_value: false, + }; + + const result = await provider.getVariant( + "test-flag", + fallbackVariant, + TEST_CONTEXT, + ); + + expect(result).toEqual({ + variant_key: "treatment", + variant_value: true, + }); + + expect(mockTracker).toHaveBeenCalledTimes(1); + + expect(mockTracker).toHaveBeenCalledWith( + "$experiment_started", + expect.objectContaining({ + distinct_id: "test-user", + "Experiment name": "test-flag", + "Variant name": "treatment", + $experiment_type: "feature_flag", + "Flag evaluation mode": "remote", + }), + expect.any(Function), + ); + }); + }); + + describe("getVariantValue", () => { + it("should return variant value when flag exists", async () => { + mockSuccessResponse({ + "test-flag": { + variant_key: "treatment", + variant_value: "blue", + }, + }); + + const result = await provider.getVariantValue( + "test-flag", + "default", + TEST_CONTEXT, + ); + + expect(result).toEqual("blue"); + }); + + it("should return fallback value when flag doesn't exist", async () => { + mockSuccessResponse({ + "different-flag": { + variant_key: "on", + variant_value: true, + }, + }); + + const result = await provider.getVariantValue( + "missing-flag", + "default-value", + TEST_CONTEXT, + ); + + expect(result).toEqual("default-value"); + }); + + it("should track exposure event by default", async () => { + mockSuccessResponse({ + "test-flag": { + variant_key: "treatment", + variant_value: "value", + }, + }); + + await provider.getVariantValue("test-flag", "default", TEST_CONTEXT); + + expect(mockTracker).toHaveBeenCalledTimes(1); + expect(mockTracker).toHaveBeenCalledWith( + "$experiment_started", + expect.objectContaining({ + "Experiment name": "test-flag", + "Variant name": "treatment", + }), + expect.any(Function), + ); }); - describe('getVariant', () => { - it('should return variant when served', async () => { - mockSuccessResponse({ - 'new-feature': { - variant_key: 'on', - variant_value: true, - } - }); - - const expectedVariant = { - variant_key: 'on', - variant_value: true, - } - - const result = await provider.getVariant('new-feature', null, TEST_CONTEXT); - - expect(result).toEqual(expectedVariant); - }); - - it('should select fallback variant when no flags are served', async () => { - nock('https://localhost') - .get('/flags') - .query(true) - .reply(200, { code: 200, flags: {} }); - - const fallbackVariant = { - variant_key: 'control', - variant_value: false, - }; - - const result = await provider.getVariant('any-flag', fallbackVariant, TEST_CONTEXT); - - expect(result).toEqual(fallbackVariant); - expect(mockTracker).not.toHaveBeenCalled(); - }); - - it('should select fallback variant if flag does not exist in served flags', async () => { - mockSuccessResponse({ - 'different-flag': { - variant_key: 'on', - variant_value: true, - } - }); - - const fallbackVariant = { - variant_key: 'control', - variant_value: false, - }; - - const result = await provider.getVariant('missing-flag', fallbackVariant, TEST_CONTEXT); - - expect(result).toEqual(fallbackVariant); - expect(mockTracker).not.toHaveBeenCalled(); - }); - - it('No exposure events are tracked when fallback variant is selected', async () => { - nock('https://localhost') - .get('/flags') - .query(true) - .reply(200, { code: 200, flags: {} }); - - const fallbackVariant = { - variant_key: 'control', - variant_value: false, - }; - - await provider.getVariant('any-flag', fallbackVariant, TEST_CONTEXT); - - expect(mockTracker).not.toHaveBeenCalled(); - }); - - it('Exposure event is tracked when a variant is selected', async () => { - mockSuccessResponse({ - 'test-flag': { - variant_key: 'treatment', - variant_value: true, - } - }); - - const fallbackVariant = { - variant_key: 'control', - variant_value: false, - }; - - const result = await provider.getVariant('test-flag', fallbackVariant, TEST_CONTEXT); - - expect(result).toEqual({ - variant_key: 'treatment', - variant_value: true, - }); - - expect(mockTracker).toHaveBeenCalledTimes(1); - - expect(mockTracker).toHaveBeenCalledWith( - '$experiment_started', - expect.objectContaining({ - 'distinct_id': 'test-user', - 'Experiment name': 'test-flag', - 'Variant name': 'treatment', - '$experiment_type': 'feature_flag', - 'Flag evaluation mode': 'remote' - }), - expect.any(Function) - ); - }); + it("should NOT track exposure event when reportExposure is false", async () => { + mockSuccessResponse({ + "test-flag": { + variant_key: "treatment", + variant_value: "value", + }, + }); + + await provider.getVariantValue( + "test-flag", + "default", + TEST_CONTEXT, + false, + ); + + expect(mockTracker).not.toHaveBeenCalled(); }); - describe('getVariantValue', () => { - it('should return variant value when flag exists', async () => { - mockSuccessResponse({ - 'test-flag': { - variant_key: 'treatment', - variant_value: 'blue', - } - }); - - const result = await provider.getVariantValue('test-flag', 'default', TEST_CONTEXT); - - expect(result).toEqual('blue'); - }); - - it('should return fallback value when flag doesn\'t exist', async () => { - mockSuccessResponse({ - 'different-flag': { - variant_key: 'on', - variant_value: true, - } - }); - - const result = await provider.getVariantValue('missing-flag', 'default-value', TEST_CONTEXT); - - expect(result).toEqual('default-value'); - }); - - it('should track exposure event by default', async () => { - mockSuccessResponse({ - 'test-flag': { - variant_key: 'treatment', - variant_value: 'value', - } - }); - - await provider.getVariantValue('test-flag', 'default', TEST_CONTEXT); - - expect(mockTracker).toHaveBeenCalledTimes(1); - expect(mockTracker).toHaveBeenCalledWith( - '$experiment_started', - expect.objectContaining({ - 'Experiment name': 'test-flag', - 'Variant name': 'treatment', - }), - expect.any(Function) - ); - }); - - it('should NOT track exposure event when reportExposure is false', async () => { - mockSuccessResponse({ - 'test-flag': { - variant_key: 'treatment', - variant_value: 'value', - } - }); - - await provider.getVariantValue('test-flag', 'default', TEST_CONTEXT, false); - - expect(mockTracker).not.toHaveBeenCalled(); - }); - - it('should handle different variant value types', async () => { - // Test string - mockSuccessResponse({ - 'string-flag': { - variant_key: 'treatment', - variant_value: 'text-value', - } - }); - let result = await provider.getVariantValue('string-flag', 'default', TEST_CONTEXT); - expect(result).toEqual('text-value'); - - // Test number - nock.cleanAll(); - mockSuccessResponse({ - 'number-flag': { - variant_key: 'treatment', - variant_value: 42, - } - }); - result = await provider.getVariantValue('number-flag', 0, TEST_CONTEXT); - expect(result).toEqual(42); - - // Test object - nock.cleanAll(); - mockSuccessResponse({ - 'object-flag': { - variant_key: 'treatment', - variant_value: { key: 'value' }, - } - }); - result = await provider.getVariantValue('object-flag', {}, TEST_CONTEXT); - expect(result).toEqual({ key: 'value' }); - }); - - it('should return fallback on network error', async () => { - nock('https://localhost') - .get('/flags') - .query(true) - .replyWithError('Network error'); - - const result = await provider.getVariantValue('test-flag', 'fallback', TEST_CONTEXT); - - expect(result).toEqual('fallback'); - }); - - it('should return fallback when no flags are served', async () => { - nock('https://localhost') - .get('/flags') - .query(true) - .reply(200, { code: 200, flags: {} }); - - const result = await provider.getVariantValue('test-flag', 'fallback', TEST_CONTEXT); - - expect(result).toEqual('fallback'); - }); - - it('should NOT track exposure when fallback is returned', async () => { - nock('https://localhost') - .get('/flags') - .query(true) - .reply(200, { code: 200, flags: {} }); - - await provider.getVariantValue('test-flag', 'fallback', TEST_CONTEXT); - - expect(mockTracker).not.toHaveBeenCalled(); - }); + it("should handle different variant value types", async () => { + // Test string + mockSuccessResponse({ + "string-flag": { + variant_key: "treatment", + variant_value: "text-value", + }, + }); + let result = await provider.getVariantValue( + "string-flag", + "default", + TEST_CONTEXT, + ); + expect(result).toEqual("text-value"); + + // Test number + nock.cleanAll(); + mockSuccessResponse({ + "number-flag": { + variant_key: "treatment", + variant_value: 42, + }, + }); + result = await provider.getVariantValue("number-flag", 0, TEST_CONTEXT); + expect(result).toEqual(42); + + // Test object + nock.cleanAll(); + mockSuccessResponse({ + "object-flag": { + variant_key: "treatment", + variant_value: { key: "value" }, + }, + }); + result = await provider.getVariantValue("object-flag", {}, TEST_CONTEXT); + expect(result).toEqual({ key: "value" }); }); - describe('getAllVariants', () => { - it('should return all variants from API', async () => { - mockSuccessResponse({ - 'flag-1': { - variant_key: 'treatment', - variant_value: true, - }, - 'flag-2': { - variant_key: 'control', - variant_value: false, - }, - 'flag-3': { - variant_key: 'blue', - variant_value: 'blue-theme', - } - }); - - const result = await provider.getAllVariants(TEST_CONTEXT); - - expect(result).toEqual({ - 'flag-1': { - variant_key: 'treatment', - variant_value: true, - }, - 'flag-2': { - variant_key: 'control', - variant_value: false, - }, - 'flag-3': { - variant_key: 'blue', - variant_value: 'blue-theme', - } - }); - }); - - it('should return empty object when no flags served', async () => { - nock('https://localhost') - .get('/flags') - .query(true) - .reply(200, { code: 200, flags: {} }); - - const result = await provider.getAllVariants(TEST_CONTEXT); - - expect(result).toEqual({}); - }); - - it('should NOT track any exposure events', async () => { - mockSuccessResponse({ - 'flag-1': { - variant_key: 'treatment', - variant_value: true, - }, - 'flag-2': { - variant_key: 'control', - variant_value: false, - } - }); - - await provider.getAllVariants(TEST_CONTEXT); - - expect(mockTracker).not.toHaveBeenCalled(); - }); + it("should return fallback on network error", async () => { + nock("https://localhost") + .get("/flags") + .query(true) + .replyWithError("Network error"); + + const result = await provider.getVariantValue( + "test-flag", + "fallback", + TEST_CONTEXT, + ); + + expect(result).toEqual("fallback"); }); - describe('isEnabled', () => { - it('should return true when variant value is boolean true', async () => { - mockSuccessResponse({ - 'test-flag': { - variant_key: 'on', - variant_value: true, - } - }); - - const result = await provider.isEnabled('test-flag', TEST_CONTEXT); - - expect(result).toBe(true); - }); - - it('should return false when variant value is boolean false', async () => { - mockSuccessResponse({ - 'test-flag': { - variant_key: 'off', - variant_value: false, - } - }); - - const result = await provider.isEnabled('test-flag', TEST_CONTEXT); - - expect(result).toBe(false); - }); - - it('should return false for truthy non-boolean values', async () => { - // Test string "true" - mockSuccessResponse({ - 'string-flag': { - variant_key: 'treatment', - variant_value: 'true', - } - }); - let result = await provider.isEnabled('string-flag', TEST_CONTEXT); - expect(result).toBe(false); - - // Test number 1 - nock.cleanAll(); - mockSuccessResponse({ - 'number-flag': { - variant_key: 'treatment', - variant_value: 1, - } - }); - result = await provider.isEnabled('number-flag', TEST_CONTEXT); - expect(result).toBe(false); - }); - - it('should return false when flag doesn\'t exist', async () => { - mockSuccessResponse({ - 'different-flag': { - variant_key: 'on', - variant_value: true, - } - }); - - const result = await provider.isEnabled('missing-flag', TEST_CONTEXT); - - expect(result).toBe(false); - }); - - it('should track exposure event', async () => { - mockSuccessResponse({ - 'test-flag': { - variant_key: 'on', - variant_value: true, - } - }); - - await provider.isEnabled('test-flag', TEST_CONTEXT); - - expect(mockTracker).toHaveBeenCalledTimes(1); - expect(mockTracker).toHaveBeenCalledWith( - '$experiment_started', - expect.objectContaining({ - 'Experiment name': 'test-flag', - 'Variant name': 'on', - }), - expect.any(Function) - ); - }); - - it('should return false on network error', async () => { - nock('https://localhost') - .get('/flags') - .query(true) - .replyWithError('Network error'); - - const result = await provider.isEnabled('test-flag', TEST_CONTEXT); - - expect(result).toBe(false); - }); + it("should return fallback when no flags are served", async () => { + nock("https://localhost") + .get("/flags") + .query(true) + .reply(200, { code: 200, flags: {} }); + + const result = await provider.getVariantValue( + "test-flag", + "fallback", + TEST_CONTEXT, + ); + + expect(result).toEqual("fallback"); + }); + + it("should NOT track exposure when fallback is returned", async () => { + nock("https://localhost") + .get("/flags") + .query(true) + .reply(200, { code: 200, flags: {} }); + + await provider.getVariantValue("test-flag", "fallback", TEST_CONTEXT); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + }); + + describe("getAllVariants", () => { + it("should return all variants from API", async () => { + mockSuccessResponse({ + "flag-1": { + variant_key: "treatment", + variant_value: true, + }, + "flag-2": { + variant_key: "control", + variant_value: false, + }, + "flag-3": { + variant_key: "blue", + variant_value: "blue-theme", + }, + }); + + const result = await provider.getAllVariants(TEST_CONTEXT); + + expect(result).toEqual({ + "flag-1": { + variant_key: "treatment", + variant_value: true, + }, + "flag-2": { + variant_key: "control", + variant_value: false, + }, + "flag-3": { + variant_key: "blue", + variant_value: "blue-theme", + }, + }); + }); + + it("should return empty object when no flags served", async () => { + nock("https://localhost") + .get("/flags") + .query(true) + .reply(200, { code: 200, flags: {} }); + + const result = await provider.getAllVariants(TEST_CONTEXT); + + expect(result).toEqual({}); + }); + + it("should NOT track any exposure events", async () => { + mockSuccessResponse({ + "flag-1": { + variant_key: "treatment", + variant_value: true, + }, + "flag-2": { + variant_key: "control", + variant_value: false, + }, + }); + + await provider.getAllVariants(TEST_CONTEXT); + + expect(mockTracker).not.toHaveBeenCalled(); + }); + }); + + describe("isEnabled", () => { + it("should return true when variant value is boolean true", async () => { + mockSuccessResponse({ + "test-flag": { + variant_key: "on", + variant_value: true, + }, + }); + + const result = await provider.isEnabled("test-flag", TEST_CONTEXT); + + expect(result).toBe(true); + }); + + it("should return false when variant value is boolean false", async () => { + mockSuccessResponse({ + "test-flag": { + variant_key: "off", + variant_value: false, + }, + }); + + const result = await provider.isEnabled("test-flag", TEST_CONTEXT); + + expect(result).toBe(false); + }); + + it("should return false for truthy non-boolean values", async () => { + // Test string "true" + mockSuccessResponse({ + "string-flag": { + variant_key: "treatment", + variant_value: "true", + }, + }); + let result = await provider.isEnabled("string-flag", TEST_CONTEXT); + expect(result).toBe(false); + + // Test number 1 + nock.cleanAll(); + mockSuccessResponse({ + "number-flag": { + variant_key: "treatment", + variant_value: 1, + }, + }); + result = await provider.isEnabled("number-flag", TEST_CONTEXT); + expect(result).toBe(false); + }); + + it("should return false when flag doesn't exist", async () => { + mockSuccessResponse({ + "different-flag": { + variant_key: "on", + variant_value: true, + }, + }); + + const result = await provider.isEnabled("missing-flag", TEST_CONTEXT); + + expect(result).toBe(false); + }); + + it("should track exposure event", async () => { + mockSuccessResponse({ + "test-flag": { + variant_key: "on", + variant_value: true, + }, + }); + + await provider.isEnabled("test-flag", TEST_CONTEXT); + + expect(mockTracker).toHaveBeenCalledTimes(1); + expect(mockTracker).toHaveBeenCalledWith( + "$experiment_started", + expect.objectContaining({ + "Experiment name": "test-flag", + "Variant name": "on", + }), + expect.any(Function), + ); + }); + + it("should return false on network error", async () => { + nock("https://localhost") + .get("/flags") + .query(true) + .replyWithError("Network error"); + + const result = await provider.isEnabled("test-flag", TEST_CONTEXT); + + expect(result).toBe(false); }); + }); }); diff --git a/test/flags/utils.js b/test/flags/utils.js index 222c0bc..0241da2 100644 --- a/test/flags/utils.js +++ b/test/flags/utils.js @@ -1,107 +1,107 @@ const { - generateTraceparent, - normalizedHash -} = require('../../lib/flags/utils'); - -describe('Utils', function() { - describe('generateTraceparent', function() { - it('should generate traceparent in W3C format', function() { - const traceparent = generateTraceparent(); - // W3C format: 00-{32 hex chars}-{16 hex chars}-01 - const pattern = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/; - expect(traceparent).to.match(pattern); - }); + generateTraceparent, + normalizedHash, +} = require("../../lib/flags/utils"); + +describe("Utils", function () { + describe("generateTraceparent", function () { + it("should generate traceparent in W3C format", function () { + const traceparent = generateTraceparent(); + // W3C format: 00-{32 hex chars}-{16 hex chars}-01 + const pattern = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/; + expect(traceparent).to.match(pattern); }); + }); - describe('normalizedHash', function() { - const expectValidHash = (hash) => { - expect(hash).to.be.a('number'); - expect(hash).to.be.at.least(0); - expect(hash).to.be.at.most(1); - }; + describe("normalizedHash", function () { + const expectValidHash = (hash) => { + expect(hash).to.be.a("number"); + expect(hash).to.be.at.least(0); + expect(hash).to.be.at.most(1); + }; - it('should match known test vectors', function() { - const hash1 = normalizedHash("abc", "variant"); - expect(hash1).equals(0.72) + it("should match known test vectors", function () { + const hash1 = normalizedHash("abc", "variant"); + expect(hash1).equals(0.72); - const hash2 = normalizedHash("def", "variant"); - expect(hash2).equals(0.21, 0.01); - }); + const hash2 = normalizedHash("def", "variant"); + expect(hash2).equals(0.21, 0.01); + }); - it('should produce consistent results', function() { - const hash1 = normalizedHash("test_key", "salt"); - const hash2 = normalizedHash("test_key", "salt"); - const hash3 = normalizedHash("test_key", "salt"); + it("should produce consistent results", function () { + const hash1 = normalizedHash("test_key", "salt"); + const hash2 = normalizedHash("test_key", "salt"); + const hash3 = normalizedHash("test_key", "salt"); - expect(hash1).equals(hash2); - expect(hash2).equals(hash3); - }); + expect(hash1).equals(hash2); + expect(hash2).equals(hash3); + }); - it('should produce different hashes when salt is changed', function() { - const hash1 = normalizedHash("same_key", "salt1"); - const hash2 = normalizedHash("same_key", "salt2"); - const hash3 = normalizedHash("same_key", "different_salt"); + it("should produce different hashes when salt is changed", function () { + const hash1 = normalizedHash("same_key", "salt1"); + const hash2 = normalizedHash("same_key", "salt2"); + const hash3 = normalizedHash("same_key", "different_salt"); - expect(hash1).to.not.equal(hash2); - expect(hash1).to.not.equal(hash3); - expect(hash2).to.not.equal(hash3); - }); + expect(hash1).to.not.equal(hash2); + expect(hash1).to.not.equal(hash3); + expect(hash2).to.not.equal(hash3); + }); - it('should produce different hashes when order is changed', function() { - const hash1 = normalizedHash("abc", "salt"); - const hash2 = normalizedHash("bac", "salt"); - const hash3 = normalizedHash("cba", "salt"); + it("should produce different hashes when order is changed", function () { + const hash1 = normalizedHash("abc", "salt"); + const hash2 = normalizedHash("bac", "salt"); + const hash3 = normalizedHash("cba", "salt"); - expect(hash1).to.not.equal(hash2); - expect(hash1).to.not.equal(hash3); - expect(hash2).to.not.equal(hash3); - }); + expect(hash1).to.not.equal(hash2); + expect(hash1).to.not.equal(hash3); + expect(hash2).to.not.equal(hash3); + }); - describe('should handle edge cases with empty strings', function() { - const testCases = [ - { key: "", salt: "salt", description: "empty key" }, - { key: "key", salt: "", description: "empty salt" }, - { key: "", salt: "", description: "both empty" } - ]; - - testCases.forEach(({ key, salt, description }) => { - it(`should return valid hash for ${description}`, function() { - const hash = normalizedHash(key, salt); - expectValidHash(hash); - }); - }); - - it('empty strings in different positions should produce different results', function() { - const hash1 = normalizedHash("", "salt"); - const hash2 = normalizedHash("key", ""); - expect(hash1).to.not.equal(hash2); - }); + describe("should handle edge cases with empty strings", function () { + const testCases = [ + { key: "", salt: "salt", description: "empty key" }, + { key: "key", salt: "", description: "empty salt" }, + { key: "", salt: "", description: "both empty" }, + ]; + + testCases.forEach(({ key, salt, description }) => { + it(`should return valid hash for ${description}`, function () { + const hash = normalizedHash(key, salt); + expectValidHash(hash); }); + }); + + it("empty strings in different positions should produce different results", function () { + const hash1 = normalizedHash("", "salt"); + const hash2 = normalizedHash("key", ""); + expect(hash1).to.not.equal(hash2); + }); + }); - describe('should handle special characters', function() { - const testCases = [ - { key: "🎉", description: "emoji" }, - { key: "beyoncé", description: "accented characters" }, - { key: "key@#$%^&*()", description: "special symbols" }, - { key: "key with spaces", description: "spaces" } - ]; - - testCases.forEach(({ key, description }) => { - it(`should return valid hash for ${description}`, function() { - const hash = normalizedHash(key, "salt"); - expectValidHash(hash); - }); - }); - - it('produces different results for different special characters', function() { - const hashes = testCases.map(tc => normalizedHash(tc.key, "salt")); - - for (let i = 0; i < hashes.length; i++) { - for (let j = i + 1; j < hashes.length; j++) { - expect(hashes[i]).to.not.equal(hashes[j]); - } - } - }); + describe("should handle special characters", function () { + const testCases = [ + { key: "🎉", description: "emoji" }, + { key: "beyoncé", description: "accented characters" }, + { key: "key@#$%^&*()", description: "special symbols" }, + { key: "key with spaces", description: "spaces" }, + ]; + + testCases.forEach(({ key, description }) => { + it(`should return valid hash for ${description}`, function () { + const hash = normalizedHash(key, "salt"); + expectValidHash(hash); }); + }); + + it("produces different results for different special characters", function () { + const hashes = testCases.map((tc) => normalizedHash(tc.key, "salt")); + + for (let i = 0; i < hashes.length; i++) { + for (let j = i + 1; j < hashes.length; j++) { + expect(hashes[i]).to.not.equal(hashes[j]); + } + } + }); }); + }); }); diff --git a/test/groups.js b/test/groups.js index 3c91303..2d69f07 100644 --- a/test/groups.js +++ b/test/groups.js @@ -1,372 +1,385 @@ -const Mixpanel = require('../lib/mixpanel-node'); +const Mixpanel = require("../lib/mixpanel-node"); describe("groups", () => { - const endpoint = '/groups'; - const group_key = 'company'; - const group_id = 'Acme Inc.'; - const token = 'token'; - let mixpanel; - beforeEach(() => { - mixpanel = Mixpanel.init(token); - vi.spyOn(mixpanel, 'send_request'); - - return () => { - mixpanel.send_request.mockRestore(); - } - }); - - // shared test case - const test_send_request_args = function(func, {args, expected, use_modifiers, use_callback} = {}) { - let expected_data = {$token: token, $group_key: group_key, $group_id: group_id, ...expected}; - let callback; - - args = [group_key, group_id, ...(args ? args : [])]; - - if (use_modifiers) { - let modifiers = { - '$ignore_alias': true, - '$ignore_time': true, - '$ip': '1.2.3.4', - '$time': 1234567890 - }; - Object.assign(expected_data, modifiers); - args.push(modifiers); - } - if (use_callback) { - callback = function() {}; - args.push(callback); - } - - mixpanel.groups[func](...args); - - const expectedSendRequestArgs = [ - { method: 'GET', endpoint, data: expected_data }, - use_callback ? callback : undefined, - ]; - expect(mixpanel.send_request).toHaveBeenCalledWith(...expectedSendRequestArgs) + const endpoint = "/groups"; + const group_key = "company"; + const group_id = "Acme Inc."; + const token = "token"; + let mixpanel; + beforeEach(() => { + mixpanel = Mixpanel.init(token); + vi.spyOn(mixpanel, "send_request"); + + return () => { + mixpanel.send_request.mockRestore(); }; + }); + + // shared test case + const test_send_request_args = function ( + func, + { args, expected, use_modifiers, use_callback } = {}, + ) { + let expected_data = { + $token: token, + $group_key: group_key, + $group_id: group_id, + ...expected, + }; + let callback; + + args = [group_key, group_id, ...(args ? args : [])]; + + if (use_modifiers) { + let modifiers = { + $ignore_alias: true, + $ignore_time: true, + $ip: "1.2.3.4", + $time: 1234567890, + }; + Object.assign(expected_data, modifiers); + args.push(modifiers); + } + if (use_callback) { + callback = function () {}; + args.push(callback); + } + + mixpanel.groups[func](...args); + + const expectedSendRequestArgs = [ + { method: "GET", endpoint, data: expected_data }, + use_callback ? callback : undefined, + ]; + expect(mixpanel.send_request).toHaveBeenCalledWith( + ...expectedSendRequestArgs, + ); + }; + + describe("_set", () => { + it("handles set_once correctly", () => { + test_send_request_args("set_once", { + args: ["key1", "val1"], + expected: { $set_once: { key1: "val1" } }, + }); + }); + + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("set", { + args: ["key1", "val1"], + expected: { $set: { key1: "val1" } }, + }); + }); + + it("supports being called with a property object", () => { + test_send_request_args("set", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set: { key1: "val1", key2: "val2" } }, + }); + }); + + it("supports being called with a property object (set_once)", () => { + test_send_request_args("set_once", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set_once: { key1: "val1", key2: "val2" } }, + }); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("set", { + args: ["key1", "val1"], + expected: { $set: { key1: "val1" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a modifiers argument (set_once)", () => { + test_send_request_args("set_once", { + args: ["key1", "val1"], + expected: { $set_once: { key1: "val1" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a properties object and a modifiers argument", () => { + test_send_request_args("set", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set: { key1: "val1", key2: "val2" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a properties object and a modifiers argument (set_once)", () => { + test_send_request_args("set_once", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set_once: { key1: "val1", key2: "val2" } }, + use_modifiers: true, + }); + }); + + it("handles the ip property in a property object properly", () => { + test_send_request_args("set", { + args: [{ ip: "1.2.3.4", key1: "val1", key2: "val2" }], + expected: { + $ip: "1.2.3.4", + $set: { key1: "val1", key2: "val2" }, + }, + }); + }); + + it("handles the $ignore_time property in a property object properly", () => { + test_send_request_args("set", { + args: [{ $ignore_time: true, key1: "val1", key2: "val2" }], + expected: { + $ignore_time: true, + $set: { key1: "val1", key2: "val2" }, + }, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("set", { + args: ["key1", "val1"], + expected: { $set: { key1: "val1" } }, + use_callback: true, + }); + }); + + it("supports being called with a callback (set_once)", () => { + test_send_request_args("set_once", { + args: ["key1", "val1"], + expected: { $set_once: { key1: "val1" } }, + use_callback: true, + }); + }); + + it("supports being called with a properties object and a callback", () => { + test_send_request_args("set", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set: { key1: "val1", key2: "val2" } }, + use_callback: true, + }); + }); + + it("supports being called with a properties object and a callback (set_once)", () => { + test_send_request_args("set_once", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set_once: { key1: "val1", key2: "val2" } }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("set", { + args: ["key1", "val1"], + expected: { $set: { key1: "val1" } }, + use_callback: true, + use_modifiers: true, + }); + }); + + it("supports being called with a modifiers argument and a callback (set_once)", () => { + test_send_request_args("set_once", { + args: ["key1", "val1"], + expected: { $set_once: { key1: "val1" } }, + use_callback: true, + use_modifiers: true, + }); + }); + + it("supports being called with a properties object, a modifiers argument and a callback", () => { + test_send_request_args("set", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set: { key1: "val1", key2: "val2" } }, + use_callback: true, + use_modifiers: true, + }); + }); + + it("supports being called with a properties object, a modifiers argument and a callback (set_once)", () => { + test_send_request_args("set_once", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set_once: { key1: "val1", key2: "val2" } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("delete_group", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("delete_group", { + expected: { $delete: "" }, + }); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("delete_group", { + expected: { $delete: "" }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("delete_group", { + expected: { $delete: "" }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("delete_group", { + expected: { $delete: "" }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("remove", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("remove", { + args: [{ key1: "value1", key2: "value2" }], + expected: { $remove: { key1: "value1", key2: "value2" } }, + }); + }); + + it("errors on non-scalar argument types", () => { + mixpanel.groups.remove(group_key, group_id, { key1: ["value1"] }); + mixpanel.groups.remove(group_key, group_id, { key1: { key: "val" } }); + mixpanel.groups.remove(group_key, group_id, 1231241.123); + mixpanel.groups.remove(group_key, group_id, [5]); + mixpanel.groups.remove(group_key, group_id, { key1: function () {} }); + mixpanel.groups.remove(group_key, group_id, { key1: [function () {}] }); + + expect(mixpanel.send_request).not.toHaveBeenCalled(); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("remove", { + args: [{ key1: "value1" }], + expected: { $remove: { key1: "value1" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("remove", { + args: [{ key1: "value1" }], + expected: { $remove: { key1: "value1" } }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("remove", { + args: [{ key1: "value1" }], + expected: { $remove: { key1: "value1" } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("union", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("union", { + args: [{ key1: ["value1", "value2"] }], + expected: { $union: { key1: ["value1", "value2"] } }, + }); + }); + + it("supports being called with a scalar value", () => { + test_send_request_args("union", { + args: [{ key1: "value1" }], + expected: { $union: { key1: ["value1"] } }, + }); + }); + + it("errors on other argument types", () => { + mixpanel.groups.union(group_key, group_id, { key1: { key: "val" } }); + mixpanel.groups.union(group_key, group_id, 1231241.123); + mixpanel.groups.union(group_key, group_id, [5]); + mixpanel.groups.union(group_key, group_id, { key1: function () {} }); + mixpanel.groups.union(group_key, group_id, { key1: [function () {}] }); + + expect(mixpanel.send_request).not.toHaveBeenCalled(); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("union", { + args: [{ key1: ["value1", "value2"] }], + expected: { $union: { key1: ["value1", "value2"] } }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("union", { + args: [{ key1: ["value1", "value2"] }], + expected: { $union: { key1: ["value1", "value2"] } }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("union", { + args: [{ key1: ["value1", "value2"] }], + expected: { $union: { key1: ["value1", "value2"] } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("unset", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("unset", { + args: ["key1"], + expected: { $unset: ["key1"] }, + }); + }); + + it("supports being called with a property array", () => { + test_send_request_args("unset", { + args: [["key1", "key2"]], + expected: { $unset: ["key1", "key2"] }, + }); + }); + + it("errors on other argument types", () => { + mixpanel.groups.unset(group_key, group_id, { + key1: "val1", + key2: "val2", + }); + mixpanel.groups.unset(group_key, group_id, 1231241.123); + + expect(mixpanel.send_request).not.toHaveBeenCalled(); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("unset", { + args: ["key1"], + expected: { $unset: ["key1"] }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("unset", { + args: ["key1"], + expected: { $unset: ["key1"] }, + use_callback: true, + }); + }); - describe("_set", () => { - it("handles set_once correctly", () => { - test_send_request_args('set_once', { - args: ['key1', 'val1'], - expected: {$set_once: {'key1': 'val1'}}, - }); - }); - - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('set', { - args: ['key1', 'val1'], - expected: {$set: {'key1': 'val1'}}, - }); - }); - - it("supports being called with a property object", () => { - test_send_request_args('set', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, - }); - }); - - it("supports being called with a property object (set_once)", () => { - test_send_request_args('set_once', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, - }); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('set', { - args: ['key1', 'val1'], - expected: {$set: {'key1': 'val1'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a modifiers argument (set_once)", () => { - test_send_request_args('set_once', { - args: ['key1', 'val1'], - expected: {$set_once: {'key1': 'val1'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a properties object and a modifiers argument", () => { - test_send_request_args('set', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a properties object and a modifiers argument (set_once)", () => { - test_send_request_args('set_once', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, - use_modifiers: true, - }); - }); - - it("handles the ip property in a property object properly", () => { - test_send_request_args('set', { - args: [{'ip': '1.2.3.4', 'key1': 'val1', 'key2': 'val2'}], - expected: { - $ip: '1.2.3.4', - $set: {'key1': 'val1', 'key2': 'val2'}, - }, - }); - }); - - it("handles the $ignore_time property in a property object properly", () => { - test_send_request_args('set', { - args: [{'$ignore_time': true, 'key1': 'val1', 'key2': 'val2'}], - expected: { - $ignore_time: true, - $set: {'key1': 'val1', 'key2': 'val2'}, - }, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('set', { - args: ['key1', 'val1'], - expected: {$set: {'key1': 'val1'}}, - use_callback: true, - }); - }); - - it("supports being called with a callback (set_once)", () => { - test_send_request_args('set_once', { - args: ['key1', 'val1'], - expected: {$set_once: {'key1': 'val1'}}, - use_callback: true, - }); - }); - - it("supports being called with a properties object and a callback", () => { - test_send_request_args('set', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, - use_callback: true, - }); - }); - - it("supports being called with a properties object and a callback (set_once)", () => { - test_send_request_args('set_once', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('set', { - args: ['key1', 'val1'], - expected: {$set: {'key1': 'val1'}}, - use_callback: true, - use_modifiers: true, - }); - }); - - it("supports being called with a modifiers argument and a callback (set_once)", () => { - test_send_request_args('set_once', { - args: ['key1', 'val1'], - expected: {$set_once: {'key1': 'val1'}}, - use_callback: true, - use_modifiers: true, - }); - }); - - it("supports being called with a properties object, a modifiers argument and a callback", () => { - test_send_request_args('set', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, - use_callback: true, - use_modifiers: true, - }); - }); - - it("supports being called with a properties object, a modifiers argument and a callback (set_once)", () => { - test_send_request_args('set_once', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("delete_group", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('delete_group', { - expected: {$delete: ''}, - }); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('delete_group', { - expected: {$delete: ''}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('delete_group', { - expected: {$delete: ''}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('delete_group', { - expected: {$delete: ''}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("remove", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('remove', { - args: [{'key1': 'value1', 'key2': 'value2'}], - expected: {$remove: {'key1': 'value1', 'key2': 'value2'}}, - }); - }); - - it("errors on non-scalar argument types", () => { - mixpanel.groups.remove(group_key, group_id, {'key1': ['value1']}); - mixpanel.groups.remove(group_key, group_id, {key1: {key: 'val'}}); - mixpanel.groups.remove(group_key, group_id, 1231241.123); - mixpanel.groups.remove(group_key, group_id, [5]); - mixpanel.groups.remove(group_key, group_id, {key1: function() {}}); - mixpanel.groups.remove(group_key, group_id, {key1: [function() {}]}); - - expect(mixpanel.send_request).not.toHaveBeenCalled(); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('remove', { - args: [{'key1': 'value1'}], - expected: {$remove: {'key1': 'value1'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('remove', { - args: [{'key1': 'value1'}], - expected: {$remove: {'key1': 'value1'}}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('remove', { - args: [{'key1': 'value1'}], - expected: {$remove: {'key1': 'value1'}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("union", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('union', { - args: [{'key1': ['value1', 'value2']}], - expected: {$union: {'key1': ['value1', 'value2']}}, - }); - }); - - it("supports being called with a scalar value", () => { - test_send_request_args('union', { - args: [{'key1': 'value1'}], - expected: {$union: {'key1': ['value1']}}, - }); - }); - - it("errors on other argument types", () => { - mixpanel.groups.union(group_key, group_id, {key1: {key: 'val'}}); - mixpanel.groups.union(group_key, group_id, 1231241.123); - mixpanel.groups.union(group_key, group_id, [5]); - mixpanel.groups.union(group_key, group_id, {key1: function() {}}); - mixpanel.groups.union(group_key, group_id, {key1: [function() {}]}); - - expect(mixpanel.send_request).not.toHaveBeenCalled(); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('union', { - args: [{'key1': ['value1', 'value2']}], - expected: {$union: {'key1': ['value1', 'value2']}}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('union', { - args: [{'key1': ['value1', 'value2']}], - expected: {$union: {'key1': ['value1', 'value2']}}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('union', { - args: [{'key1': ['value1', 'value2']}], - expected: {$union: {'key1': ['value1', 'value2']}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("unset", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('unset', { - args: ['key1'], - expected: {$unset: ['key1']}, - }); - }); - - it("supports being called with a property array", () => { - test_send_request_args('unset', { - args: [['key1', 'key2']], - expected: {$unset: ['key1', 'key2']}, - }); - }); - - it("errors on other argument types", () => { - mixpanel.groups.unset(group_key, group_id, { key1:'val1', key2:'val2' }); - mixpanel.groups.unset(group_key, group_id, 1231241.123); - - expect(mixpanel.send_request).not.toHaveBeenCalled(); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('unset', { - args: ['key1'], - expected: {$unset: ['key1']}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('unset', { - args: ['key1'], - expected: {$unset: ['key1']}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('unset', { - args: ['key1'], - expected: {$unset: ['key1']}, - use_callback: true, - use_modifiers: true, - }); - }); + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("unset", { + args: ["key1"], + expected: { $unset: ["key1"] }, + use_callback: true, + use_modifiers: true, + }); }); + }); }); diff --git a/test/import.js b/test/import.js index 51f5f10..d913144 100644 --- a/test/import.js +++ b/test/import.js @@ -1,308 +1,327 @@ -var proxyquire = require('proxyquire'), - https = require('https'), - events = require('events'), - Mixpanel = require('../lib/mixpanel-node'); +var proxyquire = require("proxyquire"), + https = require("https"), + events = require("events"), + Mixpanel = require("../lib/mixpanel-node"); var mock_now_time = new Date(2016, 1, 1).getTime(), - six_days_ago_timestamp = mock_now_time - 1000 * 60 * 60 * 24 * 6; - -describe('import', () => { - let mixpanel; - beforeEach(() => { - mixpanel = Mixpanel.init('token', { secret: 'my api secret' }); - - vi.spyOn(mixpanel, 'send_request'); - - return () => { - mixpanel.send_request.mockRestore(); - } - }); - - it('calls send_request with correct endpoint and data', () => { - var event = 'test', - time = six_days_ago_timestamp, - props = { key1: 'val1' }, - expected_endpoint = '/import', - expected_data = { - event: 'test', - properties: expect.objectContaining({ - key1: 'val1', - token: 'token', - time: time, - }), - }; - - mixpanel.import(event, time, props); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data, - }), - undefined, - ); - }); - - it('supports a Date instance greater than 5 days old', () => { - var event = 'test', - time = new Date(six_days_ago_timestamp), - props = { key1: 'val1' }, - expected_endpoint = '/import', - expected_data = { - event: 'test', - properties: expect.objectContaining({ - key1: 'val1', - token: 'token', - time: six_days_ago_timestamp, - }), - }; - - mixpanel.import(event, time, props); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data, - }), - undefined, - ); - }); - - it('supports a Date instance less than 5 days old', () => { - var event = 'test', - time = new Date(mock_now_time), - props = { key1: 'val1' }, - expected_endpoint = '/import', - expected_data = { - event: 'test', - properties: expect.objectContaining({ - key1: 'val1', - token: 'token', - time: mock_now_time, - }), - }; - - mixpanel.import(event, time, props); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data, - }), - undefined, - ); - }); - - it('supports a unix timestamp', () => { - var event = 'test', - time = mock_now_time, - props = { key1: 'val1' }, - expected_endpoint = '/import', - expected_data = { - event: 'test', - properties: expect.objectContaining({ - key1: 'val1', - token: 'token', - time: time, - }), - }; - - mixpanel.import(event, time, props); - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data, - }), - undefined, - ); - }); - - it('requires the time argument to be a number or Date', () => { - expect(() => mixpanel.import('test', new Date())).not.toThrowError(); - expect(() => mixpanel.import('test', Date.now())).not.toThrowError(); - expect(() => mixpanel.import('test', 'not a number or Date')).toThrowError( - /`time` property must be a Date or Unix timestamp/, - ); - expect(() => mixpanel.import('test')).toThrowError( - /`time` property must be a Date or Unix timestamp/, - ); - }); + six_days_ago_timestamp = mock_now_time - 1000 * 60 * 60 * 24 * 6; + +describe("import", () => { + let mixpanel; + beforeEach(() => { + mixpanel = Mixpanel.init("token", { secret: "my api secret" }); + + vi.spyOn(mixpanel, "send_request"); + + return () => { + mixpanel.send_request.mockRestore(); + }; + }); + + it("calls send_request with correct endpoint and data", () => { + var event = "test", + time = six_days_ago_timestamp, + props = { key1: "val1" }, + expected_endpoint = "/import", + expected_data = { + event: "test", + properties: expect.objectContaining({ + key1: "val1", + token: "token", + time: time, + }), + }; + + mixpanel.import(event, time, props); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); + + it("supports a Date instance greater than 5 days old", () => { + var event = "test", + time = new Date(six_days_ago_timestamp), + props = { key1: "val1" }, + expected_endpoint = "/import", + expected_data = { + event: "test", + properties: expect.objectContaining({ + key1: "val1", + token: "token", + time: six_days_ago_timestamp, + }), + }; + + mixpanel.import(event, time, props); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); + + it("supports a Date instance less than 5 days old", () => { + var event = "test", + time = new Date(mock_now_time), + props = { key1: "val1" }, + expected_endpoint = "/import", + expected_data = { + event: "test", + properties: expect.objectContaining({ + key1: "val1", + token: "token", + time: mock_now_time, + }), + }; + + mixpanel.import(event, time, props); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); + + it("supports a unix timestamp", () => { + var event = "test", + time = mock_now_time, + props = { key1: "val1" }, + expected_endpoint = "/import", + expected_data = { + event: "test", + properties: expect.objectContaining({ + key1: "val1", + token: "token", + time: time, + }), + }; + + mixpanel.import(event, time, props); + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); + + it("requires the time argument to be a number or Date", () => { + expect(() => mixpanel.import("test", new Date())).not.toThrowError(); + expect(() => mixpanel.import("test", Date.now())).not.toThrowError(); + expect(() => mixpanel.import("test", "not a number or Date")).toThrowError( + /`time` property must be a Date or Unix timestamp/, + ); + expect(() => mixpanel.import("test")).toThrowError( + /`time` property must be a Date or Unix timestamp/, + ); + }); }); -describe('import_batch', () => { - let mixpanel; - beforeEach(() => { - mixpanel = Mixpanel.init('token', { secret: 'my api secret' }); - - vi.spyOn(mixpanel, 'send_request'); - - return () => { - mixpanel.send_request.mockRestore(); - }; - }); - - it('calls send_request with correct endpoint, data, and method', () => { - var expected_endpoint = '/import', - event_list = [ - {event: 'test', properties: {key1: 'val1', time: 500 }}, - {event: 'test', properties: {key2: 'val2', time: 1000}}, - {event: 'test2', properties: {key2: 'val2', time: 1500}}, - ], - expected_data = [ - {event: 'test', properties: {key1: 'val1', time: 500, token: 'token'}}, - {event: 'test', properties: {key2: 'val2', time: 1000, token: 'token'}}, - {event: 'test2', properties: {key2: 'val2', time: 1500, token: 'token'}}, - ]; - - mixpanel.import_batch(event_list); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - { - method: 'POST', - endpoint: expected_endpoint, - data: expected_data, - }, - expect.any(Function) - ); - }); - - it('requires the time argument for every event', () => { - var event_list = [ - { event: 'test', properties: { key1: 'val1', time: 500 } }, - { event: 'test', properties: { key2: 'val2', time: 1000 } }, - { event: 'test2', properties: { key2: 'val2' } }, - ]; - expect(() => mixpanel.import_batch(event_list)).toThrowError( - '`time` property must be a Date or Unix timestamp and is only required for `import` endpoint', - ); - }); - - it('batches 50 events at a time', () => { - var event_list = []; - for (var ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 - event_list.push({ - event: 'test', - properties: { key1: 'val1', time: 500 + ei }, - }); - } - - mixpanel.import_batch(event_list); - expect(mixpanel.send_request).toHaveBeenCalledTimes(3); - }); +describe("import_batch", () => { + let mixpanel; + beforeEach(() => { + mixpanel = Mixpanel.init("token", { secret: "my api secret" }); + + vi.spyOn(mixpanel, "send_request"); + + return () => { + mixpanel.send_request.mockRestore(); + }; + }); + + it("calls send_request with correct endpoint, data, and method", () => { + var expected_endpoint = "/import", + event_list = [ + { event: "test", properties: { key1: "val1", time: 500 } }, + { event: "test", properties: { key2: "val2", time: 1000 } }, + { event: "test2", properties: { key2: "val2", time: 1500 } }, + ], + expected_data = [ + { + event: "test", + properties: { key1: "val1", time: 500, token: "token" }, + }, + { + event: "test", + properties: { key2: "val2", time: 1000, token: "token" }, + }, + { + event: "test2", + properties: { key2: "val2", time: 1500, token: "token" }, + }, + ]; + + mixpanel.import_batch(event_list); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + { + method: "POST", + endpoint: expected_endpoint, + data: expected_data, + }, + expect.any(Function), + ); + }); + + it("requires the time argument for every event", () => { + var event_list = [ + { event: "test", properties: { key1: "val1", time: 500 } }, + { event: "test", properties: { key2: "val2", time: 1000 } }, + { event: "test2", properties: { key2: "val2" } }, + ]; + expect(() => mixpanel.import_batch(event_list)).toThrowError( + "`time` property must be a Date or Unix timestamp and is only required for `import` endpoint", + ); + }); + + it("batches 50 events at a time", () => { + var event_list = []; + for (var ei = 0; ei < 130; ei++) { + // 3 batches: 50 + 50 + 30 + event_list.push({ + event: "test", + properties: { key1: "val1", time: 500 + ei }, + }); + } + + mixpanel.import_batch(event_list); + expect(mixpanel.send_request).toHaveBeenCalledTimes(3); + }); }); -describe('import_batch_integration', () => { - let mixpanel; - let http_emitter; - let event_list; - let res; - beforeEach(() => { - mixpanel = Mixpanel.init('token', { secret: 'my api secret' }); - - vi.spyOn(https, 'request'); - - http_emitter = new events.EventEmitter(); - - // stub sequence of https responses - res = []; - for (let ri = 0; ri < 5; ri++) { - res.push(new events.EventEmitter()); - https.request.mockImplementationOnce((_, cb) => { - cb(res[ri]); - return { - write: () => {}, - end: () => {}, - on: (event) => {}, - }; - }); - } - - event_list = []; - for (var ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 - event_list.push({ - event: 'test', - properties: { key1: 'val1', time: 500 + ei }, - }); - } - - return () => { - https.request.mockRestore(); - } - }); - - it('calls provided callback after all requests finish', () => { - mixpanel.import_batch(event_list, function (error_list) { - expect(https.request).toHaveBeenCalledTimes(3); - expect(error_list).toBe(null); - }); - for (var ri = 0; ri < 3; ri++) { - res[ri].emit('data', '1'); - res[ri].emit('end'); - } - }); - - it('passes error list to callback', () => { - mixpanel.import_batch(event_list, function (error_list) { - expect(error_list.length).toBe(3); - }); - for (var ri = 0; ri < 3; ri++) { - res[ri].emit('data', '0'); - res[ri].emit('end'); - } - }); - - it('calls provided callback when options are passed', () => { - mixpanel.import_batch(event_list, { max_batch_size: 100 }, function (error_list) { - expect(https.request).toHaveBeenCalledTimes(3); - expect(error_list).toBe(null); - }); - for (var ri = 0; ri < 3; ri++) { - res[ri].emit('data', '1'); - res[ri].emit('end'); - } - }); - - it('sends more requests when max_batch_size < 50', () => { - mixpanel.import_batch(event_list, { max_batch_size: 30 }, function (error_list) { - expect(https.request).toHaveBeenCalledTimes(5); // 30 + 30 + 30 + 30 + 10 - expect(error_list).toBe(null); - }); - for (var ri = 0; ri < 5; ri++) { - res[ri].emit('data', '1'); - res[ri].emit('end'); - } +describe("import_batch_integration", () => { + let mixpanel; + let http_emitter; + let event_list; + let res; + beforeEach(() => { + mixpanel = Mixpanel.init("token", { secret: "my api secret" }); + + vi.spyOn(https, "request"); + + http_emitter = new events.EventEmitter(); + + // stub sequence of https responses + res = []; + for (let ri = 0; ri < 5; ri++) { + res.push(new events.EventEmitter()); + https.request.mockImplementationOnce((_, cb) => { + cb(res[ri]); + return { + write: () => {}, + end: () => {}, + on: (event) => {}, + }; + }); + } + + event_list = []; + for (var ei = 0; ei < 130; ei++) { + // 3 batches: 50 + 50 + 30 + event_list.push({ + event: "test", + properties: { key1: "val1", time: 500 + ei }, + }); + } + + return () => { + https.request.mockRestore(); + }; + }); + + it("calls provided callback after all requests finish", () => { + mixpanel.import_batch(event_list, function (error_list) { + expect(https.request).toHaveBeenCalledTimes(3); + expect(error_list).toBe(null); }); - - it('can set max concurrent requests', () => { - var async_all_stub = vi.fn(); - var PatchedMixpanel = proxyquire('../lib/mixpanel-node', { - './utils': { async_all: async_all_stub }, - }); - async_all_stub.mockImplementationOnce((_, __, cb) => cb(null)); - mixpanel = PatchedMixpanel.init('token', { secret: 'my api secret' }); - - mixpanel.import_batch( - event_list, - { max_batch_size: 30, max_concurrent_requests: 2 }, - function (error_list) { - // should send 5 event batches over 3 request batches: - // request batch 1: 30 events, 30 events - // request batch 2: 30 events, 30 events - // request batch 3: 10 events - expect(async_all_stub).toHaveBeenCalledTimes(3); - expect(error_list).toBe(null); - }, - ); - for (var ri = 0; ri < 5; ri++) { - res[ri].emit('data', '1'); - res[ri].emit('end'); - } + for (var ri = 0; ri < 3; ri++) { + res[ri].emit("data", "1"); + res[ri].emit("end"); + } + }); + + it("passes error list to callback", () => { + mixpanel.import_batch(event_list, function (error_list) { + expect(error_list.length).toBe(3); }); - - it('behaves well without a callback', () => { - mixpanel.import_batch(event_list); + for (var ri = 0; ri < 3; ri++) { + res[ri].emit("data", "0"); + res[ri].emit("end"); + } + }); + + it("calls provided callback when options are passed", () => { + mixpanel.import_batch( + event_list, + { max_batch_size: 100 }, + function (error_list) { expect(https.request).toHaveBeenCalledTimes(3); - mixpanel.import_batch(event_list, { max_batch_size: 100 }); - expect(https.request).toHaveBeenCalledTimes(5); + expect(error_list).toBe(null); + }, + ); + for (var ri = 0; ri < 3; ri++) { + res[ri].emit("data", "1"); + res[ri].emit("end"); + } + }); + + it("sends more requests when max_batch_size < 50", () => { + mixpanel.import_batch( + event_list, + { max_batch_size: 30 }, + function (error_list) { + expect(https.request).toHaveBeenCalledTimes(5); // 30 + 30 + 30 + 30 + 10 + expect(error_list).toBe(null); + }, + ); + for (var ri = 0; ri < 5; ri++) { + res[ri].emit("data", "1"); + res[ri].emit("end"); + } + }); + + it("can set max concurrent requests", () => { + var async_all_stub = vi.fn(); + var PatchedMixpanel = proxyquire("../lib/mixpanel-node", { + "./utils": { async_all: async_all_stub }, }); + async_all_stub.mockImplementationOnce((_, __, cb) => cb(null)); + mixpanel = PatchedMixpanel.init("token", { secret: "my api secret" }); + + mixpanel.import_batch( + event_list, + { max_batch_size: 30, max_concurrent_requests: 2 }, + function (error_list) { + // should send 5 event batches over 3 request batches: + // request batch 1: 30 events, 30 events + // request batch 2: 30 events, 30 events + // request batch 3: 10 events + expect(async_all_stub).toHaveBeenCalledTimes(3); + expect(error_list).toBe(null); + }, + ); + for (var ri = 0; ri < 5; ri++) { + res[ri].emit("data", "1"); + res[ri].emit("end"); + } + }); + + it("behaves well without a callback", () => { + mixpanel.import_batch(event_list); + expect(https.request).toHaveBeenCalledTimes(3); + mixpanel.import_batch(event_list, { max_batch_size: 100 }); + expect(https.request).toHaveBeenCalledTimes(5); + }); }); diff --git a/test/logger.js b/test/logger.js index 7b17da9..83d2141 100644 --- a/test/logger.js +++ b/test/logger.js @@ -1,138 +1,140 @@ -const Mixpanel = require('../lib/mixpanel-node'); +const Mixpanel = require("../lib/mixpanel-node"); describe("logger", () => { - describe("console logger", () => { - let mixpanel; - let consoleDebugFn; - beforeAll(() => { - consoleDebugFn = vi.spyOn(console, 'debug').mockImplementation(() => {}); - - mixpanel = Mixpanel.init('test token'); - mixpanel.send_request = () => {}; - return () => { - consoleDebugFn.mockRestore(); - }; - }); + describe("console logger", () => { + let mixpanel; + let consoleDebugFn; + beforeAll(() => { + consoleDebugFn = vi.spyOn(console, "debug").mockImplementation(() => {}); + + mixpanel = Mixpanel.init("test token"); + mixpanel.send_request = () => {}; + return () => { + consoleDebugFn.mockRestore(); + }; + }); - it("defaults to console logger", () => { - const loggerName = Object.prototype.toString.call(mixpanel.config.logger); - expect(loggerName).toBe('[object console]'); - }); + it("defaults to console logger", () => { + const loggerName = Object.prototype.toString.call(mixpanel.config.logger); + expect(loggerName).toBe("[object console]"); + }); - it("throws an error on incorrect logger object", () => { - expect(() => mixpanel.set_config({ logger: false })) - .toThrow(new TypeError('"logger" must be a valid Logger object')); - expect(() => mixpanel.set_config({logger: {log: () => {}}})) - .toThrow(new TypeError('Logger object missing "trace" method')); - }); + it("throws an error on incorrect logger object", () => { + expect(() => mixpanel.set_config({ logger: false })).toThrow( + new TypeError('"logger" must be a valid Logger object'), + ); + expect(() => mixpanel.set_config({ logger: { log: () => {} } })).toThrow( + new TypeError('Logger object missing "trace" method'), + ); + }); - it("writes log for track() method", () => { - mixpanel.set_config({debug: true}); + it("writes log for track() method", () => { + mixpanel.set_config({ debug: true }); - mixpanel.track('test', {foo: 'bar'}); + mixpanel.track("test", { foo: "bar" }); - expect(consoleDebugFn).toHaveBeenCalledTimes(1); + expect(consoleDebugFn).toHaveBeenCalledTimes(1); - const [message] = consoleDebugFn.mock.calls[0]; + const [message] = consoleDebugFn.mock.calls[0]; - expect(message).toMatch(/Sending the following event/); - }); + expect(message).toMatch(/Sending the following event/); + }); - it("writes log for increment() method", () => { - mixpanel.set_config({debug: true}); + it("writes log for increment() method", () => { + mixpanel.set_config({ debug: true }); - mixpanel.people.increment('bob', 'page_views', 1); + mixpanel.people.increment("bob", "page_views", 1); - expect(consoleDebugFn).toHaveBeenCalledTimes(2); + expect(consoleDebugFn).toHaveBeenCalledTimes(2); - const [message] = consoleDebugFn.mock.calls[1]; + const [message] = consoleDebugFn.mock.calls[1]; - expect(message).toMatch(/Sending the following data/); - }); + expect(message).toMatch(/Sending the following data/); + }); - it("writes log for remove() method", () => { - mixpanel.set_config({debug: true}); + it("writes log for remove() method", () => { + mixpanel.set_config({ debug: true }); - mixpanel.people.remove('bob', {'browsers': 'firefox'}); + mixpanel.people.remove("bob", { browsers: "firefox" }); - expect(consoleDebugFn).toHaveBeenCalledTimes(3); + expect(consoleDebugFn).toHaveBeenCalledTimes(3); - const [message] = consoleDebugFn.mock.calls[2]; + const [message] = consoleDebugFn.mock.calls[2]; - expect(message).toMatch(/Sending the following data/); - }); + expect(message).toMatch(/Sending the following data/); + }); + }); + + describe("custom logger", () => { + let mixpanel; + let customLogger; + let consoleDebugFn; + beforeAll((cb) => { + /** + * Custom logger must be an object with the following methods: + * + * interface CustomLogger { + * trace(message?: any, ...optionalParams: any[]): void; + * debug(message?: any, ...optionalParams: any[]): void; + * info(message?: any, ...optionalParams: any[]): void; + * warn(message?: any, ...optionalParams: any[]): void; + * error(message?: any, ...optionalParams: any[]): void; + * } + */ + customLogger = { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + consoleDebugFn = vi.spyOn(console, "debug"); + + mixpanel = Mixpanel.init("test token", { logger: customLogger }); + + mixpanel.send_request = () => {}; + + return () => { + consoleDebugFn.mockRestore(); + }; }); - describe('custom logger', () => { - let mixpanel; - let customLogger; - let consoleDebugFn; - beforeAll((cb) => { - /** - * Custom logger must be an object with the following methods: - * - * interface CustomLogger { - * trace(message?: any, ...optionalParams: any[]): void; - * debug(message?: any, ...optionalParams: any[]): void; - * info(message?: any, ...optionalParams: any[]): void; - * warn(message?: any, ...optionalParams: any[]): void; - * error(message?: any, ...optionalParams: any[]): void; - * } - */ - customLogger = { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - consoleDebugFn = vi.spyOn(console, 'debug'); - - mixpanel = Mixpanel.init('test token', {logger: customLogger}); - - mixpanel.send_request = () => {}; - - return () => { - consoleDebugFn.mockRestore(); - } - }); - - it("writes log for track() method", () => { - mixpanel.set_config({debug: true}); + it("writes log for track() method", () => { + mixpanel.set_config({ debug: true }); - mixpanel.track('test', {foo: 'bar'}); + mixpanel.track("test", { foo: "bar" }); - expect(customLogger.debug).toHaveBeenCalledTimes(1); - expect(consoleDebugFn).toHaveBeenCalledTimes(0); + expect(customLogger.debug).toHaveBeenCalledTimes(1); + expect(consoleDebugFn).toHaveBeenCalledTimes(0); - const [message] = customLogger.debug.mock.calls[0]; + const [message] = customLogger.debug.mock.calls[0]; - expect(message).toMatch(/Sending the following event/) - }); + expect(message).toMatch(/Sending the following event/); + }); - it("writes log for increment() method", () => { - mixpanel.set_config({debug: true}); + it("writes log for increment() method", () => { + mixpanel.set_config({ debug: true }); - mixpanel.people.increment('bob', 'page_views', 1); + mixpanel.people.increment("bob", "page_views", 1); - expect(customLogger.debug).toHaveBeenCalledTimes(2); - expect(consoleDebugFn).toHaveBeenCalledTimes(0); + expect(customLogger.debug).toHaveBeenCalledTimes(2); + expect(consoleDebugFn).toHaveBeenCalledTimes(0); - const [message] = customLogger.debug.mock.calls[1]; + const [message] = customLogger.debug.mock.calls[1]; - expect(message).toMatch(/Sending the following data/); - }); + expect(message).toMatch(/Sending the following data/); + }); - it("writes log for remove() method", (test) => { - mixpanel.set_config({debug: true}); + it("writes log for remove() method", (test) => { + mixpanel.set_config({ debug: true }); - mixpanel.people.remove('bob', {'browsers': 'firefox'}); - expect(customLogger.debug).toHaveBeenCalledTimes(3); - expect(consoleDebugFn).toHaveBeenCalledTimes(0); + mixpanel.people.remove("bob", { browsers: "firefox" }); + expect(customLogger.debug).toHaveBeenCalledTimes(3); + expect(consoleDebugFn).toHaveBeenCalledTimes(0); - const [message] = customLogger.debug.mock.calls[2]; + const [message] = customLogger.debug.mock.calls[2]; - expect(message).toMatch(/Sending the following data/) - }); + expect(message).toMatch(/Sending the following data/); }); + }); }); diff --git a/test/people.js b/test/people.js index c2c1007..5911e3f 100644 --- a/test/people.js +++ b/test/people.js @@ -1,637 +1,660 @@ -const Mixpanel = require('../lib/mixpanel-node'); -const {create_profile_helpers} = require('../lib/profile_helpers'); - -describe('people', () => { - const endpoint = '/engage'; - const distinct_id = 'user1'; - const token = 'token'; - let mixpanel; - beforeEach(() => { - mixpanel = Mixpanel.init(token); - vi.spyOn(mixpanel, 'send_request') - - return () => { - mixpanel.send_request.mockRestore(); - } - }); - - // shared test case - const test_send_request_args = function(func, {args, expected, use_modifiers, use_callback} = {}) { - let expected_data = {$token: token, $distinct_id: distinct_id, ...expected}; - let callback; - - args = [distinct_id, ...(args ? args : [])]; - - if (use_modifiers) { - var modifiers = { - '$ignore_alias': true, - '$ignore_time': true, - '$ip': '1.2.3.4', - '$time': 1234567890, - '$latitude': 40.7127753, - '$longitude': -74.0059728, - }; - Object.assign(expected_data, modifiers); - args.push(modifiers); - } - if (use_callback) { - callback = function() {}; - args.push(callback); - } - - mixpanel.people[func](...args); - - const expectedSendRequestArgs = [ - { method: 'GET', endpoint, data: expected_data }, - use_callback ? callback : undefined, - ]; - expect(mixpanel.send_request).toHaveBeenCalledWith(...expectedSendRequestArgs) +const Mixpanel = require("../lib/mixpanel-node"); +const { create_profile_helpers } = require("../lib/profile_helpers"); + +describe("people", () => { + const endpoint = "/engage"; + const distinct_id = "user1"; + const token = "token"; + let mixpanel; + beforeEach(() => { + mixpanel = Mixpanel.init(token); + vi.spyOn(mixpanel, "send_request"); + + return () => { + mixpanel.send_request.mockRestore(); }; + }); + + // shared test case + const test_send_request_args = function ( + func, + { args, expected, use_modifiers, use_callback } = {}, + ) { + let expected_data = { + $token: token, + $distinct_id: distinct_id, + ...expected, + }; + let callback; + + args = [distinct_id, ...(args ? args : [])]; + + if (use_modifiers) { + var modifiers = { + $ignore_alias: true, + $ignore_time: true, + $ip: "1.2.3.4", + $time: 1234567890, + $latitude: 40.7127753, + $longitude: -74.0059728, + }; + Object.assign(expected_data, modifiers); + args.push(modifiers); + } + if (use_callback) { + callback = function () {}; + args.push(callback); + } + + mixpanel.people[func](...args); + + const expectedSendRequestArgs = [ + { method: "GET", endpoint, data: expected_data }, + use_callback ? callback : undefined, + ]; + expect(mixpanel.send_request).toHaveBeenCalledWith( + ...expectedSendRequestArgs, + ); + }; + + describe("_set", () => { + it("handles set_once correctly", () => { + test_send_request_args("set_once", { + args: ["key1", "val1"], + expected: { $set_once: { key1: "val1" } }, + }); + }); + + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("set", { + args: ["key1", "val1"], + expected: { $set: { key1: "val1" } }, + }); + }); + + it("supports being called with a property object", () => { + test_send_request_args("set", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set: { key1: "val1", key2: "val2" } }, + }); + }); + + it("supports being called with a property object (set_once)", () => { + test_send_request_args("set_once", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set_once: { key1: "val1", key2: "val2" } }, + }); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("set", { + args: ["key1", "val1"], + expected: { $set: { key1: "val1" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a modifiers argument (set_once)", () => { + test_send_request_args("set_once", { + args: ["key1", "val1"], + expected: { $set_once: { key1: "val1" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a properties object and a modifiers argument", () => { + test_send_request_args("set", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set: { key1: "val1", key2: "val2" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a properties object and a modifiers argument (set_once)", () => { + test_send_request_args("set_once", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set_once: { key1: "val1", key2: "val2" } }, + use_modifiers: true, + }); + }); + + it("handles the ip property in a property object properly", () => { + test_send_request_args("set", { + args: [{ ip: "1.2.3.4", key1: "val1", key2: "val2" }], + expected: { + $ip: "1.2.3.4", + $set: { key1: "val1", key2: "val2" }, + }, + }); + }); + + it("handles the $ignore_time property in a property object properly", () => { + test_send_request_args("set", { + args: [{ $ignore_time: true, key1: "val1", key2: "val2" }], + expected: { + $ignore_time: true, + $set: { key1: "val1", key2: "val2" }, + }, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("set", { + args: ["key1", "val1"], + expected: { $set: { key1: "val1" } }, + use_callback: true, + }); + }); + + it("supports being called with a callback (set_once)", () => { + test_send_request_args("set_once", { + args: ["key1", "val1"], + expected: { $set_once: { key1: "val1" } }, + use_callback: true, + }); + }); + + it("supports being called with a properties object and a callback", () => { + test_send_request_args("set", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set: { key1: "val1", key2: "val2" } }, + use_callback: true, + }); + }); + + it("supports being called with a properties object and a callback (set_once)", () => { + test_send_request_args("set_once", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set_once: { key1: "val1", key2: "val2" } }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("set", { + args: ["key1", "val1"], + expected: { $set: { key1: "val1" } }, + use_callback: true, + use_modifiers: true, + }); + }); + + it("supports being called with a modifiers argument and a callback (set_once)", () => { + test_send_request_args("set_once", { + args: ["key1", "val1"], + expected: { $set_once: { key1: "val1" } }, + use_callback: true, + use_modifiers: true, + }); + }); + + it("supports being called with a properties object, a modifiers argument and a callback", () => { + test_send_request_args("set", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set: { key1: "val1", key2: "val2" } }, + use_callback: true, + use_modifiers: true, + }); + }); + + it("supports being called with a properties object, a modifiers argument and a callback (set_once)", () => { + test_send_request_args("set_once", { + args: [{ key1: "val1", key2: "val2" }], + expected: { $set_once: { key1: "val1", key2: "val2" } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("increment", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("increment", { + args: ["key1"], + expected: { $add: { key1: 1 } }, + }); + }); + + it("supports incrementing key by value", () => { + test_send_request_args("increment", { + args: ["key1", 2], + expected: { $add: { key1: 2 } }, + }); + }); + + it("supports incrementing key by value and a modifiers argument", () => { + test_send_request_args("increment", { + args: ["key1", 2], + expected: { $add: { key1: 2 } }, + use_modifiers: true, + }); + }); + + it("supports incrementing multiple keys", () => { + test_send_request_args("increment", { + args: [{ key1: 5, key2: -3 }], + expected: { $add: { key1: 5, key2: -3 } }, + }); + }); + + it("supports incrementing multiple keys and a modifiers argument", () => { + test_send_request_args("increment", { + args: [{ key1: 5, key2: -3 }], + expected: { $add: { key1: 5, key2: -3 } }, + use_modifiers: true, + }); + }); + + it("ignores invalid values", () => { + test_send_request_args("increment", { + args: [ + { + key1: "bad", + key2: 3, + key3: undefined, + key4: "5", + key5: new Date(), + key6: function () {}, + }, + ], + expected: { $add: { key2: 3, key4: "5" } }, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("increment", { + args: ["key1"], + expected: { $add: { key1: 1 } }, + use_callback: true, + }); + }); + + it("supports incrementing key by value with a callback", () => { + test_send_request_args("increment", { + args: ["key1", 2], + expected: { $add: { key1: 2 } }, + use_callback: true, + }); + }); + + it("supports incrementing key by value with a modifiers argument and callback", () => { + test_send_request_args("increment", { + args: ["key1", 2], + expected: { $add: { key1: 2 } }, + use_callback: true, + use_modifiers: true, + }); + }); + + it("supports incrementing multiple keys with a callback", () => { + test_send_request_args("increment", { + args: [{ key1: 5, key2: -3 }], + expected: { $add: { key1: 5, key2: -3 } }, + use_callback: true, + }); + }); + + it("supports incrementing multiple keys with a modifiers argument and callback", () => { + test_send_request_args("increment", { + args: [{ key1: 5, key2: -3 }], + expected: { $add: { key1: 5, key2: -3 } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("append", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("append", { + args: ["key1", "value"], + expected: { $append: { key1: "value" } }, + }); + }); + + it("supports being called with modifiers", () => { + test_send_request_args("append", { + args: ["key1", "value"], + expected: { $append: { key1: "value" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("append", { + args: ["key1", "value"], + expected: { $append: { key1: "value" } }, + use_callback: true, + }); + }); + + it("supports appending multiple keys with values", () => { + test_send_request_args("append", { + args: [{ key1: "value1", key2: "value2" }], + expected: { $append: { key1: "value1", key2: "value2" } }, + }); + }); + + it("supports appending multiple keys with values and a modifiers argument", () => { + test_send_request_args("append", { + args: [{ key1: "value1", key2: "value2" }], + expected: { $append: { key1: "value1", key2: "value2" } }, + use_modifiers: true, + }); + }); + + it("supports appending multiple keys with values and a callback", () => { + test_send_request_args("append", { + args: [{ key1: "value1", key2: "value2" }], + expected: { $append: { key1: "value1", key2: "value2" } }, + use_callback: true, + }); + }); + + it("supports appending multiple keys with values with a modifiers argument and callback", () => { + test_send_request_args("append", { + args: [{ key1: "value1", key2: "value2" }], + expected: { $append: { key1: "value1", key2: "value2" } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("track_charge", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("track_charge", { + args: [50], + expected: { $append: { $transactions: { $amount: 50 } } }, + }); + }); + + it("supports being called with a property object", () => { + var time = new Date("Feb 1 2012"); + test_send_request_args("track_charge", { + args: [50, { $time: time, isk: "isk" }], + expected: { + $append: { + $transactions: { + $amount: 50, + $time: time.toISOString(), + isk: "isk", + }, + }, + }, + }); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("track_charge", { + args: [50], + expected: { $append: { $transactions: { $amount: 50 } } }, + use_modifiers: true, + }); + }); + + it("supports being called with a property object and a modifiers argument", () => { + var time = new Date("Feb 1 2012"); + test_send_request_args("track_charge", { + args: [50, { $time: time, isk: "isk" }], + expected: { + $append: { + $transactions: { + $amount: 50, + $time: time.toISOString(), + isk: "isk", + }, + }, + }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("track_charge", { + args: [50], + expected: { $append: { $transactions: { $amount: 50 } } }, + use_callback: true, + }); + }); + + it("supports being called with properties and a callback", () => { + test_send_request_args("track_charge", { + args: [50, {}], + expected: { $append: { $transactions: { $amount: 50 } } }, + use_callback: true, + }); + }); + + it("supports being called with modifiers and a callback", () => { + test_send_request_args("track_charge", { + args: [50], + expected: { $append: { $transactions: { $amount: 50 } } }, + use_callback: true, + use_modifiers: true, + }); + }); + + it("supports being called with properties, modifiers and a callback", () => { + var time = new Date("Feb 1 2012"); + test_send_request_args("track_charge", { + args: [50, { $time: time, isk: "isk" }], + expected: { + $append: { + $transactions: { + $amount: 50, + $time: time.toISOString(), + isk: "isk", + }, + }, + }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("clear_charges", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("clear_charges", { + expected: { $set: { $transactions: [] } }, + }); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("clear_charges", { + expected: { $set: { $transactions: [] } }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("clear_charges", { + expected: { $set: { $transactions: [] } }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("clear_charges", { + expected: { $set: { $transactions: [] } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("delete_user", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("delete_user", { + expected: { $delete: "" }, + }); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("delete_user", { + expected: { $delete: "" }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("delete_user", { + expected: { $delete: "" }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("delete_user", { + expected: { $delete: "" }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("remove", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("remove", { + args: [{ key1: "value1", key2: "value2" }], + expected: { $remove: { key1: "value1", key2: "value2" } }, + }); + }); + + it("errors on non-scalar argument types", () => { + mixpanel.people.remove(distinct_id, { key1: ["value1"] }); + mixpanel.people.remove(distinct_id, { key1: { key: "val" } }); + mixpanel.people.remove(distinct_id, 1231241.123); + mixpanel.people.remove(distinct_id, [5]); + mixpanel.people.remove(distinct_id, { key1: function () {} }); + mixpanel.people.remove(distinct_id, { key1: [function () {}] }); + + expect(mixpanel.send_request).not.toHaveBeenCalled(); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("remove", { + args: [{ key1: "value1" }], + expected: { $remove: { key1: "value1" } }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("remove", { + args: [{ key1: "value1" }], + expected: { $remove: { key1: "value1" } }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("remove", { + args: [{ key1: "value1" }], + expected: { $remove: { key1: "value1" } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("union", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("union", { + args: [{ key1: ["value1", "value2"] }], + expected: { $union: { key1: ["value1", "value2"] } }, + }); + }); + + it("supports being called with a scalar value", () => { + test_send_request_args("union", { + args: [{ key1: "value1" }], + expected: { $union: { key1: ["value1"] } }, + }); + }); + + it("errors on other argument types", () => { + mixpanel.people.union(distinct_id, { key1: { key: "val" } }); + mixpanel.people.union(distinct_id, 1231241.123); + mixpanel.people.union(distinct_id, [5]); + mixpanel.people.union(distinct_id, { key1: function () {} }); + mixpanel.people.union(distinct_id, { key1: [function () {}] }); + + expect(mixpanel.send_request).not.toHaveBeenCalled(); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("union", { + args: [{ key1: ["value1", "value2"] }], + expected: { $union: { key1: ["value1", "value2"] } }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("union", { + args: [{ key1: ["value1", "value2"] }], + expected: { $union: { key1: ["value1", "value2"] } }, + use_callback: true, + }); + }); + + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("union", { + args: [{ key1: ["value1", "value2"] }], + expected: { $union: { key1: ["value1", "value2"] } }, + use_callback: true, + use_modifiers: true, + }); + }); + }); + + describe("unset", () => { + it("calls send_request with correct endpoint and data", () => { + test_send_request_args("unset", { + args: ["key1"], + expected: { $unset: ["key1"] }, + }); + }); + + it("supports being called with a property array", () => { + test_send_request_args("unset", { + args: [["key1", "key2"]], + expected: { $unset: ["key1", "key2"] }, + }); + }); + + it("errors on other argument types", () => { + mixpanel.people.unset(distinct_id, { key1: "val1", key2: "val2" }); + mixpanel.people.unset(distinct_id, 1231241.123); + + expect(mixpanel.send_request).not.toHaveBeenCalled(); + }); + + it("supports being called with a modifiers argument", () => { + test_send_request_args("unset", { + args: ["key1"], + expected: { $unset: ["key1"] }, + use_modifiers: true, + }); + }); + + it("supports being called with a callback", () => { + test_send_request_args("unset", { + args: ["key1"], + expected: { $unset: ["key1"] }, + use_callback: true, + }); + }); - describe("_set", () => { - it("handles set_once correctly", () => { - test_send_request_args('set_once', { - args: ['key1', 'val1'], - expected: {$set_once: {'key1': 'val1'}}, - }); - }); - - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('set', { - args: ['key1', 'val1'], - expected: {$set: {'key1': 'val1'}}, - }); - }); - - it("supports being called with a property object", () => { - test_send_request_args('set', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, - }); - }); - - it("supports being called with a property object (set_once)", () => { - test_send_request_args('set_once', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, - }); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('set', { - args: ['key1', 'val1'], - expected: {$set: {'key1': 'val1'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a modifiers argument (set_once)", () => { - test_send_request_args('set_once', { - args: ['key1', 'val1'], - expected: {$set_once: {'key1': 'val1'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a properties object and a modifiers argument", () => { - test_send_request_args('set', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a properties object and a modifiers argument (set_once)", () => { - test_send_request_args('set_once', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, - use_modifiers: true, - }); - }); - - it("handles the ip property in a property object properly", () => { - test_send_request_args('set', { - args: [{'ip': '1.2.3.4', 'key1': 'val1', 'key2': 'val2'}], - expected: { - $ip: '1.2.3.4', - $set: {'key1': 'val1', 'key2': 'val2'}, - }, - }); - }); - - it("handles the $ignore_time property in a property object properly", () => { - test_send_request_args('set', { - args: [{'$ignore_time': true, 'key1': 'val1', 'key2': 'val2'}], - expected: { - $ignore_time: true, - $set: {'key1': 'val1', 'key2': 'val2'}, - }, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('set', { - args: ['key1', 'val1'], - expected: {$set: {'key1': 'val1'}}, - use_callback: true, - }); - }); - - it("supports being called with a callback (set_once)", () => { - test_send_request_args('set_once', { - args: ['key1', 'val1'], - expected: {$set_once: {'key1': 'val1'}}, - use_callback: true, - }); - }); - - it("supports being called with a properties object and a callback", () => { - test_send_request_args('set', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, - use_callback: true, - }); - }); - - it("supports being called with a properties object and a callback (set_once)", () => { - test_send_request_args('set_once', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('set', { - args: ['key1', 'val1'], - expected: {$set: {'key1': 'val1'}}, - use_callback: true, - use_modifiers: true, - }); - }); - - it("supports being called with a modifiers argument and a callback (set_once)", () => { - test_send_request_args('set_once', { - args: ['key1', 'val1'], - expected: {$set_once: {'key1': 'val1'}}, - use_callback: true, - use_modifiers: true, - }); - }); - - it("supports being called with a properties object, a modifiers argument and a callback", () => { - test_send_request_args('set', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set: {'key1': 'val1', 'key2': 'val2'}}, - use_callback: true, - use_modifiers: true, - }); - }); - - it("supports being called with a properties object, a modifiers argument and a callback (set_once)", () => { - test_send_request_args('set_once', { - args: [{'key1': 'val1', 'key2': 'val2'}], - expected: {$set_once: {'key1': 'val1', 'key2': 'val2'}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("increment", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('increment', { - args: ['key1'], - expected: {$add: {'key1': 1}}, - }); - }); - - it("supports incrementing key by value", () => { - test_send_request_args('increment', { - args: ['key1', 2], - expected: {$add: {'key1': 2}}, - }); - }); - - it("supports incrementing key by value and a modifiers argument", () => { - test_send_request_args('increment', { - args: ['key1', 2], - expected: {$add: {'key1': 2}}, - use_modifiers: true, - }); - }); - - it("supports incrementing multiple keys", () => { - test_send_request_args('increment', { - args: [{'key1': 5, 'key2': -3}], - expected: {$add: {'key1': 5, 'key2': -3}}, - }); - }); - - it("supports incrementing multiple keys and a modifiers argument", () => { - test_send_request_args('increment', { - args: [{'key1': 5, 'key2': -3}], - expected: {$add: {'key1': 5, 'key2': -3}}, - use_modifiers: true, - }); - }); - - it("ignores invalid values", () => { - test_send_request_args('increment', { - args: [{ - 'key1': 'bad', - 'key2': 3, - 'key3': undefined, - 'key4': '5', - 'key5': new Date(), - 'key6': function() {}, - }], - expected: {$add: {'key2': 3, 'key4': '5'}}, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('increment', { - args: ['key1'], - expected: {$add: {'key1': 1}}, - use_callback: true, - }); - }); - - it("supports incrementing key by value with a callback", () => { - test_send_request_args('increment', { - args: ['key1', 2], - expected: {$add: {'key1': 2}}, - use_callback: true, - }); - }); - - it("supports incrementing key by value with a modifiers argument and callback", () => { - test_send_request_args('increment', { - args: ['key1', 2], - expected: {$add: {'key1': 2}}, - use_callback: true, - use_modifiers: true, - }); - }); - - it("supports incrementing multiple keys with a callback", () => { - test_send_request_args('increment', { - args: [{'key1': 5, 'key2': -3}], - expected: {$add: {'key1': 5, 'key2': -3}}, - use_callback: true, - }); - }); - - it("supports incrementing multiple keys with a modifiers argument and callback", () => { - test_send_request_args('increment', { - args: [{'key1': 5, 'key2': -3}], - expected: {$add: {'key1': 5, 'key2': -3}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("append", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('append', { - args: ['key1', 'value'], - expected: {$append: {'key1': 'value'}}, - }); - }); - - it("supports being called with modifiers", () => { - test_send_request_args('append', { - args: ['key1', 'value'], - expected: {$append: {'key1': 'value'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('append', { - args: ['key1', 'value'], - expected: {$append: {'key1': 'value'}}, - use_callback: true, - }); - }); - - it("supports appending multiple keys with values", () => { - test_send_request_args('append', { - args: [{'key1': 'value1', 'key2': 'value2'}], - expected: {$append: {'key1': 'value1', 'key2': 'value2'}}, - }); - }); - - it("supports appending multiple keys with values and a modifiers argument", () => { - test_send_request_args('append', { - args: [{'key1': 'value1', 'key2': 'value2'}], - expected: {$append: {'key1': 'value1', 'key2': 'value2'}}, - use_modifiers: true, - }); - }); - - it("supports appending multiple keys with values and a callback", () => { - test_send_request_args('append', { - args: [{'key1': 'value1', 'key2': 'value2'}], - expected: {$append: {'key1': 'value1', 'key2': 'value2'}}, - use_callback: true, - }); - }); - - it("supports appending multiple keys with values with a modifiers argument and callback", () => { - test_send_request_args('append', { - args: [{'key1': 'value1', 'key2': 'value2'}], - expected: {$append: {'key1': 'value1', 'key2': 'value2'}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("track_charge", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('track_charge', { - args: [50], - expected: {$append: {$transactions: {$amount: 50}}}, - }); - }); - - it("supports being called with a property object", () => { - var time = new Date('Feb 1 2012'); - test_send_request_args('track_charge', { - args: [50, {$time: time, isk: 'isk'}], - expected: {$append: {$transactions: { - $amount: 50, - $time: time.toISOString(), - isk: 'isk', - }}}, - }); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('track_charge', { - args: [50], - expected: {$append: {$transactions: {$amount: 50}}}, - use_modifiers: true, - }); - }); - - it("supports being called with a property object and a modifiers argument", () => { - var time = new Date('Feb 1 2012'); - test_send_request_args('track_charge', { - args: [50, {$time: time, isk: 'isk'}], - expected: {$append: {$transactions: { - $amount: 50, - $time: time.toISOString(), - isk: 'isk', - }}}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('track_charge', { - args: [50], - expected: {$append: {$transactions: {$amount: 50}}}, - use_callback: true, - }); - }); - - it("supports being called with properties and a callback", () => { - test_send_request_args('track_charge', { - args: [50, {}], - expected: {$append: {$transactions: {$amount: 50}}}, - use_callback: true, - }); - }); - - it("supports being called with modifiers and a callback", () => { - test_send_request_args('track_charge', { - args: [50], - expected: {$append: {$transactions: {$amount: 50}}}, - use_callback: true, - use_modifiers: true, - }); - }); - - it("supports being called with properties, modifiers and a callback", () => { - var time = new Date('Feb 1 2012'); - test_send_request_args('track_charge', { - args: [50, {$time: time, isk: 'isk'}], - expected: {$append: {$transactions: { - $amount: 50, - $time: time.toISOString(), - isk: 'isk', - }}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("clear_charges", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('clear_charges', { - expected: {$set: {$transactions: []}}, - }); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('clear_charges', { - expected: {$set: {$transactions: []}}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('clear_charges', { - expected: {$set: {$transactions: []}}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('clear_charges', { - expected: {$set: {$transactions: []}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("delete_user", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('delete_user', { - expected: {$delete: ''}, - }); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('delete_user', { - expected: {$delete: ''}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('delete_user', { - expected: {$delete: ''}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('delete_user', { - expected: {$delete: ''}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("remove", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('remove', { - args: [{'key1': 'value1', 'key2': 'value2'}], - expected: {$remove: {'key1': 'value1', 'key2': 'value2'}}, - }); - }); - - it("errors on non-scalar argument types", () => { - mixpanel.people.remove(distinct_id, {'key1': ['value1']}); - mixpanel.people.remove(distinct_id, {key1: {key: 'val'}}); - mixpanel.people.remove(distinct_id, 1231241.123); - mixpanel.people.remove(distinct_id, [5]); - mixpanel.people.remove(distinct_id, {key1: function() {}}); - mixpanel.people.remove(distinct_id, {key1: [function() {}]}); - - expect(mixpanel.send_request).not.toHaveBeenCalled(); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('remove', { - args: [{'key1': 'value1'}], - expected: {$remove: {'key1': 'value1'}}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('remove', { - args: [{'key1': 'value1'}], - expected: {$remove: {'key1': 'value1'}}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('remove', { - args: [{'key1': 'value1'}], - expected: {$remove: {'key1': 'value1'}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("union", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('union', { - args: [{'key1': ['value1', 'value2']}], - expected: {$union: {'key1': ['value1', 'value2']}}, - }); - }); - - it("supports being called with a scalar value", () => { - test_send_request_args('union', { - args: [{'key1': 'value1'}], - expected: {$union: {'key1': ['value1']}}, - }); - }); - - it("errors on other argument types", () => { - mixpanel.people.union(distinct_id, {key1: {key: 'val'}}); - mixpanel.people.union(distinct_id, 1231241.123); - mixpanel.people.union(distinct_id, [5]); - mixpanel.people.union(distinct_id, {key1: function() {}}); - mixpanel.people.union(distinct_id, {key1: [function() {}]}); - - expect(mixpanel.send_request).not.toHaveBeenCalled(); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('union', { - args: [{'key1': ['value1', 'value2']}], - expected: {$union: {'key1': ['value1', 'value2']}}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('union', { - args: [{'key1': ['value1', 'value2']}], - expected: {$union: {'key1': ['value1', 'value2']}}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('union', { - args: [{'key1': ['value1', 'value2']}], - expected: {$union: {'key1': ['value1', 'value2']}}, - use_callback: true, - use_modifiers: true, - }); - }); - }); - - describe("unset", () => { - it("calls send_request with correct endpoint and data", () => { - test_send_request_args('unset', { - args: ['key1'], - expected: {$unset: ['key1']}, - }); - }); - - it("supports being called with a property array", () => { - test_send_request_args('unset', { - args: [['key1', 'key2']], - expected: {$unset: ['key1', 'key2']}, - }); - }); - - it("errors on other argument types", () => { - mixpanel.people.unset(distinct_id, { key1:'val1', key2:'val2' }); - mixpanel.people.unset(distinct_id, 1231241.123); - - expect(mixpanel.send_request).not.toHaveBeenCalled(); - }); - - it("supports being called with a modifiers argument", () => { - test_send_request_args('unset', { - args: ['key1'], - expected: {$unset: ['key1']}, - use_modifiers: true, - }); - }); - - it("supports being called with a callback", () => { - test_send_request_args('unset', { - args: ['key1'], - expected: {$unset: ['key1']}, - use_callback: true, - }); - }); - - it("supports being called with a modifiers argument and a callback", () => { - test_send_request_args('unset', { - args: ['key1'], - expected: {$unset: ['key1']}, - use_callback: true, - use_modifiers: true, - }); - }); + it("supports being called with a modifiers argument and a callback", () => { + test_send_request_args("unset", { + args: ["key1"], + expected: { $unset: ["key1"] }, + use_callback: true, + use_modifiers: true, + }); }); + }); }); diff --git a/test/send_request.js b/test/send_request.js index 4cf7fd5..fa57373 100644 --- a/test/send_request.js +++ b/test/send_request.js @@ -1,312 +1,323 @@ let Mixpanel; -const proxyquire = require('proxyquire'); -const https = require('https'); -const events = require('events'); -const httpProxyOrig = process.env.HTTP_PROXY; +const proxyquire = require("proxyquire"); +const https = require("https"); +const events = require("events"); +const httpProxyOrig = process.env.HTTP_PROXY; const httpsProxyOrig = process.env.HTTPS_PROXY; let HttpsProxyAgent; describe("send_request", () => { - let mixpanel; - let http_emitter; - let res; - beforeEach(() => { - HttpsProxyAgent = vi.fn(); - Mixpanel = proxyquire('../lib/mixpanel-node', { - 'https-proxy-agent': HttpsProxyAgent, - }); - - http_emitter = new events.EventEmitter(); - res = new events.EventEmitter(); - vi.spyOn(https, 'request') - .mockImplementation((_, cb) => { - cb(res); - return http_emitter; - }) - http_emitter.write = vi.fn(); - http_emitter.end = vi.fn(); - - mixpanel = Mixpanel.init('token'); - - return () => { - https.request.mockRestore(); - - // restore proxy variables - process.env.HTTP_PROXY = httpProxyOrig; - process.env.HTTPS_PROXY = httpsProxyOrig; - } + let mixpanel; + let http_emitter; + let res; + beforeEach(() => { + HttpsProxyAgent = vi.fn(); + Mixpanel = proxyquire("../lib/mixpanel-node", { + "https-proxy-agent": HttpsProxyAgent, }); - it("sends correct data on GET", () => { - var endpoint = "/track", - data = { - event: 'test', - properties: { - key1: 'val1', - token: 'token', - time: 1346876621 - } - }, - expected_http_request = { - method: 'GET', - host: 'api.mixpanel.com', - headers: {}, - path: '/track?ip=0&verbose=0&data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ%3D%3D' - }; - - mixpanel.send_request({ method: 'get', endpoint: endpoint, data: data }); - expect(https.request).toHaveBeenCalledWith( - expect.objectContaining(expected_http_request), - expect.any(Function) - ); - expect(http_emitter.end).toHaveBeenCalledTimes(1); - expect(http_emitter.write).toHaveBeenCalledTimes(0); + http_emitter = new events.EventEmitter(); + res = new events.EventEmitter(); + vi.spyOn(https, "request").mockImplementation((_, cb) => { + cb(res); + return http_emitter; }); - - it("defaults to GET", () => { - var endpoint = "/track", - data = { - event: 'test', - properties: { - key1: 'val1', - token: 'token', - time: 1346876621 - } - }, - expected_http_request = { - method: 'GET', - host: 'api.mixpanel.com', - headers: {}, - path: '/track?ip=0&verbose=0&data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ%3D%3D' - }; - - mixpanel.send_request({ endpoint: endpoint, data: data }); // method option not defined - - expect(https.request).toHaveBeenCalledWith( - expect.objectContaining(expected_http_request), - expect.any(Function), - ); - }); - - it("sends correct data on POST", () => { - var endpoint = "/track", - data = { - event: 'test', - properties: { - key1: 'val1', - token: 'token', - time: 1346876621 - } - }, - expected_http_request = { - method: 'POST', - host: 'api.mixpanel.com', - headers: expect.any(Object), - path: '/track?ip=0&verbose=0' - }, - expected_http_request_body = "data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ=="; - - mixpanel.send_request({ method: 'post', endpoint: endpoint, data: data }); - - expect(https.request).toHaveBeenCalledWith( - expect.objectContaining(expected_http_request), - expect.any(Function), - ); - expect(http_emitter.end).toHaveBeenCalledTimes(1); - expect(http_emitter.write).toHaveBeenCalledWith(expected_http_request_body); - }); - - it("sets ip=1 when geolocate option is on", () => { - mixpanel.set_config({ geolocate: true }); - - mixpanel.send_request({ method: "get", endpoint: "/track", event: "test", data: {} }); - - expect(https.request).toHaveBeenCalledWith( - expect.objectContaining({ - path: expect.stringContaining('ip=1'), - }), - expect.any(Function), - ); - }); - - it("handles mixpanel errors", () => { - mixpanel.send_request({ endpoint: "/track", data: { event: "test" } }, function(e) { - expect(e.message).toBe('Mixpanel Server Error: 0') - }); - - res.emit('data', '0'); - res.emit('end'); - }); - - it("handles https.request errors", () => { - mixpanel.send_request({ endpoint: "/track", data: { event: "test" } }, function(e) { - expect(e).toBe('error'); - }); - http_emitter.emit('error', 'error'); - }); - - it("default use keepAlive agent", () => { - var agent = new https.Agent({ keepAlive: false }); - var httpsStub = { - request: vi.fn().mockImplementation((_, cb) => { - cb(res) - return http_emitter; - }), - Agent: vi.fn().mockImplementation(function() { - return agent; - }), - }; - // force SDK not use `undefined` string to initialize proxy-agent - delete process.env.HTTP_PROXY - delete process.env.HTTPS_PROXY - Mixpanel = proxyquire('../lib/mixpanel-node', { - 'https': httpsStub - }); - var proxyMixpanel = Mixpanel.init('token'); - proxyMixpanel.send_request({ endpoint: '', data: {} }); - - var getConfig = httpsStub.request.mock.calls[0][0]; - var agentOpts = httpsStub.Agent.mock.calls[0][0]; - expect(agentOpts.keepAlive).toBe(true); - expect(getConfig.agent).toBe(agent); - }); - - it("uses correct hostname", () => { - var host = 'testhost.fakedomain'; - var customHostnameMixpanel = Mixpanel.init('token', { host: host }); - var expected_http_request = { - host: host - }; - - customHostnameMixpanel.send_request({ endpoint: "", data: {} }); - - expect(https.request).toHaveBeenCalledWith( - expect.objectContaining(expected_http_request), - expect.any(Function), - ); + http_emitter.write = vi.fn(); + http_emitter.end = vi.fn(); + + mixpanel = Mixpanel.init("token"); + + return () => { + https.request.mockRestore(); + + // restore proxy variables + process.env.HTTP_PROXY = httpProxyOrig; + process.env.HTTPS_PROXY = httpsProxyOrig; + }; + }); + + it("sends correct data on GET", () => { + var endpoint = "/track", + data = { + event: "test", + properties: { + key1: "val1", + token: "token", + time: 1346876621, + }, + }, + expected_http_request = { + method: "GET", + host: "api.mixpanel.com", + headers: {}, + path: "/track?ip=0&verbose=0&data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ%3D%3D", + }; + + mixpanel.send_request({ method: "get", endpoint: endpoint, data: data }); + expect(https.request).toHaveBeenCalledWith( + expect.objectContaining(expected_http_request), + expect.any(Function), + ); + expect(http_emitter.end).toHaveBeenCalledTimes(1); + expect(http_emitter.write).toHaveBeenCalledTimes(0); + }); + + it("defaults to GET", () => { + var endpoint = "/track", + data = { + event: "test", + properties: { + key1: "val1", + token: "token", + time: 1346876621, + }, + }, + expected_http_request = { + method: "GET", + host: "api.mixpanel.com", + headers: {}, + path: "/track?ip=0&verbose=0&data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ%3D%3D", + }; + + mixpanel.send_request({ endpoint: endpoint, data: data }); // method option not defined + + expect(https.request).toHaveBeenCalledWith( + expect.objectContaining(expected_http_request), + expect.any(Function), + ); + }); + + it("sends correct data on POST", () => { + var endpoint = "/track", + data = { + event: "test", + properties: { + key1: "val1", + token: "token", + time: 1346876621, + }, + }, + expected_http_request = { + method: "POST", + host: "api.mixpanel.com", + headers: expect.any(Object), + path: "/track?ip=0&verbose=0", + }, + expected_http_request_body = + "data=eyJldmVudCI6InRlc3QiLCJwcm9wZXJ0aWVzIjp7ImtleTEiOiJ2YWwxIiwidG9rZW4iOiJ0b2tlbiIsInRpbWUiOjEzNDY4NzY2MjF9fQ=="; + + mixpanel.send_request({ method: "post", endpoint: endpoint, data: data }); + + expect(https.request).toHaveBeenCalledWith( + expect.objectContaining(expected_http_request), + expect.any(Function), + ); + expect(http_emitter.end).toHaveBeenCalledTimes(1); + expect(http_emitter.write).toHaveBeenCalledWith(expected_http_request_body); + }); + + it("sets ip=1 when geolocate option is on", () => { + mixpanel.set_config({ geolocate: true }); + + mixpanel.send_request({ + method: "get", + endpoint: "/track", + event: "test", + data: {}, }); - it("uses correct port", () => { - var host = 'testhost.fakedomain:1337'; - var customHostnameMixpanel = Mixpanel.init('token', { host: host }); - var expected_http_request = { - host: 'testhost.fakedomain', - port: 1337 - }; - - customHostnameMixpanel.send_request({ endpoint: "", data: {} }); - - expect(https.request).toHaveBeenCalledWith( - expect.objectContaining(expected_http_request), - expect.any(Function), - ); + expect(https.request).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining("ip=1"), + }), + expect.any(Function), + ); + }); + + it("handles mixpanel errors", () => { + mixpanel.send_request( + { endpoint: "/track", data: { event: "test" } }, + function (e) { + expect(e.message).toBe("Mixpanel Server Error: 0"); + }, + ); + + res.emit("data", "0"); + res.emit("end"); + }); + + it("handles https.request errors", () => { + mixpanel.send_request( + { endpoint: "/track", data: { event: "test" } }, + function (e) { + expect(e).toBe("error"); + }, + ); + http_emitter.emit("error", "error"); + }); + + it("default use keepAlive agent", () => { + var agent = new https.Agent({ keepAlive: false }); + var httpsStub = { + request: vi.fn().mockImplementation((_, cb) => { + cb(res); + return http_emitter; + }), + Agent: vi.fn().mockImplementation(function () { + return agent; + }), + }; + // force SDK not use `undefined` string to initialize proxy-agent + delete process.env.HTTP_PROXY; + delete process.env.HTTPS_PROXY; + Mixpanel = proxyquire("../lib/mixpanel-node", { + https: httpsStub, }); - - it("uses correct path", () => { - var host = 'testhost.fakedomain'; - var customPath = '/mypath'; - var customHostnameMixpanel = Mixpanel.init('token', { - host, - path: customPath, - }); - var expected_http_request = { - host, - path: '/mypath?ip=0&verbose=0&data=e30%3D', - }; - - customHostnameMixpanel.send_request({endpoint: "", data: {}}); - expect(https.request).toHaveBeenCalledWith( - expect.objectContaining(expected_http_request), - expect.any(Function), - ); - }); - - it("combines custom path and endpoint", () => { - var host = 'testhost.fakedomain'; - var customPath = '/mypath'; - var customHostnameMixpanel = Mixpanel.init('token', { - host, - path: customPath, - }); - var expected_http_request = { - host, - path: '/mypath/track?ip=0&verbose=0&data=e30%3D', - }; - - customHostnameMixpanel.send_request({endpoint: '/track', data: {}}); - expect(https.request).toHaveBeenCalledWith( - expect.objectContaining(expected_http_request), - expect.any(Function), - ); - }); - - it("uses HTTP_PROXY if set", () => { - HttpsProxyAgent.mockReset(); // Mixpanel is instantiated in setup, need to reset callcount - delete process.env.HTTPS_PROXY; - process.env.HTTP_PROXY = 'this.aint.real.https'; - - var proxyMixpanel = Mixpanel.init('token'); - proxyMixpanel.send_request({ endpoint: '', data: {} }); - - expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); - - var agentOpts = HttpsProxyAgent.mock.calls[0][0]; - expect(agentOpts.pathname).toBe('this.aint.real.https'); - expect(agentOpts.keepAlive).toBe(true); - - var getConfig = https.request.mock.calls[0][0]; - expect(getConfig.agent).toBeTruthy(); + var proxyMixpanel = Mixpanel.init("token"); + proxyMixpanel.send_request({ endpoint: "", data: {} }); + + var getConfig = httpsStub.request.mock.calls[0][0]; + var agentOpts = httpsStub.Agent.mock.calls[0][0]; + expect(agentOpts.keepAlive).toBe(true); + expect(getConfig.agent).toBe(agent); + }); + + it("uses correct hostname", () => { + var host = "testhost.fakedomain"; + var customHostnameMixpanel = Mixpanel.init("token", { host: host }); + var expected_http_request = { + host: host, + }; + + customHostnameMixpanel.send_request({ endpoint: "", data: {} }); + + expect(https.request).toHaveBeenCalledWith( + expect.objectContaining(expected_http_request), + expect.any(Function), + ); + }); + + it("uses correct port", () => { + var host = "testhost.fakedomain:1337"; + var customHostnameMixpanel = Mixpanel.init("token", { host: host }); + var expected_http_request = { + host: "testhost.fakedomain", + port: 1337, + }; + + customHostnameMixpanel.send_request({ endpoint: "", data: {} }); + + expect(https.request).toHaveBeenCalledWith( + expect.objectContaining(expected_http_request), + expect.any(Function), + ); + }); + + it("uses correct path", () => { + var host = "testhost.fakedomain"; + var customPath = "/mypath"; + var customHostnameMixpanel = Mixpanel.init("token", { + host, + path: customPath, }); - - it("uses HTTPS_PROXY if set", () => { - HttpsProxyAgent.mockReset(); // Mixpanel is instantiated in setup, need to reset callcount - delete process.env.HTTP_PROXY; - process.env.HTTPS_PROXY = 'this.aint.real.https'; - - var proxyMixpanel = Mixpanel.init('token'); - proxyMixpanel.send_request({ endpoint: '', data: {} }); - - expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); - - var proxyOpts = HttpsProxyAgent.mock.calls[0][0]; - expect(proxyOpts.pathname).toBe('this.aint.real.https'); - - var getConfig = https.request.mock.calls[0][0]; - expect(getConfig.agent).toBeTruthy(); + var expected_http_request = { + host, + path: "/mypath?ip=0&verbose=0&data=e30%3D", + }; + + customHostnameMixpanel.send_request({ endpoint: "", data: {} }); + expect(https.request).toHaveBeenCalledWith( + expect.objectContaining(expected_http_request), + expect.any(Function), + ); + }); + + it("combines custom path and endpoint", () => { + var host = "testhost.fakedomain"; + var customPath = "/mypath"; + var customHostnameMixpanel = Mixpanel.init("token", { + host, + path: customPath, }); - - it("requires credentials for import requests", () => { - expect(() => { - mixpanel.send_request({ - endpoint: `/import`, - data: {event: `test event`}, - }) - }).toThrowError( - /The Mixpanel Client needs a Mixpanel API Secret when importing old events/, - ) + var expected_http_request = { + host, + path: "/mypath/track?ip=0&verbose=0&data=e30%3D", + }; + + customHostnameMixpanel.send_request({ endpoint: "/track", data: {} }); + expect(https.request).toHaveBeenCalledWith( + expect.objectContaining(expected_http_request), + expect.any(Function), + ); + }); + + it("uses HTTP_PROXY if set", () => { + HttpsProxyAgent.mockReset(); // Mixpanel is instantiated in setup, need to reset callcount + delete process.env.HTTPS_PROXY; + process.env.HTTP_PROXY = "this.aint.real.https"; + + var proxyMixpanel = Mixpanel.init("token"); + proxyMixpanel.send_request({ endpoint: "", data: {} }); + + expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); + + var agentOpts = HttpsProxyAgent.mock.calls[0][0]; + expect(agentOpts.pathname).toBe("this.aint.real.https"); + expect(agentOpts.keepAlive).toBe(true); + + var getConfig = https.request.mock.calls[0][0]; + expect(getConfig.agent).toBeTruthy(); + }); + + it("uses HTTPS_PROXY if set", () => { + HttpsProxyAgent.mockReset(); // Mixpanel is instantiated in setup, need to reset callcount + delete process.env.HTTP_PROXY; + process.env.HTTPS_PROXY = "this.aint.real.https"; + + var proxyMixpanel = Mixpanel.init("token"); + proxyMixpanel.send_request({ endpoint: "", data: {} }); + + expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); + + var proxyOpts = HttpsProxyAgent.mock.calls[0][0]; + expect(proxyOpts.pathname).toBe("this.aint.real.https"); + + var getConfig = https.request.mock.calls[0][0]; + expect(getConfig.agent).toBeTruthy(); + }); + + it("requires credentials for import requests", () => { + expect(() => { + mixpanel.send_request({ + endpoint: `/import`, + data: { event: `test event` }, + }); + }).toThrowError( + /The Mixpanel Client needs a Mixpanel API Secret when importing old events/, + ); + }); + + it("sets basic auth header if API secret is provided", () => { + mixpanel.set_config({ secret: `foobar` }); + mixpanel.send_request({ + endpoint: `/import`, + data: { event: `test event` }, }); - - it("sets basic auth header if API secret is provided", () => { - mixpanel.set_config({secret: `foobar`}); - mixpanel.send_request({ - endpoint: `/import`, - data: {event: `test event`}, - }); - expect(https.request).toHaveBeenCalledTimes(1); - expect(https.request.mock.calls[0][0].headers).toEqual({ - 'Authorization': `Basic Zm9vYmFyOg==`, // base64 of "foobar:" - }) + expect(https.request).toHaveBeenCalledTimes(1); + expect(https.request.mock.calls[0][0].headers).toEqual({ + Authorization: `Basic Zm9vYmFyOg==`, // base64 of "foobar:" }); + }); - it("still supports import with api_key (legacy)", () => { - mixpanel.set_config({key: `barbaz`}); - mixpanel.send_request({ - endpoint: `/import`, - data: {}, - }); - expect(https.request).toHaveBeenCalledTimes(1); - expect(https.request.mock.calls[0][0].path).toBe( - `/import?ip=0&verbose=0&data=e30%3D&api_key=barbaz`, - ); + it("still supports import with api_key (legacy)", () => { + mixpanel.set_config({ key: `barbaz` }); + mixpanel.send_request({ + endpoint: `/import`, + data: {}, }); + expect(https.request).toHaveBeenCalledTimes(1); + expect(https.request.mock.calls[0][0].path).toBe( + `/import?ip=0&verbose=0&data=e30%3D&api_key=barbaz`, + ); + }); }); diff --git a/test/track.js b/test/track.js index 3d00c4b..07cf3d1 100644 --- a/test/track.js +++ b/test/track.js @@ -1,333 +1,351 @@ -const https = require('https'); -const events = require('events'); -const proxyquire = require('proxyquire'); -const Mixpanel = require('../lib/mixpanel-node'); -const packageInfo = require('../package.json'); -const utils = require('../lib/utils'); +const https = require("https"); +const events = require("events"); +const proxyquire = require("proxyquire"); +const Mixpanel = require("../lib/mixpanel-node"); +const packageInfo = require("../package.json"); +const utils = require("../lib/utils"); var mock_now_time = new Date(2016, 1, 1).getTime(); -describe('track', () => { - let mixpanel; - beforeAll(() => { - mixpanel = Mixpanel.init('token'); - vi.useFakeTimers(); - vi.setSystemTime(mock_now_time); - vi.spyOn(mixpanel, 'send_request'); - - return () => { - vi.useRealTimers(); - mixpanel.send_request.mockRestore(); - } - }); - - it('calls send_request with correct endpoint and data', () => { - var event = 'test', - props = { key1: 'val1' }, - expected_endpoint = '/track', - expected_data = { - event: 'test', - properties: expect.objectContaining({ - key1: 'val1', - token: 'token' - }), - }; - - mixpanel.track(event, props); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data - }), - undefined, - ); - }); - - it('can be called with optional properties', () => { - var expected_endpoint = '/track', - expected_data = { - event: 'test', - properties: expect.objectContaining({ - token: 'token', - }), - }; - - mixpanel.track('test'); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data, - }), - undefined, - ); - }); - - it('can be called with optional callback', (test) => { - var expected_endpoint = '/track', - expected_data = { - event: 'test', - properties: { - token: 'token', - }, - }; - - mixpanel.send_request.mockImplementationOnce((_, cb) => cb(undefined)); - - const callback = vi.fn(); - mixpanel.track('test', callback); - expect(callback).toHaveBeenCalledWith(undefined); - }); - - it('supports Date object for time', (test) => { - var event = 'test', - time = new Date(mock_now_time), - props = { time: time }, - expected_endpoint = '/track', - expected_data = { - event: 'test', - properties: expect.objectContaining({ - token: 'token', - time: time.getTime(), - mp_lib: 'node', - $lib_version: packageInfo.version - }), - }; - - mixpanel.track(event, props); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data, - }), - undefined, - ); - }); - - it('supports unix timestamp for time', (test) => { - var event = 'test', - time = mock_now_time, - props = { time: time }, - expected_endpoint = '/track', - expected_data = { - event: 'test', - properties: expect.objectContaining({ - token: 'token', - time: time, - mp_lib: 'node', - $lib_version: packageInfo.version - }), - }; - - mixpanel.track(event, props); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - expect.objectContaining({ - endpoint: expected_endpoint, - data: expected_data, - }), - undefined, - ); - }); - - it('throws error if time is not a number or Date', () => { - var event = 'test', - props = { time: 'not a number or Date' }; - - expect(() => mixpanel.track(event, props)).toThrowError( - /`time` property must be a Date or Unix timestamp/, - ); - }); - - it('does not require time property', (test) => { - var event = 'test', - props = {}; - - expect(() => mixpanel.track(event, props)).not.toThrowError(); - }); +describe("track", () => { + let mixpanel; + beforeAll(() => { + mixpanel = Mixpanel.init("token"); + vi.useFakeTimers(); + vi.setSystemTime(mock_now_time); + vi.spyOn(mixpanel, "send_request"); + + return () => { + vi.useRealTimers(); + mixpanel.send_request.mockRestore(); + }; + }); + + it("calls send_request with correct endpoint and data", () => { + var event = "test", + props = { key1: "val1" }, + expected_endpoint = "/track", + expected_data = { + event: "test", + properties: expect.objectContaining({ + key1: "val1", + token: "token", + }), + }; + + mixpanel.track(event, props); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); + + it("can be called with optional properties", () => { + var expected_endpoint = "/track", + expected_data = { + event: "test", + properties: expect.objectContaining({ + token: "token", + }), + }; + + mixpanel.track("test"); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); + + it("can be called with optional callback", (test) => { + var expected_endpoint = "/track", + expected_data = { + event: "test", + properties: { + token: "token", + }, + }; + + mixpanel.send_request.mockImplementationOnce((_, cb) => cb(undefined)); + + const callback = vi.fn(); + mixpanel.track("test", callback); + expect(callback).toHaveBeenCalledWith(undefined); + }); + + it("supports Date object for time", (test) => { + var event = "test", + time = new Date(mock_now_time), + props = { time: time }, + expected_endpoint = "/track", + expected_data = { + event: "test", + properties: expect.objectContaining({ + token: "token", + time: time.getTime(), + mp_lib: "node", + $lib_version: packageInfo.version, + }), + }; + + mixpanel.track(event, props); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); + + it("supports unix timestamp for time", (test) => { + var event = "test", + time = mock_now_time, + props = { time: time }, + expected_endpoint = "/track", + expected_data = { + event: "test", + properties: expect.objectContaining({ + token: "token", + time: time, + mp_lib: "node", + $lib_version: packageInfo.version, + }), + }; + + mixpanel.track(event, props); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expected_endpoint, + data: expected_data, + }), + undefined, + ); + }); + + it("throws error if time is not a number or Date", () => { + var event = "test", + props = { time: "not a number or Date" }; + + expect(() => mixpanel.track(event, props)).toThrowError( + /`time` property must be a Date or Unix timestamp/, + ); + }); + + it("does not require time property", (test) => { + var event = "test", + props = {}; + + expect(() => mixpanel.track(event, props)).not.toThrowError(); + }); }); -describe('track_batch', () => { - let mixpanel; - beforeEach(() => { - mixpanel = Mixpanel.init('token'); - vi.useFakeTimers(); - vi.spyOn(mixpanel, 'send_request'); - - return () => { - vi.useRealTimers(); - mixpanel.send_request.mockRestore(); - } - }); - - it('calls send_request with correct endpoint, data, and method', () => { - var expected_endpoint = '/track', - event_list = [ - {event: 'test', properties: { key1: 'val1', time: 500 }}, - {event: 'test', properties: { key2: 'val2', time: 1000}}, - {event: 'test2', properties: { key2: 'val2', time: 1500}}, - ], - expected_data = [ - {event: 'test', properties: { key1: 'val1', time: 500, token: 'token'}}, - {event: 'test', properties: { key2: 'val2', time: 1000, token: 'token'}}, - {event: 'test2', properties: { key2: 'val2', time: 1500, token: 'token'}} - ].map((val) => expect.objectContaining(val)); - - mixpanel.track_batch(event_list); - - expect(mixpanel.send_request).toHaveBeenCalledWith( - { - method: 'POST', - endpoint: expected_endpoint, - data: expected_data, - }, - expect.any(Function), - ); - }); - - it('does not require the time argument for every event', () => { - var event_list = [ - {event: 'test', properties: {key1: 'val1', time: 500 }}, - {event: 'test', properties: {key2: 'val2', time: 1000}}, - {event: 'test2', properties: {key2: 'val2' }} - ]; - expect(() => mixpanel.track_batch(event_list)).not.toThrowError(); - }); - - it('batches 50 events at a time', () => { - var event_list = []; - for (var ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 - event_list.push({ - event: 'test', - properties: { key1: 'val1', time: 500 + ei }, - }); - } - - mixpanel.track_batch(event_list); - - expect(mixpanel.send_request).toHaveBeenCalledTimes(3); - }); +describe("track_batch", () => { + let mixpanel; + beforeEach(() => { + mixpanel = Mixpanel.init("token"); + vi.useFakeTimers(); + vi.spyOn(mixpanel, "send_request"); + + return () => { + vi.useRealTimers(); + mixpanel.send_request.mockRestore(); + }; + }); + + it("calls send_request with correct endpoint, data, and method", () => { + var expected_endpoint = "/track", + event_list = [ + { event: "test", properties: { key1: "val1", time: 500 } }, + { event: "test", properties: { key2: "val2", time: 1000 } }, + { event: "test2", properties: { key2: "val2", time: 1500 } }, + ], + expected_data = [ + { + event: "test", + properties: { key1: "val1", time: 500, token: "token" }, + }, + { + event: "test", + properties: { key2: "val2", time: 1000, token: "token" }, + }, + { + event: "test2", + properties: { key2: "val2", time: 1500, token: "token" }, + }, + ].map((val) => expect.objectContaining(val)); + + mixpanel.track_batch(event_list); + + expect(mixpanel.send_request).toHaveBeenCalledWith( + { + method: "POST", + endpoint: expected_endpoint, + data: expected_data, + }, + expect.any(Function), + ); + }); + + it("does not require the time argument for every event", () => { + var event_list = [ + { event: "test", properties: { key1: "val1", time: 500 } }, + { event: "test", properties: { key2: "val2", time: 1000 } }, + { event: "test2", properties: { key2: "val2" } }, + ]; + expect(() => mixpanel.track_batch(event_list)).not.toThrowError(); + }); + + it("batches 50 events at a time", () => { + var event_list = []; + for (var ei = 0; ei < 130; ei++) { + // 3 batches: 50 + 50 + 30 + event_list.push({ + event: "test", + properties: { key1: "val1", time: 500 + ei }, + }); + } + + mixpanel.track_batch(event_list); + + expect(mixpanel.send_request).toHaveBeenCalledTimes(3); + }); }); -describe('track_batch_integration', () => { - let mixpanel; - let http_emitter; - let res; - let event_list; - beforeEach(() => { - mixpanel = Mixpanel.init('token', { key: 'key' }); - vi.useFakeTimers(); - - vi.spyOn(https, 'request'); - - http_emitter = new events.EventEmitter(); - - // stub sequence of https responses - res = []; - for (let ri = 0; ri < 5; ri++) { - res.push(new events.EventEmitter()); - https.request.mockImplementationOnce((_, cb) => { - cb(res[ri]); - return { - write: function () {}, - end: function () {}, - on: function (event) {}, - }; - }); - } - - event_list = []; - for (var ei = 0; ei < 130; ei++) {// 3 batches: 50 + 50 + 30 - event_list.push({event: 'test', properties: { key1: 'val1', time: 500 + ei }}); - } - - return () => { - vi.restoreAllMocks(); - } - }); - - it('calls provided callback after all requests finish', () => { - const callback = vi.fn(); - mixpanel.track_batch(event_list, callback); - for (var ri = 0; ri < 3; ri++) { - res[ri].emit('data', '1'); - res[ri].emit('end'); - } - expect(https.request).toHaveBeenCalledTimes(3); - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(null, [ - undefined, - undefined, - undefined, - ]); - }); - - it('passes error list to callback', () => { - const callback = vi.fn(); - mixpanel.track_batch(event_list, callback); - for (var ri = 0; ri < 3; ri++) { - res[ri].emit('data', '0'); - res[ri].emit('end'); - } - expect(callback.mock.calls[0][0].length).toBe(3); - }); - - it('calls provided callback when options are passed', () => { - const callback = vi.fn(); - mixpanel.track_batch(event_list, { max_batch_size: 100 }, callback); - for (var ri = 0; ri < 3; ri++) { - res[ri].emit('data', '1'); - res[ri].emit('end'); - } - expect(callback).toHaveBeenCalledTimes(1); - expect(https.request).toHaveBeenCalledTimes(3); - expect(callback).toHaveBeenCalledWith(null, [undefined]); - }); - - it('sends more requests when max_batch_size < 50', (test) => { - const callback = vi.fn(); - mixpanel.track_batch(event_list, { max_batch_size: 30 }, callback); - for (var ri = 0; ri < 5; ri++) { - res[ri].emit('data', '1'); - res[ri].emit('end'); - } - expect(callback).toHaveBeenCalledTimes(1); - expect(https.request).toHaveBeenCalledTimes(5); - expect(callback).toHaveBeenCalledWith(null, [ - undefined, - undefined, - undefined, - undefined, - undefined, - ]); - }); - - it('can set max concurrent requests', (test) => { - const async_all_stub = vi.fn(); - async_all_stub.mockImplementation((_, __, cb) => cb(null)); - const PatchedMixpanel = proxyquire('../lib/mixpanel-node', { - './utils': { async_all: async_all_stub }, - }); - mixpanel = PatchedMixpanel.init('token', { key: 'key' }); - - const callback = vi.fn(); - - mixpanel.track_batch(event_list, { max_batch_size: 30, max_concurrent_requests: 2 }, callback); - for (var ri = 0; ri < 3; ri++) { - res[ri].emit('data', '1'); - res[ri].emit('end'); - } - expect(callback).toHaveBeenCalledTimes(1); - expect(async_all_stub).toHaveBeenCalledTimes(3); - expect(callback).toHaveBeenCalledWith(null, undefined); - }); - - it('behaves well without a callback', () => { - mixpanel.track_batch(event_list); - expect(https.request).toHaveBeenCalledTimes(3); - mixpanel.track_batch(event_list, { max_batch_size: 100 }); - expect(https.request).toHaveBeenCalledTimes(5); +describe("track_batch_integration", () => { + let mixpanel; + let http_emitter; + let res; + let event_list; + beforeEach(() => { + mixpanel = Mixpanel.init("token", { key: "key" }); + vi.useFakeTimers(); + + vi.spyOn(https, "request"); + + http_emitter = new events.EventEmitter(); + + // stub sequence of https responses + res = []; + for (let ri = 0; ri < 5; ri++) { + res.push(new events.EventEmitter()); + https.request.mockImplementationOnce((_, cb) => { + cb(res[ri]); + return { + write: function () {}, + end: function () {}, + on: function (event) {}, + }; + }); + } + + event_list = []; + for (var ei = 0; ei < 130; ei++) { + // 3 batches: 50 + 50 + 30 + event_list.push({ + event: "test", + properties: { key1: "val1", time: 500 + ei }, + }); + } + + return () => { + vi.restoreAllMocks(); + }; + }); + + it("calls provided callback after all requests finish", () => { + const callback = vi.fn(); + mixpanel.track_batch(event_list, callback); + for (var ri = 0; ri < 3; ri++) { + res[ri].emit("data", "1"); + res[ri].emit("end"); + } + expect(https.request).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(null, [ + undefined, + undefined, + undefined, + ]); + }); + + it("passes error list to callback", () => { + const callback = vi.fn(); + mixpanel.track_batch(event_list, callback); + for (var ri = 0; ri < 3; ri++) { + res[ri].emit("data", "0"); + res[ri].emit("end"); + } + expect(callback.mock.calls[0][0].length).toBe(3); + }); + + it("calls provided callback when options are passed", () => { + const callback = vi.fn(); + mixpanel.track_batch(event_list, { max_batch_size: 100 }, callback); + for (var ri = 0; ri < 3; ri++) { + res[ri].emit("data", "1"); + res[ri].emit("end"); + } + expect(callback).toHaveBeenCalledTimes(1); + expect(https.request).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenCalledWith(null, [undefined]); + }); + + it("sends more requests when max_batch_size < 50", (test) => { + const callback = vi.fn(); + mixpanel.track_batch(event_list, { max_batch_size: 30 }, callback); + for (var ri = 0; ri < 5; ri++) { + res[ri].emit("data", "1"); + res[ri].emit("end"); + } + expect(callback).toHaveBeenCalledTimes(1); + expect(https.request).toHaveBeenCalledTimes(5); + expect(callback).toHaveBeenCalledWith(null, [ + undefined, + undefined, + undefined, + undefined, + undefined, + ]); + }); + + it("can set max concurrent requests", (test) => { + const async_all_stub = vi.fn(); + async_all_stub.mockImplementation((_, __, cb) => cb(null)); + const PatchedMixpanel = proxyquire("../lib/mixpanel-node", { + "./utils": { async_all: async_all_stub }, }); + mixpanel = PatchedMixpanel.init("token", { key: "key" }); + + const callback = vi.fn(); + + mixpanel.track_batch( + event_list, + { max_batch_size: 30, max_concurrent_requests: 2 }, + callback, + ); + for (var ri = 0; ri < 3; ri++) { + res[ri].emit("data", "1"); + res[ri].emit("end"); + } + expect(callback).toHaveBeenCalledTimes(1); + expect(async_all_stub).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenCalledWith(null, undefined); + }); + + it("behaves well without a callback", () => { + mixpanel.track_batch(event_list); + expect(https.request).toHaveBeenCalledTimes(3); + mixpanel.track_batch(event_list, { max_batch_size: 100 }); + expect(https.request).toHaveBeenCalledTimes(5); + }); }); diff --git a/test/utils.js b/test/utils.js index 3c1659f..efe3e46 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,47 +1,52 @@ -const async_all = require('../lib/utils').async_all; +const async_all = require("../lib/utils").async_all; -describe('async_all', () => { - it('calls callback with empty results if no requests', (done) => { - const requests = []; - const handler_fn = vi.fn((_, cb) => cb()); - const callback = vi.fn(); +describe("async_all", () => { + it("calls callback with empty results if no requests", (done) => { + const requests = []; + const handler_fn = vi.fn((_, cb) => cb()); + const callback = vi.fn(); - async_all(requests, handler_fn, callback); - expect(callback).toHaveBeenCalledTimes(1); - }); + async_all(requests, handler_fn, callback); + expect(callback).toHaveBeenCalledTimes(1); + }); - it('runs handler for each request and calls callback with results', () => { - const requests = [1, 2, 3]; - const handler_fn = vi.fn() - .mockImplementationOnce((_, cb) => cb(null, 4)) - .mockImplementationOnce((_, cb) => cb(null, 5)) - .mockImplementationOnce((_, cb) => cb(null, 6)); + it("runs handler for each request and calls callback with results", () => { + const requests = [1, 2, 3]; + const handler_fn = vi + .fn() + .mockImplementationOnce((_, cb) => cb(null, 4)) + .mockImplementationOnce((_, cb) => cb(null, 5)) + .mockImplementationOnce((_, cb) => cb(null, 6)); - const callback = vi.fn(); + const callback = vi.fn(); - async_all(requests, handler_fn, callback); - expect(handler_fn).toHaveBeenCalledTimes(requests.length); - expect(handler_fn.mock.calls[0][0]).toBe(1); - expect(handler_fn.mock.calls[1][0]).toBe(2); - expect(handler_fn.mock.calls[2][0]).toBe(3); - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(null, [4, 5, 6]); - }); + async_all(requests, handler_fn, callback); + expect(handler_fn).toHaveBeenCalledTimes(requests.length); + expect(handler_fn.mock.calls[0][0]).toBe(1); + expect(handler_fn.mock.calls[1][0]).toBe(2); + expect(handler_fn.mock.calls[2][0]).toBe(3); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(null, [4, 5, 6]); + }); - it('calls callback with errors and results from handler', () => { - const requests = [1, 2, 3]; - const handler_fn = vi.fn() - .mockImplementationOnce((_, cb) => cb('error1', null)) - .mockImplementationOnce((_, cb) => cb('error2', null)) - .mockImplementationOnce((_, cb) => cb(null, 6)); - const callback = vi.fn(); + it("calls callback with errors and results from handler", () => { + const requests = [1, 2, 3]; + const handler_fn = vi + .fn() + .mockImplementationOnce((_, cb) => cb("error1", null)) + .mockImplementationOnce((_, cb) => cb("error2", null)) + .mockImplementationOnce((_, cb) => cb(null, 6)); + const callback = vi.fn(); - async_all(requests, handler_fn, callback); - expect(handler_fn).toHaveBeenCalledTimes(requests.length); - expect(handler_fn.mock.calls[0][0]).toBe(1); - expect(handler_fn.mock.calls[1][0]).toBe(2); - expect(handler_fn.mock.calls[2][0]).toBe(3); - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(['error1', 'error2'], [null, null, 6]); - }); + async_all(requests, handler_fn, callback); + expect(handler_fn).toHaveBeenCalledTimes(requests.length); + expect(handler_fn.mock.calls[0][0]).toBe(1); + expect(handler_fn.mock.calls[1][0]).toBe(2); + expect(handler_fn.mock.calls[2][0]).toBe(3); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith( + ["error1", "error2"], + [null, null, 6], + ); + }); }); From bc94992569fb74b1479f95d5e8207000ef3616a6 Mon Sep 17 00:00:00 2001 From: Hans Li Date: Thu, 13 Nov 2025 13:39:43 -0800 Subject: [PATCH 02/23] Initial formatting + linting --- .github/workflows/tests.yml | 19 ++- lib/mixpanel-node.d.ts | 2 - lib/mixpanel-node.js | 3 +- package.json | 6 +- readme.md | 305 +++++++++++++++++++----------------- test/alias.js | 1 - test/import.js | 6 +- test/logger.js | 4 +- test/people.js | 1 - test/track.js | 27 ++-- test/utils.js | 2 +- vitest.config.mjs | 15 +- 12 files changed, 194 insertions(+), 197 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c47d07f..c10f96c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,13 +2,12 @@ name: Tests on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] jobs: build: - runs-on: ubuntu-latest strategy: @@ -16,10 +15,10 @@ jobs: node-version: [20.x, 22.x, 24.x] steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - run: npm ci - - run: npm test -- run --coverage + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + - run: npm test -- run --coverage diff --git a/lib/mixpanel-node.d.ts b/lib/mixpanel-node.d.ts index c70a8ef..bbd316d 100644 --- a/lib/mixpanel-node.d.ts +++ b/lib/mixpanel-node.d.ts @@ -2,8 +2,6 @@ import LocalFeatureFlagsProvider from "./flags/local_flags"; import RemoteFeatureFlagsProvider from "./flags/remote_flags"; import { LocalFlagsConfig, RemoteFlagsConfig } from "./flags/types"; -declare const mixpanel: mixpanel.Mixpanel; - declare namespace mixpanel { export type Callback = (err: Error | undefined) => any; export type BatchCallback = (errors: [Error] | undefined) => any; diff --git a/lib/mixpanel-node.js b/lib/mixpanel-node.js index fc8c4b1..c3bfb76 100644 --- a/lib/mixpanel-node.js +++ b/lib/mixpanel-node.js @@ -158,7 +158,7 @@ var create_client = function (token, config) { e = new Error("Mixpanel Server Error: " + result.error); } } catch (ex) { - e = new Error("Could not parse response from Mixpanel"); + e = new Error("Could not parse response from Mixpanel " + ex.message); } } else { e = @@ -271,7 +271,6 @@ var create_client = function (token, config) { function send_event_batch(batch, cb) { if (batch.length > 0) { batch = batch.map(function (event) { - var properties = event.properties; if (endpoint === "/import" || event.properties.time) { // usually there will be a time property, but not required for `/track` endpoint event.properties.time = ensure_timestamp(event.properties.time); diff --git a/package.json b/package.json index 8850a07..fb4ce99 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,9 @@ }, "scripts": { "test": "vitest", - "format": "prettier --write '**/*.{js,ts}'", - "check-format": "prettier --check '**/*.{js,ts}'", - "lint": "oxlint '**/*.{js,ts}'" + "format": "prettier --write .", + "check-format": "prettier --check .", + "lint": "oxlint ." }, "types": "./lib/mixpanel-node.d.ts", "devDependencies": { diff --git a/readme.md b/readme.md index 4ec115c..decd413 100644 --- a/readme.md +++ b/readme.md @@ -1,197 +1,218 @@ -Mixpanel-node -============= +# Mixpanel-node + ![Build Status](https://github.com/mixpanel/mixpanel-node/actions/workflows/tests.yml/badge.svg) -This library provides many of the features in the official JavaScript mixpanel library. It is easy to use, and fully async. It is intended to be used on the server (it is not a client module). The in-browser client library is available +This library provides many of the features in the official JavaScript mixpanel library. It is easy to use, and fully async. It is intended to be used on the server (it is not a client module). The in-browser client library is available at [https://github.com/mixpanel/mixpanel-js](https://github.com/mixpanel/mixpanel-js). -Installation ------------- +## Installation npm install mixpanel -Quick Start ------------ +## Quick Start ```javascript // grab the Mixpanel factory -var Mixpanel = require('mixpanel'); +var Mixpanel = require("mixpanel"); // create an instance of the mixpanel client -var mixpanel = Mixpanel.init(''); +var mixpanel = Mixpanel.init(""); // initialize mixpanel client configured to communicate over http instead of https -var mixpanel = Mixpanel.init('', { - protocol: 'http', +var mixpanel = Mixpanel.init("", { + protocol: "http", }); // turn off keepAlive (reestablish connection on each request) -var mixpanel = Mixpanel.init('', { - keepAlive: false, +var mixpanel = Mixpanel.init("", { + keepAlive: false, }); // pass the custom logger (default is console) -var mixpanel = Mixpanel.init('', { - debug: true, - logger: pinoLogger, // or bunyan, or any other logger that implements the same interface +var mixpanel = Mixpanel.init("", { + debug: true, + logger: pinoLogger, // or bunyan, or any other logger that implements the same interface }); // track an event with optional properties -mixpanel.track('my event', { - distinct_id: 'some unique client id', - as: 'many', - properties: 'as', - you: 'want' +mixpanel.track("my event", { + distinct_id: "some unique client id", + as: "many", + properties: "as", + you: "want", }); -mixpanel.track('played_game'); +mixpanel.track("played_game"); // set an IP address to get automatic geolocation info -mixpanel.track('my event', {ip: '127.0.0.1'}); +mixpanel.track("my event", { ip: "127.0.0.1" }); // track an event with a specific timestamp (up to 5 days old; // use mixpanel.import() for older events) -mixpanel.track('timed event', {time: new Date()}); +mixpanel.track("timed event", { time: new Date() }); // create or update a user in Mixpanel Engage -mixpanel.people.set('billybob', { - $first_name: 'Billy', - $last_name: 'Bob', - $created: (new Date('jan 1 2013')).toISOString(), - plan: 'premium', - games_played: 1, - points: 0 +mixpanel.people.set("billybob", { + $first_name: "Billy", + $last_name: "Bob", + $created: new Date("jan 1 2013").toISOString(), + plan: "premium", + games_played: 1, + points: 0, }); // create or update a user in Mixpanel Engage without altering $last_seen // - pass option $ignore_time: true to prevent the $last_seen property from being updated -mixpanel.people.set('billybob', { - plan: 'premium', - games_played: 1 -}, { - $ignore_time: true -}); +mixpanel.people.set( + "billybob", + { + plan: "premium", + games_played: 1, + }, + { + $ignore_time: true, + }, +); // set a user profile's IP address to get automatic geolocation info -mixpanel.people.set('billybob', { - plan: 'premium', - games_played: 1 -}, { - $ip: '127.0.0.1' -}); +mixpanel.people.set( + "billybob", + { + plan: "premium", + games_played: 1, + }, + { + $ip: "127.0.0.1", + }, +); // set a user profile's latitude and longitude to get automatic geolocation info -mixpanel.people.set('billybob', { - plan: 'premium', - games_played: 1 -}, { +mixpanel.people.set( + "billybob", + { + plan: "premium", + games_played: 1, + }, + { $latitude: 40.7127753, - $longitude: -74.0059728 -}); + $longitude: -74.0059728, + }, +); // set a single property on a user -mixpanel.people.set('billybob', 'plan', 'free'); +mixpanel.people.set("billybob", "plan", "free"); // set a single property on a user, don't override -mixpanel.people.set_once('billybob', 'first_game_play', (new Date('jan 1 2013')).toISOString()); +mixpanel.people.set_once( + "billybob", + "first_game_play", + new Date("jan 1 2013").toISOString(), +); // increment a numeric property -mixpanel.people.increment('billybob', 'games_played'); +mixpanel.people.increment("billybob", "games_played"); // increment a numeric property by a different amount -mixpanel.people.increment('billybob', 'points', 15); +mixpanel.people.increment("billybob", "points", 15); // increment multiple properties -mixpanel.people.increment('billybob', {'points': 10, 'games_played': 1}); +mixpanel.people.increment("billybob", { points: 10, games_played: 1 }); // append value to a list -mixpanel.people.append('billybob', 'awards', 'Great Player'); +mixpanel.people.append("billybob", "awards", "Great Player"); // append multiple values to a list -mixpanel.people.append('billybob', {'awards': 'Great Player', 'levels_finished': 'Level 4'}); +mixpanel.people.append("billybob", { + awards: "Great Player", + levels_finished: "Level 4", +}); // merge value to a list (ignoring duplicates) -mixpanel.people.union('billybob', {'browsers': 'ie'}); +mixpanel.people.union("billybob", { browsers: "ie" }); // merge multiple values to a list (ignoring duplicates) -mixpanel.people.union('billybob', {'browsers': ['ie', 'chrome']}); - +mixpanel.people.union("billybob", { browsers: ["ie", "chrome"] }); // record a transaction for revenue analytics -mixpanel.people.track_charge('billybob', 39.99); +mixpanel.people.track_charge("billybob", 39.99); // clear a users transaction history -mixpanel.people.clear_charges('billybob'); +mixpanel.people.clear_charges("billybob"); // delete a user -mixpanel.people.delete_user('billybob'); +mixpanel.people.delete_user("billybob"); // delete a user in Mixpanel Engage without altering $last_seen or resolving aliases // - pass option $ignore_time: true to prevent the $last_seen property from being updated // (useful if you subsequently re-import data for the same distinct ID) -mixpanel.people.delete_user('billybob', {$ignore_time: true, $ignore_alias: true}); +mixpanel.people.delete_user("billybob", { + $ignore_time: true, + $ignore_alias: true, +}); // Create an alias for an existing distinct id -mixpanel.alias('distinct_id', 'your_alias'); +mixpanel.alias("distinct_id", "your_alias"); // all functions that send data to mixpanel take an optional // callback as the last argument -mixpanel.track('test', function(err) { if (err) throw err; }); +mixpanel.track("test", function (err) { + if (err) throw err; +}); // track multiple events at once mixpanel.track_batch([ - { - event: 'recent event', - properties: { - time: new Date(), - distinct_id: 'billybob', - gender: 'male' - } + { + event: "recent event", + properties: { + time: new Date(), + distinct_id: "billybob", + gender: "male", + }, + }, + { + event: "another recent event", + properties: { + distinct_id: "billybob", + color: "red", }, - { - event: 'another recent event', - properties: { - distinct_id: 'billybob', - color: 'red' - } - } + }, ]); // import an old event -var mixpanel_importer = Mixpanel.init('valid mixpanel token', { - secret: 'valid api secret for project' +var mixpanel_importer = Mixpanel.init("valid mixpanel token", { + secret: "valid api secret for project", }); // needs to be in the system once for it to show up in the interface -mixpanel_importer.track('old event', { gender: '' }); +mixpanel_importer.track("old event", { gender: "" }); -mixpanel_importer.import('old event', new Date(2012, 4, 20, 12, 34, 56), { - distinct_id: 'billybob', - gender: 'male' +mixpanel_importer.import("old event", new Date(2012, 4, 20, 12, 34, 56), { + distinct_id: "billybob", + gender: "male", }); // import multiple events at once mixpanel_importer.import_batch([ - { - event: 'old event', - properties: { - time: new Date(2012, 4, 20, 12, 34, 56), - distinct_id: 'billybob', - gender: 'male' - } + { + event: "old event", + properties: { + time: new Date(2012, 4, 20, 12, 34, 56), + distinct_id: "billybob", + gender: "male", }, - { - event: 'another old event', - properties: { - time: new Date(2012, 4, 21, 11, 33, 55), - distinct_id: 'billybob', - color: 'red' - } - } + }, + { + event: "another old event", + properties: { + time: new Date(2012, 4, 21, 11, 33, 55), + distinct_id: "billybob", + color: "red", + }, + }, ]); ``` -FAQ ---- +## FAQ + **Where is `mixpanel.identify()`?** `mixpanel-node` is a server-side library, optimized for stateless shared usage; e.g., @@ -209,23 +230,19 @@ for users between requests, you will need to load these properties from a source to your app (e.g., your session store or database) and pass them explicitly with each tracking call. - -Tests ------ +## Tests # in the mixpanel directory npm install npm test -Alternative Clients and Related Tools -------------------------------------- +## Alternative Clients and Related Tools - [Mixpanel-CLI](https://github.com/FGRibreau/mixpanel-cli) - CLI for Mixpanel API (currently only supports tracking functions) - [Mixpanel Data Export](https://github.com/michaelcarter/mixpanel-data-export-js) - Supports various query and data-management APIs; runs in both Node.js and browser - [Mixpanel Data Export (strawbrary)](https://github.com/strawbrary/mixpanel-data-export-js) - Fork of previous library, optimized for Node.js with support for streaming large raw exports -Attribution/Credits -------------------- +## Attribution/Credits Heavily inspired by the original js library copyright Mixpanel, Inc. (http://mixpanel.com/) @@ -234,39 +251,39 @@ Copyright (c) 2014-21 Mixpanel Original Library Copyright (c) 2012-14 Carl Sverre Contributions from: - - [Andres Gottlieb](https://github.com/andresgottlieb) - - [Ken Perkins](https://github.com/kenperkins) - - [Nathan Rajlich](https://github.com/TooTallNate) - - [Thomas Watson Steen](https://github.com/watson) - - [Gabor Ratky](https://github.com/rgabo) - - [wwlinx](https://github.com/wwlinx) - - [PierrickP](https://github.com/PierrickP) - - [lukapril](https://github.com/lukapril) - - [sandinmyjoints](https://github.com/sandinmyjoints) - - [Jyrki Laurila](https://github.com/jylauril) - - [Zeevl](https://github.com/zeevl) - - [Tobias Baunbæk](https://github.com/freeall) - - [Eduardo Sorribas](https://github.com/sorribas) - - [Nick Chang](https://github.com/maeldur) - - [Michael G](https://github.com/gmichael225) - - [Tejas Manohar](https://github.com/tejasmanohar) - - [Eelke Boezeman](https://github.com/godspeedelbow) - - [Jim Thomas](https://github.com/Left47) - - [Frank Chiang](https://github.com/chiangf) - - [Morgan Croney](https://github.com/cruzanmo) - - [Cole Furfaro-Strode](https://github.com/colestrode) - - [Jonas Hermsmeier](https://github.com/jhermsmeier) - - [Marko Klopets](https://github.com/mklopets) - - [Cameron Diver](https://github.com/CameronDiver) - - [veerabio](https://github.com/veerabio) - - [Will Neild](https://github.com/wneild) - - [Elijah Insua](https://github.com/tmpvar) - - [Arsal Imam](https://github.com/ArsalImam) - - [Aleksei Iatsiuk](https://github.com/iatsiuk) - - [Vincent Giersch](https://github.com/gierschv) - -License -------------------- - -Released under the MIT license. See file called LICENSE for more + +- [Andres Gottlieb](https://github.com/andresgottlieb) +- [Ken Perkins](https://github.com/kenperkins) +- [Nathan Rajlich](https://github.com/TooTallNate) +- [Thomas Watson Steen](https://github.com/watson) +- [Gabor Ratky](https://github.com/rgabo) +- [wwlinx](https://github.com/wwlinx) +- [PierrickP](https://github.com/PierrickP) +- [lukapril](https://github.com/lukapril) +- [sandinmyjoints](https://github.com/sandinmyjoints) +- [Jyrki Laurila](https://github.com/jylauril) +- [Zeevl](https://github.com/zeevl) +- [Tobias Baunbæk](https://github.com/freeall) +- [Eduardo Sorribas](https://github.com/sorribas) +- [Nick Chang](https://github.com/maeldur) +- [Michael G](https://github.com/gmichael225) +- [Tejas Manohar](https://github.com/tejasmanohar) +- [Eelke Boezeman](https://github.com/godspeedelbow) +- [Jim Thomas](https://github.com/Left47) +- [Frank Chiang](https://github.com/chiangf) +- [Morgan Croney](https://github.com/cruzanmo) +- [Cole Furfaro-Strode](https://github.com/colestrode) +- [Jonas Hermsmeier](https://github.com/jhermsmeier) +- [Marko Klopets](https://github.com/mklopets) +- [Cameron Diver](https://github.com/CameronDiver) +- [veerabio](https://github.com/veerabio) +- [Will Neild](https://github.com/wneild) +- [Elijah Insua](https://github.com/tmpvar) +- [Arsal Imam](https://github.com/ArsalImam) +- [Aleksei Iatsiuk](https://github.com/iatsiuk) +- [Vincent Giersch](https://github.com/gierschv) + +## License + +Released under the MIT license. See file called LICENSE for more details. diff --git a/test/alias.js b/test/alias.js index 66ea539..f422c10 100644 --- a/test/alias.js +++ b/test/alias.js @@ -2,7 +2,6 @@ const Mixpanel = require("../lib/mixpanel-node"); describe("alias", () => { let mixpanel; - let sendRequestMock; beforeEach(() => { mixpanel = Mixpanel.init("token", { key: "key" }); vi.spyOn(mixpanel, "send_request"); diff --git a/test/import.js b/test/import.js index d913144..5a1b867 100644 --- a/test/import.js +++ b/test/import.js @@ -203,7 +203,7 @@ describe("import_batch", () => { describe("import_batch_integration", () => { let mixpanel; - let http_emitter; + let _http_emitter; let event_list; let res; beforeEach(() => { @@ -211,7 +211,7 @@ describe("import_batch_integration", () => { vi.spyOn(https, "request"); - http_emitter = new events.EventEmitter(); + _http_emitter = new events.EventEmitter(); // stub sequence of https responses res = []; @@ -222,7 +222,7 @@ describe("import_batch_integration", () => { return { write: () => {}, end: () => {}, - on: (event) => {}, + on: () => {}, }; }); } diff --git a/test/logger.js b/test/logger.js index 83d2141..c201035 100644 --- a/test/logger.js +++ b/test/logger.js @@ -69,7 +69,7 @@ describe("logger", () => { let mixpanel; let customLogger; let consoleDebugFn; - beforeAll((cb) => { + beforeAll(() => { /** * Custom logger must be an object with the following methods: * @@ -125,7 +125,7 @@ describe("logger", () => { expect(message).toMatch(/Sending the following data/); }); - it("writes log for remove() method", (test) => { + it("writes log for remove() method", () => { mixpanel.set_config({ debug: true }); mixpanel.people.remove("bob", { browsers: "firefox" }); diff --git a/test/people.js b/test/people.js index 5911e3f..7d69c26 100644 --- a/test/people.js +++ b/test/people.js @@ -1,5 +1,4 @@ const Mixpanel = require("../lib/mixpanel-node"); -const { create_profile_helpers } = require("../lib/profile_helpers"); describe("people", () => { const endpoint = "/engage"; diff --git a/test/track.js b/test/track.js index 07cf3d1..3e6ac08 100644 --- a/test/track.js +++ b/test/track.js @@ -3,7 +3,6 @@ const events = require("events"); const proxyquire = require("proxyquire"); const Mixpanel = require("../lib/mixpanel-node"); const packageInfo = require("../package.json"); -const utils = require("../lib/utils"); var mock_now_time = new Date(2016, 1, 1).getTime(); @@ -64,15 +63,7 @@ describe("track", () => { ); }); - it("can be called with optional callback", (test) => { - var expected_endpoint = "/track", - expected_data = { - event: "test", - properties: { - token: "token", - }, - }; - + it("can be called with optional callback", () => { mixpanel.send_request.mockImplementationOnce((_, cb) => cb(undefined)); const callback = vi.fn(); @@ -80,7 +71,7 @@ describe("track", () => { expect(callback).toHaveBeenCalledWith(undefined); }); - it("supports Date object for time", (test) => { + it("supports Date object for time", () => { var event = "test", time = new Date(mock_now_time), props = { time: time }, @@ -106,7 +97,7 @@ describe("track", () => { ); }); - it("supports unix timestamp for time", (test) => { + it("supports unix timestamp for time", () => { var event = "test", time = mock_now_time, props = { time: time }, @@ -141,7 +132,7 @@ describe("track", () => { ); }); - it("does not require time property", (test) => { + it("does not require time property", () => { var event = "test", props = {}; @@ -223,7 +214,7 @@ describe("track_batch", () => { describe("track_batch_integration", () => { let mixpanel; - let http_emitter; + let _http_emitter; let res; let event_list; beforeEach(() => { @@ -232,7 +223,7 @@ describe("track_batch_integration", () => { vi.spyOn(https, "request"); - http_emitter = new events.EventEmitter(); + _http_emitter = new events.EventEmitter(); // stub sequence of https responses res = []; @@ -243,7 +234,7 @@ describe("track_batch_integration", () => { return { write: function () {}, end: function () {}, - on: function (event) {}, + on: function () {}, }; }); } @@ -300,7 +291,7 @@ describe("track_batch_integration", () => { expect(callback).toHaveBeenCalledWith(null, [undefined]); }); - it("sends more requests when max_batch_size < 50", (test) => { + it("sends more requests when max_batch_size < 50", () => { const callback = vi.fn(); mixpanel.track_batch(event_list, { max_batch_size: 30 }, callback); for (var ri = 0; ri < 5; ri++) { @@ -318,7 +309,7 @@ describe("track_batch_integration", () => { ]); }); - it("can set max concurrent requests", (test) => { + it("can set max concurrent requests", () => { const async_all_stub = vi.fn(); async_all_stub.mockImplementation((_, __, cb) => cb(null)); const PatchedMixpanel = proxyquire("../lib/mixpanel-node", { diff --git a/test/utils.js b/test/utils.js index efe3e46..8b170e2 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,7 +1,7 @@ const async_all = require("../lib/utils").async_all; describe("async_all", () => { - it("calls callback with empty results if no requests", (done) => { + it("calls callback with empty results if no requests", () => { const requests = []; const handler_fn = vi.fn((_, cb) => cb()); const callback = vi.fn(); diff --git a/vitest.config.mjs b/vitest.config.mjs index 389bc88..99de07b 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -1,16 +1,11 @@ -import {coverageConfigDefaults, defineConfig} from 'vitest/config'; +import { coverageConfigDefaults, defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, - include: [ - 'test/**/*.js' - ], + include: ["test/**/*.js"], coverage: { - exclude: [ - ...coverageConfigDefaults.exclude, - 'example.js' - ] - } + exclude: [...coverageConfigDefaults.exclude, "example.js"], + }, }, -}) +}); From 32ce172e14c52744bee2fdb36467f0502486db0d Mon Sep 17 00:00:00 2001 From: Hans Li Date: Thu, 13 Nov 2025 13:40:26 -0800 Subject: [PATCH 03/23] Github actions --- .github/workflows/tests.yml | 2 ++ lib/mixpanel-node.js | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c10f96c..c64c8a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,4 +21,6 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm ci + - run: npm run check-format + - run: npm run lint - run: npm test -- run --coverage diff --git a/lib/mixpanel-node.js b/lib/mixpanel-node.js index c3bfb76..0b7721b 100644 --- a/lib/mixpanel-node.js +++ b/lib/mixpanel-node.js @@ -158,7 +158,9 @@ var create_client = function (token, config) { e = new Error("Mixpanel Server Error: " + result.error); } } catch (ex) { - e = new Error("Could not parse response from Mixpanel " + ex.message); + e = new Error( + "Could not parse response from Mixpanel " + ex.message, + ); } } else { e = From fce257ef4e4e6a64949d780d37541622afd10c34 Mon Sep 17 00:00:00 2001 From: Hans Li Date: Thu, 13 Nov 2025 13:42:25 -0800 Subject: [PATCH 04/23] no-var --- .oxlintrc.json | 5 ++++ example.js | 6 ++--- lib/mixpanel-node.js | 26 ++++++++++---------- lib/people.js | 14 +++++------ lib/profile_helpers.js | 2 +- lib/utils.js | 4 +-- test/alias.js | 2 +- test/config.js | 2 +- test/import.js | 36 +++++++++++++-------------- test/people.js | 8 +++--- test/send_request.js | 56 +++++++++++++++++++++--------------------- test/track.js | 34 ++++++++++++------------- 12 files changed, 100 insertions(+), 95 deletions(-) create mode 100644 .oxlintrc.json diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..f6f33dc --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-var": "error" + } +} diff --git a/example.js b/example.js index f20a3b2..7d05a7e 100644 --- a/example.js +++ b/example.js @@ -1,8 +1,8 @@ // grab the Mixpanel factory -var Mixpanel = require("./lib/mixpanel-node"); +const Mixpanel = require("./lib/mixpanel-node"); // create an instance of the mixpanel client -var mixpanel = Mixpanel.init("962dbca1bbc54701d402c94d65b4a20e"); +const mixpanel = Mixpanel.init("962dbca1bbc54701d402c94d65b4a20e"); mixpanel.set_config({ debug: true }); // track an event with optional properties @@ -83,7 +83,7 @@ mixpanel.track("test", function (err) { }); // import an old event -var mixpanel_importer = Mixpanel.init("valid mixpanel token", { +const mixpanel_importer = Mixpanel.init("valid mixpanel token", { secret: "valid api secret for project", }); mixpanel_importer.set_config({ debug: true }); diff --git a/lib/mixpanel-node.js b/lib/mixpanel-node.js index 0b7721b..d7cdb85 100644 --- a/lib/mixpanel-node.js +++ b/lib/mixpanel-node.js @@ -37,7 +37,7 @@ const DEFAULT_CONFIG = { logger: console, }; -var create_client = function (token, config) { +const create_client = function (token, config) { if (!token) { throw new Error( "The Mixpanel Client needs a Mixpanel token: `init(token)`", @@ -144,16 +144,16 @@ var create_client = function (token, config) { querystring.stringify(query_params); request = request_lib.request(request_options, function (res) { - var data = ""; + let data = ""; res.on("data", function (chunk) { data += chunk; }); res.on("end", function () { - var e; + let e; if (metrics.config.verbose) { try { - var result = JSON.parse(data); + const result = JSON.parse(data); if (result.status != 1) { e = new Error("Mixpanel Server Error: " + result.error); } @@ -203,7 +203,7 @@ var create_client = function (token, config) { properties.mp_lib = "node"; properties.$lib_version = packageInfo.version; - var data = { + const data = { event: event, properties: properties, }; @@ -226,8 +226,8 @@ var create_client = function (token, config) { * @param {number} size * @returns {Array} */ - var chunk = function (arr, size) { - var chunks = [], + const chunk = function (arr, size) { + let chunks = [], i = 0, total = arr.length; @@ -247,8 +247,8 @@ var create_client = function (token, config) { * @param {Function} [callback] callback receives array of errors if any * */ - var send_batch_requests = function (options, callback) { - var event_list = options.event_list, + const send_batch_requests = function (options, callback) { + const event_list = options.event_list, endpoint = options.endpoint, max_batch_size = options.max_batch_size ? Math.min(MAX_BATCH_SIZE, options.max_batch_size) @@ -294,7 +294,7 @@ var create_client = function (token, config) { * @param {number} index */ function send_next_request_batch(index) { - var request_batch = request_batches[index], + const request_batch = request_batches[index], cb = function (errors, results) { index += 1; if (index === total_request_batches) { @@ -361,7 +361,7 @@ var create_client = function (token, config) { callback = options; options = {}; } - var batch_options = { + const batch_options = { event_list: event_list, endpoint: "/track", max_concurrent_requests: options.max_concurrent_requests, @@ -445,7 +445,7 @@ var create_client = function (token, config) { finished or an error occurs */ metrics.import_batch = function (event_list, options, callback) { - var batch_options; + let batch_options; if (typeof options === "function" || !options) { callback = options; @@ -472,7 +472,7 @@ var create_client = function (token, config) { alias:string the future alias */ metrics.alias = function (distinct_id, alias, callback) { - var properties = { + const properties = { distinct_id: distinct_id, alias: alias, }; diff --git a/lib/people.js b/lib/people.js index b35827d..aba1e97 100644 --- a/lib/people.js +++ b/lib/people.js @@ -69,7 +69,7 @@ class MixpanelPeople extends ProfileHelpers() { increment(distinct_id, prop, by, modifiers, callback) { // TODO extract to ProfileHelpers - var $add = {}; + const $add = {}; if (typeof prop === "object") { if (typeof by === "object") { @@ -107,7 +107,7 @@ class MixpanelPeople extends ProfileHelpers() { } } - var data = { + let data = { $add: $add, $token: this.mixpanel.token, $distinct_id: distinct_id, @@ -147,7 +147,7 @@ class MixpanelPeople extends ProfileHelpers() { append(distinct_id, prop, value, modifiers, callback) { // TODO extract to ProfileHelpers - var $append = {}; + const $append = {}; if (typeof prop === "object") { if (typeof value === "object") { @@ -166,7 +166,7 @@ class MixpanelPeople extends ProfileHelpers() { } } - var data = { + let data = { $append: $append, $token: this.mixpanel.token, $distinct_id: distinct_id, @@ -231,13 +231,13 @@ class MixpanelPeople extends ProfileHelpers() { properties.$amount = amount; if (properties.hasOwnProperty("$time")) { - var time = properties.$time; + const time = properties.$time; if (Object.prototype.toString.call(time) === "[object Date]") { properties.$time = time.toISOString(); } } - var data = { + let data = { $append: { $transactions: properties }, $token: this.mixpanel.token, $distinct_id: distinct_id, @@ -268,7 +268,7 @@ class MixpanelPeople extends ProfileHelpers() { mixpanel.people.clear_charges('bob'); */ clear_charges(distinct_id, modifiers, callback) { - var data = { + let data = { $set: { $transactions: [] }, $token: this.mixpanel.token, $distinct_id: distinct_id, diff --git a/lib/profile_helpers.js b/lib/profile_helpers.js index 0b729b3..5d528b0 100644 --- a/lib/profile_helpers.js +++ b/lib/profile_helpers.js @@ -183,7 +183,7 @@ exports.ProfileHelpers = (Base = Object) => for (const [key, val] of Object.entries(data)) { if (Array.isArray(val)) { - var merge_values = val.filter(function (v) { + const merge_values = val.filter(function (v) { return typeof v === "string" || typeof v === "number"; }); if (merge_values.length > 0) { diff --git a/lib/utils.js b/lib/utils.js index 0ccc6be..6466d14 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -6,7 +6,7 @@ * @param {Function} callback */ exports.async_all = function (requests, handler, callback) { - var total = requests.length, + let total = requests.length, errors = null, results = [], done = function (err, result) { @@ -24,7 +24,7 @@ exports.async_all = function (requests, handler, callback) { if (total === 0) { callback(errors, results); } else { - for (var i = 0, l = requests.length; i < l; i++) { + for (let i = 0, l = requests.length; i < l; i++) { handler(requests[i], done); } } diff --git a/test/alias.js b/test/alias.js index f422c10..40618e0 100644 --- a/test/alias.js +++ b/test/alias.js @@ -11,7 +11,7 @@ describe("alias", () => { }); it("calls send_request with correct endpoint and data", () => { - var alias = "test", + const alias = "test", distinct_id = "old_id", expected_endpoint = "/track", expected_data = { diff --git a/test/config.js b/test/config.js index e27773f..6293f1c 100644 --- a/test/config.js +++ b/test/config.js @@ -28,7 +28,7 @@ describe("config", () => { }); it("can be set during init", () => { - var mp = Mixpanel.init("token", { test: true }); + const mp = Mixpanel.init("token", { test: true }); expect(mp.config.test).toBe(true); }); diff --git a/test/import.js b/test/import.js index 5a1b867..fee959a 100644 --- a/test/import.js +++ b/test/import.js @@ -1,9 +1,9 @@ -var proxyquire = require("proxyquire"), +const proxyquire = require("proxyquire"), https = require("https"), events = require("events"), Mixpanel = require("../lib/mixpanel-node"); -var mock_now_time = new Date(2016, 1, 1).getTime(), +const mock_now_time = new Date(2016, 1, 1).getTime(), six_days_ago_timestamp = mock_now_time - 1000 * 60 * 60 * 24 * 6; describe("import", () => { @@ -19,7 +19,7 @@ describe("import", () => { }); it("calls send_request with correct endpoint and data", () => { - var event = "test", + const event = "test", time = six_days_ago_timestamp, props = { key1: "val1" }, expected_endpoint = "/import", @@ -44,7 +44,7 @@ describe("import", () => { }); it("supports a Date instance greater than 5 days old", () => { - var event = "test", + const event = "test", time = new Date(six_days_ago_timestamp), props = { key1: "val1" }, expected_endpoint = "/import", @@ -69,7 +69,7 @@ describe("import", () => { }); it("supports a Date instance less than 5 days old", () => { - var event = "test", + const event = "test", time = new Date(mock_now_time), props = { key1: "val1" }, expected_endpoint = "/import", @@ -94,7 +94,7 @@ describe("import", () => { }); it("supports a unix timestamp", () => { - var event = "test", + const event = "test", time = mock_now_time, props = { key1: "val1" }, expected_endpoint = "/import", @@ -142,7 +142,7 @@ describe("import_batch", () => { }); it("calls send_request with correct endpoint, data, and method", () => { - var expected_endpoint = "/import", + const expected_endpoint = "/import", event_list = [ { event: "test", properties: { key1: "val1", time: 500 } }, { event: "test", properties: { key2: "val2", time: 1000 } }, @@ -176,7 +176,7 @@ describe("import_batch", () => { }); it("requires the time argument for every event", () => { - var event_list = [ + const event_list = [ { event: "test", properties: { key1: "val1", time: 500 } }, { event: "test", properties: { key2: "val2", time: 1000 } }, { event: "test2", properties: { key2: "val2" } }, @@ -187,8 +187,8 @@ describe("import_batch", () => { }); it("batches 50 events at a time", () => { - var event_list = []; - for (var ei = 0; ei < 130; ei++) { + const event_list = []; + for (let ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 event_list.push({ event: "test", @@ -228,7 +228,7 @@ describe("import_batch_integration", () => { } event_list = []; - for (var ei = 0; ei < 130; ei++) { + for (let ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 event_list.push({ event: "test", @@ -246,7 +246,7 @@ describe("import_batch_integration", () => { expect(https.request).toHaveBeenCalledTimes(3); expect(error_list).toBe(null); }); - for (var ri = 0; ri < 3; ri++) { + for (let ri = 0; ri < 3; ri++) { res[ri].emit("data", "1"); res[ri].emit("end"); } @@ -256,7 +256,7 @@ describe("import_batch_integration", () => { mixpanel.import_batch(event_list, function (error_list) { expect(error_list.length).toBe(3); }); - for (var ri = 0; ri < 3; ri++) { + for (let ri = 0; ri < 3; ri++) { res[ri].emit("data", "0"); res[ri].emit("end"); } @@ -271,7 +271,7 @@ describe("import_batch_integration", () => { expect(error_list).toBe(null); }, ); - for (var ri = 0; ri < 3; ri++) { + for (let ri = 0; ri < 3; ri++) { res[ri].emit("data", "1"); res[ri].emit("end"); } @@ -286,15 +286,15 @@ describe("import_batch_integration", () => { expect(error_list).toBe(null); }, ); - for (var ri = 0; ri < 5; ri++) { + for (let ri = 0; ri < 5; ri++) { res[ri].emit("data", "1"); res[ri].emit("end"); } }); it("can set max concurrent requests", () => { - var async_all_stub = vi.fn(); - var PatchedMixpanel = proxyquire("../lib/mixpanel-node", { + const async_all_stub = vi.fn(); + const PatchedMixpanel = proxyquire("../lib/mixpanel-node", { "./utils": { async_all: async_all_stub }, }); async_all_stub.mockImplementationOnce((_, __, cb) => cb(null)); @@ -312,7 +312,7 @@ describe("import_batch_integration", () => { expect(error_list).toBe(null); }, ); - for (var ri = 0; ri < 5; ri++) { + for (let ri = 0; ri < 5; ri++) { res[ri].emit("data", "1"); res[ri].emit("end"); } diff --git a/test/people.js b/test/people.js index 7d69c26..060a4f6 100644 --- a/test/people.js +++ b/test/people.js @@ -29,7 +29,7 @@ describe("people", () => { args = [distinct_id, ...(args ? args : [])]; if (use_modifiers) { - var modifiers = { + const modifiers = { $ignore_alias: true, $ignore_time: true, $ip: "1.2.3.4", @@ -369,7 +369,7 @@ describe("people", () => { }); it("supports being called with a property object", () => { - var time = new Date("Feb 1 2012"); + const time = new Date("Feb 1 2012"); test_send_request_args("track_charge", { args: [50, { $time: time, isk: "isk" }], expected: { @@ -393,7 +393,7 @@ describe("people", () => { }); it("supports being called with a property object and a modifiers argument", () => { - var time = new Date("Feb 1 2012"); + const time = new Date("Feb 1 2012"); test_send_request_args("track_charge", { args: [50, { $time: time, isk: "isk" }], expected: { @@ -435,7 +435,7 @@ describe("people", () => { }); it("supports being called with properties, modifiers and a callback", () => { - var time = new Date("Feb 1 2012"); + const time = new Date("Feb 1 2012"); test_send_request_args("track_charge", { args: [50, { $time: time, isk: "isk" }], expected: { diff --git a/test/send_request.js b/test/send_request.js index fa57373..01be8dc 100644 --- a/test/send_request.js +++ b/test/send_request.js @@ -37,7 +37,7 @@ describe("send_request", () => { }); it("sends correct data on GET", () => { - var endpoint = "/track", + const endpoint = "/track", data = { event: "test", properties: { @@ -63,7 +63,7 @@ describe("send_request", () => { }); it("defaults to GET", () => { - var endpoint = "/track", + const endpoint = "/track", data = { event: "test", properties: { @@ -88,7 +88,7 @@ describe("send_request", () => { }); it("sends correct data on POST", () => { - var endpoint = "/track", + const endpoint = "/track", data = { event: "test", properties: { @@ -157,8 +157,8 @@ describe("send_request", () => { }); it("default use keepAlive agent", () => { - var agent = new https.Agent({ keepAlive: false }); - var httpsStub = { + const agent = new https.Agent({ keepAlive: false }); + const httpsStub = { request: vi.fn().mockImplementation((_, cb) => { cb(res); return http_emitter; @@ -173,19 +173,19 @@ describe("send_request", () => { Mixpanel = proxyquire("../lib/mixpanel-node", { https: httpsStub, }); - var proxyMixpanel = Mixpanel.init("token"); + const proxyMixpanel = Mixpanel.init("token"); proxyMixpanel.send_request({ endpoint: "", data: {} }); - var getConfig = httpsStub.request.mock.calls[0][0]; - var agentOpts = httpsStub.Agent.mock.calls[0][0]; + const getConfig = httpsStub.request.mock.calls[0][0]; + const agentOpts = httpsStub.Agent.mock.calls[0][0]; expect(agentOpts.keepAlive).toBe(true); expect(getConfig.agent).toBe(agent); }); it("uses correct hostname", () => { - var host = "testhost.fakedomain"; - var customHostnameMixpanel = Mixpanel.init("token", { host: host }); - var expected_http_request = { + const host = "testhost.fakedomain"; + const customHostnameMixpanel = Mixpanel.init("token", { host: host }); + const expected_http_request = { host: host, }; @@ -198,9 +198,9 @@ describe("send_request", () => { }); it("uses correct port", () => { - var host = "testhost.fakedomain:1337"; - var customHostnameMixpanel = Mixpanel.init("token", { host: host }); - var expected_http_request = { + const host = "testhost.fakedomain:1337"; + const customHostnameMixpanel = Mixpanel.init("token", { host: host }); + const expected_http_request = { host: "testhost.fakedomain", port: 1337, }; @@ -214,13 +214,13 @@ describe("send_request", () => { }); it("uses correct path", () => { - var host = "testhost.fakedomain"; - var customPath = "/mypath"; - var customHostnameMixpanel = Mixpanel.init("token", { + const host = "testhost.fakedomain"; + const customPath = "/mypath"; + const customHostnameMixpanel = Mixpanel.init("token", { host, path: customPath, }); - var expected_http_request = { + const expected_http_request = { host, path: "/mypath?ip=0&verbose=0&data=e30%3D", }; @@ -233,13 +233,13 @@ describe("send_request", () => { }); it("combines custom path and endpoint", () => { - var host = "testhost.fakedomain"; - var customPath = "/mypath"; - var customHostnameMixpanel = Mixpanel.init("token", { + const host = "testhost.fakedomain"; + const customPath = "/mypath"; + const customHostnameMixpanel = Mixpanel.init("token", { host, path: customPath, }); - var expected_http_request = { + const expected_http_request = { host, path: "/mypath/track?ip=0&verbose=0&data=e30%3D", }; @@ -256,16 +256,16 @@ describe("send_request", () => { delete process.env.HTTPS_PROXY; process.env.HTTP_PROXY = "this.aint.real.https"; - var proxyMixpanel = Mixpanel.init("token"); + const proxyMixpanel = Mixpanel.init("token"); proxyMixpanel.send_request({ endpoint: "", data: {} }); expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); - var agentOpts = HttpsProxyAgent.mock.calls[0][0]; + const agentOpts = HttpsProxyAgent.mock.calls[0][0]; expect(agentOpts.pathname).toBe("this.aint.real.https"); expect(agentOpts.keepAlive).toBe(true); - var getConfig = https.request.mock.calls[0][0]; + const getConfig = https.request.mock.calls[0][0]; expect(getConfig.agent).toBeTruthy(); }); @@ -274,15 +274,15 @@ describe("send_request", () => { delete process.env.HTTP_PROXY; process.env.HTTPS_PROXY = "this.aint.real.https"; - var proxyMixpanel = Mixpanel.init("token"); + const proxyMixpanel = Mixpanel.init("token"); proxyMixpanel.send_request({ endpoint: "", data: {} }); expect(HttpsProxyAgent).toHaveBeenCalledTimes(1); - var proxyOpts = HttpsProxyAgent.mock.calls[0][0]; + const proxyOpts = HttpsProxyAgent.mock.calls[0][0]; expect(proxyOpts.pathname).toBe("this.aint.real.https"); - var getConfig = https.request.mock.calls[0][0]; + const getConfig = https.request.mock.calls[0][0]; expect(getConfig.agent).toBeTruthy(); }); diff --git a/test/track.js b/test/track.js index 3e6ac08..f6da647 100644 --- a/test/track.js +++ b/test/track.js @@ -4,7 +4,7 @@ const proxyquire = require("proxyquire"); const Mixpanel = require("../lib/mixpanel-node"); const packageInfo = require("../package.json"); -var mock_now_time = new Date(2016, 1, 1).getTime(); +const mock_now_time = new Date(2016, 1, 1).getTime(); describe("track", () => { let mixpanel; @@ -21,7 +21,7 @@ describe("track", () => { }); it("calls send_request with correct endpoint and data", () => { - var event = "test", + const event = "test", props = { key1: "val1" }, expected_endpoint = "/track", expected_data = { @@ -44,7 +44,7 @@ describe("track", () => { }); it("can be called with optional properties", () => { - var expected_endpoint = "/track", + const expected_endpoint = "/track", expected_data = { event: "test", properties: expect.objectContaining({ @@ -72,7 +72,7 @@ describe("track", () => { }); it("supports Date object for time", () => { - var event = "test", + const event = "test", time = new Date(mock_now_time), props = { time: time }, expected_endpoint = "/track", @@ -98,7 +98,7 @@ describe("track", () => { }); it("supports unix timestamp for time", () => { - var event = "test", + const event = "test", time = mock_now_time, props = { time: time }, expected_endpoint = "/track", @@ -124,7 +124,7 @@ describe("track", () => { }); it("throws error if time is not a number or Date", () => { - var event = "test", + const event = "test", props = { time: "not a number or Date" }; expect(() => mixpanel.track(event, props)).toThrowError( @@ -133,7 +133,7 @@ describe("track", () => { }); it("does not require time property", () => { - var event = "test", + const event = "test", props = {}; expect(() => mixpanel.track(event, props)).not.toThrowError(); @@ -154,7 +154,7 @@ describe("track_batch", () => { }); it("calls send_request with correct endpoint, data, and method", () => { - var expected_endpoint = "/track", + const expected_endpoint = "/track", event_list = [ { event: "test", properties: { key1: "val1", time: 500 } }, { event: "test", properties: { key2: "val2", time: 1000 } }, @@ -188,7 +188,7 @@ describe("track_batch", () => { }); it("does not require the time argument for every event", () => { - var event_list = [ + const event_list = [ { event: "test", properties: { key1: "val1", time: 500 } }, { event: "test", properties: { key2: "val2", time: 1000 } }, { event: "test2", properties: { key2: "val2" } }, @@ -197,8 +197,8 @@ describe("track_batch", () => { }); it("batches 50 events at a time", () => { - var event_list = []; - for (var ei = 0; ei < 130; ei++) { + const event_list = []; + for (let ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 event_list.push({ event: "test", @@ -240,7 +240,7 @@ describe("track_batch_integration", () => { } event_list = []; - for (var ei = 0; ei < 130; ei++) { + for (let ei = 0; ei < 130; ei++) { // 3 batches: 50 + 50 + 30 event_list.push({ event: "test", @@ -256,7 +256,7 @@ describe("track_batch_integration", () => { it("calls provided callback after all requests finish", () => { const callback = vi.fn(); mixpanel.track_batch(event_list, callback); - for (var ri = 0; ri < 3; ri++) { + for (let ri = 0; ri < 3; ri++) { res[ri].emit("data", "1"); res[ri].emit("end"); } @@ -272,7 +272,7 @@ describe("track_batch_integration", () => { it("passes error list to callback", () => { const callback = vi.fn(); mixpanel.track_batch(event_list, callback); - for (var ri = 0; ri < 3; ri++) { + for (let ri = 0; ri < 3; ri++) { res[ri].emit("data", "0"); res[ri].emit("end"); } @@ -282,7 +282,7 @@ describe("track_batch_integration", () => { it("calls provided callback when options are passed", () => { const callback = vi.fn(); mixpanel.track_batch(event_list, { max_batch_size: 100 }, callback); - for (var ri = 0; ri < 3; ri++) { + for (let ri = 0; ri < 3; ri++) { res[ri].emit("data", "1"); res[ri].emit("end"); } @@ -294,7 +294,7 @@ describe("track_batch_integration", () => { it("sends more requests when max_batch_size < 50", () => { const callback = vi.fn(); mixpanel.track_batch(event_list, { max_batch_size: 30 }, callback); - for (var ri = 0; ri < 5; ri++) { + for (let ri = 0; ri < 5; ri++) { res[ri].emit("data", "1"); res[ri].emit("end"); } @@ -324,7 +324,7 @@ describe("track_batch_integration", () => { { max_batch_size: 30, max_concurrent_requests: 2 }, callback, ); - for (var ri = 0; ri < 3; ri++) { + for (let ri = 0; ri < 3; ri++) { res[ri].emit("data", "1"); res[ri].emit("end"); } From d1ea57bd1484d72f9657a4b732822a667705eb95 Mon Sep 17 00:00:00 2001 From: Hans Li Date: Thu, 13 Nov 2025 16:51:01 -0800 Subject: [PATCH 05/23] Correct export --- lib/mixpanel-node.d.ts | 820 ++++++++++++++++++++--------------------- 1 file changed, 410 insertions(+), 410 deletions(-) diff --git a/lib/mixpanel-node.d.ts b/lib/mixpanel-node.d.ts index bbd316d..173d139 100644 --- a/lib/mixpanel-node.d.ts +++ b/lib/mixpanel-node.d.ts @@ -2,415 +2,415 @@ import LocalFeatureFlagsProvider from "./flags/local_flags"; import RemoteFeatureFlagsProvider from "./flags/remote_flags"; import { LocalFlagsConfig, RemoteFlagsConfig } from "./flags/types"; -declare namespace mixpanel { - export type Callback = (err: Error | undefined) => any; - export type BatchCallback = (errors: [Error] | undefined) => any; - - type Scalar = string | number | boolean; - - export interface CustomLogger { - trace(message?: any, ...optionalParams: any[]): void; - debug(message?: any, ...optionalParams: any[]): void; - info(message?: any, ...optionalParams: any[]): void; - warn(message?: any, ...optionalParams: any[]): void; - error(message?: any, ...optionalParams: any[]): void; - } - - export interface InitConfig { - test: boolean; - debug: boolean; - verbose: boolean; - host: string; - protocol: string; - path: string; - secret: string; - keepAlive: boolean; - geolocate: boolean; - logger: CustomLogger; - local_flags_config?: LocalFlagsConfig; - remote_flags_config?: RemoteFlagsConfig; - } - - export interface PropertyDict { - [key: string]: any; - } - - export interface NumberMap { - [key: string]: number; - } - - export interface Event { - event: string; - properties: PropertyDict; - } - export interface Modifiers { - $ip?: string; - $ignore_time?: boolean; - $time?: string; - $ignore_alias?: boolean; - $latitude?: number; - $longitude?: number; - } - - export interface BatchOptions { - max_concurrent_requests?: number; - max_batch_size?: number; - } - - export interface UnionData { - [key: string]: Scalar | Scalar[]; - } - - export interface RemoveData { - [key: string]: string | number; - } - - interface Mixpanel { - init(mixpanelToken: string, config?: Partial): Mixpanel; - - track(eventName: string, callback?: Callback): void; - track( - eventName: string, - properties: PropertyDict, - callback?: Callback, - ): void; - - track_batch( - events: Event[], - options?: BatchOptions, - callback?: BatchCallback, - ): void; - track_batch(events: Event[], callback: BatchCallback): void; - track_batch( - eventNames: string[], - options?: BatchOptions, - callback?: BatchCallback, - ): void; - track_batch(eventNames: string[], callback?: BatchCallback): void; - - import( - eventName: string, - time: Date | number, - properties?: PropertyDict, - callback?: Callback, - ): void; - import(eventName: string, time: Date | number, callback: Callback): void; - - import_batch( - eventNames: string[], - options?: BatchOptions, - callback?: BatchCallback, - ): void; - import_batch(eventNames: string[], callback?: BatchCallback): void; - import_batch(events: Event[], callback?: BatchCallback): void; - - alias(distinctId: string, alias: string, callback?: Callback): void; - - people: People; - - groups: Groups; - - local_flags?: LocalFeatureFlagsProvider; - - remote_flags?: RemoteFeatureFlagsProvider; - } - - interface People { - set( - distinctId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - set( - distinctId: string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - set( - distinctId: string, - propertyName: string, - value: string | number, - modifiers: Modifiers, - ): void; - set( - distinctId: string, - propertyName: string, - value: string | number, - callback?: Callback, - ): void; - set( - distinctId: string, - propertyName: string, - value: string | number, - modifiers: Modifiers, - callback: Callback, - ): void; - - unset( - distinctId: string, - propertyName: string | string[], - callback?: Callback, - ): void; - unset( - distinctId: string, - propertyName: string | string[], - modifiers?: Modifiers, - callback?: Callback, - ): void; - - set_once( - distinctId: string, - propertyName: string, - value: string, - callback?: Callback, - ): void; - set_once( - distinctId: string, - propertyName: string, - value: string, - modifiers: Modifiers, - callback?: Callback, - ): void; - set_once( - distinctId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - set_once( - distinctId: string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - - increment( - distinctId: string, - propertyName: string, - modifiers?: Modifiers, - callback?: Callback, - ): void; - increment( - distinctId: string, - propertyName: string, - incrementBy: number, - modifiers: Modifiers, - callback?: Callback, - ): void; - increment( - distinctId: string, - propertyName: string, - incrementBy: number, - callback?: Callback, - ): void; - increment( - distinctId: string, - properties: NumberMap, - modifiers: Modifiers, - callback?: Callback, - ): void; - increment( - distinctId: string, - properties: NumberMap, - callback?: Callback, - ): void; - - append( - distinctId: string, - propertyName: string, - value: any, - modifiers: Modifiers, - callback?: Callback, - ): void; - append( - distinctId: string, - propertyName: string, - value: any, - callback?: Callback, - ): void; - append( - distinctId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - append( - distinctId: string, - properties: PropertyDict, - modifiers: Modifiers, - callback?: Callback, - ): void; - - union( - distinctId: string, - data: UnionData, - modifiers?: Modifiers, - callback?: Callback, - ): void; - union(distinctId: string, data: UnionData, callback: Callback): void; - - remove( - distinctId: string, - data: RemoveData, - modifiers?: Modifiers, - callback?: Callback, - ): void; - remove(distinctId: string, data: RemoveData, callback: Callback): void; - - track_charge( - distinctId: string, - amount: number | string, - properties?: PropertyDict, - callback?: Callback, - ): void; - track_charge( - distinctId: string, - amount: number | string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - - clear_charges( - distinctId: string, - modifiers?: Modifiers, - callback?: Callback, - ): void; - clear_charges(distinctId: string, callback: Callback): void; - - delete_user( - distinctId: string, - modifiers?: Modifiers, - callback?: Callback, - ): void; - delete_user(distinctId: string, callback: Callback): void; - } - - interface Groups { - set( - groupKey: string, - groupId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - set( - groupKey: string, - groupId: string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - set( - groupKey: string, - groupId: string, - propertyName: string, - value: string | number, - modifiers: Modifiers, - ): void; - set( - groupKey: string, - groupId: string, - propertyName: string, - value: string | number, - callback?: Callback, - ): void; - set( - groupKey: string, - groupId: string, - propertyName: string, - value: string | number, - modifiers: Modifiers, - callback: Callback, - ): void; - - unset( - groupKey: string, - groupId: string, - propertyName: string | string[], - callback?: Callback, - ): void; - unset( - groupKey: string, - groupId: string, - propertyName: string | string[], - modifiers?: Modifiers, - callback?: Callback, - ): void; - - set_once( - groupKey: string, - groupId: string, - propertyName: string, - value: string, - callback?: Callback, - ): void; - set_once( - groupKey: string, - groupId: string, - propertyName: string, - value: string, - modifiers: Modifiers, - callback?: Callback, - ): void; - set_once( - groupKey: string, - groupId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - set_once( - groupKey: string, - groupId: string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - - union( - groupKey: string, - groupId: string, - data: UnionData, - modifiers?: Modifiers, - callback?: Callback, - ): void; - union( - groupKey: string, - groupId: string, - data: UnionData, - callback: Callback, - ): void; - - remove( - groupKey: string, - groupId: string, - data: RemoveData, - modifiers?: Modifiers, - callback?: Callback, - ): void; - remove( - groupKey: string, - groupId: string, - data: RemoveData, - callback: Callback, - ): void; - - delete_group( - groupKey: string, - groupId: string, - modifiers?: Modifiers, - callback?: Callback, - ): void; - delete_group(groupKey: string, groupId: string, callback: Callback): void; - } - - // Export feature flags types for convenience - export { - LocalFlagsConfig, - RemoteFlagsConfig, - FlagContext, - SelectedVariant, - } from "./flags/types"; - export { default as LocalFeatureFlagsProvider } from "./flags/local_flags"; - export { default as RemoteFeatureFlagsProvider } from "./flags/remote_flags"; +export type Callback = (err: Error | undefined) => any; +export type BatchCallback = (errors: [Error] | undefined) => any; + +type Scalar = string | number | boolean; + +export interface CustomLogger { + trace(message?: any, ...optionalParams: any[]): void; + debug(message?: any, ...optionalParams: any[]): void; + info(message?: any, ...optionalParams: any[]): void; + warn(message?: any, ...optionalParams: any[]): void; + error(message?: any, ...optionalParams: any[]): void; +} + +export interface InitConfig { + test: boolean; + debug: boolean; + verbose: boolean; + host: string; + protocol: string; + path: string; + secret: string; + keepAlive: boolean; + geolocate: boolean; + logger: CustomLogger; + local_flags_config?: LocalFlagsConfig; + remote_flags_config?: RemoteFlagsConfig; +} + +export interface PropertyDict { + [key: string]: any; +} + +export interface NumberMap { + [key: string]: number; +} + +export interface Event { + event: string; + properties: PropertyDict; +} +export interface Modifiers { + $ip?: string; + $ignore_time?: boolean; + $time?: string; + $ignore_alias?: boolean; + $latitude?: number; + $longitude?: number; +} + +export interface BatchOptions { + max_concurrent_requests?: number; + max_batch_size?: number; } -export = mixpanel; +export interface UnionData { + [key: string]: Scalar | Scalar[]; +} + +export interface RemoveData { + [key: string]: string | number; +} + +interface Mixpanel { + init(mixpanelToken: string, config?: Partial): Mixpanel; + + track(eventName: string, callback?: Callback): void; + track( + eventName: string, + properties: PropertyDict, + callback?: Callback, + ): void; + + track_batch( + events: Event[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; + track_batch(events: Event[], callback: BatchCallback): void; + track_batch( + eventNames: string[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; + track_batch(eventNames: string[], callback?: BatchCallback): void; + + import( + eventName: string, + time: Date | number, + properties?: PropertyDict, + callback?: Callback, + ): void; + import(eventName: string, time: Date | number, callback: Callback): void; + + import_batch( + eventNames: string[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; + import_batch(eventNames: string[], callback?: BatchCallback): void; + import_batch(events: Event[], callback?: BatchCallback): void; + + alias(distinctId: string, alias: string, callback?: Callback): void; + + people: People; + + groups: Groups; + + local_flags?: LocalFeatureFlagsProvider; + + remote_flags?: RemoteFeatureFlagsProvider; +} + +interface People { + set( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set( + distinctId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + callback?: Callback, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + callback: Callback, + ): void; + + unset( + distinctId: string, + propertyName: string | string[], + callback?: Callback, + ): void; + unset( + distinctId: string, + propertyName: string | string[], + modifiers?: Modifiers, + callback?: Callback, + ): void; + + set_once( + distinctId: string, + propertyName: string, + value: string, + callback?: Callback, + ): void; + set_once( + distinctId: string, + propertyName: string, + value: string, + modifiers: Modifiers, + callback?: Callback, + ): void; + set_once( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set_once( + distinctId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + increment( + distinctId: string, + propertyName: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + propertyName: string, + incrementBy: number, + modifiers: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + propertyName: string, + incrementBy: number, + callback?: Callback, + ): void; + increment( + distinctId: string, + properties: NumberMap, + modifiers: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + properties: NumberMap, + callback?: Callback, + ): void; + + append( + distinctId: string, + propertyName: string, + value: any, + modifiers: Modifiers, + callback?: Callback, + ): void; + append( + distinctId: string, + propertyName: string, + value: any, + callback?: Callback, + ): void; + append( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + append( + distinctId: string, + properties: PropertyDict, + modifiers: Modifiers, + callback?: Callback, + ): void; + + union( + distinctId: string, + data: UnionData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + union(distinctId: string, data: UnionData, callback: Callback): void; + + remove( + distinctId: string, + data: RemoveData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + remove(distinctId: string, data: RemoveData, callback: Callback): void; + + track_charge( + distinctId: string, + amount: number | string, + properties?: PropertyDict, + callback?: Callback, + ): void; + track_charge( + distinctId: string, + amount: number | string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + clear_charges( + distinctId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + clear_charges(distinctId: string, callback: Callback): void; + + delete_user( + distinctId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + delete_user(distinctId: string, callback: Callback): void; +} + +interface Groups { + set( + groupKey: string, + groupId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + callback: Callback, + ): void; + + unset( + groupKey: string, + groupId: string, + propertyName: string | string[], + callback?: Callback, + ): void; + unset( + groupKey: string, + groupId: string, + propertyName: string | string[], + modifiers?: Modifiers, + callback?: Callback, + ): void; + + set_once( + groupKey: string, + groupId: string, + propertyName: string, + value: string, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + propertyName: string, + value: string, + modifiers: Modifiers, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + union( + groupKey: string, + groupId: string, + data: UnionData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + union( + groupKey: string, + groupId: string, + data: UnionData, + callback: Callback, + ): void; + + remove( + groupKey: string, + groupId: string, + data: RemoveData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + remove( + groupKey: string, + groupId: string, + data: RemoveData, + callback: Callback, + ): void; + + delete_group( + groupKey: string, + groupId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + delete_group(groupKey: string, groupId: string, callback: Callback): void; +} + +// Export feature flags types for convenience +export { + LocalFlagsConfig, + RemoteFlagsConfig, + FlagContext, + SelectedVariant, +} from "./flags/types"; +export { default as LocalFeatureFlagsProvider } from "./flags/local_flags"; +export { default as RemoteFeatureFlagsProvider } from "./flags/remote_flags"; + +declare const Mixpanel: Mixpanel; + +export = Mixpanel; From bdeaa59228f98c71095c80d3da6034e0b6e3bef6 Mon Sep 17 00:00:00 2001 From: Hans Li Date: Thu, 13 Nov 2025 16:56:44 -0800 Subject: [PATCH 06/23] Fixed --- .github/workflows/tests.yml | 20 +- lib/mixpanel-node.d.ts | 812 ++++++++++++++++++------------------ tsconfig.json | 2 +- 3 files changed, 413 insertions(+), 421 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0684ab6..f55e4aa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,13 +15,13 @@ jobs: node-version: [20.x, 22.x, 24.x] steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - run: npm ci - - run: npm run check-format - - run: npm run lint - - run: npm test -- run --coverage - - run: npx tsc + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + - run: npm run check-format + - run: npm run lint + - run: npm test -- run --coverage + - run: npx tsc diff --git a/lib/mixpanel-node.d.ts b/lib/mixpanel-node.d.ts index 173d139..2982f73 100644 --- a/lib/mixpanel-node.d.ts +++ b/lib/mixpanel-node.d.ts @@ -2,415 +2,407 @@ import LocalFeatureFlagsProvider from "./flags/local_flags"; import RemoteFeatureFlagsProvider from "./flags/remote_flags"; import { LocalFlagsConfig, RemoteFlagsConfig } from "./flags/types"; -export type Callback = (err: Error | undefined) => any; -export type BatchCallback = (errors: [Error] | undefined) => any; - -type Scalar = string | number | boolean; - -export interface CustomLogger { - trace(message?: any, ...optionalParams: any[]): void; - debug(message?: any, ...optionalParams: any[]): void; - info(message?: any, ...optionalParams: any[]): void; - warn(message?: any, ...optionalParams: any[]): void; - error(message?: any, ...optionalParams: any[]): void; -} - -export interface InitConfig { - test: boolean; - debug: boolean; - verbose: boolean; - host: string; - protocol: string; - path: string; - secret: string; - keepAlive: boolean; - geolocate: boolean; - logger: CustomLogger; - local_flags_config?: LocalFlagsConfig; - remote_flags_config?: RemoteFlagsConfig; -} - -export interface PropertyDict { - [key: string]: any; -} - -export interface NumberMap { - [key: string]: number; -} - -export interface Event { - event: string; - properties: PropertyDict; -} -export interface Modifiers { - $ip?: string; - $ignore_time?: boolean; - $time?: string; - $ignore_alias?: boolean; - $latitude?: number; - $longitude?: number; -} - -export interface BatchOptions { - max_concurrent_requests?: number; - max_batch_size?: number; +declare const mixpanel: mixpanel.Mixpanel; + +declare namespace mixpanel { + export type Callback = (err: Error | undefined) => any; + export type BatchCallback = (errors: [Error] | undefined) => any; + + type Scalar = string | number | boolean; + + export interface CustomLogger { + trace(message?: any, ...optionalParams: any[]): void; + debug(message?: any, ...optionalParams: any[]): void; + info(message?: any, ...optionalParams: any[]): void; + warn(message?: any, ...optionalParams: any[]): void; + error(message?: any, ...optionalParams: any[]): void; + } + + export interface InitConfig { + test: boolean; + debug: boolean; + verbose: boolean; + host: string; + protocol: string; + path: string; + secret: string; + keepAlive: boolean; + geolocate: boolean; + logger: CustomLogger; + local_flags_config?: LocalFlagsConfig; + remote_flags_config?: RemoteFlagsConfig; + } + + export interface PropertyDict { + [key: string]: any; + } + + export interface NumberMap { + [key: string]: number; + } + + export interface Event { + event: string; + properties: PropertyDict; + } + export interface Modifiers { + $ip?: string; + $ignore_time?: boolean; + $time?: string; + $ignore_alias?: boolean; + $latitude?: number; + $longitude?: number; + } + + export interface BatchOptions { + max_concurrent_requests?: number; + max_batch_size?: number; + } + + export interface UnionData { + [key: string]: Scalar | Scalar[]; + } + + export interface RemoveData { + [key: string]: string | number; + } + + interface Mixpanel { + init(mixpanelToken: string, config?: Partial): Mixpanel; + + track(eventName: string, callback?: Callback): void; + track( + eventName: string, + properties: PropertyDict, + callback?: Callback, + ): void; + + track_batch( + events: Event[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; + track_batch(events: Event[], callback: BatchCallback): void; + track_batch( + eventNames: string[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; + track_batch(eventNames: string[], callback?: BatchCallback): void; + + import( + eventName: string, + time: Date | number, + properties?: PropertyDict, + callback?: Callback, + ): void; + import(eventName: string, time: Date | number, callback: Callback): void; + + import_batch( + eventNames: string[], + options?: BatchOptions, + callback?: BatchCallback, + ): void; + import_batch(eventNames: string[], callback?: BatchCallback): void; + import_batch(events: Event[], callback?: BatchCallback): void; + + alias(distinctId: string, alias: string, callback?: Callback): void; + + people: People; + + groups: Groups; + + local_flags?: LocalFeatureFlagsProvider; + + remote_flags?: RemoteFeatureFlagsProvider; + } + + interface People { + set( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set( + distinctId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + callback?: Callback, + ): void; + set( + distinctId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + callback: Callback, + ): void; + + unset( + distinctId: string, + propertyName: string | string[], + callback?: Callback, + ): void; + unset( + distinctId: string, + propertyName: string | string[], + modifiers?: Modifiers, + callback?: Callback, + ): void; + + set_once( + distinctId: string, + propertyName: string, + value: string, + callback?: Callback, + ): void; + set_once( + distinctId: string, + propertyName: string, + value: string, + modifiers: Modifiers, + callback?: Callback, + ): void; + set_once( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set_once( + distinctId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + increment( + distinctId: string, + propertyName: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + propertyName: string, + incrementBy: number, + modifiers: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + propertyName: string, + incrementBy: number, + callback?: Callback, + ): void; + increment( + distinctId: string, + properties: NumberMap, + modifiers: Modifiers, + callback?: Callback, + ): void; + increment( + distinctId: string, + properties: NumberMap, + callback?: Callback, + ): void; + + append( + distinctId: string, + propertyName: string, + value: any, + modifiers: Modifiers, + callback?: Callback, + ): void; + append( + distinctId: string, + propertyName: string, + value: any, + callback?: Callback, + ): void; + append( + distinctId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + append( + distinctId: string, + properties: PropertyDict, + modifiers: Modifiers, + callback?: Callback, + ): void; + + union( + distinctId: string, + data: UnionData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + union(distinctId: string, data: UnionData, callback: Callback): void; + + remove( + distinctId: string, + data: RemoveData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + remove(distinctId: string, data: RemoveData, callback: Callback): void; + + track_charge( + distinctId: string, + amount: number | string, + properties?: PropertyDict, + callback?: Callback, + ): void; + track_charge( + distinctId: string, + amount: number | string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + clear_charges( + distinctId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + clear_charges(distinctId: string, callback: Callback): void; + + delete_user( + distinctId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + delete_user(distinctId: string, callback: Callback): void; + } + + interface Groups { + set( + groupKey: string, + groupId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + callback?: Callback, + ): void; + set( + groupKey: string, + groupId: string, + propertyName: string, + value: string | number, + modifiers: Modifiers, + callback: Callback, + ): void; + + unset( + groupKey: string, + groupId: string, + propertyName: string | string[], + callback?: Callback, + ): void; + unset( + groupKey: string, + groupId: string, + propertyName: string | string[], + modifiers?: Modifiers, + callback?: Callback, + ): void; + + set_once( + groupKey: string, + groupId: string, + propertyName: string, + value: string, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + propertyName: string, + value: string, + modifiers: Modifiers, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + properties: PropertyDict, + callback?: Callback, + ): void; + set_once( + groupKey: string, + groupId: string, + properties: PropertyDict, + modifiers?: Modifiers, + callback?: Callback, + ): void; + + union( + groupKey: string, + groupId: string, + data: UnionData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + union( + groupKey: string, + groupId: string, + data: UnionData, + callback: Callback, + ): void; + + remove( + groupKey: string, + groupId: string, + data: RemoveData, + modifiers?: Modifiers, + callback?: Callback, + ): void; + remove( + groupKey: string, + groupId: string, + data: RemoveData, + callback: Callback, + ): void; + + delete_group( + groupKey: string, + groupId: string, + modifiers?: Modifiers, + callback?: Callback, + ): void; + delete_group(groupKey: string, groupId: string, callback: Callback): void; + } } -export interface UnionData { - [key: string]: Scalar | Scalar[]; -} - -export interface RemoveData { - [key: string]: string | number; -} - -interface Mixpanel { - init(mixpanelToken: string, config?: Partial): Mixpanel; - - track(eventName: string, callback?: Callback): void; - track( - eventName: string, - properties: PropertyDict, - callback?: Callback, - ): void; - - track_batch( - events: Event[], - options?: BatchOptions, - callback?: BatchCallback, - ): void; - track_batch(events: Event[], callback: BatchCallback): void; - track_batch( - eventNames: string[], - options?: BatchOptions, - callback?: BatchCallback, - ): void; - track_batch(eventNames: string[], callback?: BatchCallback): void; - - import( - eventName: string, - time: Date | number, - properties?: PropertyDict, - callback?: Callback, - ): void; - import(eventName: string, time: Date | number, callback: Callback): void; - - import_batch( - eventNames: string[], - options?: BatchOptions, - callback?: BatchCallback, - ): void; - import_batch(eventNames: string[], callback?: BatchCallback): void; - import_batch(events: Event[], callback?: BatchCallback): void; - - alias(distinctId: string, alias: string, callback?: Callback): void; - - people: People; - - groups: Groups; - - local_flags?: LocalFeatureFlagsProvider; - - remote_flags?: RemoteFeatureFlagsProvider; -} - -interface People { - set( - distinctId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - set( - distinctId: string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - set( - distinctId: string, - propertyName: string, - value: string | number, - modifiers: Modifiers, - ): void; - set( - distinctId: string, - propertyName: string, - value: string | number, - callback?: Callback, - ): void; - set( - distinctId: string, - propertyName: string, - value: string | number, - modifiers: Modifiers, - callback: Callback, - ): void; - - unset( - distinctId: string, - propertyName: string | string[], - callback?: Callback, - ): void; - unset( - distinctId: string, - propertyName: string | string[], - modifiers?: Modifiers, - callback?: Callback, - ): void; - - set_once( - distinctId: string, - propertyName: string, - value: string, - callback?: Callback, - ): void; - set_once( - distinctId: string, - propertyName: string, - value: string, - modifiers: Modifiers, - callback?: Callback, - ): void; - set_once( - distinctId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - set_once( - distinctId: string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - - increment( - distinctId: string, - propertyName: string, - modifiers?: Modifiers, - callback?: Callback, - ): void; - increment( - distinctId: string, - propertyName: string, - incrementBy: number, - modifiers: Modifiers, - callback?: Callback, - ): void; - increment( - distinctId: string, - propertyName: string, - incrementBy: number, - callback?: Callback, - ): void; - increment( - distinctId: string, - properties: NumberMap, - modifiers: Modifiers, - callback?: Callback, - ): void; - increment( - distinctId: string, - properties: NumberMap, - callback?: Callback, - ): void; - - append( - distinctId: string, - propertyName: string, - value: any, - modifiers: Modifiers, - callback?: Callback, - ): void; - append( - distinctId: string, - propertyName: string, - value: any, - callback?: Callback, - ): void; - append( - distinctId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - append( - distinctId: string, - properties: PropertyDict, - modifiers: Modifiers, - callback?: Callback, - ): void; - - union( - distinctId: string, - data: UnionData, - modifiers?: Modifiers, - callback?: Callback, - ): void; - union(distinctId: string, data: UnionData, callback: Callback): void; - - remove( - distinctId: string, - data: RemoveData, - modifiers?: Modifiers, - callback?: Callback, - ): void; - remove(distinctId: string, data: RemoveData, callback: Callback): void; - - track_charge( - distinctId: string, - amount: number | string, - properties?: PropertyDict, - callback?: Callback, - ): void; - track_charge( - distinctId: string, - amount: number | string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - - clear_charges( - distinctId: string, - modifiers?: Modifiers, - callback?: Callback, - ): void; - clear_charges(distinctId: string, callback: Callback): void; - - delete_user( - distinctId: string, - modifiers?: Modifiers, - callback?: Callback, - ): void; - delete_user(distinctId: string, callback: Callback): void; -} - -interface Groups { - set( - groupKey: string, - groupId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - set( - groupKey: string, - groupId: string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - set( - groupKey: string, - groupId: string, - propertyName: string, - value: string | number, - modifiers: Modifiers, - ): void; - set( - groupKey: string, - groupId: string, - propertyName: string, - value: string | number, - callback?: Callback, - ): void; - set( - groupKey: string, - groupId: string, - propertyName: string, - value: string | number, - modifiers: Modifiers, - callback: Callback, - ): void; - - unset( - groupKey: string, - groupId: string, - propertyName: string | string[], - callback?: Callback, - ): void; - unset( - groupKey: string, - groupId: string, - propertyName: string | string[], - modifiers?: Modifiers, - callback?: Callback, - ): void; - - set_once( - groupKey: string, - groupId: string, - propertyName: string, - value: string, - callback?: Callback, - ): void; - set_once( - groupKey: string, - groupId: string, - propertyName: string, - value: string, - modifiers: Modifiers, - callback?: Callback, - ): void; - set_once( - groupKey: string, - groupId: string, - properties: PropertyDict, - callback?: Callback, - ): void; - set_once( - groupKey: string, - groupId: string, - properties: PropertyDict, - modifiers?: Modifiers, - callback?: Callback, - ): void; - - union( - groupKey: string, - groupId: string, - data: UnionData, - modifiers?: Modifiers, - callback?: Callback, - ): void; - union( - groupKey: string, - groupId: string, - data: UnionData, - callback: Callback, - ): void; - - remove( - groupKey: string, - groupId: string, - data: RemoveData, - modifiers?: Modifiers, - callback?: Callback, - ): void; - remove( - groupKey: string, - groupId: string, - data: RemoveData, - callback: Callback, - ): void; - - delete_group( - groupKey: string, - groupId: string, - modifiers?: Modifiers, - callback?: Callback, - ): void; - delete_group(groupKey: string, groupId: string, callback: Callback): void; -} - -// Export feature flags types for convenience -export { - LocalFlagsConfig, - RemoteFlagsConfig, - FlagContext, - SelectedVariant, -} from "./flags/types"; -export { default as LocalFeatureFlagsProvider } from "./flags/local_flags"; -export { default as RemoteFeatureFlagsProvider } from "./flags/remote_flags"; - -declare const Mixpanel: Mixpanel; - -export = Mixpanel; +export = mixpanel; diff --git a/tsconfig.json b/tsconfig.json index f25f045..b7b0aa2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,6 @@ "isolatedModules": true, "noUncheckedSideEffectImports": true, "moduleDetection": "force", - "skipLibCheck": true, + "skipLibCheck": true } } From 064f4af63e0c31f6c5d387d442bf264f428d010e Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 14:48:08 -0600 Subject: [PATCH 07/23] extract var for test values --- test/flags/local_flags.js | 60 ++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 03dd2e3..32dfd41 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -20,6 +20,8 @@ const mockFailedFlagDefinitionsResponse = (statusCode) => { .reply(statusCode); }; +const USER_ID = "user123"; + const createTestFlag = ({ flagKey = "test_flag", context = "distinct_id", @@ -67,11 +69,21 @@ const createTestFlag = ({ }; }; + describe("LocalFeatureFlagsProvider", () => { + const TEST_TOKEN = "test-token"; const TEST_CONTEXT = { distinct_id: "test-user", }; + const FLAG_KEY = "test_flag"; + + function userContextWithRuntimeParameters(custom_properties) { + return { + ...TEST_CONTEXT, + custom_properties: custom_properties, + }; + } let mockTracker; let mockLogger; @@ -156,7 +168,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, {}, ); @@ -169,9 +181,9 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, - { distinct_id: "user123" }, + { distinct_id: USER_ID }, ); expect(result.variant_value).toBe("fallback"); }); @@ -190,7 +202,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "control" }, { distinct_id: "test_user" }, ); @@ -211,7 +223,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, { distinct_id: "test_user" }, ); @@ -224,7 +236,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, TEST_CONTEXT, ); @@ -237,7 +249,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, TEST_CONTEXT, ); @@ -248,20 +260,16 @@ describe("LocalFeatureFlagsProvider", () => { it("should respect runtime evaluation when satisfied", async () => { const runtimeEval = { plan: "premium", region: "US" }; const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); - mockFlagDefinitionsResponse([flag]); await provider.startPollingForDefinitions(); - const context = { - distinct_id: "user123", - custom_properties: { + const context = userContextWithRuntimeParameters({ plan: "premium", region: "US", - }, - }; + }); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, context, ); @@ -276,7 +284,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const context = { - distinct_id: "user123", + distinct_id: USER_ID, custom_properties: { plan: "basic", region: "US", @@ -284,7 +292,7 @@ describe("LocalFeatureFlagsProvider", () => { }; const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, context, ); @@ -306,7 +314,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, TEST_CONTEXT, ); @@ -330,7 +338,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, TEST_CONTEXT, ); @@ -354,7 +362,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, TEST_CONTEXT, ); @@ -375,7 +383,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "control" }, TEST_CONTEXT, ); @@ -388,7 +396,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, TEST_CONTEXT, ); @@ -406,7 +414,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, { distinct_id: "qa_user" }, ); @@ -439,7 +447,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); provider.getVariant( - "test_flag", + FLAG_KEY, { variant_value: "fallback" }, { company_id: "company123" }, ); @@ -552,7 +560,7 @@ describe("LocalFeatureFlagsProvider", () => { await provider.startPollingForDefinitions(); const result = provider.getVariantValue( - "test_flag", + FLAG_KEY, "default", TEST_CONTEXT, ); @@ -607,7 +615,7 @@ describe("LocalFeatureFlagsProvider", () => { mockFlagDefinitionsResponse([flag]); await provider.startPollingForDefinitions(); - const result = provider.isEnabled("test_flag", TEST_CONTEXT); + const result = provider.isEnabled(FLAG_KEY, TEST_CONTEXT); expect(result).toBe(true); }); @@ -624,7 +632,7 @@ describe("LocalFeatureFlagsProvider", () => { mockFlagDefinitionsResponse([flag]); await provider.startPollingForDefinitions(); - const result = provider.isEnabled("test_flag", TEST_CONTEXT); + const result = provider.isEnabled(FLAG_KEY, TEST_CONTEXT); expect(result).toBe(false); }); From 29b2d3b3fac022af4c935d8b18dac9f0f2a9817d Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 14:50:29 -0600 Subject: [PATCH 08/23] mark old runtime rules as legacy --- test/flags/local_flags.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 32dfd41..61db78a 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -257,9 +257,9 @@ describe("LocalFeatureFlagsProvider", () => { expect(["control", "treatment"]).toContain(result.variant_value); }); - it("should respect runtime evaluation when satisfied", async () => { - const runtimeEval = { plan: "premium", region: "US" }; - const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); + it("should respect legacy runtime evaluation when satisfied", async () => { + const legacyRuntimeRule = { plan: "premium", region: "US" }; + const flag = createTestFlag({ runtimeEvaluation: legacyRuntimeRule }); mockFlagDefinitionsResponse([flag]); await provider.startPollingForDefinitions(); @@ -276,9 +276,9 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe("fallback"); }); - it("should return fallback when runtime evaluation not satisfied", async () => { - const runtimeEval = { plan: "premium", region: "US" }; - const flag = createTestFlag({ runtimeEvaluation: runtimeEval }); + it("should return fallback when legacy runtime evaluation not satisfied", async () => { + const legacyRuntimeRule = { plan: "premium", region: "US" }; + const flag = createTestFlag({ runtimeEvaluation: legacyRuntimeRule }); mockFlagDefinitionsResponse([flag]); await provider.startPollingForDefinitions(); From fbde335123a45f0b9d4b509b7eca03e6515b5a74 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 15:00:09 -0600 Subject: [PATCH 09/23] succint flag setup to one line --- test/flags/local_flags.js | 41 +++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 61db78a..09ab077 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -68,7 +68,37 @@ const createTestFlag = ({ }, }; }; - +async function createFlagAndLoadItIntoSDK({ + flagKey = "test_flag", + context = "distinct_id", + variants = null, + variantOverride = null, + rolloutPercentage = 100.0, + runtimeEvaluation = null, + testUsers = null, + experimentId = null, + isExperimentActive = null, + variantSplits = null, + hashSalt = null, +} = {}, + provider +) { + const flag = createTestFlag({ + flagKey, + context, + variants, + variantOverride, + rolloutPercentage, + runtimeEvaluation, + testUsers, + experimentId, + isExperimentActive, + variantSplits, + hashSalt, + }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); +} describe("LocalFeatureFlagsProvider", () => { @@ -259,9 +289,7 @@ describe("LocalFeatureFlagsProvider", () => { it("should respect legacy runtime evaluation when satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; - const flag = createTestFlag({ runtimeEvaluation: legacyRuntimeRule }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); const context = userContextWithRuntimeParameters({ plan: "premium", @@ -278,10 +306,7 @@ describe("LocalFeatureFlagsProvider", () => { it("should return fallback when legacy runtime evaluation not satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; - const flag = createTestFlag({ runtimeEvaluation: legacyRuntimeRule }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); const context = { distinct_id: USER_ID, From 76d839308f9d934c0ce4ff2ce0b1cbdb2c10b451 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 15:23:30 -0600 Subject: [PATCH 10/23] DRY tests with helper - more succint --- test/flags/local_flags.js | 98 +++++++++++---------------------------- 1 file changed, 27 insertions(+), 71 deletions(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 09ab077..f633883 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -180,9 +180,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when flag does not exist", async () => { - const otherFlag = createTestFlag({ flagKey: "other_flag" }); - mockFlagDefinitionsResponse([otherFlag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ flagKey: "other_flag" }, provider); const result = provider.getVariant( "nonexistent_flag", @@ -193,9 +191,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when no context", async () => { - const flag = createTestFlag({ context: "distinct_id" }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ context: "distinct_id" }, provider); const result = provider.getVariant( FLAG_KEY, @@ -206,9 +202,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when wrong context key", async () => { - const flag = createTestFlag({ context: "user_id" }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ context: "user_id" }, provider); const result = provider.getVariant( FLAG_KEY, @@ -223,13 +217,10 @@ describe("LocalFeatureFlagsProvider", () => { { key: "control", value: "false", is_control: true, split: 50.0 }, { key: "treatment", value: "true", is_control: false, split: 50.0 }, ]; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, testUsers: { test_user: "treatment" }, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.getVariant( FLAG_KEY, @@ -244,13 +235,10 @@ describe("LocalFeatureFlagsProvider", () => { { key: "control", value: "false", is_control: true, split: 50.0 }, { key: "treatment", value: "true", is_control: false, split: 50.0 }, ]; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, testUsers: { test_user: "nonexistent_variant" }, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.getVariant( FLAG_KEY, @@ -261,9 +249,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when rollout percentage zero", async () => { - const flag = createTestFlag({ rolloutPercentage: 0.0 }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ rolloutPercentage: 0.0 }, provider); const result = provider.getVariant( FLAG_KEY, @@ -274,9 +260,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return variant when rollout percentage hundred", async () => { - const flag = createTestFlag({ rolloutPercentage: 100.0 }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ rolloutPercentage: 100.0 }, provider); const result = provider.getVariant( FLAG_KEY, @@ -330,13 +314,10 @@ describe("LocalFeatureFlagsProvider", () => { { key: "B", value: "variant_b", is_control: false, split: 0.0 }, { key: "C", value: "variant_c", is_control: false, split: 0.0 }, ]; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, rolloutPercentage: 100.0, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.getVariant( FLAG_KEY, @@ -353,14 +334,11 @@ describe("LocalFeatureFlagsProvider", () => { { key: "C", value: "variant_c", is_control: false, split: 0.0 }, ]; const variantSplits = { A: 0.0, B: 100.0, C: 0.0 }; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, rolloutPercentage: 100.0, variantSplits: variantSplits, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.getVariant( FLAG_KEY, @@ -377,14 +355,11 @@ describe("LocalFeatureFlagsProvider", () => { { key: "C", value: "variant_c", is_control: false }, ]; const variantSplits = { A: 0.0, B: 0.0, C: 100.0 }; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, rolloutPercentage: 100.0, variantSplits: variantSplits, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.getVariant( FLAG_KEY, @@ -399,13 +374,10 @@ describe("LocalFeatureFlagsProvider", () => { { key: "A", value: "variant_a", is_control: false, split: 100.0 }, { key: "B", value: "variant_b", is_control: false, split: 0.0 }, ]; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, variantOverride: { key: "B" }, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.getVariant( FLAG_KEY, @@ -416,9 +388,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should track exposure when variant selected", async () => { - const flag = createTestFlag(); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({}, provider); provider.getVariant( FLAG_KEY, @@ -429,14 +399,11 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should track exposure with correct properties", async () => { - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ experimentId: "exp-123", isExperimentActive: true, testUsers: { qa_user: "treatment" }, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); provider.getVariant( FLAG_KEY, @@ -467,9 +434,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should not track exposure without distinct_id", async () => { - const flag = createTestFlag({ context: "company" }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + await createFlagAndLoadItIntoSDK({ context: "company" }, provider); provider.getVariant( FLAG_KEY, @@ -576,13 +541,10 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "treatment", value: "blue", is_control: false, split: 100.0 }, ]; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, rolloutPercentage: 100.0, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.getVariantValue( FLAG_KEY, @@ -632,13 +594,10 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "treatment", value: true, is_control: false, split: 100.0 }, ]; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, rolloutPercentage: 100.0, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.isEnabled(FLAG_KEY, TEST_CONTEXT); @@ -649,13 +608,10 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "control", value: false, is_control: true, split: 100.0 }, ]; - const flag = createTestFlag({ + await createFlagAndLoadItIntoSDK({ variants: variants, rolloutPercentage: 100.0, - }); - - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + }, provider); const result = provider.isEnabled(FLAG_KEY, TEST_CONTEXT); From 89fea7f22b67c6b180d1693fd57df4fda52a3f22 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 15:24:38 -0600 Subject: [PATCH 11/23] DRY context creation for runtime --- test/flags/local_flags.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index f633883..5532a41 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -292,13 +292,10 @@ describe("LocalFeatureFlagsProvider", () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); - const context = { - distinct_id: USER_ID, - custom_properties: { + const context = userContextWithRuntimeParameters({ plan: "basic", region: "US", - }, - }; + }); const result = provider.getVariant( FLAG_KEY, From a8033d4e9d0d569c89d4fbab1f3d40bf7cae7786 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 15:26:43 -0600 Subject: [PATCH 12/23] use random string for clarity on wrong values --- test/flags/local_flags.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 5532a41..7950303 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -20,6 +20,10 @@ const mockFailedFlagDefinitionsResponse = (statusCode) => { .reply(statusCode); }; +function randomString() { + Math.random().toString(36).substring(7) +} + const USER_ID = "user123"; const createTestFlag = ({ @@ -293,7 +297,7 @@ describe("LocalFeatureFlagsProvider", () => { await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); const context = userContextWithRuntimeParameters({ - plan: "basic", + plan: randomString(), region: "US", }); From 748324f73d7c9897f38f77695a22deabf15d72b3 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 15:31:47 -0600 Subject: [PATCH 13/23] DRY fallback --- test/flags/local_flags.js | 42 ++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 7950303..d954d09 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -25,6 +25,8 @@ function randomString() { } const USER_ID = "user123"; +const FALLBACK_NAME = "fallback"; +const FALLBACK = { variant_value: FALLBACK_NAME } const createTestFlag = ({ flagKey = "test_flag", @@ -199,10 +201,10 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, {}, ); - expect(result.variant_value).toBe("fallback"); + expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return fallback when wrong context key", async () => { @@ -210,10 +212,10 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, { distinct_id: USER_ID }, ); - expect(result.variant_value).toBe("fallback"); + expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return test user variant when configured", async () => { @@ -246,7 +248,7 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, { distinct_id: "test_user" }, ); expect(["false", "true"]).toContain(result.variant_value); @@ -257,10 +259,10 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, TEST_CONTEXT, ); - expect(result.variant_value).toBe("fallback"); + expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return variant when rollout percentage hundred", async () => { @@ -268,10 +270,10 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, TEST_CONTEXT, ); - expect(result.variant_value).not.toBe("fallback"); + expect(result.variant_value).not.toBe(FALLBACK_NAME); expect(["control", "treatment"]).toContain(result.variant_value); }); @@ -286,10 +288,10 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, context, ); - expect(result.variant_value).not.toBe("fallback"); + expect(result.variant_value).not.toBe(FALLBACK_NAME); }); it("should return fallback when legacy runtime evaluation not satisfied", async () => { @@ -303,10 +305,10 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, context, ); - expect(result.variant_value).toBe("fallback"); + expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should pick correct variant with hundred percent split", async () => { @@ -322,7 +324,7 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, TEST_CONTEXT, ); expect(result.variant_value).toBe("variant_a"); @@ -343,7 +345,7 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, TEST_CONTEXT, ); expect(result.variant_value).toBe("variant_b"); @@ -364,7 +366,7 @@ describe("LocalFeatureFlagsProvider", () => { const result = provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, TEST_CONTEXT, ); expect(result.variant_value).toBe("variant_c"); @@ -393,7 +395,7 @@ describe("LocalFeatureFlagsProvider", () => { provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, TEST_CONTEXT, ); expect(mockTracker).toHaveBeenCalledTimes(1); @@ -408,7 +410,7 @@ describe("LocalFeatureFlagsProvider", () => { provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, { distinct_id: "qa_user" }, ); @@ -428,7 +430,7 @@ describe("LocalFeatureFlagsProvider", () => { provider.getVariant( "nonexistent_flag", - { variant_value: "fallback" }, + FALLBACK, TEST_CONTEXT, ); expect(mockTracker).not.toHaveBeenCalled(); @@ -439,7 +441,7 @@ describe("LocalFeatureFlagsProvider", () => { provider.getVariant( FLAG_KEY, - { variant_value: "fallback" }, + FALLBACK, { company_id: "company123" }, ); expect(mockTracker).not.toHaveBeenCalled(); From 43b344f11c7237e8f5d9fac333109812d23b9e07 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 15:38:41 -0600 Subject: [PATCH 14/23] =?UTF-8?q?runtime=20rule=20negative=20case=20?= =?UTF-8?q?=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/flags/local_flags.js | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index d954d09..a61b99e 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -34,7 +34,8 @@ const createTestFlag = ({ variants = null, variantOverride = null, rolloutPercentage = 100.0, - runtimeEvaluation = null, + legacyRuntimeRule = null, + runtimeEvaluationRule = null, testUsers = null, experimentId = null, isExperimentActive = null, @@ -49,7 +50,8 @@ const createTestFlag = ({ const rollout = [ { rollout_percentage: rolloutPercentage, - runtime_evaluation_definition: runtimeEvaluation, + runtime_evaluation_definition: legacyRuntimeRule, + runtime_evaluation_rule: runtimeEvaluationRule, variant_override: variantOverride, variant_splits: variantSplits, }, @@ -80,7 +82,8 @@ async function createFlagAndLoadItIntoSDK({ variants = null, variantOverride = null, rolloutPercentage = 100.0, - runtimeEvaluation = null, + legacyRuntimeRule = null, + runtimeEvaluationRule = null, testUsers = null, experimentId = null, isExperimentActive = null, @@ -95,7 +98,8 @@ async function createFlagAndLoadItIntoSDK({ variants, variantOverride, rolloutPercentage, - runtimeEvaluation, + legacyRuntimeRule, + runtimeEvaluationRule, testUsers, experimentId, isExperimentActive, @@ -277,6 +281,19 @@ describe("LocalFeatureFlagsProvider", () => { expect(["control", "treatment"]).toContain(result.variant_value); }); + // TODO + it("should return fallback when runtime evaluation not satisfied", async () => { + const runtimeEvaluationRule = { "==": [ { var: "plan" }, "premium" ] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + + const context = userContextWithRuntimeParameters({ + plan: randomString(), + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + it("should respect legacy runtime evaluation when satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); @@ -296,7 +313,7 @@ describe("LocalFeatureFlagsProvider", () => { it("should return fallback when legacy runtime evaluation not satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); + await createFlagAndLoadItIntoSDK({ legacyRuntimeRule}, provider); const context = userContextWithRuntimeParameters({ plan: randomString(), From 9c31468747099a128ffa503e8ebc488aa16d7685 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 15:40:51 -0600 Subject: [PATCH 15/23] add stopgap positive test case (doesn't fail) --- test/flags/local_flags.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index a61b99e..0b0001e 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -281,6 +281,19 @@ describe("LocalFeatureFlagsProvider", () => { expect(["control", "treatment"]).toContain(result.variant_value); }); + // Stopgap + it("should return variant when runtime evaluation satisfied", async () => { + const runtimeEvaluationRule = { "==": [ { var: "plan" }, "Premium" ] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + + const context = userContextWithRuntimeParameters({ + plan: "Premium", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).not.toBe(FALLBACK_NAME); + }); + // TODO it("should return fallback when runtime evaluation not satisfied", async () => { const runtimeEvaluationRule = { "==": [ { var: "plan" }, "premium" ] }; From 1278be342cd2db615f1118eae90b0661fe3517fa Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 16:01:57 -0600 Subject: [PATCH 16/23] =?UTF-8?q?import=20jsonLogic=20and=20evaluate=20run?= =?UTF-8?q?time=20rule=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/flags/local_flags.js | 28 +++++++++++++++++++++++++--- package-lock.json | 14 +++++++++++++- package.json | 3 ++- test/flags/local_flags.js | 2 +- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lib/flags/local_flags.js b/lib/flags/local_flags.js index b3961cc..3d8278a 100644 --- a/lib/flags/local_flags.js +++ b/lib/flags/local_flags.js @@ -13,6 +13,7 @@ const FeatureFlagsProvider = require("./flags"); const { normalizedHash } = require("./utils"); +const { apply } = require("json-logic-js"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { /** @@ -316,13 +317,33 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { }; } + _extractRuntimeParameters(context) { + const customProperties = context.custom_properties; + if (!customProperties || typeof customProperties !== "object") { + return null; + } + return customProperties; + } + + _isRuntimeRuleSatisfied(rollout, context) { + return apply(rollout.runtime_evaluation_rule, this._extractRuntimeParameters(context)); + } + _isRuntimeEvaluationSatisfied(rollout, context) { - if (!rollout.runtime_evaluation_definition) { + if (rollout.runtime_evaluation_rule) { + return this._isRuntimeRuleSatisfied(rollout, context); + } + else if (rollout.runtime_evaluation_definition) { // legacy rule + return this._isLegacyRuntimeEvaluationSatisfied(rollout, context); + } + else { return true; } + } - const customProperties = context.custom_properties; - if (!customProperties || typeof customProperties !== "object") { + _isLegacyRuntimeEvaluationSatisfied(rollout, context) { + const customProperties = this._extractRuntimeParameters(context); + if (!customProperties) { return false; } @@ -345,4 +366,5 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { } } + module.exports = LocalFeatureFlagsProvider; diff --git a/package-lock.json b/package-lock.json index 489134a..646d6b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.19.1", "license": "MIT", "dependencies": { - "https-proxy-agent": "7.0.6" + "https-proxy-agent": "7.0.6", + "json-logic-js": "^2.0.5" }, "devDependencies": { "@types/node": "^24.10.1", @@ -1519,6 +1520,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-logic-js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz", + "integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==", + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -2963,6 +2970,11 @@ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true }, + "json-logic-js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/json-logic-js/-/json-logic-js-2.0.5.tgz", + "integrity": "sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g==" + }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", diff --git a/package.json b/package.json index a84c18e..a2c78d0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "vitest": "^4.0.8" }, "dependencies": { - "https-proxy-agent": "7.0.6" + "https-proxy-agent": "7.0.6", + "json-logic-js": "^2.0.5" } } diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 0b0001e..3fa0417 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -296,7 +296,7 @@ describe("LocalFeatureFlagsProvider", () => { // TODO it("should return fallback when runtime evaluation not satisfied", async () => { - const runtimeEvaluationRule = { "==": [ { var: "plan" }, "premium" ] }; + const runtimeEvaluationRule = { "==": [ { var: "plan" }, "Premium" ] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); const context = userContextWithRuntimeParameters({ From c095d5004a7c13e393348f160e1ddeb6705f42ba Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 16:09:18 -0600 Subject: [PATCH 17/23] caseinsensitive to parameters Still need caseinsensitive on rule --- lib/flags/local_flags.js | 7 ++++--- lib/flags/utils.js | 26 ++++++++++++++++++++++++++ test/flags/local_flags.js | 14 ++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/lib/flags/local_flags.js b/lib/flags/local_flags.js index 3d8278a..b14c01a 100644 --- a/lib/flags/local_flags.js +++ b/lib/flags/local_flags.js @@ -12,7 +12,7 @@ * */ const FeatureFlagsProvider = require("./flags"); -const { normalizedHash } = require("./utils"); +const { normalizedHash, lowercaseAllKeysAndValues } = require("./utils"); const { apply } = require("json-logic-js"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { @@ -317,12 +317,13 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { }; } + _extractRuntimeParameters(context) { const customProperties = context.custom_properties; if (!customProperties || typeof customProperties !== "object") { return null; } - return customProperties; + return lowercaseAllKeysAndValues(customProperties); } _isRuntimeRuleSatisfied(rollout, context) { @@ -333,7 +334,7 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { if (rollout.runtime_evaluation_rule) { return this._isRuntimeRuleSatisfied(rollout, context); } - else if (rollout.runtime_evaluation_definition) { // legacy rule + else if (rollout.runtime_evaluation_definition) { return this._isLegacyRuntimeEvaluationSatisfied(rollout, context); } else { diff --git a/lib/flags/utils.js b/lib/flags/utils.js index ed9511d..028b894 100644 --- a/lib/flags/utils.js +++ b/lib/flags/utils.js @@ -2,6 +2,7 @@ * Utility functions for Mixpanel feature flags */ const crypto = require("crypto"); +const { type } = require("os"); // Constants const EXPOSURE_EVENT = "$experiment_started"; @@ -70,10 +71,35 @@ function generateTraceparent() { return `${version}-${traceId}-${parentId}-${traceFlags}`; } +function lowercaseAllKeysAndValues(obj) { + if (obj === null || obj === undefined) { + return obj; + } + else if (typeof obj === "string") { + return obj.toLowerCase(); + } + else if (typeof obj === "object") { + if (Array.isArray(obj)) { + return obj.map(lowercaseAllKeysAndValues); + } else { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [ + k.toLowerCase(), + lowercaseAllKeysAndValues(v), + ]), + ); + } + } + else { + return obj; + } +} + module.exports = { EXPOSURE_EVENT, REQUEST_HEADERS, normalizedHash, prepareCommonQueryParams, generateTraceparent, + lowercaseAllKeysAndValues, }; diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 3fa0417..4b5737b 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -281,7 +281,6 @@ describe("LocalFeatureFlagsProvider", () => { expect(["control", "treatment"]).toContain(result.variant_value); }); - // Stopgap it("should return variant when runtime evaluation satisfied", async () => { const runtimeEvaluationRule = { "==": [ { var: "plan" }, "Premium" ] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); @@ -294,7 +293,6 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); - // TODO it("should return fallback when runtime evaluation not satisfied", async () => { const runtimeEvaluationRule = { "==": [ { var: "plan" }, "Premium" ] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); @@ -307,6 +305,18 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).toBe(FALLBACK_NAME); }); + it("should return variant when runtime evaluation case-insensitively satisfied", async () => { + const runtimeEvaluationRule = { "==": [ { var: "plan" }, "premium" ] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + + const context = userContextWithRuntimeParameters({ + plan: "PREMIUM", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).not.toBe(FALLBACK_NAME); + }); + it("should respect legacy runtime evaluation when satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); From 08cd7db303aebab9b62de17ff1085581476753d8 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 16:12:18 -0600 Subject: [PATCH 18/23] =?UTF-8?q?case-insensitivity=20=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/flags/local_flags.js | 4 ++-- lib/flags/utils.js | 25 +++++++++++++++++++++++++ test/flags/local_flags.js | 14 +++++++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/flags/local_flags.js b/lib/flags/local_flags.js index b14c01a..6259325 100644 --- a/lib/flags/local_flags.js +++ b/lib/flags/local_flags.js @@ -12,7 +12,7 @@ * */ const FeatureFlagsProvider = require("./flags"); -const { normalizedHash, lowercaseAllKeysAndValues } = require("./utils"); +const { normalizedHash, lowercaseAllKeysAndValues, lowercaseLeafNodes } = require("./utils"); const { apply } = require("json-logic-js"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { @@ -327,7 +327,7 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { } _isRuntimeRuleSatisfied(rollout, context) { - return apply(rollout.runtime_evaluation_rule, this._extractRuntimeParameters(context)); + return apply(lowercaseLeafNodes(rollout.runtime_evaluation_rule), this._extractRuntimeParameters(context)); } _isRuntimeEvaluationSatisfied(rollout, context) { diff --git a/lib/flags/utils.js b/lib/flags/utils.js index 028b894..324013d 100644 --- a/lib/flags/utils.js +++ b/lib/flags/utils.js @@ -95,6 +95,30 @@ function lowercaseAllKeysAndValues(obj) { } } +function lowercaseLeafNodes(obj) { + if (obj === null || obj === undefined) { + return obj; + } + else if (typeof obj === "string") { + return obj.toLowerCase(); + } + else if (typeof obj === "object") { + if (Array.isArray(obj)) { + return obj.map(lowercaseLeafNodes); + } else { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [ + k, + lowercaseLeafNodes(v), + ]), + ); + } + } + else { + return obj; + } +} + module.exports = { EXPOSURE_EVENT, REQUEST_HEADERS, @@ -102,4 +126,5 @@ module.exports = { prepareCommonQueryParams, generateTraceparent, lowercaseAllKeysAndValues, + lowercaseLeafNodes, }; diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 4b5737b..0fafad1 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -305,7 +305,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).toBe(FALLBACK_NAME); }); - it("should return variant when runtime evaluation case-insensitively satisfied", async () => { + it("should return variant when runtime evaluation parameters case-insensitively satisfied", async () => { const runtimeEvaluationRule = { "==": [ { var: "plan" }, "premium" ] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); @@ -317,6 +317,18 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); + it("should return variant when runtime evaluation rule case-insensitively satisfied", async () => { + const runtimeEvaluationRule = { "==": [ { var: "plan" }, "PREMIUM" ] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + + const context = userContextWithRuntimeParameters({ + plan: "premium", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).not.toBe(FALLBACK_NAME); + }); + it("should respect legacy runtime evaluation when satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); From 0c9ffcf4eb502c13b83c809c0969650b2dcc924b Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 16:57:12 -0600 Subject: [PATCH 19/23] =?UTF-8?q?add=20complex=20rule=20test-cases=20?= =?UTF-8?q?=E2=9C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/flags/local_flags.js | 108 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 0fafad1..3731bcd 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -329,6 +329,114 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); + it("should return variant when string contains substring (in operator)", async () => { + const runtimeEvaluationRule = {"in": ["Springfield", {"var": "url"}]}; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + url: "https://helloworld.com/Springfield/all-about-it", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).not.toBe(FALLBACK_NAME); + }); + + it("should return fallback when string does not contain substring (in operator)", async () => { + const runtimeEvaluationRule = {"in": ["Springfield", {"var": "url"}]}; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + url: "https://helloworld.com/Boston/all-about-it", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when value exists in array (multi-value)", async () => { + const runtimeEvaluationRule = {"in": [{"var": "name"}, ["a", "b", "c", "all-from-the-ui"]]}; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "b", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).not.toBe(FALLBACK_NAME); + }); + + it("should return fallback when value does not exist in array (multi-value)", async () => { + const runtimeEvaluationRule = {"in": [{"var": "name"}, ["a", "b", "c", "all-from-the-ui"]]}; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "d", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when multiple conditions satisfied (and operator)", async () => { + const runtimeEvaluationRule = { + "and": [ + {"==": [{"var": "name"}, "Johannes"]}, + {"==": [{"var": "country"}, "Deutschland"]} + ] + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "Johannes", + country: "Deutschland", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).not.toBe(FALLBACK_NAME); + }); + + it("should return fallback when one condition fails (and operator)", async () => { + const runtimeEvaluationRule = { + "and": [ + {"==": [{"var": "name"}, "Johannes"]}, + {"==": [{"var": "country"}, "Deutschland"]} + ] + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + name: "Johannes", + country: "USA", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return variant when numeric comparison satisfied (greater than)", async () => { + const runtimeEvaluationRule = {">": [{"var": "queries_ran"}, 25]}; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + queries_ran: 27, + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).not.toBe(FALLBACK_NAME); + }); + + it("should return fallback when numeric comparison not satisfied (greater than)", async () => { + const runtimeEvaluationRule = {">": [{"var": "queries_ran"}, 25]}; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); + + const context = userContextWithRuntimeParameters({ + queries_ran: 20, + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + it("should respect legacy runtime evaluation when satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); From 67069088137f1aee7ab6f232f818009935243a76 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Fri, 14 Nov 2025 17:27:52 -0600 Subject: [PATCH 20/23] consistent runtime rule test names --- test/flags/local_flags.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 3731bcd..8d2bb4e 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -329,7 +329,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); - it("should return variant when string contains substring (in operator)", async () => { + it("should return variant when runtime evaluation with in operator satisfied", async () => { const runtimeEvaluationRule = {"in": ["Springfield", {"var": "url"}]}; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); @@ -341,7 +341,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); - it("should return fallback when string does not contain substring (in operator)", async () => { + it("should return fallback when runtime evaluation with in operator not satisfied", async () => { const runtimeEvaluationRule = {"in": ["Springfield", {"var": "url"}]}; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); @@ -353,7 +353,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).toBe(FALLBACK_NAME); }); - it("should return variant when value exists in array (multi-value)", async () => { + it("should return variant when runtime evaluation with in operator for array satisfied", async () => { const runtimeEvaluationRule = {"in": [{"var": "name"}, ["a", "b", "c", "all-from-the-ui"]]}; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); @@ -365,7 +365,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); - it("should return fallback when value does not exist in array (multi-value)", async () => { + it("should return fallback when runtime evaluation with in operator for array not satisfied", async () => { const runtimeEvaluationRule = {"in": [{"var": "name"}, ["a", "b", "c", "all-from-the-ui"]]}; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); @@ -377,7 +377,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).toBe(FALLBACK_NAME); }); - it("should return variant when multiple conditions satisfied (and operator)", async () => { + it("should return variant when runtime evaluation with and operator satisfied", async () => { const runtimeEvaluationRule = { "and": [ {"==": [{"var": "name"}, "Johannes"]}, @@ -395,7 +395,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); - it("should return fallback when one condition fails (and operator)", async () => { + it("should return fallback when runtime evaluation with and operator not satisfied", async () => { const runtimeEvaluationRule = { "and": [ {"==": [{"var": "name"}, "Johannes"]}, @@ -413,7 +413,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).toBe(FALLBACK_NAME); }); - it("should return variant when numeric comparison satisfied (greater than)", async () => { + it("should return variant when runtime evaluation with greater than operator satisfied", async () => { const runtimeEvaluationRule = {">": [{"var": "queries_ran"}, 25]}; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); @@ -425,7 +425,7 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); - it("should return fallback when numeric comparison not satisfied (greater than)", async () => { + it("should return fallback when runtime evaluation with greater than operator not satisfied", async () => { const runtimeEvaluationRule = {">": [{"var": "queries_ran"}, 25]}; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); From 6fd7958ac837a027ce986604ae9be9728f86a88b Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 17 Nov 2025 09:06:02 -0600 Subject: [PATCH 21/23] add error handling for garbled jsonlogic --- lib/flags/local_flags.js | 7 ++++++- test/flags/local_flags.js | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/flags/local_flags.js b/lib/flags/local_flags.js index 6259325..d7472f3 100644 --- a/lib/flags/local_flags.js +++ b/lib/flags/local_flags.js @@ -327,7 +327,12 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { } _isRuntimeRuleSatisfied(rollout, context) { - return apply(lowercaseLeafNodes(rollout.runtime_evaluation_rule), this._extractRuntimeParameters(context)); + try { + return apply(lowercaseLeafNodes(rollout.runtime_evaluation_rule), this._extractRuntimeParameters(context)); + } catch (error) { + this.logger?.error(`Error evaluating runtime rule: ${error.message}`); + return false; + } } _isRuntimeEvaluationSatisfied(rollout, context) { diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 8d2bb4e..3a4133c 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -305,6 +305,26 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).toBe(FALLBACK_NAME); }); + it("should return fallback when no runtime parameters are provided", async () => { + const runtimeEvaluationRule = { "==": [ { var: "plan" }, "Premium" ] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + + const context = userContextWithRuntimeParameters(null) + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + + it("should return fallback when runtime rule is invalid", async () => { + const runtimeEvaluationRule = { "=oops=": [ { var: "plan" }, "Premium" ] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + + const context = userContextWithRuntimeParameters(null) + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).toBe(FALLBACK_NAME); + }); + it("should return variant when runtime evaluation parameters case-insensitively satisfied", async () => { const runtimeEvaluationRule = { "==": [ { var: "plan" }, "premium" ] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); From 558b2d9f3d756c41a9825c615f4679c9501c0c08 Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 17 Nov 2025 09:12:41 -0600 Subject: [PATCH 22/23] add multi-condition case-insensitive check --- test/flags/local_flags.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index 3a4133c..cbeaebe 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -337,6 +337,19 @@ describe("LocalFeatureFlagsProvider", () => { expect(result.variant_value).not.toBe(FALLBACK_NAME); }); + it("should return variant when runtime evaluation parameters case-insensitively satisfied - multi-condition", async () => { + const runtimeEvaluationRule = { "and": [{ "==": [ { var: "plan" }, "prEmium" ] }, { ">=": [ {var: "date"}, "2025-11-24T09:23"]}] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + + const context = userContextWithRuntimeParameters({ + plan: "PReMIuM", + date: "2025-11-24t09:23", + }); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); + expect(result.variant_value).not.toBe(FALLBACK_NAME); + }); + it("should return variant when runtime evaluation rule case-insensitively satisfied", async () => { const runtimeEvaluationRule = { "==": [ { var: "plan" }, "PREMIUM" ] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); From c6b4031c3239155306eae9c01c2bec9a5758968f Mon Sep 17 00:00:00 2001 From: Joshua Koehler Date: Mon, 17 Nov 2025 09:26:02 -0600 Subject: [PATCH 23/23] pretty --- lib/flags/local_flags.js | 19 +- lib/flags/utils.js | 24 +-- test/flags/local_flags.js | 356 ++++++++++++++++++-------------------- 3 files changed, 191 insertions(+), 208 deletions(-) diff --git a/lib/flags/local_flags.js b/lib/flags/local_flags.js index d7472f3..4b0fa61 100644 --- a/lib/flags/local_flags.js +++ b/lib/flags/local_flags.js @@ -12,7 +12,11 @@ * */ const FeatureFlagsProvider = require("./flags"); -const { normalizedHash, lowercaseAllKeysAndValues, lowercaseLeafNodes } = require("./utils"); +const { + normalizedHash, + lowercaseAllKeysAndValues, + lowercaseLeafNodes, +} = require("./utils"); const { apply } = require("json-logic-js"); class LocalFeatureFlagsProvider extends FeatureFlagsProvider { @@ -317,7 +321,6 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { }; } - _extractRuntimeParameters(context) { const customProperties = context.custom_properties; if (!customProperties || typeof customProperties !== "object") { @@ -328,7 +331,10 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { _isRuntimeRuleSatisfied(rollout, context) { try { - return apply(lowercaseLeafNodes(rollout.runtime_evaluation_rule), this._extractRuntimeParameters(context)); + return apply( + lowercaseLeafNodes(rollout.runtime_evaluation_rule), + this._extractRuntimeParameters(context), + ); } catch (error) { this.logger?.error(`Error evaluating runtime rule: ${error.message}`); return false; @@ -338,11 +344,9 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { _isRuntimeEvaluationSatisfied(rollout, context) { if (rollout.runtime_evaluation_rule) { return this._isRuntimeRuleSatisfied(rollout, context); - } - else if (rollout.runtime_evaluation_definition) { + } else if (rollout.runtime_evaluation_definition) { return this._isLegacyRuntimeEvaluationSatisfied(rollout, context); - } - else { + } else { return true; } } @@ -372,5 +376,4 @@ class LocalFeatureFlagsProvider extends FeatureFlagsProvider { } } - module.exports = LocalFeatureFlagsProvider; diff --git a/lib/flags/utils.js b/lib/flags/utils.js index 324013d..0526355 100644 --- a/lib/flags/utils.js +++ b/lib/flags/utils.js @@ -2,7 +2,6 @@ * Utility functions for Mixpanel feature flags */ const crypto = require("crypto"); -const { type } = require("os"); // Constants const EXPOSURE_EVENT = "$experiment_started"; @@ -74,11 +73,9 @@ function generateTraceparent() { function lowercaseAllKeysAndValues(obj) { if (obj === null || obj === undefined) { return obj; - } - else if (typeof obj === "string") { + } else if (typeof obj === "string") { return obj.toLowerCase(); - } - else if (typeof obj === "object") { + } else if (typeof obj === "object") { if (Array.isArray(obj)) { return obj.map(lowercaseAllKeysAndValues); } else { @@ -89,8 +86,7 @@ function lowercaseAllKeysAndValues(obj) { ]), ); } - } - else { + } else { return obj; } } @@ -98,23 +94,17 @@ function lowercaseAllKeysAndValues(obj) { function lowercaseLeafNodes(obj) { if (obj === null || obj === undefined) { return obj; - } - else if (typeof obj === "string") { + } else if (typeof obj === "string") { return obj.toLowerCase(); - } - else if (typeof obj === "object") { + } else if (typeof obj === "object") { if (Array.isArray(obj)) { return obj.map(lowercaseLeafNodes); } else { return Object.fromEntries( - Object.entries(obj).map(([k, v]) => [ - k, - lowercaseLeafNodes(v), - ]), + Object.entries(obj).map(([k, v]) => [k, lowercaseLeafNodes(v)]), ); } - } - else { + } else { return obj; } } diff --git a/test/flags/local_flags.js b/test/flags/local_flags.js index cbeaebe..588cf53 100644 --- a/test/flags/local_flags.js +++ b/test/flags/local_flags.js @@ -21,12 +21,12 @@ const mockFailedFlagDefinitionsResponse = (statusCode) => { }; function randomString() { - Math.random().toString(36).substring(7) + Math.random().toString(36).substring(7); } const USER_ID = "user123"; const FALLBACK_NAME = "fallback"; -const FALLBACK = { variant_value: FALLBACK_NAME } +const FALLBACK = { variant_value: FALLBACK_NAME }; const createTestFlag = ({ flagKey = "test_flag", @@ -76,7 +76,8 @@ const createTestFlag = ({ }, }; }; -async function createFlagAndLoadItIntoSDK({ +async function createFlagAndLoadItIntoSDK( + { flagKey = "test_flag", context = "distinct_id", variants = null, @@ -89,41 +90,40 @@ async function createFlagAndLoadItIntoSDK({ isExperimentActive = null, variantSplits = null, hashSalt = null, -} = {}, - provider + } = {}, + provider, ) { - const flag = createTestFlag({ - flagKey, - context, - variants, - variantOverride, - rolloutPercentage, - legacyRuntimeRule, - runtimeEvaluationRule, - testUsers, - experimentId, - isExperimentActive, - variantSplits, - hashSalt, - }); - mockFlagDefinitionsResponse([flag]); - await provider.startPollingForDefinitions(); + const flag = createTestFlag({ + flagKey, + context, + variants, + variantOverride, + rolloutPercentage, + legacyRuntimeRule, + runtimeEvaluationRule, + testUsers, + experimentId, + isExperimentActive, + variantSplits, + hashSalt, + }); + mockFlagDefinitionsResponse([flag]); + await provider.startPollingForDefinitions(); } describe("LocalFeatureFlagsProvider", () => { - const TEST_TOKEN = "test-token"; const TEST_CONTEXT = { distinct_id: "test-user", }; const FLAG_KEY = "test_flag"; - function userContextWithRuntimeParameters(custom_properties) { - return { - ...TEST_CONTEXT, - custom_properties: custom_properties, - }; - } + function userContextWithRuntimeParameters(custom_properties) { + return { + ...TEST_CONTEXT, + custom_properties: custom_properties, + }; + } let mockTracker; let mockLogger; @@ -203,22 +203,16 @@ describe("LocalFeatureFlagsProvider", () => { it("should return fallback when no context", async () => { await createFlagAndLoadItIntoSDK({ context: "distinct_id" }, provider); - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - {}, - ); + const result = provider.getVariant(FLAG_KEY, FALLBACK, {}); expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return fallback when wrong context key", async () => { await createFlagAndLoadItIntoSDK({ context: "user_id" }, provider); - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - { distinct_id: USER_ID }, - ); + const result = provider.getVariant(FLAG_KEY, FALLBACK, { + distinct_id: USER_ID, + }); expect(result.variant_value).toBe(FALLBACK_NAME); }); @@ -227,10 +221,13 @@ describe("LocalFeatureFlagsProvider", () => { { key: "control", value: "false", is_control: true, split: 50.0 }, { key: "treatment", value: "true", is_control: false, split: 50.0 }, ]; - await createFlagAndLoadItIntoSDK({ - variants: variants, - testUsers: { test_user: "treatment" }, - }, provider); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + testUsers: { test_user: "treatment" }, + }, + provider, + ); const result = provider.getVariant( FLAG_KEY, @@ -245,48 +242,41 @@ describe("LocalFeatureFlagsProvider", () => { { key: "control", value: "false", is_control: true, split: 50.0 }, { key: "treatment", value: "true", is_control: false, split: 50.0 }, ]; - await createFlagAndLoadItIntoSDK({ - variants: variants, - testUsers: { test_user: "nonexistent_variant" }, - }, provider); - - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - { distinct_id: "test_user" }, + await createFlagAndLoadItIntoSDK( + { + variants: variants, + testUsers: { test_user: "nonexistent_variant" }, + }, + provider, ); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, { + distinct_id: "test_user", + }); expect(["false", "true"]).toContain(result.variant_value); }); it("should return fallback when rollout percentage zero", async () => { await createFlagAndLoadItIntoSDK({ rolloutPercentage: 0.0 }, provider); - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - TEST_CONTEXT, - ); + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return variant when rollout percentage hundred", async () => { await createFlagAndLoadItIntoSDK({ rolloutPercentage: 100.0 }, provider); - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - TEST_CONTEXT, - ); + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(result.variant_value).not.toBe(FALLBACK_NAME); expect(["control", "treatment"]).toContain(result.variant_value); }); it("should return variant when runtime evaluation satisfied", async () => { - const runtimeEvaluationRule = { "==": [ { var: "plan" }, "Premium" ] }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "Premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ - plan: "Premium", + plan: "Premium", }); const result = provider.getVariant(FLAG_KEY, FALLBACK, context); @@ -294,11 +284,11 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when runtime evaluation not satisfied", async () => { - const runtimeEvaluationRule = { "==": [ { var: "plan" }, "Premium" ] }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "Premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ - plan: randomString(), + plan: randomString(), }); const result = provider.getVariant(FLAG_KEY, FALLBACK, context); @@ -306,31 +296,31 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when no runtime parameters are provided", async () => { - const runtimeEvaluationRule = { "==": [ { var: "plan" }, "Premium" ] }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "Premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); - const context = userContextWithRuntimeParameters(null) + const context = userContextWithRuntimeParameters(null); const result = provider.getVariant(FLAG_KEY, FALLBACK, context); expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return fallback when runtime rule is invalid", async () => { - const runtimeEvaluationRule = { "=oops=": [ { var: "plan" }, "Premium" ] }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + const runtimeEvaluationRule = { "=oops=": [{ var: "plan" }, "Premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); - const context = userContextWithRuntimeParameters(null) + const context = userContextWithRuntimeParameters(null); const result = provider.getVariant(FLAG_KEY, FALLBACK, context); expect(result.variant_value).toBe(FALLBACK_NAME); }); it("should return variant when runtime evaluation parameters case-insensitively satisfied", async () => { - const runtimeEvaluationRule = { "==": [ { var: "plan" }, "premium" ] }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "premium"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ - plan: "PREMIUM", + plan: "PREMIUM", }); const result = provider.getVariant(FLAG_KEY, FALLBACK, context); @@ -338,12 +328,17 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return variant when runtime evaluation parameters case-insensitively satisfied - multi-condition", async () => { - const runtimeEvaluationRule = { "and": [{ "==": [ { var: "plan" }, "prEmium" ] }, { ">=": [ {var: "date"}, "2025-11-24T09:23"]}] }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + const runtimeEvaluationRule = { + and: [ + { "==": [{ var: "plan" }, "prEmium"] }, + { ">=": [{ var: "date" }, "2025-11-24T09:23"] }, + ], + }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ - plan: "PReMIuM", - date: "2025-11-24t09:23", + plan: "PReMIuM", + date: "2025-11-24t09:23", }); const result = provider.getVariant(FLAG_KEY, FALLBACK, context); @@ -351,11 +346,11 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return variant when runtime evaluation rule case-insensitively satisfied", async () => { - const runtimeEvaluationRule = { "==": [ { var: "plan" }, "PREMIUM" ] }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule}, provider); + const runtimeEvaluationRule = { "==": [{ var: "plan" }, "PREMIUM"] }; + await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ - plan: "premium", + plan: "premium", }); const result = provider.getVariant(FLAG_KEY, FALLBACK, context); @@ -363,7 +358,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return variant when runtime evaluation with in operator satisfied", async () => { - const runtimeEvaluationRule = {"in": ["Springfield", {"var": "url"}]}; + const runtimeEvaluationRule = { in: ["Springfield", { var: "url" }] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ @@ -375,7 +370,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when runtime evaluation with in operator not satisfied", async () => { - const runtimeEvaluationRule = {"in": ["Springfield", {"var": "url"}]}; + const runtimeEvaluationRule = { in: ["Springfield", { var: "url" }] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ @@ -387,7 +382,9 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return variant when runtime evaluation with in operator for array satisfied", async () => { - const runtimeEvaluationRule = {"in": [{"var": "name"}, ["a", "b", "c", "all-from-the-ui"]]}; + const runtimeEvaluationRule = { + in: [{ var: "name" }, ["a", "b", "c", "all-from-the-ui"]], + }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ @@ -399,7 +396,9 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when runtime evaluation with in operator for array not satisfied", async () => { - const runtimeEvaluationRule = {"in": [{"var": "name"}, ["a", "b", "c", "all-from-the-ui"]]}; + const runtimeEvaluationRule = { + in: [{ var: "name" }, ["a", "b", "c", "all-from-the-ui"]], + }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ @@ -412,10 +411,10 @@ describe("LocalFeatureFlagsProvider", () => { it("should return variant when runtime evaluation with and operator satisfied", async () => { const runtimeEvaluationRule = { - "and": [ - {"==": [{"var": "name"}, "Johannes"]}, - {"==": [{"var": "country"}, "Deutschland"]} - ] + and: [ + { "==": [{ var: "name" }, "Johannes"] }, + { "==": [{ var: "country" }, "Deutschland"] }, + ], }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); @@ -430,10 +429,10 @@ describe("LocalFeatureFlagsProvider", () => { it("should return fallback when runtime evaluation with and operator not satisfied", async () => { const runtimeEvaluationRule = { - "and": [ - {"==": [{"var": "name"}, "Johannes"]}, - {"==": [{"var": "country"}, "Deutschland"]} - ] + and: [ + { "==": [{ var: "name" }, "Johannes"] }, + { "==": [{ var: "country" }, "Deutschland"] }, + ], }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); @@ -447,7 +446,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return variant when runtime evaluation with greater than operator satisfied", async () => { - const runtimeEvaluationRule = {">": [{"var": "queries_ran"}, 25]}; + const runtimeEvaluationRule = { ">": [{ var: "queries_ran" }, 25] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ @@ -459,7 +458,7 @@ describe("LocalFeatureFlagsProvider", () => { }); it("should return fallback when runtime evaluation with greater than operator not satisfied", async () => { - const runtimeEvaluationRule = {">": [{"var": "queries_ran"}, 25]}; + const runtimeEvaluationRule = { ">": [{ var: "queries_ran" }, 25] }; await createFlagAndLoadItIntoSDK({ runtimeEvaluationRule }, provider); const context = userContextWithRuntimeParameters({ @@ -472,35 +471,30 @@ describe("LocalFeatureFlagsProvider", () => { it("should respect legacy runtime evaluation when satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; - await createFlagAndLoadItIntoSDK({ runtimeEvaluation: legacyRuntimeRule}, provider); + await createFlagAndLoadItIntoSDK( + { runtimeEvaluation: legacyRuntimeRule }, + provider, + ); const context = userContextWithRuntimeParameters({ - plan: "premium", - region: "US", + plan: "premium", + region: "US", }); - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - context, - ); + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); expect(result.variant_value).not.toBe(FALLBACK_NAME); }); it("should return fallback when legacy runtime evaluation not satisfied", async () => { const legacyRuntimeRule = { plan: "premium", region: "US" }; - await createFlagAndLoadItIntoSDK({ legacyRuntimeRule}, provider); + await createFlagAndLoadItIntoSDK({ legacyRuntimeRule }, provider); const context = userContextWithRuntimeParameters({ - plan: randomString(), - region: "US", + plan: randomString(), + region: "US", }); - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - context, - ); + const result = provider.getVariant(FLAG_KEY, FALLBACK, context); expect(result.variant_value).toBe(FALLBACK_NAME); }); @@ -510,16 +504,15 @@ describe("LocalFeatureFlagsProvider", () => { { key: "B", value: "variant_b", is_control: false, split: 0.0 }, { key: "C", value: "variant_c", is_control: false, split: 0.0 }, ]; - await createFlagAndLoadItIntoSDK({ - variants: variants, - rolloutPercentage: 100.0, - }, provider); - - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - TEST_CONTEXT, + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + }, + provider, ); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(result.variant_value).toBe("variant_a"); }); @@ -530,17 +523,16 @@ describe("LocalFeatureFlagsProvider", () => { { key: "C", value: "variant_c", is_control: false, split: 0.0 }, ]; const variantSplits = { A: 0.0, B: 100.0, C: 0.0 }; - await createFlagAndLoadItIntoSDK({ - variants: variants, - rolloutPercentage: 100.0, - variantSplits: variantSplits, - }, provider); - - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - TEST_CONTEXT, + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + variantSplits: variantSplits, + }, + provider, ); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(result.variant_value).toBe("variant_b"); }); @@ -551,17 +543,16 @@ describe("LocalFeatureFlagsProvider", () => { { key: "C", value: "variant_c", is_control: false }, ]; const variantSplits = { A: 0.0, B: 0.0, C: 100.0 }; - await createFlagAndLoadItIntoSDK({ - variants: variants, - rolloutPercentage: 100.0, - variantSplits: variantSplits, - }, provider); - - const result = provider.getVariant( - FLAG_KEY, - FALLBACK, - TEST_CONTEXT, + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + variantSplits: variantSplits, + }, + provider, ); + + const result = provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(result.variant_value).toBe("variant_c"); }); @@ -570,10 +561,13 @@ describe("LocalFeatureFlagsProvider", () => { { key: "A", value: "variant_a", is_control: false, split: 100.0 }, { key: "B", value: "variant_b", is_control: false, split: 0.0 }, ]; - await createFlagAndLoadItIntoSDK({ - variants: variants, - variantOverride: { key: "B" }, - }, provider); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + variantOverride: { key: "B" }, + }, + provider, + ); const result = provider.getVariant( FLAG_KEY, @@ -586,27 +580,22 @@ describe("LocalFeatureFlagsProvider", () => { it("should track exposure when variant selected", async () => { await createFlagAndLoadItIntoSDK({}, provider); - provider.getVariant( - FLAG_KEY, - FALLBACK, - TEST_CONTEXT, - ); + provider.getVariant(FLAG_KEY, FALLBACK, TEST_CONTEXT); expect(mockTracker).toHaveBeenCalledTimes(1); }); it("should track exposure with correct properties", async () => { - await createFlagAndLoadItIntoSDK({ - experimentId: "exp-123", - isExperimentActive: true, - testUsers: { qa_user: "treatment" }, - }, provider); - - provider.getVariant( - FLAG_KEY, - FALLBACK, - { distinct_id: "qa_user" }, + await createFlagAndLoadItIntoSDK( + { + experimentId: "exp-123", + isExperimentActive: true, + testUsers: { qa_user: "treatment" }, + }, + provider, ); + provider.getVariant(FLAG_KEY, FALLBACK, { distinct_id: "qa_user" }); + expect(mockTracker).toHaveBeenCalledTimes(1); const call = mockTracker.mock.calls[0]; @@ -621,22 +610,14 @@ describe("LocalFeatureFlagsProvider", () => { mockFlagDefinitionsResponse([]); await provider.startPollingForDefinitions(); - provider.getVariant( - "nonexistent_flag", - FALLBACK, - TEST_CONTEXT, - ); + provider.getVariant("nonexistent_flag", FALLBACK, TEST_CONTEXT); expect(mockTracker).not.toHaveBeenCalled(); }); it("should not track exposure without distinct_id", async () => { await createFlagAndLoadItIntoSDK({ context: "company" }, provider); - provider.getVariant( - FLAG_KEY, - FALLBACK, - { company_id: "company123" }, - ); + provider.getVariant(FLAG_KEY, FALLBACK, { company_id: "company123" }); expect(mockTracker).not.toHaveBeenCalled(); }); }); @@ -737,10 +718,13 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "treatment", value: "blue", is_control: false, split: 100.0 }, ]; - await createFlagAndLoadItIntoSDK({ - variants: variants, - rolloutPercentage: 100.0, - }, provider); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + }, + provider, + ); const result = provider.getVariantValue( FLAG_KEY, @@ -790,10 +774,13 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "treatment", value: true, is_control: false, split: 100.0 }, ]; - await createFlagAndLoadItIntoSDK({ - variants: variants, - rolloutPercentage: 100.0, - }, provider); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + }, + provider, + ); const result = provider.isEnabled(FLAG_KEY, TEST_CONTEXT); @@ -804,10 +791,13 @@ describe("LocalFeatureFlagsProvider", () => { const variants = [ { key: "control", value: false, is_control: true, split: 100.0 }, ]; - await createFlagAndLoadItIntoSDK({ - variants: variants, - rolloutPercentage: 100.0, - }, provider); + await createFlagAndLoadItIntoSDK( + { + variants: variants, + rolloutPercentage: 100.0, + }, + provider, + ); const result = provider.isEnabled(FLAG_KEY, TEST_CONTEXT);