From 36f842d2cb853f3c792983643f1d62f08ca83c7d Mon Sep 17 00:00:00 2001 From: Nicolas Zelaya Date: Thu, 16 Jul 2026 20:24:45 -0300 Subject: [PATCH 1/2] Support impressions disabled AI-Session-Id: f4a08ac7-1d39-4164-94c8-d1ee344a29e5 AI-Tool: claude-code AI-Model: unknown --- CHANGES.txt | 3 + src/SplitIO/Grammar/Split.php | 13 +- src/SplitIO/Sdk/Client.php | 43 +- src/SplitIO/Sdk/Evaluator.php | 2 + .../Sdk/Impressions/DecoratedImpression.php | 47 ++ .../Sdk/Manager/LocalhostSplitManager.php | 8 +- src/SplitIO/Sdk/Manager/SplitManager.php | 3 +- src/SplitIO/Sdk/Manager/SplitView.php | 22 +- .../ImpressionsDisabledE2EListener.php | 30 + .../Helpers/ImpressionsDisabledListener.php | 24 + .../Suite/Sdk/ImpressionsDisabledE2ETest.php | 425 ++++++++++++++ tests/Suite/Sdk/ImpressionsDisabledTest.php | 535 ++++++++++++++++++ 12 files changed, 1141 insertions(+), 14 deletions(-) create mode 100644 src/SplitIO/Sdk/Impressions/DecoratedImpression.php create mode 100644 tests/Suite/Sdk/Helpers/ImpressionsDisabledE2EListener.php create mode 100644 tests/Suite/Sdk/Helpers/ImpressionsDisabledListener.php create mode 100644 tests/Suite/Sdk/ImpressionsDisabledE2ETest.php create mode 100644 tests/Suite/Sdk/ImpressionsDisabledTest.php diff --git a/CHANGES.txt b/CHANGES.txt index ca646cfb..2f86224f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +x.x.x (Unreleased) + - Added support for the new impressions tracking toggle available on feature flags. + 7.4.0 (Apr 15, 2026) - Added support for PHP 8.5 version, removing deprecated warning messages. diff --git a/src/SplitIO/Grammar/Split.php b/src/SplitIO/Grammar/Split.php index 6d6cf385..65de997b 100644 --- a/src/SplitIO/Grammar/Split.php +++ b/src/SplitIO/Grammar/Split.php @@ -33,6 +33,8 @@ class Split private $configurations = null; private $sets = null; + private $impressionsDisabled = false; + public function __construct(array $split) { SplitApp::logger()->debug(print_r($split, true)); @@ -52,7 +54,8 @@ public function __construct(array $split) $this->configurations = isset($split['configurations']) && count($split['configurations']) > 0 ? $split['configurations'] : null; $this->sets = isset($split['sets']) ? $split['sets'] : array(); - + $this->impressionsDisabled = isset($split['impressionsDisabled']) ? $split['impressionsDisabled'] : false; + SplitApp::logger()->info("Constructing Feature Flag: ".$this->name); if (isset($split['conditions']) && is_array($split['conditions'])) { @@ -182,4 +185,12 @@ public function getSets() { return $this->sets; } + + /** + * @return bool + */ + public function impressionsDisabled() + { + return (bool) $this->impressionsDisabled; + } } diff --git a/src/SplitIO/Sdk/Client.php b/src/SplitIO/Sdk/Client.php index e21432d4..b9686b4a 100644 --- a/src/SplitIO/Sdk/Client.php +++ b/src/SplitIO/Sdk/Client.php @@ -7,6 +7,7 @@ use SplitIO\Sdk\Events\EventQueueMessage; use SplitIO\Sdk\QueueMetadataMessage; use SplitIO\Sdk\Impressions\Impression; +use SplitIO\Sdk\Impressions\DecoratedImpression; use SplitIO\TreatmentImpression; use SplitIO\Sdk\Impressions\ImpressionLabel; use SplitIO\Grammar\Condition\Partition\TreatmentEnum; @@ -134,7 +135,8 @@ private function doEvaluation($operation, $key, $featureFlagName, $attributes) $bucketingKey ); - $this->registerData($impression, $attributes); + $disabled = isset($result['impressionsDisabled']) && $result['impressionsDisabled']; + $this->registerData(new DecoratedImpression($impression, $disabled), $attributes); return array( 'treatment' => $result['treatment'], 'config' => $result['config'], @@ -155,7 +157,7 @@ private function doEvaluation($operation, $key, $featureFlagName, $attributes) ImpressionLabel::EXCEPTION, $bucketingKey ); - $this->registerData($impression, $attributes); + $this->registerData(new DecoratedImpression($impression, false), $attributes); } catch (\Exception $e) { SplitApp::logger()->critical( "An error occurred when attempting to log impression for " . @@ -237,12 +239,33 @@ private function doInputValidationForTreatments($key, $featureFlagNames, $operat ); } - private function registerData($impressions, $attributes) + private function registerData($decoratedImpressions, $attributes) { try { - TreatmentImpression::log($impressions, $this->queueMetadata); - if (isset($this->impressionListener)) { - $this->impressionListener->sendDataToClient($impressions, $attributes); + // Normalize to array + if (!is_array($decoratedImpressions)) { + $decoratedImpressions = array($decoratedImpressions); + } + + // Partition: all go to listener, only enabled go to log + $forListener = array(); + $forLog = array(); + foreach ($decoratedImpressions as $decorated) { + $impression = $decorated->getImpression(); + $forListener[] = $impression; + if (!$decorated->isDisabled()) { + $forLog[] = $impression; + } + } + + // Log enabled impressions to Redis + if (!empty($forLog)) { + TreatmentImpression::log($forLog, $this->queueMetadata); + } + + // Send all impressions to listener + if (isset($this->impressionListener) && !empty($forListener)) { + $this->impressionListener->sendDataToClient($forListener, $attributes); } } catch (\Exception $e) { SplitApp::logger()->critical( @@ -526,11 +549,11 @@ private function processEvaluations( $evaluations ) { $result = array(); - $impressions = array(); + $decoratedImpressions = array(); foreach ($evaluations as $featureFlagName => $evalResult) { if (InputValidator::isSplitFound($evalResult['impression']['label'], $featureFlagName, $operation)) { // Creates impression - $impressions[] = $this->createImpression( + $impression = $this->createImpression( $matchingKey, $featureFlagName, $evalResult['treatment'], @@ -538,6 +561,8 @@ private function processEvaluations( $evalResult['impression']['label'], $bucketingKey ); + $disabled = isset($evalResult['impressionsDisabled']) && $evalResult['impressionsDisabled']; + $decoratedImpressions[] = new DecoratedImpression($impression, $disabled); $result[$featureFlagName] = array( 'treatment' => $evalResult['treatment'], 'config' => $evalResult['config'], @@ -546,7 +571,7 @@ private function processEvaluations( $result[$featureFlagName] = array('treatment' => TreatmentEnum::CONTROL, 'config' => null); } } - $this->registerData($impressions, $attributes); + $this->registerData($decoratedImpressions, $attributes); return $result; } } diff --git a/src/SplitIO/Sdk/Evaluator.php b/src/SplitIO/Sdk/Evaluator.php index a403278d..45e1e748 100644 --- a/src/SplitIO/Sdk/Evaluator.php +++ b/src/SplitIO/Sdk/Evaluator.php @@ -108,11 +108,13 @@ private function evalTreatment($key, $bucketingKey, $split, ?array $attributes = if (is_null($split)) { $result['impression']['label'] = ImpressionLabel::SPLIT_NOT_FOUND; + $result['impressionsDisabled'] = false; return $result; } try { $configs = $split->getConfigurations(); $result['impression']['changeNumber'] = $split->getChangeNumber(); + $result['impressionsDisabled'] = $split->impressionsDisabled(); if ($split->killed()) { $defaultTreatment = $split->getDefaultTratment(); $result['treatment'] = $defaultTreatment; diff --git a/src/SplitIO/Sdk/Impressions/DecoratedImpression.php b/src/SplitIO/Sdk/Impressions/DecoratedImpression.php new file mode 100644 index 00000000..46502948 --- /dev/null +++ b/src/SplitIO/Sdk/Impressions/DecoratedImpression.php @@ -0,0 +1,47 @@ +impression = $impression; + $this->disabled = (bool) $disabled; + } + + /** + * @return Impression + */ + public function getImpression() + { + return $this->impression; + } + + /** + * @return bool + */ + public function isDisabled() + { + return $this->disabled; + } +} diff --git a/src/SplitIO/Sdk/Manager/LocalhostSplitManager.php b/src/SplitIO/Sdk/Manager/LocalhostSplitManager.php index 3628f952..88b53055 100644 --- a/src/SplitIO/Sdk/Manager/LocalhostSplitManager.php +++ b/src/SplitIO/Sdk/Manager/LocalhostSplitManager.php @@ -66,7 +66,10 @@ public function splits() false, $this->splits[$featureFlagName]["treatments"], 0, - $configs + $configs, + null, + array(), + false ); } } @@ -92,7 +95,8 @@ public function split($featureFlagName) 0, $configs, null, - array() + array(), + false ); } diff --git a/src/SplitIO/Sdk/Manager/SplitManager.php b/src/SplitIO/Sdk/Manager/SplitManager.php index b67bbd69..fd2818a5 100644 --- a/src/SplitIO/Sdk/Manager/SplitManager.php +++ b/src/SplitIO/Sdk/Manager/SplitManager.php @@ -68,7 +68,8 @@ private static function parseSplitView($splitRepresentation) $split->getChangeNumber(), $configs, $split->getDefaultTratment(), - $split->getSets() + $split->getSets(), + $split->impressionsDisabled() ); } } diff --git a/src/SplitIO/Sdk/Manager/SplitView.php b/src/SplitIO/Sdk/Manager/SplitView.php index 558635db..366ca8a6 100644 --- a/src/SplitIO/Sdk/Manager/SplitView.php +++ b/src/SplitIO/Sdk/Manager/SplitView.php @@ -11,6 +11,7 @@ class SplitView private $configs; private $defaultTreatment; private $sets; + private $impressionsDisabled; /** * SplitView constructor. @@ -22,6 +23,7 @@ class SplitView * @param $configurations * @param $defaultTreatment * @param $sets + * @param $impressionsDisabled */ public function __construct( $name, @@ -31,7 +33,8 @@ public function __construct( $changeNumber, $configs, $defaultTreatment, - $sets + $sets, + $impressionsDisabled = false ) { $this->name = $name; $this->trafficType = $trafficType; @@ -41,6 +44,7 @@ public function __construct( $this->configs = $configs; $this->defaultTreatment = $defaultTreatment; $this->sets = $sets; + $this->impressionsDisabled = $impressionsDisabled; } @@ -171,4 +175,20 @@ public function getSets() { return $this->sets; } + + /** + * @return mixed + */ + public function getImpressionsDisabled() + { + return $this->impressionsDisabled; + } + + /** + * @param mixed $impressionsDisabled + */ + public function setImpressionsDisabled($impressionsDisabled) + { + $this->impressionsDisabled = $impressionsDisabled; + } } diff --git a/tests/Suite/Sdk/Helpers/ImpressionsDisabledE2EListener.php b/tests/Suite/Sdk/Helpers/ImpressionsDisabledE2EListener.php new file mode 100644 index 00000000..cfef6a60 --- /dev/null +++ b/tests/Suite/Sdk/Helpers/ImpressionsDisabledE2EListener.php @@ -0,0 +1,30 @@ +receivedImpressions[] = array( + 'feature' => $impression->getFeature(), + 'key' => $impression->getId(), + 'treatment' => $impression->getTreatment(), + 'label' => $impression->getLabel(), + 'changeNumber' => $impression->getChangeNumber() + ); + } + + public function close() + { + } + + public function reset() + { + $this->receivedImpressions = array(); + } +} diff --git a/tests/Suite/Sdk/Helpers/ImpressionsDisabledListener.php b/tests/Suite/Sdk/Helpers/ImpressionsDisabledListener.php new file mode 100644 index 00000000..ccf532df --- /dev/null +++ b/tests/Suite/Sdk/Helpers/ImpressionsDisabledListener.php @@ -0,0 +1,24 @@ +receivedImpressions[] = $impression; + } + + public function close() + { + } + + public function reset() + { + $this->receivedImpressions = array(); + $this->receivedAttributes = array(); + } +} diff --git a/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php b/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php new file mode 100644 index 00000000..f42deba8 --- /dev/null +++ b/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php @@ -0,0 +1,425 @@ + 'redis', + 'host' => REDIS_HOST, + 'port' => REDIS_PORT, + 'timeout' => 881, + ); + $options = array(); + $options['cache'] = array( + 'adapter' => 'predis', + 'parameters' => $parameters, + 'options' => array('prefix' => TEST_PREFIX) + ); + if ($listener !== null) { + $options['impressionListener'] = $listener; + } + return \SplitIO\Sdk::factory('test-api-key', $options); + } + + private function getRedisClient() + { + return new \Predis\Client( + array( + 'host' => REDIS_HOST, + 'port' => REDIS_PORT, + ), + array('prefix' => TEST_PREFIX) + ); + } + + private function seedE2EFixtures() + { + $redis = $this->getRedisClient(); + + // Fixture 1: disabled, in set_e2e + $split1 = array( + 'name' => 'flag_e2e_disabled', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array('set_e2e'), + 'impressionsDisabled' => true, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Fixture 2: enabled, in set_e2e + $split2 = array( + 'name' => 'flag_e2e_enabled', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array('set_e2e'), + 'impressionsDisabled' => false, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Fixture 3: legacy (no property) + $split3 = array( + 'name' => 'flag_e2e_legacy', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Fixture 4: disabled + killed + $split4 = array( + 'name' => 'flag_e2e_disabled_killed', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => true, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => true, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Fixture 5: truthy (numeric 1) + $split5 = array( + 'name' => 'flag_e2e_truthy', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => 1, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array(array('matcherType' => 'ALL_KEYS', 'negate' => false)) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + // Seed all splits + $redis->set('SPLITIO.split.flag_e2e_disabled', json_encode($split1)); + $redis->set('SPLITIO.split.flag_e2e_enabled', json_encode($split2)); + $redis->set('SPLITIO.split.flag_e2e_legacy', json_encode($split3)); + $redis->set('SPLITIO.split.flag_e2e_disabled_killed', json_encode($split4)); + $redis->set('SPLITIO.split.flag_e2e_truthy', json_encode($split5)); + $redis->set('SPLITIO.splits.till', 1750000000000); + + // Seed flag-set index (set_e2e contains fixtures 1 and 2) + $redis->sadd('SPLITIO.flagSet.set_e2e', 'flag_e2e_disabled'); + $redis->sadd('SPLITIO.flagSet.set_e2e', 'flag_e2e_enabled'); + } + + public function setUp(): void + { + Utils\Utils::cleanCache(); + Di::set(Di::KEY_FACTORY_TRACKER, false); + $this->seedE2EFixtures(); + } + + public function tearDown(): void + { + Utils\Utils::cleanCache(); + } + + public function testE2EAllFixtures() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // Exercise all fixtures via getTreatments + $treatments = $client->getTreatments('e2e_user_1', array( + 'flag_e2e_disabled', + 'flag_e2e_enabled', + 'flag_e2e_legacy', + 'flag_e2e_disabled_killed', + 'flag_e2e_truthy' + )); + + // All treatments correct + $this->assertEquals('on', $treatments['flag_e2e_disabled']); + $this->assertEquals('on', $treatments['flag_e2e_enabled']); + $this->assertEquals('on', $treatments['flag_e2e_legacy']); + $this->assertEquals('off', $treatments['flag_e2e_disabled_killed']); // killed + $this->assertEquals('on', $treatments['flag_e2e_truthy']); + + // Redis: only enabled and legacy should be queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(2, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + $this->assertContains('flag_e2e_legacy', $queuedFeatures); + + // Listener: all 5 should be sent + $this->assertCount(5, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + $this->assertContains('flag_e2e_legacy', $listenerFeatures); + $this->assertContains('flag_e2e_disabled_killed', $listenerFeatures); + $this->assertContains('flag_e2e_truthy', $listenerFeatures); + } + + public function testE2EGetTreatmentWithConfigDisabled() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $result = $client->getTreatmentWithConfig('e2e_user_1', 'flag_e2e_disabled'); + $this->assertEquals('on', $result['treatment']); + + // Not in Redis + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw); + + // In listener + $this->assertCount(1, $listener->receivedImpressions); + $this->assertEquals('flag_e2e_disabled', $listener->receivedImpressions[0]['feature']); + } + + public function testE2EGetTreatmentsWithConfigMixed() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $results = $client->getTreatmentsWithConfig('e2e_user_1', array('flag_e2e_disabled', 'flag_e2e_enabled')); + $this->assertEquals('on', $results['flag_e2e_disabled']['treatment']); + $this->assertEquals('on', $results['flag_e2e_enabled']['treatment']); + + // Only enabled queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(1, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + + // Both in listener + $this->assertCount(2, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + } + + public function testE2EGetTreatmentsByFlagSet() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // set_e2e contains flag_e2e_disabled (disabled) + flag_e2e_enabled (enabled) + $treatments = $client->getTreatmentsByFlagSet('e2e_user_1', 'set_e2e'); + + // Both treatments correct + $this->assertEquals('on', $treatments['flag_e2e_disabled']); + $this->assertEquals('on', $treatments['flag_e2e_enabled']); + + // Only enabled queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(1, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + + // Both in listener + $this->assertCount(2, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + } + + public function testE2EGetTreatmentsByFlagSets() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // set_e2e contains flag_e2e_disabled (disabled) + flag_e2e_enabled (enabled) + $treatments = $client->getTreatmentsByFlagSets('e2e_user_1', array('set_e2e')); + + // Both treatments correct + $this->assertEquals('on', $treatments['flag_e2e_disabled']); + $this->assertEquals('on', $treatments['flag_e2e_enabled']); + + // Only enabled queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(1, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + + // Both in listener + $this->assertCount(2, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + } + + public function testE2EGetTreatmentsWithConfigByFlagSets() + { + $listener = new ImpressionsDisabledE2EListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // set_e2e contains flag_e2e_disabled (disabled) + flag_e2e_enabled (enabled) + $results = $client->getTreatmentsWithConfigByFlagSets('e2e_user_1', array('set_e2e')); + + // Both treatments correct + $this->assertEquals('on', $results['flag_e2e_disabled']['treatment']); + $this->assertEquals('on', $results['flag_e2e_enabled']['treatment']); + + // Only enabled queued + $queuedFeatures = array(); + while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { + $parsed = json_decode($raw, true); + $queuedFeatures[] = $parsed['i']['f']; + } + $this->assertCount(1, $queuedFeatures); + $this->assertContains('flag_e2e_enabled', $queuedFeatures); + + // Both in listener + $this->assertCount(2, $listener->receivedImpressions); + $listenerFeatures = array_map(function ($imp) { + return $imp['feature']; + }, $listener->receivedImpressions); + $this->assertContains('flag_e2e_disabled', $listenerFeatures); + $this->assertContains('flag_e2e_enabled', $listenerFeatures); + } + + public function testE2EEnabledImpressionPayloadFidelity() + { + // Full-field assertion for ENABLED impression payload (brief 3b). + // Assert every field in the serialized impression, plus TTL unchanged. + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // Check TTL BEFORE rpop (rpop drains the list, leaving no TTL on empty list) + $treatment = $client->getTreatment('e2e_user_1', 'flag_e2e_enabled'); + $this->assertEquals('on', $treatment); + + // Assert TTL is within bounds (> 0 and <= 3600) + $ttl = $redis->ttl(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertGreaterThan(0, $ttl); + $this->assertLessThanOrEqual(3600, $ttl); + + // Now rpop and assert full payload + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + + // Assert every impression field + $this->assertEquals('e2e_user_1', $parsed['i']['k']); // key + $this->assertNull($parsed['i']['b']); // bucketing key (null when simple string key used) + $this->assertEquals('flag_e2e_enabled', $parsed['i']['f']); // feature + $this->assertEquals('on', $parsed['i']['t']); // treatment + $this->assertArrayHasKey('r', $parsed['i']); // label field exists (may be null if labels disabled) + $this->assertEquals(1750000000000, $parsed['i']['c']); // changeNumber + $this->assertIsInt($parsed['i']['m']); // timestamp + $this->assertGreaterThan(0, $parsed['i']['m']); + + // Assert metadata exists + $this->assertArrayHasKey('m', $parsed); + $this->assertIsArray($parsed['m']); + } +} diff --git a/tests/Suite/Sdk/ImpressionsDisabledTest.php b/tests/Suite/Sdk/ImpressionsDisabledTest.php new file mode 100644 index 00000000..2272e7bc --- /dev/null +++ b/tests/Suite/Sdk/ImpressionsDisabledTest.php @@ -0,0 +1,535 @@ + 'redis', + 'host' => REDIS_HOST, + 'port' => REDIS_PORT, + 'timeout' => 881, + ); + $options = array(); + $options['cache'] = array( + 'adapter' => 'predis', + 'parameters' => $parameters, + 'options' => array('prefix' => TEST_PREFIX) + ); + if ($impressionListener !== null) { + $options['impressionListener'] = $impressionListener; + } + return \SplitIO\Sdk::factory('test-api-key', $options); + } + + private function getRedisClient() + { + return new \Predis\Client( + array( + 'host' => REDIS_HOST, + 'port' => REDIS_PORT, + ), + array('prefix' => TEST_PREFIX) + ); + } + + private function seedSplitWithImpressionsDisabled($name, $impressionsDisabled, $killed = false, $sets = array()) + { + $split = array( + 'name' => $name, + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => $killed, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => $sets, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array( + array( + 'matcherType' => 'ALL_KEYS', + 'negate' => false, + ) + ) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + + if ($impressionsDisabled !== null) { + $split['impressionsDisabled'] = $impressionsDisabled; + } + + $splitChanges = json_encode(array('splits' => array($split), 'till' => 1750000000000)); + Utils\Utils::addSplitsInCache($splitChanges); + } + + public function setUp(): void + { + Utils\Utils::cleanCache(); + Di::set(Di::KEY_FACTORY_TRACKER, false); + } + + public function tearDown(): void + { + Utils\Utils::cleanCache(); + } + + public function testSingleEvalImpressionDisabled() + { + $this->seedSplitWithImpressionsDisabled('flag_disabled', true); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_disabled'); + $this->assertEquals('on', $treatment); + + // Should NOT be in Redis queue + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw); + } + + public function testSingleEvalImpressionEnabled() + { + $this->seedSplitWithImpressionsDisabled('flag_enabled', false); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_enabled'); + $this->assertEquals('on', $treatment); + + // Should be in Redis queue + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_enabled', $parsed['i']['f']); + $this->assertEquals('e2e_user_1', $parsed['i']['k']); + $this->assertEquals('on', $parsed['i']['t']); + } + + public function testPropertyAbsentDefaultsToEnabled() + { + $this->seedSplitWithImpressionsDisabled('flag_legacy', null); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_legacy'); + $this->assertEquals('on', $treatment); + + // Should be in Redis queue (default is tracked) + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_legacy', $parsed['i']['f']); + } + + public function testNonBooleanJsonValueTruthy() + { + // Seed with numeric 1 (truthy) + $redis = $this->getRedisClient(); + $split = array( + 'name' => 'flag_truthy', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => 1, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array( + array('matcherType' => 'ALL_KEYS', 'negate' => false) + ) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + $redis->set('SPLITIO.split.flag_truthy', json_encode($split)); + $redis->set('SPLITIO.splits.till', 1750000000000); + + $factory = $this->createFactory(); + $client = $factory->client(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_truthy'); + $this->assertEquals('on', $treatment); + + // (bool)1 === true, so should NOT be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw); + } + + public function testMixedBatchGetTreatments() + { + $this->seedSplitWithImpressionsDisabled('flag_batch_disabled', true); + $this->seedSplitWithImpressionsDisabled('flag_batch_enabled', false); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatments = $client->getTreatments('e2e_user_1', array('flag_batch_disabled', 'flag_batch_enabled')); + $this->assertEquals('on', $treatments['flag_batch_disabled']); + $this->assertEquals('on', $treatments['flag_batch_enabled']); + + // Only the enabled one should be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_batch_enabled', $parsed['i']['f']); + + // No second impression + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testMixedBatchGetTreatmentsWithConfig() + { + $this->seedSplitWithImpressionsDisabled('flag_config_disabled', true); + $this->seedSplitWithImpressionsDisabled('flag_config_enabled', false); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $results = $client->getTreatmentsWithConfig('e2e_user_1', array('flag_config_disabled', 'flag_config_enabled')); + $this->assertEquals('on', $results['flag_config_disabled']['treatment']); + $this->assertEquals('on', $results['flag_config_enabled']['treatment']); + + // Only the enabled one should be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_config_enabled', $parsed['i']['f']); + + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testDisabledAndKilled() + { + $this->seedSplitWithImpressionsDisabled('flag_disabled_killed', true, true); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_disabled_killed'); + // Killed returns defaultTreatment + $this->assertEquals('off', $treatment); + + // Even though killed, impressionsDisabled=true means NOT queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw); + } + + public function testManagerSplitDisabled() + { + $this->seedSplitWithImpressionsDisabled('flag_manager_disabled', true); + $factory = $this->createFactory(); + $manager = $factory->manager(); + + $splitView = $manager->split('flag_manager_disabled'); + $this->assertNotNull($splitView); + $this->assertEquals('flag_manager_disabled', $splitView->getName()); + $this->assertTrue($splitView->getImpressionsDisabled()); + } + + public function testManagerSplitLegacy() + { + $this->seedSplitWithImpressionsDisabled('flag_manager_legacy', null); + $factory = $this->createFactory(); + $manager = $factory->manager(); + + $splitView = $manager->split('flag_manager_legacy'); + $this->assertNotNull($splitView); + $this->assertEquals('flag_manager_legacy', $splitView->getName()); + $this->assertFalse($splitView->getImpressionsDisabled()); + } + + public function testMixedBatchGetTreatmentsByFlagSet() + { + $this->seedSplitWithImpressionsDisabled('flag_flagset_disabled', true, false, array('set_unit')); + $this->seedSplitWithImpressionsDisabled('flag_flagset_enabled', false, false, array('set_unit')); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatments = $client->getTreatmentsByFlagSet('e2e_user_1', 'set_unit'); + $this->assertEquals('on', $treatments['flag_flagset_disabled']); + $this->assertEquals('on', $treatments['flag_flagset_enabled']); + + // Only the enabled one should be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_flagset_enabled', $parsed['i']['f']); + + // No second impression + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testMixedBatchGetTreatmentsByFlagSets() + { + $this->seedSplitWithImpressionsDisabled('flag_flagsets_disabled', true, false, array('set_unit2')); + $this->seedSplitWithImpressionsDisabled('flag_flagsets_enabled', false, false, array('set_unit2')); + $factory = $this->createFactory(); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + $treatments = $client->getTreatmentsByFlagSets('e2e_user_1', array('set_unit2')); + $this->assertEquals('on', $treatments['flag_flagsets_disabled']); + $this->assertEquals('on', $treatments['flag_flagsets_enabled']); + + // Only the enabled one should be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_flagsets_enabled', $parsed['i']['f']); + + // No second impression + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testExceptionPathStillQueues() + { + // Induce evaluation exception by storing malformed JSON that causes json_decode + // or Split constructor to fail. When Evaluator catches the exception, + // Client::doEvaluation's outer catch builds a control impression with label EXCEPTION + // and changeNumber -1. This impression MUST still be queued (wrapped as not-disabled). + // This is a regression guard: exception queuing behavior is unchanged by impressionsDisabled. + $redis = $this->getRedisClient(); + + // Store invalid JSON (missing required fields) that will cause Split construction to fail + $redis->set('SPLITIO.split.flag_exception', '{"name":"flag_exception"}'); + $redis->set('SPLITIO.splits.till', 1750000000000); + + $factory = $this->createFactory(); + $client = $factory->client(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_exception'); + $this->assertEquals('control', $treatment); + + // The EXCEPTION impression MUST still be queued + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_exception', $parsed['i']['f']); + $this->assertEquals('control', $parsed['i']['t']); + $this->assertEquals('exception', $parsed['i']['r']); + } + + public function testNonBooleanJsonValueNull() + { + // Non-boolean JSON value null must cast to false → impression IS logged. + // (bool)null === false, so tracking is enabled (back-compat critical). + // The existing helper omits null, so seed manually with explicit null. + $redis = $this->getRedisClient(); + $split = array( + 'name' => 'flag_null', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => null, + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array( + array('matcherType' => 'ALL_KEYS', 'negate' => false) + ) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + $redis->set('SPLITIO.split.flag_null', json_encode($split)); + $redis->set('SPLITIO.splits.till', 1750000000000); + + $factory = $this->createFactory(); + $client = $factory->client(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_null'); + $this->assertEquals('on', $treatment); + + // Should be queued: (bool)null === false → tracked + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_null', $parsed['i']['f']); + } + + public function testNonBooleanJsonValueStringTrue() + { + // Non-boolean JSON string "true" casts truthy → NOT queued. + // (bool)"true" === true in PHP (any non-empty string is truthy). + $redis = $this->getRedisClient(); + $split = array( + 'name' => 'flag_string_true', + 'trafficTypeName' => 'user', + 'seed' => 123456789, + 'status' => 'ACTIVE', + 'killed' => false, + 'defaultTreatment' => 'off', + 'changeNumber' => 1750000000000, + 'algo' => 2, + 'sets' => array(), + 'impressionsDisabled' => 'true', + 'conditions' => array( + array( + 'matcherGroup' => array( + 'combiner' => 'AND', + 'matchers' => array( + array('matcherType' => 'ALL_KEYS', 'negate' => false) + ) + ), + 'partitions' => array( + array('treatment' => 'on', 'size' => 100), + array('treatment' => 'off', 'size' => 0) + ) + ) + ) + ); + $redis->set('SPLITIO.split.flag_string_true', json_encode($split)); + $redis->set('SPLITIO.splits.till', 1750000000000); + + $factory = $this->createFactory(); + $client = $factory->client(); + + $treatment = $client->getTreatment('e2e_user_1', 'flag_string_true'); + $this->assertEquals('on', $treatment); + + // Should NOT be queued: (bool)"true" === true + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw); + } + + public function testListenerDivergenceUnit() + { + // Listener divergence guard: disabled + listener → listener fires with full payload; + // enabled + listener → listener fires AND queue receives. This tests both directions. + $listener = new ImpressionsDisabledListener(); + $this->seedSplitWithImpressionsDisabled('flag_listener_disabled', true); + $this->seedSplitWithImpressionsDisabled('flag_listener_enabled', false); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // Call getTreatment on each + $treatment1 = $client->getTreatment('e2e_user_1', 'flag_listener_disabled'); + $treatment2 = $client->getTreatment('e2e_user_1', 'flag_listener_enabled'); + $this->assertEquals('on', $treatment1); + $this->assertEquals('on', $treatment2); + + // Listener should receive BOTH (disabled fires listener) + $this->assertCount(2, $listener->receivedImpressions); + + // Redis queue should contain ONLY the enabled one + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_listener_enabled', $parsed['i']['f']); + + // No second impression in queue + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + } + + public function testCrossSdkParityCanonicalFixture() + { + // Cross-SDK parity test using canonical fixture from official Split spec. + // Canonical spec fixture uses flag names "tracked" (impressionsDisabled: false) + // and "not_tracked" (impressionsDisabled: true). The spec's canonical conditions + // use an IN_SEGMENT matcher (segment "new_segment") with partitions putting 100% on + // treatment "free". Reproducing that verbatim would require seeding a segment and + // would make the test about segment-matching, not impression-toggling. Per the + // standard fixture-reconciliation rule (repo conventions win for structure; only + // the impressionsDisabled lines are authoritative from the spec), we substitute + // an ALL_KEYS matcher with partitions on:100 / off:0, defaultTreatment off, + // like the other tests in this file. This keeps the test deterministic without + // segment seeding while preserving the parity-relevant flag names and + // impressionsDisabled values from the spec. + $this->seedSplitWithImpressionsDisabled('tracked', false); + $this->seedSplitWithImpressionsDisabled('not_tracked', true); + + // Redis analog surface (spec tests #2-#4 reduced to drop-silently): + // In the spec's other SDKs, tests #2-#4 verify mode-based POST behavior + // (None/Debug/Optimized). This SDK is Redis-consumer-only with no HTTP + // impression recorder, no mode machinery, no uniquekeys/impressionsCount. + // The parity analog for this SDK is: both flags evaluate correctly, only + // "tracked" lands in Redis SPLITIO.impressions queue, "not_tracked" is + // dropped silently, and the listener (if attached) receives both. + $listener = new ImpressionsDisabledListener(); + $factory = $this->createFactory($listener); + $client = $factory->client(); + $redis = $this->getRedisClient(); + + // Use eval key "CUSTOMER_ID" to echo the spec's canonical example + $trackedTreatment = $client->getTreatment('CUSTOMER_ID', 'tracked'); + $notTrackedTreatment = $client->getTreatment('CUSTOMER_ID', 'not_tracked'); + + // Both treatments should be "on" (ALL_KEYS matcher with on:100 partition) + $this->assertEquals('on', $trackedTreatment); + $this->assertEquals('on', $notTrackedTreatment); + + // Listener should receive BOTH (drop-silently still fires listener) + $this->assertCount(2, $listener->receivedImpressions); + + // Redis queue should contain ONLY "tracked" impression + $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('tracked', $parsed['i']['f']); + $this->assertEquals('CUSTOMER_ID', $parsed['i']['k']); + $this->assertEquals('on', $parsed['i']['t']); + + // "not_tracked" impression should NOT be in queue + $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); + $this->assertNull($raw2); + + // Manager surface (spec test #1): verify impressionsDisabled property exposure + $manager = $factory->manager(); + + $trackedSplit = $manager->split('tracked'); + $this->assertNotNull($trackedSplit); + $this->assertEquals('tracked', $trackedSplit->getName()); + $this->assertFalse($trackedSplit->getImpressionsDisabled()); + + $notTrackedSplit = $manager->split('not_tracked'); + $this->assertNotNull($notTrackedSplit); + $this->assertEquals('not_tracked', $notTrackedSplit->getName()); + $this->assertTrue($notTrackedSplit->getImpressionsDisabled()); + } +} From d5c23f48c21a8131e4bf6c60a6045b9720d5f7ca Mon Sep 17 00:00:00 2001 From: Nicolas Zelaya Date: Thu, 16 Jul 2026 21:20:41 -0300 Subject: [PATCH 2/2] making boolean parsing strict --- src/SplitIO/Grammar/Split.php | 6 +- .../Suite/Sdk/ImpressionsDisabledE2ETest.php | 14 ++-- tests/Suite/Sdk/ImpressionsDisabledTest.php | 65 ++++++++----------- 3 files changed, 39 insertions(+), 46 deletions(-) diff --git a/src/SplitIO/Grammar/Split.php b/src/SplitIO/Grammar/Split.php index 65de997b..05459866 100644 --- a/src/SplitIO/Grammar/Split.php +++ b/src/SplitIO/Grammar/Split.php @@ -54,7 +54,9 @@ public function __construct(array $split) $this->configurations = isset($split['configurations']) && count($split['configurations']) > 0 ? $split['configurations'] : null; $this->sets = isset($split['sets']) ? $split['sets'] : array(); - $this->impressionsDisabled = isset($split['impressionsDisabled']) ? $split['impressionsDisabled'] : false; + // Only an explicit boolean true disables impression tracking. Any other value + // (numbers, strings, null, missing or malformed) leaves impressions tracked. + $this->impressionsDisabled = isset($split['impressionsDisabled']) && $split['impressionsDisabled'] === true; SplitApp::logger()->info("Constructing Feature Flag: ".$this->name); @@ -191,6 +193,6 @@ public function getSets() */ public function impressionsDisabled() { - return (bool) $this->impressionsDisabled; + return $this->impressionsDisabled; } } diff --git a/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php b/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php index f42deba8..6b6a9c16 100644 --- a/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php +++ b/tests/Suite/Sdk/ImpressionsDisabledE2ETest.php @@ -146,7 +146,7 @@ private function seedE2EFixtures() ) ); - // Fixture 5: truthy (numeric 1) + // Fixture 5: impressionsDisabled is numeric 1 (not boolean true) -> tracked $split5 = array( 'name' => 'flag_e2e_truthy', 'trafficTypeName' => 'user', @@ -220,15 +220,19 @@ public function testE2EAllFixtures() $this->assertEquals('off', $treatments['flag_e2e_disabled_killed']); // killed $this->assertEquals('on', $treatments['flag_e2e_truthy']); - // Redis: only enabled and legacy should be queued + // Redis: only flags that are NOT explicitly disabled are queued. + // flag_e2e_truthy has impressionsDisabled=1 (not boolean true), so it IS tracked. $queuedFeatures = array(); while ($raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY)) { $parsed = json_decode($raw, true); $queuedFeatures[] = $parsed['i']['f']; } - $this->assertCount(2, $queuedFeatures); + $this->assertCount(3, $queuedFeatures); $this->assertContains('flag_e2e_enabled', $queuedFeatures); $this->assertContains('flag_e2e_legacy', $queuedFeatures); + $this->assertContains('flag_e2e_truthy', $queuedFeatures); + $this->assertNotContains('flag_e2e_disabled', $queuedFeatures); + $this->assertNotContains('flag_e2e_disabled_killed', $queuedFeatures); // Listener: all 5 should be sent $this->assertCount(5, $listener->receivedImpressions); @@ -388,8 +392,8 @@ public function testE2EGetTreatmentsWithConfigByFlagSets() public function testE2EEnabledImpressionPayloadFidelity() { - // Full-field assertion for ENABLED impression payload (brief 3b). - // Assert every field in the serialized impression, plus TTL unchanged. + // Assert every field in the serialized impression payload for an enabled + // flag, plus that the queue TTL is unchanged. $factory = $this->createFactory(); $client = $factory->client(); $redis = $this->getRedisClient(); diff --git a/tests/Suite/Sdk/ImpressionsDisabledTest.php b/tests/Suite/Sdk/ImpressionsDisabledTest.php index 2272e7bc..d622ce69 100644 --- a/tests/Suite/Sdk/ImpressionsDisabledTest.php +++ b/tests/Suite/Sdk/ImpressionsDisabledTest.php @@ -140,12 +140,13 @@ public function testPropertyAbsentDefaultsToEnabled() $this->assertEquals('flag_legacy', $parsed['i']['f']); } - public function testNonBooleanJsonValueTruthy() + public function testNonBooleanJsonValueNumericOneIsTracked() { - // Seed with numeric 1 (truthy) + // Only an explicit boolean true disables impressions. A numeric 1 is not + // boolean true, so the impression must still be tracked. $redis = $this->getRedisClient(); $split = array( - 'name' => 'flag_truthy', + 'name' => 'flag_numeric_one', 'trafficTypeName' => 'user', 'seed' => 123456789, 'status' => 'ACTIVE', @@ -170,18 +171,20 @@ public function testNonBooleanJsonValueTruthy() ) ) ); - $redis->set('SPLITIO.split.flag_truthy', json_encode($split)); + $redis->set('SPLITIO.split.flag_numeric_one', json_encode($split)); $redis->set('SPLITIO.splits.till', 1750000000000); $factory = $this->createFactory(); $client = $factory->client(); - $treatment = $client->getTreatment('e2e_user_1', 'flag_truthy'); + $treatment = $client->getTreatment('e2e_user_1', 'flag_numeric_one'); $this->assertEquals('on', $treatment); - // (bool)1 === true, so should NOT be queued + // 1 is not boolean true, so the impression IS queued $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); - $this->assertNull($raw); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_numeric_one', $parsed['i']['f']); } public function testMixedBatchGetTreatments() @@ -345,8 +348,8 @@ public function testExceptionPathStillQueues() public function testNonBooleanJsonValueNull() { - // Non-boolean JSON value null must cast to false → impression IS logged. - // (bool)null === false, so tracking is enabled (back-compat critical). + // Only an explicit boolean true disables impressions. An explicit null is + // not true, so the impression IS tracked (back-compat critical). // The existing helper omits null, so seed manually with explicit null. $redis = $this->getRedisClient(); $split = array( @@ -391,10 +394,9 @@ public function testNonBooleanJsonValueNull() $this->assertEquals('flag_null', $parsed['i']['f']); } - public function testNonBooleanJsonValueStringTrue() + public function testNonBooleanJsonValueStringTrueIsTracked() { - // Non-boolean JSON string "true" casts truthy → NOT queued. - // (bool)"true" === true in PHP (any non-empty string is truthy). + // The string "true" is not the boolean true, so the impression IS tracked. $redis = $this->getRedisClient(); $split = array( 'name' => 'flag_string_true', @@ -431,9 +433,11 @@ public function testNonBooleanJsonValueStringTrue() $treatment = $client->getTreatment('e2e_user_1', 'flag_string_true'); $this->assertEquals('on', $treatment); - // Should NOT be queued: (bool)"true" === true + // The string "true" is not boolean true, so the impression IS queued $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); - $this->assertNull($raw); + $this->assertNotNull($raw); + $parsed = json_decode($raw, true); + $this->assertEquals('flag_string_true', $parsed['i']['f']); } public function testListenerDivergenceUnit() @@ -469,45 +473,28 @@ public function testListenerDivergenceUnit() public function testCrossSdkParityCanonicalFixture() { - // Cross-SDK parity test using canonical fixture from official Split spec. - // Canonical spec fixture uses flag names "tracked" (impressionsDisabled: false) - // and "not_tracked" (impressionsDisabled: true). The spec's canonical conditions - // use an IN_SEGMENT matcher (segment "new_segment") with partitions putting 100% on - // treatment "free". Reproducing that verbatim would require seeding a segment and - // would make the test about segment-matching, not impression-toggling. Per the - // standard fixture-reconciliation rule (repo conventions win for structure; only - // the impressionsDisabled lines are authoritative from the spec), we substitute - // an ALL_KEYS matcher with partitions on:100 / off:0, defaultTreatment off, - // like the other tests in this file. This keeps the test deterministic without - // segment seeding while preserving the parity-relevant flag names and - // impressionsDisabled values from the spec. + // Two flags: "tracked" (impressionsDisabled false) and "not_tracked" + // (impressionsDisabled true). An ALL_KEYS matcher (on:100) is used so the + // treatment is deterministic without seeding segments. $this->seedSplitWithImpressionsDisabled('tracked', false); $this->seedSplitWithImpressionsDisabled('not_tracked', true); - // Redis analog surface (spec tests #2-#4 reduced to drop-silently): - // In the spec's other SDKs, tests #2-#4 verify mode-based POST behavior - // (None/Debug/Optimized). This SDK is Redis-consumer-only with no HTTP - // impression recorder, no mode machinery, no uniquekeys/impressionsCount. - // The parity analog for this SDK is: both flags evaluate correctly, only - // "tracked" lands in Redis SPLITIO.impressions queue, "not_tracked" is - // dropped silently, and the listener (if attached) receives both. $listener = new ImpressionsDisabledListener(); $factory = $this->createFactory($listener); $client = $factory->client(); $redis = $this->getRedisClient(); - // Use eval key "CUSTOMER_ID" to echo the spec's canonical example $trackedTreatment = $client->getTreatment('CUSTOMER_ID', 'tracked'); $notTrackedTreatment = $client->getTreatment('CUSTOMER_ID', 'not_tracked'); - // Both treatments should be "on" (ALL_KEYS matcher with on:100 partition) + // Both treatments are "on" (ALL_KEYS matcher with on:100 partition) $this->assertEquals('on', $trackedTreatment); $this->assertEquals('on', $notTrackedTreatment); - // Listener should receive BOTH (drop-silently still fires listener) + // The listener receives both impressions (disabled flags still fire it) $this->assertCount(2, $listener->receivedImpressions); - // Redis queue should contain ONLY "tracked" impression + // The Redis queue contains only the "tracked" impression $raw = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); $this->assertNotNull($raw); $parsed = json_decode($raw, true); @@ -515,11 +502,11 @@ public function testCrossSdkParityCanonicalFixture() $this->assertEquals('CUSTOMER_ID', $parsed['i']['k']); $this->assertEquals('on', $parsed['i']['t']); - // "not_tracked" impression should NOT be in queue + // "not_tracked" is dropped, so nothing else is queued $raw2 = $redis->rpop(ImpressionCache::IMPRESSIONS_QUEUE_KEY); $this->assertNull($raw2); - // Manager surface (spec test #1): verify impressionsDisabled property exposure + // The manager's SplitView exposes impressionsDisabled $manager = $factory->manager(); $trackedSplit = $manager->split('tracked');