diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f48098b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,56 @@ +name: Tests + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + # Cancel in-flight runs for the same PR when a new commit lands; let + # master pushes always finish so we keep a complete trunk history. + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + phpunit: + name: "PHPUnit (PHP ${{ matrix.php }})" + runs-on: ubuntu-latest + strategy: + # Surface every failing version on every run, not just the first. + fail-fast: false + matrix: + # Floor matches the `>=8.1` declared in composer.json. The + # 8.1 entry enforces the promise; later minors cover every + # currently-supported PHP release. + php: ['8.1', '8.3', '8.4'] + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v7.0.0 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@2282b6a082fc605c8320908a4cca3a5d1ca6c6fe # 2.37.2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Resolve Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer packages + uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v6.0.0 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: | + php-${{ matrix.php }}-composer- + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run PHPUnit + run: composer run-script unit-tests diff --git a/README.md b/README.md index b6ffa8f..3afbae0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Mixpanel PHP Library [![Build Status](https://travis-ci.org/mixpanel/mixpanel-php.svg)](https://travis-ci.org/mixpanel/mixpanel-php) +Mixpanel PHP Library ============ ##### _May 13, 2026_ - [2.11.0](https://github.com/mixpanel/mixpanel-php/releases/tag/2.11.0) @@ -82,7 +82,7 @@ Documentation * Reference Docs * Full API Reference -For further examples and options checkout out the "examples" folder +For further examples and options check out the "examples" folder. Changelog ------------- diff --git a/composer.json b/composer.json index 5d43e7a..7132b3a 100644 --- a/composer.json +++ b/composer.json @@ -17,11 +17,12 @@ } ], "require": { - "php": ">=5.0" + "php": ">=8.1", + "jwadhams/json-logic-php": "^1.5", + "symfony/polyfill-mbstring": "^1.27" }, "require-dev": { - "phpunit/phpunit": "5.6.*", - "phpdocumentor/phpdocumentor": "2.9.*" + "phpunit/phpunit": "^8.5.52 || ^9.5" }, "autoload": { "files": ["lib/Mixpanel.php"] diff --git a/lib/FeatureFlags/MixpanelFlags.php b/lib/FeatureFlags/MixpanelFlags.php new file mode 100644 index 0000000..fe61dad --- /dev/null +++ b/lib/FeatureFlags/MixpanelFlags.php @@ -0,0 +1,139 @@ +flags. Decides between the local + * and remote provider based on the `mode` option, owns the underlying + * provider's lifecycle, and forwards every public method to it. + * + * Usage: + * + * $mp = Mixpanel::getInstance('TOKEN', array( + * 'flags' => array('mode' => FeatureFlags_MixpanelFlags::MODE_REMOTE), + * )); + * $enabled = $mp->flags->isEnabled('my-flag', array( + * 'distinct_id' => 'user-123', + * )); + */ +class FeatureFlags_MixpanelFlags { + + /** + * Evaluation mode values for the `mode` config key. These constants + * give callers an IDE-checkable, grep-able alternative to bare string + * literals. The raw strings remain valid input — these are exact aliases. + */ + const MODE_LOCAL = 'local'; + const MODE_REMOTE = 'remote'; + + private FeatureFlags_MixpanelFlagsBase $_provider; + + /** One of the MODE_* constants. */ + private string $_mode; + + public function __construct(string $token, string $version, callable $tracker, array $options) { + // No extension checks — FNV-1a hashing uses PHP's built-in + // ext-hash (bundled in core), case folding uses + // symfony/polyfill-mbstring (composer dep), HTTP uses the + // existing CurlConsumer which already runtime-checks ext-curl. + $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); + if (isset($flagsOpts['mode'])) { + $requested = strtolower((string) $flagsOpts['mode']); + if ($requested !== self::MODE_LOCAL && $requested !== self::MODE_REMOTE) { + // Fail loudly on typos ('lcoal' -> silently falls through to remote is a debug trap). + throw new InvalidArgumentException( + "Invalid flags 'mode' option: " . var_export($flagsOpts['mode'], true) . + ". Expected '" . self::MODE_LOCAL . "' or '" . self::MODE_REMOTE . "'." + ); + } + $this->_mode = $requested; + } else { + $this->_mode = self::MODE_REMOTE; + } + + if ($this->_mode === self::MODE_LOCAL) { + $this->_provider = new FeatureFlags_MixpanelLocalFlags($token, $version, $tracker, $options); + } else { + $this->_provider = new FeatureFlags_MixpanelRemoteFlags($token, $version, $tracker, $options); + } + } + + public function __destruct() { + try { + $this->shutdown(); + } catch (\Throwable $t) { + // Swallow: destructors run during shutdown/fatal-error paths where + // throwing could mask the original error or hit a partially-torn-down + // interpreter state. + } + } + + public function getMode(): string { + return $this->_mode; + } + + public function getProvider(): FeatureFlags_MixpanelFlagsBase { + return $this->_provider; + } + + /** + * Fetch flag definitions from the server. Local mode only; no-op + * (returns true) in remote mode. + */ + public function loadDefinitions(): bool { + if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { + return $this->_provider->loadDefinitions(); + } + return true; + } + + public function areFlagsReady(): bool { + if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { + return $this->_provider->areFlagsReady(); + } + return true; + } + + public function lastSyncedAt(): ?int { + if ($this->_provider instanceof FeatureFlags_MixpanelLocalFlags) { + return $this->_provider->lastSyncedAt(); + } + return null; + } + + public function getVariant( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $fallback, + array $context, + bool $reportExposure = true + ): FeatureFlags_MixpanelSelectedVariant { + return $this->_provider->getVariant($flagKey, $fallback, $context, $reportExposure); + } + + public function getVariantValue(string $flagKey, mixed $fallbackValue, array $context): mixed { + return $this->_provider->getVariantValue($flagKey, $fallbackValue, $context); + } + + public function isEnabled(string $flagKey, array $context): bool { + return $this->_provider->isEnabled($flagKey, $context); + } + + public function getAllVariants(array $context): array { + return $this->_provider->getAllVariants($context); + } + + public function trackExposure( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $variant, + array $context + ): void { + $this->_provider->trackExposure($flagKey, $variant, $context); + } + + public function shutdown(): void { + $this->_provider->shutdown(); + } +} diff --git a/lib/FeatureFlags/MixpanelFlagsBase.php b/lib/FeatureFlags/MixpanelFlagsBase.php new file mode 100644 index 0000000..ed83750 --- /dev/null +++ b/lib/FeatureFlags/MixpanelFlagsBase.php @@ -0,0 +1,301 @@ +track($eventName, $properties + ['distinct_id' => $distinctId]) */ + // Note: 'callable' is not a valid PHP property type. Kept untyped with phpdoc. + protected $_tracker; + + protected string $_apiHost; + + /** Seconds. */ + protected int $_requestTimeout; + + public function __construct(string $token, string $version, callable $tracker, array $options) { + parent::__construct($options); + $this->_token = $token; + $this->_version = $version; + $this->_tracker = $tracker; + + $flagsOpts = isset($options['flags']) && is_array($options['flags']) ? $options['flags'] : array(); + // Precedence: flags.api_host (explicit override) > top-level + // `host` (shared with the event/people consumers) > default. + // This means EU/India endpoints or local mocks only need to be + // configured once at the SDK level. + if (isset($flagsOpts['api_host'])) { + $this->_apiHost = (string) $flagsOpts['api_host']; + } elseif (isset($options['host'])) { + $this->_apiHost = (string) $options['host']; + } else { + $this->_apiHost = 'api.mixpanel.com'; + } + $this->_requestTimeout = isset($flagsOpts['request_timeout_in_seconds']) ? (int) $flagsOpts['request_timeout_in_seconds'] : 10; + } + + /** Release any held resources. Subclasses override to close cURL handles. */ + public function shutdown(): void { + // default: nothing held + } + + /** + * Perform an authenticated GET against the flags API and return the + * decoded JSON body. Throws on HTTP error or transport failure — + * the remote provider catches this and surfaces the failure to the + * caller (audit finding #7: don't silently swallow backend errors). + * + * @param string $path e.g. "/flags" or "/flags/definitions" + * @param array $query query params (merged with token / mp_lib / lib_version) + * @return array decoded JSON + * @throws Exception on HTTP non-2xx or cURL transport error + */ + protected function _httpGet(string $path, array $query = array()): array { + // Match the guard AbstractConsumer already applies. On minimal + // PHP builds without ext-curl (some Alpine images, custom + // builds) curl_init would fatal with an unresolved function + // and give no hint that curl is what's missing. + if (!function_exists('curl_init')) { + throw new RuntimeException( + 'Mixpanel feature flags require the PHP curl extension (ext-curl), which is not loaded.' + ); + } + + $params = array_merge( + FeatureFlags_MixpanelFlagsUtils::commonQueryParams($this->_token, $this->_version), + $query + ); + $url = 'https://' . $this->_apiHost . $path . '?' . http_build_query($params); + + $headers = array( + // GET requests have no body — describe what we accept, not what we're sending. + 'Accept: application/json', + 'X-Scheme: https', + 'X-Forwarded-Proto: https', + 'traceparent: ' . FeatureFlags_MixpanelFlagsUtils::generateTraceparent(), + // Basic Auth with token as username, empty password. + 'Authorization: Basic ' . base64_encode($this->_token . ':'), + ); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + // Single timeout budget for the whole call, matching the other + // server SDKs (httpx in Python, Net::HTTP in Ruby, http.Client + // in Go all use one timeout covering connect + read). + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->_requestTimeout); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->_requestTimeout); + $body = curl_exec($ch); + $errno = curl_errno($ch); + $errmsg = curl_error($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + // Don't call curl_close($ch): PHP 8.0+ closes on unset and 8.5 + // marks the explicit call as deprecated. + unset($ch); + + if ($errno !== 0) { + // Carry the cURL errno as the exception code so it reaches + // the user's error_callback intact. + throw new Exception('Mixpanel flags HTTP transport error (' . $errno . '): ' . $errmsg, $errno); + } + if ($status < 200 || $status >= 300) { + throw new Exception( + 'Mixpanel flags HTTP ' . $status . ': ' . substr((string) $body, 0, 500), + (int) $status + ); + } + + $decoded = json_decode($body, true); + if (!is_array($decoded)) { + throw new Exception('Mixpanel flags response was not valid JSON: ' . substr((string) $body, 0, 200)); + } + return $decoded; + } + + /** + * Build the standard $experiment_started property set. + * + * Local mode supplies $latencyMs (derived from microtime around the + * in-process eval); remote mode supplies $startTime / $endTime + * (microtime floats around the HTTP call) and we derive latency + * here and emit ISO-8601 "Variant fetch start time" / "complete + * time" strings to match the Python, Ruby, Go, Java, Node, and + * Browser SDKs in remote mode. + * + * @param string $flagKey + * @param FeatureFlags_MixpanelSelectedVariant $variant + * @param string $evaluationMode 'local' or 'remote' + * @param float|null $latencyMs when supplied directly (local mode) + * @param float|null $startTime microtime(true) before the HTTP call (remote mode) + * @param float|null $endTime microtime(true) after the HTTP call (remote mode) + * @return array + */ + protected function _buildExposureProperties( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $variant, + string $evaluationMode, + ?float $latencyMs = null, + ?float $startTime = null, + ?float $endTime = null + ): array { + $properties = array( + 'Experiment name' => $flagKey, + 'Variant name' => $variant->variantKey, + '$experiment_type' => 'feature_flag', + 'Flag evaluation mode' => $evaluationMode, + '$experiment_id' => $variant->experimentId, + '$is_experiment_active' => $variant->isExperimentActive, + '$is_qa_tester' => $variant->isQaTester, + ); + if ($startTime !== null && $endTime !== null) { + $properties['Variant fetch start time'] = self::_formatIsoMicrotime($startTime); + $properties['Variant fetch complete time'] = self::_formatIsoMicrotime($endTime); + if ($latencyMs === null) { + $latencyMs = ($endTime - $startTime) * 1000.0; + } + } + if ($latencyMs !== null) { + $properties['Variant fetch latency (ms)'] = $latencyMs; + } + return $properties; + } + + /** + * Format a microtime(true) float as a local-time ISO-8601 string + * with microsecond precision, matching Python's + * `datetime.now().isoformat()` output shape so cross-SDK analytics + * keyed on these properties parse consistently. + */ + private static function _formatIsoMicrotime(float $microtime): string { + $seconds = (int) floor($microtime); + $micros = (int) round(($microtime - $seconds) * 1000000); + if ($micros >= 1000000) { + // round-up edge case at the second boundary + $seconds += 1; + $micros = 0; + } + return date('Y-m-d\TH:i:s', $seconds) . '.' . sprintf('%06d', $micros); + } + + /** + * Dispatch the exposure event. Routes through the configured + * tracker (normally Mixpanel::track), which means the event lands + * in the same buffered queue as every other event — flushed at + * end of request. That sidesteps audit finding #3 (synchronous + * exposure blocks eval) on PHP without us needing a separate + * async path. + * + * @param string $flagKey + * @param FeatureFlags_MixpanelSelectedVariant $variant + * @param array $context + * @param string $evaluationMode + * @param float|null $latencyMs + */ + protected function _trackExposure( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $variant, + array $context, + string $evaluationMode, + ?float $latencyMs = null, + ?float $startTime = null, + ?float $endTime = null + ): void { + if (!isset($context['distinct_id']) || $context['distinct_id'] === '' || $context['distinct_id'] === null) { + // Don't drop silently — surface to the error_callback so the + // caller learns why their exposure analytics are empty + // (audit finding #8). + $this->_handleError( + 'mixpanel-flags', + "Cannot track exposure for flag '{$flagKey}': distinct_id missing from context" + ); + return; + } + $distinctId = $context['distinct_id']; + $properties = $this->_buildExposureProperties( + $flagKey, $variant, $evaluationMode, $latencyMs, $startTime, $endTime + ); + + try { + call_user_func($this->_tracker, $distinctId, FeatureFlags_MixpanelFlagsUtils::EXPOSURE_EVENT, $properties); + } catch (Exception $e) { + $this->_handleError($e->getCode(), $e->getMessage()); + } + } + + /** + * Forward an error to the user-supplied error_callback if one was + * configured (matches the existing AbstractConsumer convention). + * + * `$code` is a union because callers pass HTTP status codes (int), + * literal string codes ('mixpanel-flags'), Throwable::getCode() + * (which is int on Exception but string on PDOException), or 0/null + * placeholders. + */ + protected function _handleError(int|string|null $code, string $message): void { + if (isset($this->_options['error_callback']) && is_callable($this->_options['error_callback'])) { + call_user_func($this->_options['error_callback'], $code, $message); + } elseif ($this->_debug()) { + $this->_log('[flags] ' . $message); + } + } + + abstract public function getVariant( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $fallback, + array $context, + bool $reportExposure = true + ): FeatureFlags_MixpanelSelectedVariant; + + public function getVariantValue(string $flagKey, mixed $fallbackValue, array $context): mixed { + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, $fallbackValue); + $variant = $this->getVariant($flagKey, $fallback, $context); + return $variant->variantValue; + } + + /** + * Returns true only when the variant value is the strict boolean + * `true`. Non-boolean truthy values (`1`, `"true"`, `"on"`, ...) + * intentionally return false — they signal a type mismatch on a + * flag that was expected to be a Mixpanel Feature Gate, and we + * fail closed rather than accept an ambiguous "on" signal. + * Matches the strict semantics of isEnabled in the Node, Ruby, + * Python, and Go SDKs. + */ + public function isEnabled(string $flagKey, array $context): bool { + return $this->getVariantValue($flagKey, false, $context) === true; + } + + /** + * Manually track exposure for a previously evaluated variant. Used + * with getAllVariants() so callers can record exposure only for the + * flags they actually consume. + */ + public function trackExposure( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $variant, + array $context + ): void { + $mode = $this->_evaluationMode(); + $this->_trackExposure($flagKey, $variant, $context, $mode); + } + + abstract protected function _evaluationMode(): string; +} diff --git a/lib/FeatureFlags/MixpanelFlagsUtils.php b/lib/FeatureFlags/MixpanelFlagsUtils.php new file mode 100644 index 0000000..3521831 --- /dev/null +++ b/lib/FeatureFlags/MixpanelFlagsUtils.php @@ -0,0 +1,110 @@ +-<16 hex>-01. + * The values are random per call; their only purpose is to give the + * Mixpanel server a correlation id for the request. + */ + public static function generateTraceparent(): string { + $traceId = bin2hex(random_bytes(16)); + $spanId = bin2hex(random_bytes(8)); + return '00-' . $traceId . '-' . $spanId . '-01'; + } + + /** + * The mp_lib / lib_version / token tuple that every flags request + * sends. lib_version is read from a constant on the main Mixpanel + * class so it tracks the package release. + */ + public static function commonQueryParams(string $token, string $version): array { + return array( + 'mp_lib' => 'php', + 'lib_version' => $version, + 'token' => $token, + ); + } + + /** + * Recursively casefold (lowercase) only the leaf string nodes of a + * structure, leaving the operator/keyword keys of a JSON-Logic rule + * intact. Used on the rule side of runtime evaluation so that + * comparisons against context values are case-insensitive without + * mangling operator names like "in" or "==". + * + * INVARIANT: this MUST be applied to the rule in lockstep with + * {@link self::lowercaseKeysAndValues} on the runtime parameters. + * `lowercaseLeafNodes` lowercases the operand values inside + * `{"var": "Email"}` to `"email"`; if the parameter keys aren't + * also lowercased, JSON-Logic's `var` lookup misses and every + * runtime-rule flag silently falls back. The two functions live + * together and must stay in sync. + */ + public static function lowercaseLeafNodes(mixed $value): mixed { + if (is_string($value)) { + return mb_strtolower($value, 'UTF-8'); + } + if (is_array($value)) { + $out = array(); + foreach ($value as $key => $sub) { + $out[$key] = self::lowercaseLeafNodes($sub); + } + return $out; + } + return $value; + } + + /** + * Recursively casefold both keys and string values. Used on the + * runtime-parameters side (custom_properties) so that user-supplied + * "Email"/"EMAIL"/"email" all collide with the same rule operand. + */ + public static function lowercaseKeysAndValues(mixed $value): mixed { + if (is_string($value)) { + return mb_strtolower($value, 'UTF-8'); + } + if (is_array($value)) { + $out = array(); + foreach ($value as $key => $sub) { + $newKey = is_string($key) ? mb_strtolower($key, 'UTF-8') : $key; + $out[$newKey] = self::lowercaseKeysAndValues($sub); + } + return $out; + } + return $value; + } +} diff --git a/lib/FeatureFlags/MixpanelLocalFlags.php b/lib/FeatureFlags/MixpanelLocalFlags.php new file mode 100644 index 0000000..f115ebf --- /dev/null +++ b/lib/FeatureFlags/MixpanelLocalFlags.php @@ -0,0 +1,327 @@ + map of flag_key => flag definition (decoded JSON) */ + private array $_definitions = array(); + + private bool $_ready = false; + + /** Unix timestamp of last successful loadDefinitions. */ + private ?int $_lastSyncedAt = null; + + /** + * Fetch the latest flag definitions from Mixpanel. Throws nothing + * on transport failure — the error is routed to error_callback so + * the existing definitions (if any) keep working until the next + * successful call. Returns true on success. + */ + public function loadDefinitions(): bool { + try { + $response = $this->_httpGet(self::DEFINITIONS_PATH); + } catch (Exception $e) { + $this->_handleError($e->getCode(), 'Failed to fetch flag definitions: ' . $e->getMessage()); + return false; + } + + $flags = isset($response['flags']) && is_array($response['flags']) ? $response['flags'] : array(); + $byKey = array(); + foreach ($flags as $flag) { + if (!isset($flag['key'])) { + continue; + } + if (isset($flag['ruleset']['variants']) && is_array($flag['ruleset']['variants'])) { + // Sort variants by key for deterministic bucket assignment. + usort($flag['ruleset']['variants'], function ($a, $b) { + $ak = isset($a['key']) ? (string) $a['key'] : ''; + $bk = isset($b['key']) ? (string) $b['key'] : ''; + return strcmp($ak, $bk); + }); + } + $byKey[$flag['key']] = $flag; + } + $this->_definitions = $byKey; + $this->_ready = true; + $this->_lastSyncedAt = time(); + return true; + } + + /** True once loadDefinitions has succeeded at least once. */ + public function areFlagsReady(): bool { + return $this->_ready; + } + + /** Unix timestamp of most recent successful sync. */ + public function lastSyncedAt(): ?int { + return $this->_lastSyncedAt; + } + + protected function _evaluationMode(): string { + return 'local'; + } + + public function getVariant( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $fallback, + array $context, + bool $reportExposure = true + ): FeatureFlags_MixpanelSelectedVariant { + $startTime = microtime(true); + + if (!$this->_ready) { + // Distinguish "definitions never loaded" from "definitions + // loaded but flag not present" — the per-variant reason is + // the seam a future OpenFeature wrapper uses. + $this->_handleError( + 'mixpanel-flags', + "getVariant called before loadDefinitions() succeeded; call loadDefinitions() first." + ); + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_NOT_READY); + } + + if (!isset($this->_definitions[$flagKey])) { + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND); + } + + $flag = $this->_definitions[$flagKey]; + $bucketingKey = isset($flag['context']) ? $flag['context'] : 'distinct_id'; + if (!isset($context[$bucketingKey]) || $context[$bucketingKey] === '' || $context[$bucketingKey] === null) { + $this->_handleError( + 'mixpanel-flags', + "Flag '{$flagKey}' requires context key '{$bucketingKey}' which was not supplied" + ); + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_MISSING_CONTEXT_KEY); + } + $contextValue = (string) $context[$bucketingKey]; + + // Test-user variant overrides always win, by design. + $selected = $this->_overrideForTestUser($flag, $context); + + if ($selected === null) { + $rollout = $this->_assignedRollout($flag, $contextValue, $context); + if ($rollout !== null) { + $selected = $this->_assignedVariant($flag, $contextValue, $flagKey, $rollout); + } + } + + if ($selected === null) { + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH); + } + + if ($reportExposure) { + $latencyMs = (microtime(true) - $startTime) * 1000.0; + $this->_trackExposure($flagKey, $selected, $context, 'local', $latencyMs); + } + + return $selected->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_LOCAL); + } + + public function getAllVariants(array $context): array { + $out = array(); + foreach ($this->_definitions as $flagKey => $_def) { + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, null); + $variant = $this->getVariant($flagKey, $fallback, $context, false); + if ($variant->variantKey !== null) { + $out[$flagKey] = $variant; + } + } + return $out; + } + + private function _overrideForTestUser(array $flag, array $context): ?FeatureFlags_MixpanelSelectedVariant { + if (!isset($flag['ruleset']['test']['users']) || !is_array($flag['ruleset']['test']['users'])) { + return null; + } + if (!isset($context['distinct_id'])) { + return null; + } + $distinctId = (string) $context['distinct_id']; + $users = $flag['ruleset']['test']['users']; + if (!isset($users[$distinctId])) { + return null; + } + return $this->_matchingVariant($users[$distinctId], $flag, /* isQaTester */ true); + } + + private function _matchingVariant(string $variantKey, array $flag, bool $isQaTester = false): ?FeatureFlags_MixpanelSelectedVariant { + if (!isset($flag['ruleset']['variants']) || !is_array($flag['ruleset']['variants'])) { + return null; + } + $targetKey = mb_strtolower((string) $variantKey, 'UTF-8'); + foreach ($flag['ruleset']['variants'] as $variant) { + if (!isset($variant['key'])) { + continue; + } + if (mb_strtolower((string) $variant['key'], 'UTF-8') === $targetKey) { + return new FeatureFlags_MixpanelSelectedVariant( + $variant['key'], + isset($variant['value']) ? $variant['value'] : null, + isset($flag['experiment_id']) ? $flag['experiment_id'] : null, + isset($flag['is_experiment_active']) ? $flag['is_experiment_active'] : null, + $isQaTester ? true : null + ); + } + } + return null; + } + + private function _assignedRollout(array $flag, string $contextValue, array $context): ?array { + if (!isset($flag['ruleset']['rollout']) || !is_array($flag['ruleset']['rollout'])) { + return null; + } + $flagKey = isset($flag['key']) ? $flag['key'] : ''; + // Default to null (not '') so the branch below can distinguish + // "flag declared a salt" from "flag has no salt at all" — the two + // pick completely different salt formulas. + $hashSalt = isset($flag['hash_salt']) ? $flag['hash_salt'] : null; + + foreach ($flag['ruleset']['rollout'] as $index => $rollout) { + if ($hashSalt !== null) { + $salt = $flagKey . $hashSalt . $index; + } else { + $salt = $flagKey . 'rollout'; + } + $rolloutHash = FeatureFlags_MixpanelFlagsUtils::normalizedHash($contextValue, $salt); + + $rolloutPercentage = isset($rollout['rollout_percentage']) ? (float) $rollout['rollout_percentage'] : 0.0; + if ($rolloutHash < $rolloutPercentage && $this->_runtimeRulesSatisfied($rollout, $context)) { + return $rollout; + } + } + return null; + } + + private function _assignedVariant(array $flag, string $contextValue, string $flagKey, array $rollout): ?FeatureFlags_MixpanelSelectedVariant { + if (isset($rollout['variant_override']['key'])) { + $override = $this->_matchingVariant($rollout['variant_override']['key'], $flag); + if ($override !== null) { + return $override; + } + } + + // Default to '' (not null) — this codepath just concatenates and never + // branches on presence, so the empty string collapses cleanly. + $hashSalt = isset($flag['hash_salt']) ? $flag['hash_salt'] : ''; + $salt = $flagKey . $hashSalt . 'variant'; + $variantHash = FeatureFlags_MixpanelFlagsUtils::normalizedHash($contextValue, $salt); + + $variants = isset($flag['ruleset']['variants']) ? $flag['ruleset']['variants'] : array(); + // Apply per-rollout split overrides without mutating the cached definition. + if (isset($rollout['variant_splits']) && is_array($rollout['variant_splits'])) { + foreach ($variants as $i => $v) { + if (isset($v['key']) && isset($rollout['variant_splits'][$v['key']])) { + $variants[$i]['split'] = $rollout['variant_splits'][$v['key']]; + } + } + } + + $selected = isset($variants[0]) ? $variants[0] : null; + $cumulative = 0.0; + foreach ($variants as $variant) { + $selected = $variant; + $cumulative += isset($variant['split']) ? (float) $variant['split'] : 0.0; + if ($variantHash < $cumulative) { + break; + } + } + + if ($selected === null) { + return null; + } + + return new FeatureFlags_MixpanelSelectedVariant( + isset($selected['key']) ? $selected['key'] : null, + isset($selected['value']) ? $selected['value'] : null, + isset($flag['experiment_id']) ? $flag['experiment_id'] : null, + isset($flag['is_experiment_active']) ? $flag['is_experiment_active'] : null, + null + ); + } + + private function _runtimeRulesSatisfied(array $rollout, array $context): bool { + if (isset($rollout['runtime_evaluation_rule']) && $rollout['runtime_evaluation_rule']) { + $params = $this->_runtimeParameters($context); + if ($params === null) { + // Not an error — the rollout just doesn't match — but log so + // callers debugging "why did I fall through to REASON_NO_ROLLOUT_MATCH" + // can see the missing custom_properties is why. + $this->_handleError( + 0, + 'Runtime rule present but custom_properties missing from context; rollout skipped.' + ); + return false; + } + try { + $rule = FeatureFlags_MixpanelFlagsUtils::lowercaseLeafNodes($rollout['runtime_evaluation_rule']); + $result = JWadhams\JsonLogic::apply($rule, $params); + return (bool) $result; + } catch (Exception $e) { + $this->_handleError($e->getCode(), 'Runtime rule evaluation error: ' . $e->getMessage()); + return false; + } catch (Error $e) { + // PHP 7+ throws Error (not Exception) for some failure modes. + $this->_handleError($e->getCode(), 'Runtime rule evaluation error: ' . $e->getMessage()); + return false; + } + } + + if (isset($rollout['runtime_evaluation_definition']) && is_array($rollout['runtime_evaluation_definition'])) { + return $this->_legacyRuntimeRuleSatisfied($rollout['runtime_evaluation_definition'], $context); + } + + return true; + } + + private function _legacyRuntimeRuleSatisfied(array $definition, array $context): bool { + $params = $this->_runtimeParameters($context); + if ($params === null) { + $this->_handleError( + 0, + 'Legacy runtime rule present but custom_properties missing from context; rollout skipped.' + ); + return false; + } + foreach ($definition as $key => $expectedValue) { + if (!array_key_exists($key, $params)) { + return false; + } + // Legacy runtime rules only meaningfully compare scalars. + // Casting an array via (string) yields "Array" and an + // E_NOTICE — treat non-scalar operands as "no match" + // instead of producing junk. + if (!is_scalar($params[$key]) || !is_scalar($expectedValue)) { + return false; + } + $actual = mb_strtolower((string) $params[$key], 'UTF-8'); + $expected = mb_strtolower((string) $expectedValue, 'UTF-8'); + if ($actual !== $expected) { + return false; + } + } + return true; + } + + private function _runtimeParameters(array $context): ?array { + if (!isset($context['custom_properties']) || !is_array($context['custom_properties'])) { + return null; + } + return FeatureFlags_MixpanelFlagsUtils::lowercaseKeysAndValues($context['custom_properties']); + } +} diff --git a/lib/FeatureFlags/MixpanelRemoteFlags.php b/lib/FeatureFlags/MixpanelRemoteFlags.php new file mode 100644 index 0000000..6661ed9 --- /dev/null +++ b/lib/FeatureFlags/MixpanelRemoteFlags.php @@ -0,0 +1,108 @@ + on the Mixpanel API. The server runs + * the full evaluation and returns the selected variant. + * + * Compared with local mode, remote eval requires no cached definitions + * and works correctly even on serverless / short-lived PHP processes + * where loading definitions for every request would be wasteful. + */ +class FeatureFlags_MixpanelRemoteFlags extends FeatureFlags_MixpanelFlagsBase { + + const FLAGS_PATH = '/flags'; + + protected function _evaluationMode(): string { + return 'remote'; + } + + public function getVariant( + string $flagKey, + FeatureFlags_MixpanelSelectedVariant $fallback, + array $context, + bool $reportExposure = true + ): FeatureFlags_MixpanelSelectedVariant { + $startTime = microtime(true); + try { + $flags = $this->_fetchFlags($context, $flagKey); + } catch (Exception $e) { + // Audit finding #7: don't silently swallow backend errors. + // Surface to error_callback, tag the fallback so a future + // OF wrapper can translate to GENERAL instead of + // FLAG_NOT_FOUND. + $this->_handleError($e->getCode(), 'Remote flag fetch failed: ' . $e->getMessage()); + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_BACKEND_ERROR); + } + $endTime = microtime(true); + + if (!isset($flags[$flagKey])) { + return $fallback->withFallbackReason(FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND); + } + + $selected = FeatureFlags_MixpanelSelectedVariant::fromArray($flags[$flagKey]) + ->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_REMOTE); + + if ($reportExposure) { + // Pass start/end so the exposure event carries + // "Variant fetch start time" / "Variant fetch complete + // time" ISO strings, matching Python/Ruby/Go/Java/Node + // remote-mode payloads. Latency is derived from the pair. + $this->_trackExposure($flagKey, $selected, $context, 'remote', null, $startTime, $endTime); + } + + return $selected; + } + + public function getAllVariants(array $context): array { + try { + $flags = $this->_fetchFlags($context, null); + } catch (Exception $e) { + $this->_handleError($e->getCode(), 'Remote flag fetch failed: ' . $e->getMessage()); + return array(); + } + + $out = array(); + foreach ($flags as $key => $payload) { + $out[$key] = FeatureFlags_MixpanelSelectedVariant::fromArray($payload) + ->withSource(FeatureFlags_MixpanelSelectedVariant::SOURCE_REMOTE); + } + return $out; + } + + /** + * @param string|null $flagKey when set, the server scopes the response to that one flag + * @return array map of flag_key => variant payload + */ + private function _fetchFlags(array $context, ?string $flagKey): array { + // The Python and Ruby SDKs URL-encode the context JSON before + // placing it in the query string. http_build_query would do + // that for us, but we pre-encode to JSON first so the server + // sees the expected JSON shape. + $encodedContext = json_encode($context); + if ($encodedContext === false) { + // json_encode returns false on non-UTF-8 strings, circular + // references, etc. http_build_query would coerce that to + // an empty string and the server would silently see + // context=, so surface the real cause instead. + throw new Exception( + 'Mixpanel flags context could not be JSON-encoded: ' . json_last_error_msg() + ); + } + $query = array( + 'context' => $encodedContext, + ); + if ($flagKey !== null) { + $query['flag_key'] = $flagKey; + } + $response = $this->_httpGet(self::FLAGS_PATH, $query); + if (isset($response['flags']) && is_array($response['flags'])) { + return $response['flags']; + } + return array(); + } +} diff --git a/lib/FeatureFlags/MixpanelSelectedVariant.php b/lib/FeatureFlags/MixpanelSelectedVariant.php new file mode 100644 index 0000000..1b661d4 --- /dev/null +++ b/lib/FeatureFlags/MixpanelSelectedVariant.php @@ -0,0 +1,128 @@ +variantKey = $variantKey; + $this->variantValue = $variantValue; + $this->experimentId = $experimentId; + $this->isExperimentActive = $isExperimentActive; + $this->isQaTester = $isQaTester; + $this->fallbackReason = $fallbackReason; + $this->variantSource = $variantSource; + } + + /** + * Build a SelectedVariant from the JSON shape returned by the + * /flags remote endpoint or stored inside a flag definition. + */ + public static function fromArray(array $data): self { + return new self( + isset($data['variant_key']) ? (string) $data['variant_key'] : null, + isset($data['variant_value']) ? $data['variant_value'] : null, + isset($data['experiment_id']) ? (string) $data['experiment_id'] : null, + isset($data['is_experiment_active']) ? (bool) $data['is_experiment_active'] : null, + isset($data['is_qa_tester']) ? (bool) $data['is_qa_tester'] : null + ); + } + + /** + * Return a copy of this variant with the given source. Clears + * fallbackReason — use {@link withFallbackReason} when returning a + * fallback. + * + * @param string $source one of the SOURCE_* constants + */ + public function withSource(string $source): self { + $clone = clone $this; + $clone->variantSource = $source; + $clone->fallbackReason = null; + return $clone; + } + + /** + * Return a copy of this variant tagged as a fallback with the given + * reason. Sets `variantSource` to SOURCE_FALLBACK and `fallbackReason` + * to the supplied REASON_* constant. Used by the providers to tag the + * caller's fallback without mutating their object. + * + * @param string $reason one of the REASON_* constants + */ + public function withFallbackReason(string $reason): self { + $clone = clone $this; + $clone->variantSource = self::SOURCE_FALLBACK; + $clone->fallbackReason = $reason; + return $clone; + } + + public function toArray(): array { + return array( + 'variant_key' => $this->variantKey, + 'variant_value' => $this->variantValue, + 'experiment_id' => $this->experimentId, + 'is_experiment_active' => $this->isExperimentActive, + 'is_qa_tester' => $this->isQaTester, + 'variant_source' => $this->variantSource, + 'fallback_reason' => $this->fallbackReason, + ); + } +} diff --git a/lib/Mixpanel.php b/lib/Mixpanel.php index 632bbd7..621311c 100644 --- a/lib/Mixpanel.php +++ b/lib/Mixpanel.php @@ -4,6 +4,7 @@ require_once(dirname(__FILE__) . "/Producers/MixpanelPeople.php"); require_once(dirname(__FILE__) . "/Producers/MixpanelEvents.php"); require_once(dirname(__FILE__) . "/Producers/MixpanelGroups.php"); +require_once(dirname(__FILE__) . "/FeatureFlags/MixpanelFlags.php"); /** * This is the main class for the Mixpanel PHP Library which provides all of the methods you need to track events, @@ -109,6 +110,31 @@ */ class Mixpanel extends Base_MixpanelBase { + /** + * Resolve the installed SDK version from Composer's runtime API. + * Used to populate the `lib_version` query param on feature-flag + * HTTP requests (matching Python/Ruby/Go/Java/Node). + * + * Composer 2.x ships `\Composer\InstalledVersions` in every install + * and returns the tag the package was installed from — so this + * stays accurate without any release-time bumping. Falls back to + * "unknown" on the off chance the package was loaded outside of a + * Composer-managed environment. + */ + private static function _resolveLibVersion() { + if (class_exists('\Composer\InstalledVersions')) { + try { + $version = \Composer\InstalledVersions::getVersion('mixpanel/mixpanel-php'); + if ($version !== null && $version !== '') { + return $version; + } + } catch (\Throwable $e) { + // fall through to "unknown" + } + } + return 'unknown'; + } + /** * An instance of the MixpanelPeople class (used to create/update profiles) @@ -128,7 +154,14 @@ class Mixpanel extends Base_MixpanelBase { * @var Producers_MixpanelPeople */ public $group; - + + + /** + * An instance of the MixpanelFlags facade, present only when the + * caller passed an 'flags' entry in $options. Use it for + * `$mp->flags->isEnabled(...)` and similar calls. + */ + public ?FeatureFlags_MixpanelFlags $flags = null; /** @@ -136,7 +169,7 @@ class Mixpanel extends Base_MixpanelBase { * @var Mixpanel[] */ private static $_instances = array(); - + /** * Instantiates a new Mixpanel instance. @@ -148,6 +181,18 @@ public function __construct($token, $options = array()) { $this->people = new Producers_MixpanelPeople($token, $options); $this->_events = new Producers_MixpanelEvents($token, $options); $this->group = new Producers_MixpanelGroups($token, $options); + + if (isset($options['flags'])) { + $events = $this->_events; + // The flags providers track exposure by routing the event + // through the existing event queue, so it benefits from + // the same batching/flushing as every other tracked event. + $tracker = function ($distinctId, $eventName, $properties) use ($events) { + $properties['distinct_id'] = $distinctId; + $events->track($eventName, $properties); + }; + $this->flags = new FeatureFlags_MixpanelFlags($token, self::_resolveLibVersion(), $tracker, $options); + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 81c67c7..bfcee08 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,29 +1,24 @@ - + stopOnFailure="false"> + ./test/ - - - - examples - vendor - test - - + + + + ./lib + + diff --git a/test/Base/MixpanelBaseProducerTest.php b/test/Base/MixpanelBaseProducerTest.php index f7beca6..9f65150 100644 --- a/test/Base/MixpanelBaseProducerTest.php +++ b/test/Base/MixpanelBaseProducerTest.php @@ -1,19 +1,19 @@ _file = dirname(__FILE__)."/output-".time().".txt"; $this->_instance = new _Producers_MixpanelBaseProducer("token", array("consumer" => "file", "debug" => true, "file" => $this->_file)); } - protected function tearDown() { + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); $this->_instance = null; diff --git a/test/ConsumerStrategies/AbstractConsumerTest.php b/test/ConsumerStrategies/AbstractConsumerTest.php index 5605cbf..f84b206 100644 --- a/test/ConsumerStrategies/AbstractConsumerTest.php +++ b/test/ConsumerStrategies/AbstractConsumerTest.php @@ -1,19 +1,19 @@ _instance = new AbstractConsumer(); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance = null; diff --git a/test/ConsumerStrategies/CurlConsumerTest.php b/test/ConsumerStrategies/CurlConsumerTest.php index dd12e32..b2a82d1 100644 --- a/test/ConsumerStrategies/CurlConsumerTest.php +++ b/test/ConsumerStrategies/CurlConsumerTest.php @@ -1,6 +1,6 @@ assertEquals($expected, $cmd); // The dangerous metacharacters must live inside single quotes, never bare. - $this->assertNotContains('"; touch', str_replace(escapeshellarg($url), '', $cmd)); - $this->assertNotContains('`whoami`', str_replace(escapeshellarg($data), '', $cmd)); + $this->assertStringNotContainsString('"; touch', str_replace(escapeshellarg($url), '', $cmd)); + $this->assertStringNotContainsString('`whoami`', str_replace(escapeshellarg($data), '', $cmd)); } public function testOptions() { diff --git a/test/ConsumerStrategies/FileConsumerTest.php b/test/ConsumerStrategies/FileConsumerTest.php index 703e3c9..bed1053 100644 --- a/test/ConsumerStrategies/FileConsumerTest.php +++ b/test/ConsumerStrategies/FileConsumerTest.php @@ -1,20 +1,20 @@ _file = dirname(__FILE__)."/output-".time().".txt"; $this->_instance = new ConsumerStrategies_FileConsumer(array("file" => $this->_file)); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance = null; diff --git a/test/ConsumerStrategies/SocketConsumerTest.php b/test/ConsumerStrategies/SocketConsumerTest.php index 2115b8d..ee9479a 100644 --- a/test/ConsumerStrategies/SocketConsumerTest.php +++ b/test/ConsumerStrategies/SocketConsumerTest.php @@ -1,13 +1,13 @@ _instance = new ConsumerStrategies_SocketConsumer(array( @@ -18,7 +18,7 @@ protected function setUp() )); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance = null; diff --git a/test/FeatureFlags/MixpanelFlagsTest.php b/test/FeatureFlags/MixpanelFlagsTest.php new file mode 100644 index 0000000..d09cff8 --- /dev/null +++ b/test/FeatureFlags/MixpanelFlagsTest.php @@ -0,0 +1,116 @@ +assertNull($mp->flags); + } + + public function testMixpanelInstanceConstructsFlagsWhenConfigured() { + $mp = new Mixpanel('token-with-flags', array( + 'flags' => array('mode' => 'remote'), + )); + $this->assertInstanceOf('FeatureFlags_MixpanelFlags', $mp->flags); + $this->assertEquals('remote', $mp->flags->getMode()); + $this->assertInstanceOf('FeatureFlags_MixpanelRemoteFlags', $mp->flags->getProvider()); + } + + public function testLocalModeProvider() { + $mp = new Mixpanel('token-local', array( + 'flags' => array('mode' => 'local'), + )); + $this->assertEquals('local', $mp->flags->getMode()); + $this->assertInstanceOf('FeatureFlags_MixpanelLocalFlags', $mp->flags->getProvider()); + // Before loadDefinitions runs, the local provider is not ready. + $this->assertFalse($mp->flags->areFlagsReady()); + $this->assertNull($mp->flags->lastSyncedAt()); + } + + public function testRemoteModeReportsReadyAndNoSync() { + $mp = new Mixpanel('token-r', array( + 'flags' => array('mode' => 'remote'), + )); + // Remote mode has no concept of "definitions ready" — it's + // always ready to make a call. loadDefinitions is a no-op + // returning true. lastSyncedAt is null. + $this->assertTrue($mp->flags->areFlagsReady()); + $this->assertTrue($mp->flags->loadDefinitions()); + $this->assertNull($mp->flags->lastSyncedAt()); + } + + public function testDefaultModeIsRemote() { + $mp = new Mixpanel('token-default', array( + 'flags' => array(), + )); + $this->assertEquals('remote', $mp->flags->getMode()); + } + + public function testModeConstantsAreStable() { + // The MODE_* constants are part of the public API; lock their + // string values so a refactor can't silently change them and + // break callers that compare against them. + $this->assertSame('local', FeatureFlags_MixpanelFlags::MODE_LOCAL); + $this->assertSame('remote', FeatureFlags_MixpanelFlags::MODE_REMOTE); + } + + public function testModeAcceptsConstantOrStringLiteral() { + $viaConstant = new Mixpanel('token-c', array( + 'flags' => array('mode' => FeatureFlags_MixpanelFlags::MODE_LOCAL), + )); + $viaLiteral = new Mixpanel('token-l', array( + 'flags' => array('mode' => 'local'), + )); + $this->assertEquals('local', $viaConstant->flags->getMode()); + $this->assertEquals('local', $viaLiteral->flags->getMode()); + $this->assertInstanceOf('FeatureFlags_MixpanelLocalFlags', $viaConstant->flags->getProvider()); + $this->assertInstanceOf('FeatureFlags_MixpanelLocalFlags', $viaLiteral->flags->getProvider()); + } + + public function testInvalidModeThrows() { + // Typos like 'lcoal' used to silently fall through to remote — a debug trap + // when the intent was local. Fail loudly instead. + $this->setExpectedExceptionCompat('InvalidArgumentException'); + new Mixpanel('token', array('flags' => array('mode' => 'lcoal'))); + } + + /** Shim: setExpectedException is deprecated on 8.x+, expectException is the modern form. */ + private function setExpectedExceptionCompat($class) { + if (method_exists($this, 'expectException')) { + $this->expectException($class); + } else { + $this->setExpectedException($class); + } + } + + public function testFlagsApiHostInheritsFromTopLevelHost() { + // If the caller already pointed the SDK at a regional or mock + // endpoint via the top-level `host` option, the flags module + // should pick that up instead of going to api.mixpanel.com. + $mp = new Mixpanel('token-eu', array( + 'host' => 'api-eu.mixpanel.com', + 'flags' => array('mode' => 'remote'), + )); + $provider = $mp->flags->getProvider(); + $reflection = new ReflectionClass('FeatureFlags_MixpanelFlagsBase'); + $apiHostProp = $reflection->getProperty('_apiHost'); + if (PHP_VERSION_ID < 80100) { + $apiHostProp->setAccessible(true); + } + $this->assertEquals('api-eu.mixpanel.com', $apiHostProp->getValue($provider)); + } + + public function testFlagsApiHostExplicitOverrideWinsOverTopLevelHost() { + $mp = new Mixpanel('token-mix', array( + 'host' => 'api-eu.mixpanel.com', + 'flags' => array('mode' => 'remote', 'api_host' => 'localhost:8080'), + )); + $provider = $mp->flags->getProvider(); + $reflection = new ReflectionClass('FeatureFlags_MixpanelFlagsBase'); + $apiHostProp = $reflection->getProperty('_apiHost'); + if (PHP_VERSION_ID < 80100) { + $apiHostProp->setAccessible(true); + } + $this->assertEquals('localhost:8080', $apiHostProp->getValue($provider)); + } +} diff --git a/test/FeatureFlags/MixpanelFlagsUtilsTest.php b/test/FeatureFlags/MixpanelFlagsUtilsTest.php new file mode 100644 index 0000000..3296018 --- /dev/null +++ b/test/FeatureFlags/MixpanelFlagsUtilsTest.php @@ -0,0 +1,92 @@ +assertSame('cbf29ce484222325', hash('fnv1a64', '')); + // 0xaf63dc4c8601ec8c — canonical reference value for "a". + $this->assertSame('af63dc4c8601ec8c', hash('fnv1a64', 'a')); + // 0x85944171f73967e8 — canonical reference value for "foobar". + $this->assertSame('85944171f73967e8', hash('fnv1a64', 'foobar')); + } + + public function testNormalizedHashInRange() { + $val = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-123', 'flag-key' . 'rollout'); + $this->assertGreaterThanOrEqual(0.0, $val); + $this->assertLessThan(1.0, $val); + } + + public function testNormalizedHashIsDeterministic() { + $a = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-123', 'flag-key' . 'rollout'); + $b = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-123', 'flag-key' . 'rollout'); + $this->assertSame($a, $b); + } + + public function testNormalizedHashDiffersByKey() { + $a = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-123', 'salt'); + $b = FeatureFlags_MixpanelFlagsUtils::normalizedHash('user-124', 'salt'); + $this->assertNotEquals($a, $b); + } + + public function testTraceparentShape() { + $tp = FeatureFlags_MixpanelFlagsUtils::generateTraceparent(); + // Avoid assertMatchesRegularExpression so the suite runs across + // PHPUnit 7.5–9.x without renaming the assertion. + $this->assertEquals(1, preg_match('/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/', $tp)); + } + + public function testCommonQueryParams() { + $params = FeatureFlags_MixpanelFlagsUtils::commonQueryParams('TKN', '2.11.0'); + $this->assertEquals('php', $params['mp_lib']); + $this->assertEquals('2.11.0', $params['lib_version']); + $this->assertEquals('TKN', $params['token']); + } + + public function testLowercaseLeafNodes() { + $rule = array('==' => array(array('var' => 'Email'), 'Alice@Example.COM')); + $out = FeatureFlags_MixpanelFlagsUtils::lowercaseLeafNodes($rule); + // Operator/"var" keys are preserved; only the leaf string values + // (the property name fetched by "var" and the literal compared + // against) get casefolded. + $this->assertArrayHasKey('==', $out); + $this->assertEquals('email', $out['=='][0]['var']); + $this->assertEquals('alice@example.com', $out['=='][1]); + } + + public function testLowercaseKeysAndValues() { + $data = array('Email' => 'Alice@Example.COM', 'Count' => 5); + $out = FeatureFlags_MixpanelFlagsUtils::lowercaseKeysAndValues($data); + $this->assertArrayHasKey('email', $out); + $this->assertEquals('alice@example.com', $out['email']); + // Non-string values pass through unchanged. + $this->assertSame(5, $out['count']); + } + + /** + * Locks the invariant documented on {@link FeatureFlags_MixpanelFlagsUtils::lowercaseLeafNodes}: + * the rule side ({@code lowercaseLeafNodes}) and the parameter side + * ({@code lowercaseKeysAndValues}) must resolve the same var name to + * the same casefolded key. If someone changes one function without the + * other, JSON-Logic's `var` lookup silently misses and every + * runtime-rule flag falls back — this test would catch that. + */ + public function testLeafNodesAndKeysAndValuesUseCompatibleCasefolding() { + $rule = array('==' => array(array('var' => 'Email'), 'Alice@Example.COM')); + $params = array('Email' => 'Alice@Example.COM'); + + $normalizedRule = FeatureFlags_MixpanelFlagsUtils::lowercaseLeafNodes($rule); + $normalizedParams = FeatureFlags_MixpanelFlagsUtils::lowercaseKeysAndValues($params); + + $varName = $normalizedRule['=='][0]['var']; + $this->assertArrayHasKey($varName, $normalizedParams); + $this->assertSame($normalizedRule['=='][1], $normalizedParams[$varName]); + } +} diff --git a/test/FeatureFlags/MixpanelLocalFlagsTest.php b/test/FeatureFlags/MixpanelLocalFlagsTest.php new file mode 100644 index 0000000..1d25b73 --- /dev/null +++ b/test/FeatureFlags/MixpanelLocalFlagsTest.php @@ -0,0 +1,294 @@ +getProperty('_definitions'); + if (PHP_VERSION_ID < 80100) { + $defsProp->setAccessible(true); + } + $defsProp->setValue($this, $defs); + $readyProp = $reflection->getProperty('_ready'); + if (PHP_VERSION_ID < 80100) { + $readyProp->setAccessible(true); + } + $readyProp->setValue($this, true); + } +} + +class MixpanelLocalFlagsTest extends PHPUnit\Framework\TestCase { + + /** @var array exposure events captured by the spy tracker */ + private $_captured; + + /** @var callable */ + private $_tracker; + + /** @var _TestableLocalFlags */ + private $_provider; + + protected function setUp() : void { + $this->_captured = array(); + $captured = &$this->_captured; + $this->_tracker = function ($distinctId, $eventName, $properties) use (&$captured) { + $captured[] = array($distinctId, $eventName, $properties); + }; + $this->_provider = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('mode' => 'local'), + )); + } + + /** + * Build a minimal flag definition fixture. A single rollout at + * 100% with no runtime rules, and as many variants as supplied + * with equal splits summing to 1.0. + */ + private function makeFlag($key, array $variants, $context = 'distinct_id', $rolloutPct = 1.0, $extra = array()) { + $variantDefs = array(); + foreach ($variants as $variantKey => $value) { + $variantDefs[] = array( + 'key' => $variantKey, + 'value' => $value, + 'is_control' => false, + 'split' => 1.0 / count($variants), + ); + } + $flag = array( + 'id' => 'fid-' . $key, + 'name' => $key, + 'key' => $key, + 'status' => 'active', + 'project_id' => 1, + 'context' => $context, + 'experiment_id' => 'exp-' . $key, + 'is_experiment_active' => true, + 'ruleset' => array( + 'variants' => $variantDefs, + 'rollout' => array( + array('rollout_percentage' => $rolloutPct), + ), + ), + ); + return array_merge($flag, $extra); + } + + public function testReturnsFallbackAndSetsReasonWhenFlagMissing() { + $this->_provider->setDefinitionsForTest(array()); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); + $result = $this->_provider->getVariant('unknown', $fallback, array('distinct_id' => 'u1')); + $this->assertEquals('fallback', $result->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::SOURCE_FALLBACK, + $result->variantSource + ); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND, + $result->fallbackReason + ); + // The caller's fallback object must not be mutated — we return a clone. + $this->assertNull($fallback->fallbackReason); + $this->assertNull($fallback->variantSource); + } + + public function testGetVariantBeforeLoadReturnsNotReady() { + // Brand-new provider — no loadDefinitions / no setDefinitionsForTest. + $fresh = new FeatureFlags_MixpanelLocalFlags('token', '2.11.0', $this->_tracker, array( + 'flags' => array('mode' => 'local'), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); + $result = $fresh->getVariant('any-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertEquals('fb', $result->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_NOT_READY, + $result->fallbackReason + ); + } + + public function testReturnsFallbackAndSetsReasonWhenContextMissing() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true)), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); + // No distinct_id in context, but the flag's bucketing key IS distinct_id. + $result = $this->_provider->getVariant('my-flag', $fallback, array('email' => 'x@y.com')); + $this->assertEquals('fallback', $result->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_MISSING_CONTEXT_KEY, + $result->fallbackReason + ); + } + + public function testReturnsVariantOnSuccessfulEval() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true)), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); + $result = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertEquals('on', $result->variantKey); + $this->assertSame(true, $result->variantValue); + $this->assertEquals('exp-my-flag', $result->experimentId); + $this->assertTrue($result->isExperimentActive); + // variantSource=local marks a real local-eval match. null fallbackReason + // means evaluation succeeded — no fallback used. + $this->assertEquals(FeatureFlags_MixpanelSelectedVariant::SOURCE_LOCAL, $result->variantSource); + $this->assertNull($result->fallbackReason); + } + + public function testTracksExposureByDefault() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true)), + )); + $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'), + array('distinct_id' => 'u1') + ); + $this->assertCount(1, $this->_captured); + list($distinctId, $eventName, $props) = $this->_captured[0]; + $this->assertEquals('u1', $distinctId); + $this->assertEquals('$experiment_started', $eventName); + $this->assertEquals('my-flag', $props['Experiment name']); + $this->assertEquals('on', $props['Variant name']); + $this->assertEquals('feature_flag', $props['$experiment_type']); + $this->assertEquals('local', $props['Flag evaluation mode']); + $this->assertArrayHasKey('Variant fetch latency (ms)', $props); + } + + public function testReportExposureFalseSkipsTracking() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true)), + )); + $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'), + array('distinct_id' => 'u1'), + false + ); + $this->assertCount(0, $this->_captured); + } + + public function testReturnsFallbackWhenRolloutIsZero() { + $this->_provider->setDefinitionsForTest(array( + 'my-flag' => $this->makeFlag('my-flag', array('on' => true), 'distinct_id', 0.0), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'); + $result = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertEquals('fallback', $result->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH, + $result->fallbackReason + ); + } + + public function testCustomBucketingKeyWithoutDistinctIdLogsButReturnsValue() { + // Audit finding #8: when the bucketing key is non-distinct_id + // and distinct_id is missing, the SDK should not silently drop + // exposure — it should surface the problem. + $errors = array(); + $errorCallback = function ($code, $message) use (&$errors) { + $errors[] = array($code, $message); + }; + $provider = new _TestableLocalFlags('token', '2.11.0', $this->_tracker, array( + 'error_callback' => $errorCallback, + 'flags' => array('mode' => 'local'), + )); + $provider->setDefinitionsForTest(array( + 'device-flag' => $this->makeFlag('device-flag', array('on' => true), 'device_id'), + )); + + $result = $provider->getVariant( + 'device-flag', + new FeatureFlags_MixpanelSelectedVariant(null, 'fallback'), + array('device_id' => 'd1') + ); + $this->assertEquals('on', $result->variantKey); + // Exposure NOT fired (no distinct_id), but the error_callback fired. + $this->assertCount(0, $this->_captured); + $this->assertCount(1, $errors); + $this->assertStringContainsString('distinct_id', $errors[0][1]); + } + + public function testNestedNumericValuesPreserveType() { + // Audit finding #11: nested ints in object-valued variants + // must round-trip as ints. PHP's json_decode($x, true) does + // this by default, so the guarantee is mostly about not + // accidentally casting along the way. + $this->_provider->setDefinitionsForTest(array( + 'obj-flag' => $this->makeFlag('obj-flag', array( + 'on' => array('threshold' => 42, 'nested' => array('count' => 7)), + )), + )); + $result = $this->_provider->getVariant( + 'obj-flag', + new FeatureFlags_MixpanelSelectedVariant(null, null), + array('distinct_id' => 'u1'), + false + ); + $this->assertSame(42, $result->variantValue['threshold']); + $this->assertSame(7, $result->variantValue['nested']['count']); + } + + public function testRuntimeRuleEmailContainsMatch() { + $rule = array( + 'in' => array('gmail', array('var' => 'email')), + ); + $flag = $this->makeFlag('rt-flag', array('on' => true)); + $flag['ruleset']['rollout'][0]['runtime_evaluation_rule'] = $rule; + $this->_provider->setDefinitionsForTest(array('rt-flag' => $flag)); + + $result = $this->_provider->getVariant( + 'rt-flag', + new FeatureFlags_MixpanelSelectedVariant(null, false), + array( + 'distinct_id' => 'u1', + 'custom_properties' => array('email' => 'Alice@GMAIL.com'), + ), + false + ); + $this->assertEquals('on', $result->variantKey); + } + + public function testRuntimeRuleMissingCustomPropertiesIsNotMatch() { + $rule = array('in' => array('gmail', array('var' => 'email'))); + $flag = $this->makeFlag('rt-flag', array('on' => true)); + $flag['ruleset']['rollout'][0]['runtime_evaluation_rule'] = $rule; + $this->_provider->setDefinitionsForTest(array('rt-flag' => $flag)); + + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, false); + $result = $this->_provider->getVariant('rt-flag', $fallback, array('distinct_id' => 'u1'), false); + $this->assertSame(false, $result->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_NO_ROLLOUT_MATCH, + $result->fallbackReason + ); + } + + public function testIsEnabledReturnsTrueOnlyForBooleanTrueVariantValue() { + $this->_provider->setDefinitionsForTest(array( + 'bool-flag' => $this->makeFlag('bool-flag', array('on' => true)), + 'str-flag' => $this->makeFlag('str-flag', array('on' => 'yes')), + )); + $this->assertTrue($this->_provider->isEnabled('bool-flag', array('distinct_id' => 'u1'))); + $this->assertFalse($this->_provider->isEnabled('str-flag', array('distinct_id' => 'u1'))); + $this->assertFalse($this->_provider->isEnabled('missing-flag', array('distinct_id' => 'u1'))); + } + + public function testGetAllVariantsExcludesFallbacks() { + $this->_provider->setDefinitionsForTest(array( + 'a' => $this->makeFlag('a', array('on' => 1)), + 'b' => $this->makeFlag('b', array('on' => 2), 'distinct_id', 0.0), // 0% rollout + )); + $all = $this->_provider->getAllVariants(array('distinct_id' => 'u1')); + $this->assertArrayHasKey('a', $all); + $this->assertArrayNotHasKey('b', $all); + } +} diff --git a/test/FeatureFlags/MixpanelRemoteFlagsTest.php b/test/FeatureFlags/MixpanelRemoteFlagsTest.php new file mode 100644 index 0000000..8501c6f --- /dev/null +++ b/test/FeatureFlags/MixpanelRemoteFlagsTest.php @@ -0,0 +1,193 @@ +lastRequest = array('path' => $path, 'query' => $query); + if ($this->nextError !== null) { + throw new Exception($this->nextError); + } + return $this->nextResponse === null ? array() : $this->nextResponse; + } +} + +class MixpanelRemoteFlagsTest extends PHPUnit\Framework\TestCase { + + /** @var array */ + private $_captured; + + /** @var _TestableRemoteFlags */ + private $_provider; + + protected function setUp() : void { + $this->_captured = array(); + $captured = &$this->_captured; + $tracker = function ($distinctId, $eventName, $properties) use (&$captured) { + $captured[] = array($distinctId, $eventName, $properties); + }; + $this->_provider = new _TestableRemoteFlags('token', '2.11.0', $tracker, array( + 'flags' => array('mode' => 'remote'), + )); + } + + public function testGetVariantSendsContextAndFlagKey() { + $this->_provider->nextResponse = array('flags' => array( + 'my-flag' => array('variant_key' => 'on', 'variant_value' => true), + )); + $context = array('distinct_id' => 'u1', 'custom_properties' => array('email' => 'a@b.com')); + $variant = $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, false), + $context + ); + + $this->assertEquals('on', $variant->variantKey); + $this->assertSame(true, $variant->variantValue); + $this->assertEquals(FeatureFlags_MixpanelSelectedVariant::SOURCE_REMOTE, $variant->variantSource); + $this->assertNull($variant->fallbackReason); + $this->assertEquals('/flags', $this->_provider->lastRequest['path']); + $this->assertEquals('my-flag', $this->_provider->lastRequest['query']['flag_key']); + $this->assertEquals(json_encode($context), $this->_provider->lastRequest['query']['context']); + } + + public function testTracksExposureOnSuccess() { + $this->_provider->nextResponse = array('flags' => array( + 'my-flag' => array('variant_key' => 'on', 'variant_value' => true, 'experiment_id' => 'X'), + )); + $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, false), + array('distinct_id' => 'u1') + ); + $this->assertCount(1, $this->_captured); + list($distinctId, $event, $props) = $this->_captured[0]; + $this->assertEquals('u1', $distinctId); + $this->assertEquals('$experiment_started', $event); + $this->assertEquals('remote', $props['Flag evaluation mode']); + $this->assertEquals('X', $props['$experiment_id']); + // Remote-mode exposure carries the ISO start/complete timestamps + // so PHP analytics align with Python/Ruby/Go/Java/Node remote payloads. + $this->assertArrayHasKey('Variant fetch start time', $props); + $this->assertArrayHasKey('Variant fetch complete time', $props); + $this->assertArrayHasKey('Variant fetch latency (ms)', $props); + $iso = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}$/'; + $this->assertEquals(1, preg_match($iso, $props['Variant fetch start time'])); + $this->assertEquals(1, preg_match($iso, $props['Variant fetch complete time'])); + } + + public function testFlagMissingInResponseSetsFlagNotFoundReason() { + $this->_provider->nextResponse = array('flags' => array( + 'other-flag' => array('variant_key' => 'on', 'variant_value' => true), + )); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); + $variant = $this->_provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertEquals('fb', $variant->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::SOURCE_FALLBACK, + $variant->variantSource + ); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_FLAG_NOT_FOUND, + $variant->fallbackReason + ); + } + + public function testBackendErrorIsSurfacedDistinctlyFromFlagNotFound() { + // Audit finding #7: a backend error (HTTP 4xx/5xx) must not be + // indistinguishable from "flag not found" — otherwise a future + // OF wrapper translates it to FLAG_NOT_FOUND when it should be + // GENERAL. + $this->_provider->nextError = 'simulated HTTP 500'; + $errors = array(); + $errorCallback = function ($code, $message) use (&$errors) { + $errors[] = $message; + }; + $captured = array(); + $tracker = function ($d, $e, $p) use (&$captured) { + $captured[] = $p; + }; + $provider = new _TestableRemoteFlags('token', '2.11.0', $tracker, array( + 'error_callback' => $errorCallback, + 'flags' => array('mode' => 'remote'), + )); + $provider->nextError = 'simulated HTTP 500'; + + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); + $variant = $provider->getVariant('my-flag', $fallback, array('distinct_id' => 'u1')); + $this->assertEquals('fb', $variant->variantValue); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_BACKEND_ERROR, + $variant->fallbackReason + ); + $this->assertCount(1, $errors); + $this->assertStringContainsString('simulated HTTP 500', $errors[0]); + } + + public function testReportExposureFalseSkipsTracking() { + $this->_provider->nextResponse = array('flags' => array( + 'my-flag' => array('variant_key' => 'on', 'variant_value' => true), + )); + $this->_provider->getVariant( + 'my-flag', + new FeatureFlags_MixpanelSelectedVariant(null, false), + array('distinct_id' => 'u1'), + false + ); + $this->assertCount(0, $this->_captured); + } + + public function testJsonEncodeFailureSurfacesAsBackendError() { + // Non-UTF-8 bytes in the context make json_encode return false. + // Previously http_build_query silently coerced that to an empty + // string, the server received context= and returned a null + // response, and the caller got REASON_FLAG_NOT_FOUND — with no + // hint that the real cause was serialization. + $errors = array(); + $errorCallback = function ($code, $message) use (&$errors) { + $errors[] = $message; + }; + $provider = new _TestableRemoteFlags( + 'token', + '2.11.0', + function () {}, + array( + 'error_callback' => $errorCallback, + 'flags' => array('mode' => 'remote'), + ) + ); + + $context = array('distinct_id' => 'u1', 'bad' => "\xB1\x31"); + $fallback = new FeatureFlags_MixpanelSelectedVariant(null, 'fb'); + $variant = $provider->getVariant('my-flag', $fallback, $context); + + $this->assertNull($provider->lastRequest, 'HTTP call should not have been attempted'); + $this->assertEquals( + FeatureFlags_MixpanelSelectedVariant::REASON_BACKEND_ERROR, + $variant->fallbackReason + ); + $this->assertEquals('fb', $variant->variantValue); + $this->assertCount(1, $errors); + $this->assertStringContainsString('JSON-encoded', $errors[0]); + } + + public function testGetAllVariantsOmitsFlagKeyQueryParam() { + $this->_provider->nextResponse = array('flags' => array( + 'a' => array('variant_key' => 'on', 'variant_value' => 1), + )); + $this->_provider->getAllVariants(array('distinct_id' => 'u1')); + $this->assertArrayNotHasKey('flag_key', $this->_provider->lastRequest['query']); + } +} diff --git a/test/MixpanelTest.php b/test/MixpanelTest.php index 9380612..59db582 100644 --- a/test/MixpanelTest.php +++ b/test/MixpanelTest.php @@ -1,18 +1,18 @@ _instance = Mixpanel::getInstance("token"); } - protected function tearDown() { + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); $this->_instance = null; diff --git a/test/Producers/MixpanelEventsProducerTest.php b/test/Producers/MixpanelEventsProducerTest.php index b38172b..d169605 100644 --- a/test/Producers/MixpanelEventsProducerTest.php +++ b/test/Producers/MixpanelEventsProducerTest.php @@ -1,19 +1,19 @@ _instance = new Producers_MixpanelEvents("token"); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); @@ -86,7 +86,7 @@ public function testCreateAlias() { public function testCreateAliasRespectsConsumerSetting() { $tmp_file = __DIR__ . '/test.tmp'; - $this->assertFileNotExists($tmp_file); + $this->assertFalse(file_exists($tmp_file)); $options = array('consumer' => 'file', 'file' => $tmp_file); $instance = new Producers_MixpanelEvents('token', $options); diff --git a/test/Producers/MixpanelGroupsProducerTest.php b/test/Producers/MixpanelGroupsProducerTest.php index 8c735ef..e0c8ed3 100644 --- a/test/Producers/MixpanelGroupsProducerTest.php +++ b/test/Producers/MixpanelGroupsProducerTest.php @@ -1,19 +1,19 @@ _instance = new Producers_MixpanelGroups("token"); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset(); @@ -97,9 +97,9 @@ public function testRemove() { $this->assertEquals("Mixpanel", $msg['$group_id']); $this->assertEquals("token", $msg['$token']); $this->assertArrayNotHasKey('$ignore_time', $msg); - $this->assertArrayHasKey('$unset', $msg); - $this->assertArrayHasKey("industry", $msg['$unset']); - $this->assertEquals("tech", $msg['$unset']['industry']); + $this->assertArrayHasKey('$remove', $msg); + $this->assertArrayHasKey("industry", $msg['$remove']); + $this->assertEquals("tech", $msg['$remove']['industry']); } diff --git a/test/Producers/MixpanelPeopleProducerTest.php b/test/Producers/MixpanelPeopleProducerTest.php index e748e26..d4d29f2 100644 --- a/test/Producers/MixpanelPeopleProducerTest.php +++ b/test/Producers/MixpanelPeopleProducerTest.php @@ -1,19 +1,19 @@ _instance = new Producers_MixpanelPeople("token"); } - protected function tearDown() + protected function tearDown() : void { parent::tearDown(); $this->_instance->reset();