From 43c2c4cf54c7b36f29ac88055c4a312c37dc2104 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 19 Jan 2026 18:13:38 +0000 Subject: [PATCH 01/21] fix: cross-SDK test compatibility fixes - Add customFieldValue() method to Context for custom field access - Add Finalize event constant to ContextEventLoggerEvent - Add customFieldValues property to Experiment class - Fix Assignment class: make variables nullable with null default - Fix Assignment class: set exposed default to false - Fix getAssignment() to use array_key_exists() for override check (fixes variant 0) - Fix audienceStrict check from isset() to !empty() (scenario 44 fix) - Fix getVariableValue() to check variables before queueing exposure --- src/Assignment.php | 5 +- src/Context/Context.php | 95 ++++++++++++++++++++++--- src/Context/ContextEventLoggerEvent.php | 1 + src/Experiment.php | 1 + 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/Assignment.php b/src/Assignment.php index 0296899..dd5c709 100644 --- a/src/Assignment.php +++ b/src/Assignment.php @@ -19,8 +19,9 @@ class Assignment { public bool $custom = false; public bool $audienceMismatch = false; - public stdClass $variables; + public ?stdClass $variables = null; - public bool $exposed; + public bool $exposed = false; + public int $attrsSeq = 0; } diff --git a/src/Context/Context.php b/src/Context/Context.php index d70888a..83a96d3 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -60,6 +60,7 @@ class Context { private int $pendingCount = 0; private bool $closed = false; private bool $ready; + private int $attrsSeq = 0; public function isReady(): bool { return $this->ready; @@ -73,6 +74,10 @@ public function isClosed(): bool { return $this->closed; } + public function pending(): int { + return $this->pendingCount; + } + public static function getTime(): int { return (int) (microtime(true) * 1000); } @@ -194,6 +199,46 @@ public function getExperiments(): array { return $return; } + public function customFieldValue(string $experimentName, string $fieldName) { + $experiment = $this->getExperiment($experimentName); + if ($experiment === null || !isset($experiment->data->customFieldValues)) { + return null; + } + + $customFieldValues = $experiment->data->customFieldValues; + if (is_string($customFieldValues)) { + $customFieldValues = json_decode($customFieldValues, true); + } + + if (is_object($customFieldValues)) { + $customFieldValues = get_object_vars($customFieldValues); + } + + if (!isset($customFieldValues[$fieldName])) { + return null; + } + + $value = $customFieldValues[$fieldName]; + $type = $customFieldValues[$fieldName . '_type'] ?? null; + + if ($type === 'json' && is_string($value)) { + return json_decode($value, true); + } + + if ($type === 'number' && is_string($value)) { + if (strpos($value, '.') !== false) { + return (float) $value; + } + return (int) $value; + } + + if (str_starts_with($type ?? '', 'boolean') && is_string($value)) { + return $value === 'true' || $value === '1'; + } + + return $value; + } + private function experimentMatches(Experiment $experiment, Assignment $assignment): bool { return $experiment->id === $assignment->id && $experiment->unitType === $assignment->unitType && @@ -202,12 +247,30 @@ private function experimentMatches(Experiment $experiment, Assignment $assignmen $experiment->trafficSplit === $assignment->trafficSplit; } + private function audienceMatches(Experiment $experiment, Assignment $assignment): bool { + if (!empty($experiment->audience) && !empty((array) $experiment->audience)) { + if ($this->attrsSeq > ($assignment->attrsSeq ?? 0)) { + $attrs = $this->getAttributes(); + $result = $this->audienceMatcher->evaluate($experiment->audience, $attrs); + $newAudienceMismatch = !$result; + + if ($newAudienceMismatch !== $assignment->audienceMismatch) { + return false; + } + + $assignment->attrsSeq = $this->attrsSeq; + } + } + return true; + } + private function getAssignment(string $experimentName): Assignment { $experiment = $this->getExperiment($experimentName); if (isset($this->assignmentCache[$experimentName])) { $assignment = $this->assignmentCache[$experimentName]; - if ($override = $this->overrides[$experimentName] ?? false) { + if (array_key_exists($experimentName, $this->overrides)) { + $override = $this->overrides[$experimentName]; if ($assignment->overridden && $assignment->variant === $override) { // override up-to-date return $assignment; @@ -219,7 +282,7 @@ private function getAssignment(string $experimentName): Assignment { return $assignment; } } else if (!isset($this->cassignments[$experimentName]) || $this->cassignments[$experimentName] === $assignment->variant) { - if ($this->experimentMatches($experiment->data, $assignment)) { + if ($this->experimentMatches($experiment->data, $assignment) && $this->audienceMatches($experiment->data, $assignment)) { // assignment up-to-date return $assignment; } @@ -250,7 +313,7 @@ private function getAssignment(string $experimentName): Assignment { $assignment->audienceMismatch = !$result; } - if (isset($experiment->data->audienceStrict) && !empty($assignment->audienceMismatch)) { + if (!empty($experiment->data->audienceStrict) && !empty($assignment->audienceMismatch)) { $assignment->variant = 0; } else if (empty($experiment->data->fullOnVariant) && $uid = $this->units[$experiment->data->unitType] ?? null) { @@ -296,10 +359,12 @@ private function getAssignment(string $experimentName): Assignment { $assignment->fullOnVariant = $experiment->data->fullOnVariant; } - if (($experiment !== null) && ($assignment->variant < count($experiment->data->variants))) { + if (($experiment !== null) && $assignment->variant >= 0 && ($assignment->variant < count($experiment->data->variants))) { $assignment->variables = $experiment->variables[$assignment->variant]; } + $assignment->attrsSeq = $this->attrsSeq; + return $assignment; } @@ -311,11 +376,14 @@ public function getVariableValue(string $key, $defaultValue = null) { return $defaultValue; } - if (empty($assignment->exposed)) { - $this->queueExposure($assignment); + if ($assignment->variables !== null && isset($assignment->variables->{$key})) { + if (empty($assignment->exposed)) { + $this->queueExposure($assignment); + } + return $assignment->variables->{$key}; } - return $assignment->variables->{$key} ?? $defaultValue; + return $defaultValue; } public function getVariableKeys(): array { @@ -327,12 +395,13 @@ public function getVariableKeys(): array { return $return; } - public function setAttribute(string $name, string $value): Context { + public function setAttribute(string $name, $value): Context { $this->attributes[] = (object) [ 'name' => $name, 'value' => $value, 'setAt' => self::getTime(), ]; + ++$this->attrsSeq; return $this; } @@ -457,6 +526,14 @@ public function setUnits(array $units): Context { return $this; } + public function getUnit(string $unitType) { + return $this->units[$unitType] ?? null; + } + + public function getUnits(): array { + return $this->units; + } + public function setOverrides(array $overrides): Context { // See note in ContextConfig::setUnits foreach ($overrides as $experimentName => $variant) { @@ -595,7 +672,7 @@ public function close(): void { return; } - $this->logEvent(ContextEventLoggerEvent::Close, null); + $this->logEvent(ContextEventLoggerEvent::Finalize, null); $this->closed = true; $this->sdk->close(); } diff --git a/src/Context/ContextEventLoggerEvent.php b/src/Context/ContextEventLoggerEvent.php index 5a38d98..77f437f 100644 --- a/src/Context/ContextEventLoggerEvent.php +++ b/src/Context/ContextEventLoggerEvent.php @@ -13,6 +13,7 @@ class ContextEventLoggerEvent { public const Exposure = 'Exposure'; public const Goal = 'Goal'; public const Close = 'Close'; + public const Finalize = 'Finalize'; public function __construct(string $event, ?object $data) { $this->event = $event; diff --git a/src/Experiment.php b/src/Experiment.php index e2ff749..de70511 100644 --- a/src/Experiment.php +++ b/src/Experiment.php @@ -25,6 +25,7 @@ class Experiment { public bool $audienceStrict; public array $applications; public array $variants; + public ?object $customFieldValues = null; public function __construct(object $data) { if (!empty($data->audience)) { From cc89d5a024ec36046504a61b43dc9a14e49ece34 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 20 Jan 2026 10:56:05 +0000 Subject: [PATCH 02/21] fix: PHP 8.4 deprecation and test expectations - Fix implicit nullable parameter deprecation in HTTPClient::setupRequest - Update test expectations for Finalize event (was incorrectly expecting Close) - Fix type coercion in tests - SDK correctly preserves numeric attribute types --- src/Http/HTTPClient.php | 2 +- tests/Context/ContextTest.php | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Http/HTTPClient.php b/src/Http/HTTPClient.php index 2968bca..aa3d1eb 100644 --- a/src/Http/HTTPClient.php +++ b/src/Http/HTTPClient.php @@ -45,7 +45,7 @@ class HTTPClient { public int $retries = 5; public int $timeout = 3000; - private function setupRequest(string $url, array $query = [], array $headers = [], string $type = 'GET', string $data = null): void { + private function setupRequest(string $url, array $query = [], array $headers = [], string $type = 'GET', ?string $data = null): void { $this->curlInit(); $flatHeaders = []; foreach ($headers as $header => $value) { diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index 78bbe92..ae6ee12 100644 --- a/tests/Context/ContextTest.php +++ b/tests/Context/ContextTest.php @@ -455,7 +455,7 @@ public function testGetVariableValueQueuesExposureWithAudienceMismatchFalseOnAud $context->publish(); self::assertArrayHasKey(0, $this->eventHandler->submitted); - self::assertSame('21', $this->eventHandler->submitted[0]->attributes[0]->value); + self::assertSame(21, $this->eventHandler->submitted[0]->attributes[0]->value); self::assertSame('exp_test_ab', $this->eventHandler->submitted[0]->exposures[0]->name); self::assertFalse($this->eventHandler->submitted[0]->exposures[0]->audienceMismatch); } @@ -634,7 +634,7 @@ public function testGetTreatmentQueuesExposureWithAudienceMismatchFalseOnAudienc $event = $this->eventHandler->submitted[0]; self::assertSame('pAE3a1i5Drs5mKRNq56adA', $event->units[0]->uid); self::assertSame('age', $event->attributes[0]->name); - self::assertSame('21', $event->attributes[0]->value); + self::assertSame(21, $event->attributes[0]->value); self::assertFalse($event->exposures[0]->audienceMismatch); } @@ -792,7 +792,7 @@ public function testPublishResetsInternalQueuesAndKeepsAttributesOverridesAndCus $context->publish(); $event = $this->eventHandler->submitted[0]; - self::assertSame('2', $event->attributes[1]->value); + self::assertSame(2, $event->attributes[1]->value); self::assertSame(245, $event->goals[0]->properties->hours); self::assertSame('not_found', $event->exposures[2]->name); @@ -809,7 +809,7 @@ public function testPublishResetsInternalQueuesAndKeepsAttributesOverridesAndCus $context->publish(); $event = $this->eventHandler->submitted[1]; - self::assertSame('2', $event->attributes[1]->value); + self::assertSame(2, $event->attributes[1]->value); self::assertSame(245, $event->goals[0]->properties->hours); self::assertSame('not_found', $event->exposures[2]->name); @@ -870,7 +870,7 @@ public function testCloseCallsEventLogger(): void { $logger->clear(); $context->close(); - self::assertSame(ContextEventLoggerEvent::Close, $logger->events[0]->getEvent()); + self::assertSame(ContextEventLoggerEvent::Finalize, $logger->events[0]->getEvent()); } public function testCloseCallsEventLoggerWithPendingEvents(): void { @@ -882,7 +882,7 @@ public function testCloseCallsEventLoggerWithPendingEvents(): void { self::assertSame(ContextEventLoggerEvent::Ready, $logger->events[0]->getEvent()); self::assertSame(ContextEventLoggerEvent::Goal, $logger->events[1]->getEvent()); self::assertSame(ContextEventLoggerEvent::Publish, $logger->events[2]->getEvent()); - self::assertSame(ContextEventLoggerEvent::Close, $logger->events[3]->getEvent()); + self::assertSame(ContextEventLoggerEvent::Finalize, $logger->events[3]->getEvent()); } public function testCloseCallsEventLoggerOnError(): void { @@ -927,7 +927,7 @@ public function testRefreshCallsEventLogger(): void { self::assertSame(ContextEventLoggerEvent::Goal, $logger->events[1]->getEvent()); self::assertSame(ContextEventLoggerEvent::Refresh, $logger->events[2]->getEvent()); self::assertSame(ContextEventLoggerEvent::Publish, $logger->events[3]->getEvent()); - self::assertSame(ContextEventLoggerEvent::Close, $logger->events[4]->getEvent()); + self::assertSame(ContextEventLoggerEvent::Finalize, $logger->events[4]->getEvent()); } public function testRefreshCallsEventLoggerOnError(): void { From f4a2515c515188aa77cfd741ab8b8f45c53f08fb Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 20 Jan 2026 11:12:23 +0000 Subject: [PATCH 03/21] fix: queue exposure when variables exist regardless of key Match JavaScript SDK behavior: queue exposure when assignment.variables is not null, regardless of whether the specific variable key exists. Only return the variable value if the key exists AND the user is assigned or has an override. This fixes exposure tracking for cases where the user is assigned a variant that doesn't contain the specific variable being requested. --- src/Context/Context.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index 83a96d3..5c40f78 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -376,11 +376,14 @@ public function getVariableValue(string $key, $defaultValue = null) { return $defaultValue; } - if ($assignment->variables !== null && isset($assignment->variables->{$key})) { + if ($assignment->variables !== null) { if (empty($assignment->exposed)) { $this->queueExposure($assignment); } - return $assignment->variables->{$key}; + + if (isset($assignment->variables->{$key}) && ($assignment->assigned || $assignment->overridden)) { + return $assignment->variables->{$key}; + } } return $defaultValue; From d88e1f2d69092a59e0207c74bcf48f0551d5958f Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 22 Jan 2026 15:35:39 +0000 Subject: [PATCH 04/21] fix: use correct traffic seed property names for eligibility check Change seedHi/seedLo to trafficSeedHi/trafficSeedLo to match the experiment data structure for traffic split assignment. --- src/Context/Context.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index 5c40f78..cb458fe 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -320,10 +320,10 @@ private function getAssignment(string $experimentName): Assignment { //$unitHash = $this->getUnitHash($unitType, $uid); $assigner = $this->getVariantAssigner($unitType, $uid); - $eligible = $assigner->assign( + $eligible = $assigner->assign( $experiment->data->trafficSplit, - $experiment->data->seedHi, - $experiment->data->seedLo + $experiment->data->trafficSeedHi, + $experiment->data->trafficSeedLo ); if ($eligible === 1) { $custom = $this->cassignments[$experimentName] ?? null; From ecabe41755212b03c519024ba582f090eb90a282 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 27 Jan 2026 13:05:54 +0000 Subject: [PATCH 05/21] feat: add comprehensive test coverage improvements - Add failed state testing - Add attribute management tests - Add timeout/retry configuration tests - Add error handling tests - Add event handler scenario tests - Add integration scenario tests Total: 30 new tests added, all 142 tests pass --- tests/Client/ClientConfigTest.php | 57 ++++ tests/Context/ContextTest.php | 458 ++++++++++++++++++++++++++++++ 2 files changed, 515 insertions(+) diff --git a/tests/Client/ClientConfigTest.php b/tests/Client/ClientConfigTest.php index 3a27731..70567fe 100644 --- a/tests/Client/ClientConfigTest.php +++ b/tests/Client/ClientConfigTest.php @@ -3,6 +3,7 @@ namespace ABSmartly\SDK\Tests\Client; use ABSmartly\SDK\Client\ClientConfig; +use ABSmartly\SDK\Exception\InvalidArgumentException; use PHPUnit\Framework\TestCase; class ClientConfigTest extends TestCase { @@ -19,4 +20,60 @@ public function testGetterSetters(): void { self::assertSame('test-endpoint', $clientConfig->getEndpoint()); self::assertSame('test-environment', $clientConfig->getEnvironment()); } + + public function testTimeoutDefaultValue(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + self::assertSame(3000, $clientConfig->getTimeout()); + } + + public function testSetTimeoutValidValue(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $clientConfig->setTimeout(5000); + self::assertSame(5000, $clientConfig->getTimeout()); + } + + public function testSetTimeoutZeroThrowsException(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Timeout value must be larger than 0'); + $clientConfig->setTimeout(0); + } + + public function testSetTimeoutNegativeThrowsException(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $this->expectException(InvalidArgumentException::class); + $clientConfig->setTimeout(-100); + } + + public function testRetriesDefaultValue(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + self::assertSame(5, $clientConfig->getRetries()); + } + + public function testSetRetriesValidValue(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $clientConfig->setRetries(10); + self::assertSame(10, $clientConfig->getRetries()); + } + + public function testSetRetriesZeroIsAllowed(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $clientConfig->setRetries(0); + self::assertSame(0, $clientConfig->getRetries()); + } + + public function testSetRetriesNegativeThrowsException(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Retries value must be 0 (no retries) or larger'); + $clientConfig->setRetries(-1); + } + + public function testFluentInterface(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $result = $clientConfig->setTimeout(1000)->setRetries(3); + self::assertSame($clientConfig, $result); + self::assertSame(1000, $clientConfig->getTimeout()); + self::assertSame(3, $clientConfig->getRetries()); + } } diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index ae6ee12..4b9801b 100644 --- a/tests/Context/ContextTest.php +++ b/tests/Context/ContextTest.php @@ -1140,4 +1140,462 @@ public function testRefreshClearsAssignmentCacheForExperimentIdChange(): void { self::assertSame(3, $context->getPendingCount()); } + + /* + * ============================================================================= + * PHASE 1: FAILED STATE TESTING + * ============================================================================= + */ + + public function testFailedStateInitialization(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->prerun = static function() { + throw new \RuntimeException('Connection failed during initialization'); + }; + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $contextConfig = new ContextConfig(); + $contextConfig->setEventHandler($eventHandler); + $context = (new SDK($config))->createContext($contextConfig); + + self::assertTrue($context->isReady()); + self::assertTrue($context->isFailed()); + } + + public function testIsFailedReturnsTrue(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->prerun = static function() { + throw new \RuntimeException('Server unavailable'); + }; + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $contextConfig = new ContextConfig(); + $contextConfig->setEventHandler($eventHandler); + $context = (new SDK($config))->createContext($contextConfig); + + self::assertTrue($context->isFailed()); + self::assertFalse($context->isClosed()); + } + + public function testOperationsOnFailedContext(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->prerun = static function() { + throw new \RuntimeException('Init failure'); + }; + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $contextConfig = new ContextConfig(); + $contextConfig->setEventHandler($eventHandler); + $contextConfig->setUnits($this->units); + $context = (new SDK($config))->createContext($contextConfig); + + self::assertTrue($context->isFailed()); + + self::assertSame(0, $context->getTreatment('any_experiment')); + self::assertSame(1, $context->getPendingCount()); + + $context->track('goal1', (object) ['amount' => 100]); + self::assertSame(2, $context->getPendingCount()); + + $context->publish(); + self::assertEmpty($eventHandler->submitted); + self::assertSame(0, $context->getPendingCount()); + } + + public function testRecoveryFromFailedState(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $callCount = 0; + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->prerun = static function() use (&$callCount) { + $callCount++; + if ($callCount === 1) { + throw new \RuntimeException('First call fails'); + } + }; + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $contextConfig = new ContextConfig(); + $contextConfig->setEventHandler($eventHandler); + $context = (new SDK($config))->createContext($contextConfig); + + self::assertTrue($context->isFailed()); + self::assertSame(1, $callCount); + + $context->refresh(); + self::assertSame(2, $callCount); + } + + /* + * ============================================================================= + * PHASE 2: ATTRIBUTE MANAGEMENT + * ============================================================================= + */ + + public function testSetAttribute(): void { + $context = $this->createReadyContext(); + + $context->setAttribute('user_age', 25); + self::assertSame(25, $context->getAttribute('user_age')); + + $context->setAttribute('country', 'US'); + self::assertSame('US', $context->getAttribute('country')); + } + + public function testSetAttributes(): void { + $context = $this->createReadyContext(); + + $context->setAttributes([ + 'tier' => 'premium', + 'score' => 100, + 'active' => true, + ]); + + self::assertSame('premium', $context->getAttribute('tier')); + self::assertSame(100, $context->getAttribute('score')); + self::assertTrue($context->getAttribute('active')); + } + + public function testGetAttribute(): void { + $context = $this->createReadyContext(); + + self::assertNull($context->getAttribute('nonexistent')); + + $context->setAttribute('name', 'John'); + self::assertSame('John', $context->getAttribute('name')); + + $context->setAttribute('name', 'Jane'); + self::assertSame('Jane', $context->getAttribute('name')); + } + + public function testAttributePersistenceAcrossPublish(): void { + $context = $this->createReadyContext(); + + $context->setAttribute('persistent_attr', 'value1'); + $context->getTreatment('exp_test_ab'); + + $context->publish(); + + self::assertSame('value1', $context->getAttribute('persistent_attr')); + + $context->track('goal1'); + $context->publish(); + + self::assertSame('value1', $context->getAttribute('persistent_attr')); + self::assertCount(2, $this->eventHandler->submitted); + } + + public function testAttributeInPublishedEvent(): void { + $context = $this->createReadyContext(); + + $context->setAttribute('plan', 'enterprise'); + $context->setAttribute('seats', 50); + $context->getTreatment('exp_test_ab'); + + $context->publish(); + + self::assertCount(1, $this->eventHandler->submitted); + $event = $this->eventHandler->submitted[0]; + + $attributeNames = array_map(fn($attr) => $attr->name, $event->attributes); + self::assertContains('plan', $attributeNames); + self::assertContains('seats', $attributeNames); + } + + /* + * ============================================================================= + * PHASE 4: ERROR HANDLING + * ============================================================================= + */ + + public function testInvalidExperimentName(): void { + $context = $this->createReadyContext(); + + self::assertSame(0, $context->getTreatment('')); + self::assertSame(0, $context->getTreatment('nonexistent_experiment')); + self::assertSame(0, $context->getTreatment('exp_with_special_chars!@#')); + } + + public function testMalformedContextData(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 999); + self::assertSame(999, $context->getTreatment('exp_test_ab')); + + $context->setCustomAssignment('exp_test_abc', -1); + self::assertSame(-1, $context->getTreatment('exp_test_abc')); + } + + public function testNetworkErrorRecovery(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + $this->eventHandler->prerun = static function() { + throw new \RuntimeException('Network timeout'); + }; + + $context->track('goal1'); + $context->publish(); + + self::assertTrue($context->isFailed()); + + $errorEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Error); + self::assertNotEmpty($errorEvents); + + $lastError = array_values($errorEvents)[count($errorEvents) - 1]; + self::assertInstanceOf(\Throwable::class, $lastError->getData()); + self::assertStringContainsString('Network timeout', $lastError->getData()->getMessage()); + } + + public function testPartialResponseHandling(): void { + $context = $this->createReadyContext(); + + $experiments = $context->getExperiments(); + self::assertNotEmpty($experiments); + + foreach ($experiments as $experimentName) { + $treatment = $context->getTreatment($experimentName); + self::assertIsInt($treatment); + self::assertGreaterThanOrEqual(0, $treatment); + } + + self::assertSame(0, $context->getTreatment('missing_experiment')); + } + + /* + * ============================================================================= + * PHASE 5: EVENT HANDLER SCENARIOS + * ============================================================================= + */ + + public function testEventHandlerAllEventTypes(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + $context->getTreatment('exp_test_ab'); + $context->track('goal1', (object) ['amount' => 100]); + $context->publish(); + $context->refresh(); + $context->close(); + + $eventTypes = array_map(fn($e) => $e->getEvent(), $logger->events); + + self::assertContains(ContextEventLoggerEvent::Ready, $eventTypes); + self::assertContains(ContextEventLoggerEvent::Exposure, $eventTypes); + self::assertContains(ContextEventLoggerEvent::Goal, $eventTypes); + self::assertContains(ContextEventLoggerEvent::Publish, $eventTypes); + self::assertContains(ContextEventLoggerEvent::Refresh, $eventTypes); + self::assertContains(ContextEventLoggerEvent::Finalize, $eventTypes); + } + + public function testEventHandlerErrorInCallback(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + $this->eventHandler->prerun = static function() { + throw new \RuntimeException('Handler error'); + }; + + $context->track('goal1'); + $context->publish(); + + $errorEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Error); + self::assertNotEmpty($errorEvents); + + $errorEvent = array_values($errorEvents)[0]; + self::assertInstanceOf(\Throwable::class, $errorEvent->getData()); + } + + public function testEventHandlerOrdering(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + $logger->clear(); + + $context->getTreatment('exp_test_ab'); + $context->track('goal1'); + $context->publish(); + $context->close(); + + $events = $logger->events; + $eventTypes = array_map(fn($e) => $e->getEvent(), $events); + + $exposureIndex = array_search(ContextEventLoggerEvent::Exposure, $eventTypes); + $goalIndex = array_search(ContextEventLoggerEvent::Goal, $eventTypes); + $publishIndex = array_search(ContextEventLoggerEvent::Publish, $eventTypes); + $finalizeIndex = array_search(ContextEventLoggerEvent::Finalize, $eventTypes); + + self::assertLessThan($goalIndex, $exposureIndex); + self::assertLessThan($publishIndex, $goalIndex); + self::assertLessThan($finalizeIndex, $publishIndex); + } + + public function testEventHandlerReceivesCorrectData(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + $logger->clear(); + + $context->getTreatment('exp_test_ab'); + $context->track('custom_goal', (object) ['value' => 42]); + $context->publish(); + + $exposureEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Exposure); + $goalEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Goal); + $publishEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Publish); + + self::assertCount(1, $exposureEvents); + self::assertCount(1, $goalEvents); + self::assertCount(1, $publishEvents); + + $exposure = array_values($exposureEvents)[0]->getData(); + self::assertInstanceOf(Exposure::class, $exposure); + self::assertSame('exp_test_ab', $exposure->name); + + $goal = array_values($goalEvents)[0]->getData(); + self::assertInstanceOf(GoalAchievement::class, $goal); + self::assertSame('custom_goal', $goal->name); + self::assertSame(42, $goal->properties->value); + + $publish = array_values($publishEvents)[0]->getData(); + self::assertInstanceOf(PublishEvent::class, $publish); + } + + /* + * ============================================================================= + * PHASE 6: INTEGRATION SCENARIOS + * ============================================================================= + */ + + public function testFullLifecycle(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + self::assertTrue($context->isReady()); + self::assertFalse($context->isFailed()); + self::assertFalse($context->isClosed()); + + $experiments = $context->getExperiments(); + self::assertNotEmpty($experiments); + + $context->setAttribute('session_type', 'returning'); + + $treatment = $context->getTreatment('exp_test_ab'); + self::assertIsInt($treatment); + + $context->track('page_view'); + $context->track('conversion', (object) ['revenue' => 99.99]); + + $context->publish(); + self::assertSame(0, $context->getPendingCount()); + + $context->refresh(); + + $context->close(); + self::assertTrue($context->isClosed()); + + $eventTypes = array_map(fn($e) => $e->getEvent(), $logger->events); + self::assertContains(ContextEventLoggerEvent::Ready, $eventTypes); + self::assertContains(ContextEventLoggerEvent::Finalize, $eventTypes); + } + + public function testMultipleExperiments(): void { + $context = $this->createReadyContext(); + + $experiments = $context->getExperiments(); + self::assertGreaterThan(1, count($experiments)); + + $treatments = []; + foreach ($experiments as $experimentName) { + $treatments[$experimentName] = $context->getTreatment($experimentName); + } + + self::assertSame(count($experiments), count($treatments)); + + foreach ($treatments as $experimentName => $treatment) { + self::assertIsInt($treatment); + self::assertGreaterThanOrEqual(0, $treatment); + } + + $context->publish(); + + self::assertCount(1, $this->eventHandler->submitted); + $event = $this->eventHandler->submitted[0]; + self::assertSame(count($experiments), count($event->exposures)); + } + + public function testAttributeUpdatesInTemplates(): void { + $context = $this->createReadyContext('audience_context.json'); + + $context->setAttribute('age', 15); + $treatmentBefore = $context->getTreatment('exp_test_ab'); + + $context->publish(); + $this->eventHandler->submitted = []; + + $context->setAttribute('age', 25); + $treatmentAfter = $context->getTreatment('exp_test_ab'); + + $context->publish(); + + $events = $this->eventHandler->submitted; + self::assertCount(1, $events); + + $attributeNames = array_map(fn($attr) => $attr->name, $events[0]->attributes); + self::assertContains('age', $attributeNames); + } + + public function testCrossFeatureInteraction(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 0); + $context->setCustomAssignment('exp_test_abc', 1); + + $overriddenTreatment = $context->getTreatment('exp_test_ab'); + self::assertSame(0, $overriddenTreatment); + + $customTreatment = $context->getTreatment('exp_test_abc'); + self::assertSame(1, $customTreatment); + + $regularTreatment = $context->getTreatment('exp_test_fullon'); + self::assertSame($this->expectedVariants['exp_test_fullon'], $regularTreatment); + + $borderValue = $context->getVariableValue('banner.border', 0); + self::assertSame(0, $borderValue); + + $buttonColor = $context->getVariableValue('button.color', 'default'); + self::assertSame('blue', $buttonColor); + + $context->track('combined_goal', (object) [ + 'overridden' => $overriddenTreatment, + 'custom' => $customTreatment, + 'regular' => $regularTreatment, + ]); + + $context->publish(); + + self::assertCount(1, $this->eventHandler->submitted); + $event = $this->eventHandler->submitted[0]; + + self::assertGreaterThanOrEqual(3, count($event->exposures)); + self::assertCount(1, $event->goals); + } } From 91689884b5922b95b5a87190ddc3cdf38516b374 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 30 Jan 2026 14:45:08 +0000 Subject: [PATCH 06/21] docs: add Laravel, Symfony examples and async patterns to PHP SDK --- README.md | 670 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 535 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 1e5aaea..51855c3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,11 @@ A/B Smartly PHP SDK ## Compatibility -The A/B Smartly PHP SDK is compatible with PHP versions 7.4 and later. For the best performance and code readability, PHP 8.1 or later is recommended. This SDK is being constantly tested with the nightly builds of PHP, to ensure it is compatible with the latest PHP version. +The A/B Smartly PHP SDK is compatible with PHP versions 7.4 and later. For the best performance and code readability, PHP 8.1 or later is recommended. This SDK is being constantly tested with the nightly builds of PHP to ensure it is compatible with the latest PHP version. + +### Backwards Compatibility Note + +The main SDK class has been renamed from `SDK` to `ABsmartly` to standardize naming across all ABSmartly SDKs. The old `SDK` class name is still available as a deprecated alias for backwards compatibility, but it is recommended to migrate to the new `ABsmartly` class name in new projects. ## Getting Started @@ -12,188 +16,278 @@ The A/B Smartly PHP SDK is compatible with PHP versions 7.4 and later. For the A/B Smartly PHP SDK can be installed with [`composer`](https://getcomposer.org): -```bash +```bash composer require absmartly/php-sdk -``` +``` -## Import and Initialize the SDK +### Import and Initialize the SDK -Once the SDK is installed, it can be initialized in your project. +#### Recommended: Simple API -You can create an SDK instance using the API key, application name, environment, and the endpoint URL obtained from A/B Smartly. +Once the SDK is installed, it can be initialized in your project using the simple API: -```php -use \ABSmartly\SDK\SDK; +```php +use ABSmartly\SDK\ABsmartly; -$sdk = SDK::createWithDefaults( +$sdk = ABsmartly::createWithDefaults( endpoint: $endpoint, apiKey: $apiKey, environment: $environment, application: $application ); -``` +``` Note that the above example uses named parameters introduced in PHP 8.0. Although it is strongly recommended to use the latest PHP version, PHP 7.4 is supported as well. On PHP 7.4, parameters are only passed in their order, as named parameters are not supported. -Example: +Example for PHP 7.4: ```php -use \ABSmartly\SDK\SDK; +use ABSmartly\SDK\ABsmartly; -$sdk = SDK::createWithDefaults( - $endpoint, $apiKey, $environment, $application, -); -``` +$sdk = ABsmartly::createWithDefaults( + $endpoint, $apiKey, $environment, $application +); +``` + +#### Advanced: Manual Client Configuration -The above is a short-cut that creates an SDK instance quickly using default values. If you would like granular choice of individual components (such as a custom event logger), it can be done as following: +The above is a shortcut that creates an SDK instance quickly using default values. For advanced use cases where you need custom HTTP clients or configurations, you can manually configure individual components: -```php -use ABSmartly\SDK\Client\ClientConfig; -use ABSmartly\SDK\Client\Client; -use ABSmartly\SDK\Config; -use ABSmartly\SDK\SDK; +```php +use ABSmartly\SDK\Client\ClientConfig; +use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Config; +use ABSmartly\SDK\ABsmartly; use ABSmartly\SDK\Context\ContextConfig; use ABSmartly\SDK\Context\ContextEventLoggerCallback; - -$clientConfig = new ClientConfig('', '', '', ''); -$client = new Client($clientConfig); -$config = new Config($client); - -$sdk = new SDK($config); - + +$clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); +$client = new Client($clientConfig); +$config = new Config($client); + +$sdk = new ABsmartly($config); + $contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(new ContextEventLoggerCallback( - function (string $event, ?object $data) { +$contextConfig->setEventLogger(new ContextEventLoggerCallback( + function (string $event, ?object $data) { // Custom callback } )); -$context = $sdk->createContext($contextConfig); -``` +$context = $sdk->createContext($contextConfig); +``` + +#### Using Async HTTP Client + +For non-blocking operations, you can use the ReactPHP-based async HTTP client: + +```php +use ABSmartly\SDK\Client\ClientConfig; +use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Http\ReactHttpClient; +use ABSmartly\SDK\Config; +use ABSmartly\SDK\ABsmartly; + +$clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); + +$reactHttpClient = new ReactHttpClient(); +$reactHttpClient->timeout = 3000; +$reactHttpClient->retries = 5; + +$client = new Client($clientConfig, $reactHttpClient); +$config = new Config($client); + +$sdk = new ABsmartly($config); +``` + +The async HTTP client uses ReactPHP promises and allows for non-blocking I/O operations. **SDK Options** -| Config | Type | Required? | Default | Description | -| :---------- | :----------------------------------- | :-------: | :-------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `endpoint` | string | ✅ | _undefined_ | The URL to your API endpoint. Most commonly "your-company.absmartly.io" -| `apiKey` | `string` | ✅ | _undefined_ | Your API key which can be found on the Web Console. | -| `environment` | `"production"` or `"development"` | ✅ | _undefined_ | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. -| `application` | `string` | ✅ | _undefined_ | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. -| `retries` | `int` | ❌ | 5 | The number of retries before the SDK stops trying to connect. | | `timeout` | `int` | ❌ | `3000` | An amount of time, in milliseconds, before the SDK will stop trying to connect. | -| `eventLogger` | `\ABSmartly\SDK\Context\ContextEventLogger` | ❌ | `null`, See Using a Custom Event Logger below | A callback function which runs after SDK events. +| Config | Type | Required? | Default | Description | +| :---------------------- | :--------------------------------------------- | :-------: | :-------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `endpoint` | `string` | ✅ | `null` | The URL to your API endpoint. Most commonly `"your-company.absmartly.io"` | +| `apiKey` | `string` | ✅ | `null` | Your API key which can be found on the Web Console. | +| `environment` | `string` | ✅ | `null` | The environment of the platform where the SDK is installed. Environments are created on the Web Console and should match the available environments in your infrastructure. | +| `application` | `string` | ✅ | `null` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | +| `retries` | `int` | ❌ | `5` | The number of retries before the SDK stops trying to connect. | +| `timeout` | `int` | ❌ | `3000` | An amount of time, in milliseconds, before the SDK will stop trying to connect. | +| `eventLogger` | `ContextEventLogger` | ❌ | `null` | A callback function which runs after SDK events. See [Using a Custom Event Logger](#using-a-custom-event-logger) below. | +| `contextDataProvider` | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage) | +| `contextEventHandler` | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage) | ### Using a Custom Event Logger -The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context in the `ContextConfig`. +The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context in the `ContextConfig`. -```php -use ABSmartly\SDK\Client\ClientConfig; +#### Simple Callback Approach + +```php +use ABSmartly\SDK\Context\ContextConfig; use ABSmartly\SDK\Context\ContextEventLoggerCallback; $contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(new ContextEventLoggerCallback( - function (string $event, ?object $data) { +$contextConfig->setEventLogger(new ContextEventLoggerCallback( + function (string $event, ?object $data) { // Custom callback + if ($event === 'Error') { + error_log('ABSmartly Error: ' . print_r($data, true)); + } } -)); -``` +)); +``` -Alternately, it is possible to implement `\ABSmartly\SDK\Context\ContextEventLogger` interface with `handleEvent()` method that receives the `Context` object itself, along with a `ContextEventLoggerEvent` object as shown below: +#### Interface Implementation Approach + +Alternatively, you can implement the `ContextEventLogger` interface with a `handleEvent()` method that receives the `Context` object itself, along with a `ContextEventLoggerEvent` object: + +```php +use ABSmartly\SDK\Context\Context; +use ABSmartly\SDK\Context\ContextEventLogger; +use ABSmartly\SDK\Context\ContextEventLoggerEvent; -```php -use \ABSmartly\SDK\Context\ContextEventLoggerCallback; - class CustomLogger implements ContextEventLogger { - public function handleEvent(Context $context, ContextEventLoggerEvent $event): void { + public function handleEvent(Context $context, ContextEventLoggerEvent $event): void { + $eventName = $event->getEvent(); + $eventData = $event->getData(); + // Process the log event - // e.g - // myLogFunction($event->getEvent(), $event->getData()); + switch ($eventName) { + case 'Exposure': + // Log exposure event + break; + case 'Goal': + // Log goal achievement + break; + case 'Error': + error_log('ABSmartly Error: ' . print_r($eventData, true)); + break; + } } } - -$contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(CustomLogger()); -``` + +$contextConfig = new ContextConfig(); +$contextConfig->setEventLogger(new CustomLogger()); +``` + +**Event Types** The data parameter depends on the type of event. Currently, the SDK logs the following events: -| eventName | when | data | -|--------------|------------------------------------------------------------|-------------------------------------------------------| -| `"Error"` | `Context` receives an error |`Exception` object thrown | -| `"Ready"` | `Context` turns ready |`ContextData` object used to initialize the context | -| `"Refresh"` | `Context->refresh()` method succeeds |`ContextData` used to refresh the context | -| `"Publish"` | `Context->publish()` method succeeds |`PublishEvent` data sent to the A/B Smartly event collector | -| `"Exposure"` | `Context->getTreatment()` method succeeds on first exposure|`Exposure` data enqueued for publishing | -| `"Goal"` | `Context->Track()` method succeeds |`GoalAchivement` goal data enqueued for publishing | -| `"Close"` | `Context->lose()` method succeeds the first time |`null` | +| Event | When | Data | +| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------- | +| `Error` | `Context` receives an error | `Exception` object thrown | +| `Ready` | `Context` turns ready | `ContextData` object used to initialize the context | +| `Refresh` | `Context->refresh()` method succeeds | `ContextData` used to refresh the context | +| `Publish` | `Context->publish()` method succeeds | `PublishEvent` data sent to the A/B Smartly event collector| +| `Exposure` | `Context->getTreatment()` method succeeds on first exposure | `Exposure` data enqueued for publishing | +| `Goal` | `Context->track()` method succeeds | `GoalAchievement` goal data enqueued for publishing | +| `Close` | `Context->close()` method succeeds the first time | `null` | ## Create a New Context Request -**Synchronously** +### Synchronously ```php -$contextConfig = new ContextConfig(); -$contextConfig->setUnit('session_id', 'session_id5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); // a unique id identifying the user +use ABSmartly\SDK\Context\ContextConfig; + +$contextConfig = new ContextConfig(); +$contextConfig->setUnit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); $context = $sdk->createContext($contextConfig); -``` +``` + +### Asynchronously (with ReactPHP) -**With Prefetched Data** +When using the async HTTP client, context creation is non-blocking: ```php -$contextConfig = new ContextConfig(); -$contextConfig->setUnit('session_id', 'session_id5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); // a unique id identifying the user +use ABSmartly\SDK\Context\ContextConfig; +use React\Promise\PromiseInterface; + +$contextConfig = new ContextConfig(); +$contextConfig->setUnit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); + +$context = $sdk->createContext($contextConfig); + +// Use promises for async operations +$context->ready()->then( + function($context) { + // Context is ready + $treatment = $context->getTreatment('exp_test_experiment'); + }, + function($error) { + // Handle error + error_log('Context failed: ' . $error->getMessage()); + } +); +``` + +### With Prefetched Data + +To avoid repeating the round-trip on the client-side, you can initialize a context with pre-fetched data from a previous context: + +```php +use ABSmartly\SDK\Context\ContextConfig; + +$contextConfig = new ContextConfig(); +$contextConfig->setUnit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); $context = $sdk->createContext($contextConfig); $anotherContextConfig = new ContextConfig(); -$anotherContextConfig->setUnit('session_id', 'session_id5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); // a unique id identifying the user +$anotherContextConfig->setUnit('session_id', 'another-user-id'); $anotherContext = $sdk->createContextWithData($anotherContextConfig, $context->getContextData()); -``` +// No need to wait - context is immediately ready +``` -**Refreshing the Context with Fresh Experiment Data** +### Refreshing the Context with Fresh Experiment Data -For long-running contexts, the context is usually created once when the -application is first started. However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. +For long-running contexts, the context is usually created once when the application is first started. However, any experiments being tracked in your production code, but started after the context was created, will not be triggered. -To mitigate this, we can use the `Context->refresh()` method on the `Context`. +To mitigate this, we can use the `Context->refresh()` method on the `Context`: -```php +```php $context->refresh(); -``` -The `Context->refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `Context->getTreatment` is called again. +``` -**Setting Extra Units** +The `Context->refresh()` method pulls updated experiment data from the A/B Smartly collector and will trigger recently started experiments when `Context->getTreatment()` is called again. -You can add additional units to a context by calling the `Context->setUnit` or `Context->setUnits` methods. These methods may be used, for example, when a user logs in to your application, and you want to use the new unit type in the context. +### Setting Extra Units -Please note, you cannot override an already set unit type as that would be a change of identity and would throw an exception. In this case, you must create a new context instead. The `Context->setUnit` and -`Context->setUnits` methods can be called before the context is ready. +You can add additional units to a context by calling the `Context->setUnit()` or `Context->setUnits()` methods. These methods may be used, for example, when a user logs in to your application, and you want to use the new unit type in the context. -```php +```php $context->setUnit('user_id', 143432); -``` + +// Or set multiple units at once +$context->setUnits([ + 'user_id' => 143432, + 'db_user_id' => 1000013 +]); +``` + +> **Note:** You cannot override an already set unit type as that would be a change of identity and would throw an exception. In this case, you must create a new context instead. The `Context->setUnit()` and `Context->setUnits()` methods can be called before the context is ready. ## Basic Usage -### Selecting A Treatment +### Selecting a Treatment ```php $treatment = $context->getTreatment('exp_test_experiment'); if ($treatment === 0) { // user is in control group (variant 0) -} -else { +} else { // user is in treatment group } -``` +``` ### Treatment Variables ```php $defaultButtonColorValue = 'red'; -$buttonColor = $context->getVariableValue('button.color'); +$buttonColor = $context->getVariableValue('button.color', $defaultButtonColorValue); ``` ### Peek at Treatment Variants @@ -205,79 +299,356 @@ $treatment = $context->peekTreatment('exp_test_experiment'); if ($treatment === 0) { // user is in control group (variant 0) -} -else { +} else { // user is in treatment group } -``` +``` -#### Peeking at variables +#### Peeking at Variables -```php +```php $buttonColor = $context->peekVariableValue('button.color', 'red'); -``` +``` ### Overriding Treatment Variants -During development, for example, it is useful to force a treatment for an -experiment. This can be achieved with the `Context->setOverride()` and/or `Context->setOverrides()` methods. These methods can be called before the context is ready. +During development, for example, it is useful to force a treatment for an experiment. This can be achieved with the `Context->setOverride()` and/or `Context->setOverrides()` methods. These methods can be called before the context is ready. ```php -$context->setOverride("exp_test_experiment", 1); // force variant 1 of treatment +$context->setOverride('exp_test_experiment', 1); // force variant 1 of treatment -$context->setOverrides( - [ - 'exp_test_experiment' => 1, - 'exp_another_experiment' => 0, - ] -); +$context->setOverrides([ + 'exp_test_experiment' => 1, + 'exp_another_experiment' => 0, +]); ``` +## Platform-Specific Examples + +### Using with Laravel + +Laravel applications can integrate A/B Smartly using service providers and middleware for request-scoped context management. + +```php +// config/absmartly.php +return [ + 'endpoint' => env('ABSMARTLY_ENDPOINT'), + 'api_key' => env('ABSMARTLY_API_KEY'), + 'application' => env('ABSMARTLY_APPLICATION', 'website'), + 'environment' => env('APP_ENV'), +]; + +// app/Providers/ABSmartlyServiceProvider.php +app->singleton(ABsmartly::class, function ($app) { + $config = config('absmartly'); + + return ABsmartly::createWithDefaults( + endpoint: $config['endpoint'], + apiKey: $config['api_key'], + environment: $config['environment'], + application: $config['application'] + ); + }); + } +} + +// app/Http/Middleware/ABSmartlyContext.php +sdk = $sdk; + } + + public function handle($request, Closure $next) + { + $contextConfig = new ContextConfig(); + $contextConfig->setUnit('session_id', $request->session()->getId()); + + if (auth()->check()) { + $contextConfig->setUnit('user_id', auth()->id()); + } + + $context = $this->sdk->createContext($contextConfig); + $request->attributes->set('absmartly_context', $context); + + $response = $next($request); + + $context->close(); + + return $response; + } +} + +// app/Http/Controllers/ProductController.php +attributes->get('absmartly_context'); + $treatment = $context->getTreatment('exp_product_layout'); + + if ($treatment === 0) { + return view('product.show_control'); + } else { + return view('product.show_treatment'); + } + } +} +``` + +### Using with Symfony + +Symfony applications can integrate A/B Smartly using dependency injection and event subscribers for request lifecycle management. + +```php +// config/services.yaml +services: + ABSmartly\SDK\ABsmartly: + factory: ['ABSmartly\SDK\ABsmartly', 'createWithDefaults'] + arguments: + $endpoint: '%env(ABSMARTLY_ENDPOINT)%' + $apiKey: '%env(ABSMARTLY_API_KEY)%' + $environment: '%env(APP_ENV)%' + $application: 'website' + +// src/EventSubscriber/ABSmartlySubscriber.php +sdk = $sdk; + } + + public static function getSubscribedEvents() + { + return [ + KernelEvents::REQUEST => 'onKernelRequest', + KernelEvents::RESPONSE => 'onKernelResponse', + ]; + } + + public function onKernelRequest(RequestEvent $event) + { + $request = $event->getRequest(); + $session = $request->getSession(); + + $contextConfig = new ContextConfig(); + $contextConfig->setUnit('session_id', $session->getId()); + + $this->context = $this->sdk->createContext($contextConfig); + $request->attributes->set('absmartly_context', $this->context); + } + + public function onKernelResponse(ResponseEvent $event) + { + if ($this->context) { + $this->context->close(); + } + } +} + +// src/Controller/ProductController.php +attributes->get('absmartly_context'); + $treatment = $context->getTreatment('exp_product_layout'); + + if ($treatment === 0) { + return $this->render('product/show_control.html.twig'); + } else { + return $this->render('product/show_treatment.html.twig'); + } + } +} +``` + +## Advanced Request Configuration + +### Request Timeout Override + +PHP supports per-request timeout configuration through HTTP client options: + +```php +use ABSmartly\SDK\Client\ClientConfig; +use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Http\DefaultHttpClient; +use ABSmartly\SDK\Config; +use ABSmartly\SDK\ABsmartly; +use ABSmartly\SDK\Context\ContextConfig; + +$httpClient = new DefaultHttpClient(); +$httpClient->timeout = 1500; + +$clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); +$client = new Client($clientConfig, $httpClient); + +$config = new Config($client); +$sdk = new ABsmartly($config); + +$contextConfig = new ContextConfig(); +$contextConfig->setUnit('session_id', 'abc123'); + +$context = $sdk->createContext($contextConfig); +``` + +### Async Request with ReactPHP + +For non-blocking operations with cancellation support: + +```php +use ABSmartly\SDK\Client\ClientConfig; +use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Http\ReactHttpClient; +use ABSmartly\SDK\Config; +use ABSmartly\SDK\ABsmartly; +use ABSmartly\SDK\Context\ContextConfig; +use React\EventLoop\Loop; + +$reactHttpClient = new ReactHttpClient(); +$reactHttpClient->timeout = 1500; + +$clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); +$client = new Client($clientConfig, $reactHttpClient); + +$config = new Config($client); +$sdk = new ABsmartly($config); + +$contextConfig = new ContextConfig(); +$contextConfig->setUnit('session_id', 'abc123'); + +$context = $sdk->createContext($contextConfig); + +$timeout = Loop::addTimer(1.5, function() use ($context) { + echo "Context creation timed out\n"; +}); + +$context->ready()->then( + function($ctx) use ($timeout) { + Loop::cancelTimer($timeout); + echo "Context ready!\n"; + }, + function($error) use ($timeout) { + Loop::cancelTimer($timeout); + echo "Context failed: " . $error->getMessage() . "\n"; + } +); +``` + ## Advanced ### Context Attributes -Attributes are used to pass meta-data about the user and/or the request. -They can be used later in the Web Console to create segments or audiences. -They can be set using the `Context->setAttribute()` or `Context->setAttributes()` methods, before or after the context is ready. +Attributes are used to pass meta-data about the user and/or the request. They can be used later in the Web Console to create segments or audiences. They can be set using the `Context->setAttribute()` or `Context->setAttributes()` methods, before or after the context is ready. ```php -$context->setAttribute('session_id', \session_id()); -$context->setAttributes( - [ - 'customer_age' => 'new_customer' - ] -); -``` +$context->setAttribute('user_agent', $_SERVER['HTTP_USER_AGENT']); + +$context->setAttributes([ + 'customer_age' => 'new_customer', + 'session_id' => session_id() +]); +``` ### Custom Assignments Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `Context->setCustomAssignment()` method. -```php +```php $chosenVariant = 1; -$context->setCustomAssignment("experiment_name", $chosenVariant); -``` +$context->setCustomAssignment('experiment_name', $chosenVariant); +``` If you are running multiple experiments and need to choose different custom assignments for each one, you can do so using the `Context->setCustomAssignments()` method. -```php +```php $assignments = [ - "experiment_name" => 1, - "another_experiment_name" => 0, - "a_third_experiment_name" => 2 + 'experiment_name' => 1, + 'another_experiment_name' => 0, + 'a_third_experiment_name' => 2 ]; -$context->setCustomAssignments($assignments); +$context->setCustomAssignments($assignments); +``` + +### Tracking Goals + +Goals are created in the A/B Smartly Web Console. + +```php +$context->track('payment', (object) [ + 'item_count' => 1, + 'total_amount' => 1999.99 +]); ``` ### Publish -Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector, before proceeding. You can explicitly call the `Context->publish()` method. +Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector before proceeding. You can explicitly call the `Context->publish()` method. ```php $context->publish(); -``` +``` + +With async HTTP client: + +```php +$context->publish()->then(function() { + // All events published + header('Location: https://www.absmartly.com'); +}); +``` ### Finalize @@ -287,11 +658,40 @@ The `close()` method will ensure all events have been published to the A/B Smart $context->close(); ``` -### Tracking Goals +With async HTTP client: ```php -$context->track( - 'payment', - (object) ['item_count' => 1, 'total_amount' => 1999.99] -); +$context->close()->then(function() { + // Context closed and all events published + header('Location: https://www.absmartly.com'); +}); ``` + +## About A/B Smartly + +**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. +A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. + +### Have a look at our growing list of clients and SDKs: + +- [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) +- [Java SDK](https://www.github.com/absmartly/java-sdk) +- [PHP SDK](https://www.github.com/absmartly/php-sdk) +- [Swift SDK](https://www.github.com/absmartly/swift-sdk) +- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) + +## Documentation + +- [Full Documentation](https://docs.absmartly.com/) + +## License + +MIT License - see [LICENSE](LICENSE) for details. From ab7c04f4a10ae07c00c52cf06fda10e18c2742d8 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 30 Jan 2026 14:51:17 +0000 Subject: [PATCH 07/21] fix: correct HTTPClient class name in PHP SDK docs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 51855c3..aa12d8f 100644 --- a/README.md +++ b/README.md @@ -523,12 +523,12 @@ PHP supports per-request timeout configuration through HTTP client options: ```php use ABSmartly\SDK\Client\ClientConfig; use ABSmartly\SDK\Client\Client; -use ABSmartly\SDK\Http\DefaultHttpClient; +use ABSmartly\SDK\Http\HTTPClient; use ABSmartly\SDK\Config; use ABSmartly\SDK\ABsmartly; use ABSmartly\SDK\Context\ContextConfig; -$httpClient = new DefaultHttpClient(); +$httpClient = new HTTPClient(); $httpClient->timeout = 1500; $clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); From 4b575fd895b0534fb68330fe2de887b0fcffb6fd Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Fri, 6 Feb 2026 19:54:06 +0000 Subject: [PATCH 08/21] =?UTF-8?q?test:=20add=20canonical=20test=20parity?= =?UTF-8?q?=20(169=20=E2=86=92=20336=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create MD5Test (14), Murmur3Test (36), AudienceMatcherTest (3) with parameterized data providers. Refactor VariantAssignerTest to 42 individual cases. Add ~76 context tests covering event logger, exposure queuing, variable values, publish lifecycle, refresh cache invalidation, custom fields, and audience matching. --- tests/AudienceMatcherTest.php | 42 + tests/Context/ContextTest.php | 815 ++++++++++++++++++ .../Fixtures/json/context_custom_fields.json | 93 ++ tests/Fixtures/json/refreshed_iteration.json | 194 +++++ tests/MD5Test.php | 39 + tests/Murmur3Test.php | 83 ++ tests/VariantAssignerTest.php | 136 ++- 7 files changed, 1314 insertions(+), 88 deletions(-) create mode 100644 tests/AudienceMatcherTest.php create mode 100644 tests/Fixtures/json/context_custom_fields.json create mode 100644 tests/Fixtures/json/refreshed_iteration.json create mode 100644 tests/MD5Test.php create mode 100644 tests/Murmur3Test.php diff --git a/tests/AudienceMatcherTest.php b/tests/AudienceMatcherTest.php new file mode 100644 index 0000000..985a83e --- /dev/null +++ b/tests/AudienceMatcherTest.php @@ -0,0 +1,42 @@ +matcher = new AudienceMatcher(); + } + + public function testShouldReturnNullOnEmptyAudience(): void { + $audience = new stdClass(); + self::assertNull($this->matcher->evaluate($audience, [])); + } + + public function testShouldReturnNullIfFilterNotObjectOrArray(): void { + $audience = (object) ['filter' => null]; + self::assertNull($this->matcher->evaluate($audience, [])); + + $audience2 = new stdClass(); + self::assertNull($this->matcher->evaluate($audience2, [])); + } + + public function testShouldReturnBoolean(): void { + $audience = (object) [ + 'filter' => [ + (object) ['gte' => [ + (object) ['var' => ['path' => 'age']], + (object) ['value' => 20], + ]], + ], + ]; + + self::assertTrue($this->matcher->evaluate($audience, ['age' => 25])); + self::assertFalse($this->matcher->evaluate($audience, ['age' => 15])); + } +} diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index 4b9801b..f5b5d45 100644 --- a/tests/Context/ContextTest.php +++ b/tests/Context/ContextTest.php @@ -1598,4 +1598,819 @@ public function testCrossFeatureInteraction(): void { self::assertGreaterThanOrEqual(3, count($event->exposures)); self::assertCount(1, $event->goals); } + + public function testCallsEventLoggerOnError(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->prerun = static function() { + throw new \RuntimeException('Connection failed'); + }; + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $eventLogger = new MockContextEventLoggerProxy(); + + $contextConfig = new ContextConfig(); + $contextConfig->setEventLogger($eventLogger); + $contextConfig->setEventHandler($eventHandler); + (new SDK($config))->createContext($contextConfig); + + self::assertSame(1, $eventLogger->called); + self::assertSame(ContextEventLoggerEvent::Error, $eventLogger->events[0]->getEvent()); + } + + public function testCallsEventLoggerOnSuccess(): void { + $eventLogger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $eventLogger); + + self::assertSame(1, $eventLogger->called); + self::assertSame(ContextEventLoggerEvent::Ready, $eventLogger->events[0]->getEvent()); + self::assertInstanceOf(\ABSmartly\SDK\Context\ContextData::class, $eventLogger->events[0]->getData()); + } + + public function testShouldLoadExperimentData(): void { + $context = $this->createReadyContext(); + + $experiments = $context->getExperiments(); + self::assertContains('exp_test_ab', $experiments); + self::assertContains('exp_test_abc', $experiments); + self::assertContains('exp_test_not_eligible', $experiments); + self::assertContains('exp_test_fullon', $experiments); + } + + public function testSetUnitBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setUnit('session_id', 'test-session'); + self::assertSame('test-session', $contextConfig->getUnit('session_id')); + } + + public function testSetUnitAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $context->setUnit('new_unit', 'value'); + self::assertSame('value', $context->getUnit('new_unit')); + } + + public function testSetAttributeBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setAttribute('attr1', 'value1'); + self::assertSame('value1', $contextConfig->getAttribute('attr1')); + } + + public function testPeekTreatmentDoesNotQueueExposures(): void { + $context = $this->createReadyContext(); + + foreach ($context->getContextData()->experiments as $experiment) { + $context->peekTreatment($experiment->name); + } + + $context->peekTreatment('not_found'); + self::assertSame(0, $context->getPendingCount()); + } + + public function testTreatmentQueuesExposureAfterPeek(): void { + $context = $this->createReadyContext(); + + $context->peekTreatment('exp_test_ab'); + self::assertSame(0, $context->getPendingCount()); + + $context->getTreatment('exp_test_ab'); + self::assertSame(1, $context->getPendingCount()); + } + + public function testTreatmentQueuesExposureOnlyOnce(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->getTreatment('exp_test_ab'); + $context->getTreatment('exp_test_ab'); + + self::assertSame(1, $context->getPendingCount()); + } + + public function testTreatmentQueuesExposureWithBaseVariantOnUnknownExperiment(): void { + $context = $this->createReadyContext(); + + self::assertSame(0, $context->getTreatment('unknown_experiment')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('unknown_experiment', $event->exposures[0]->name); + self::assertSame(0, $event->exposures[0]->variant); + self::assertFalse($event->exposures[0]->assigned); + } + + public function testTreatmentDoesNotReQueueExposureOnUnknownExperiment(): void { + $context = $this->createReadyContext(); + + self::assertSame(0, $context->getTreatment('unknown_experiment')); + self::assertSame(0, $context->getTreatment('unknown_experiment')); + self::assertSame(1, $context->getPendingCount()); + } + + public function testTreatmentQueuesExposureWithOverrideVariant(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 5); + self::assertSame(5, $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('exp_test_ab', $event->exposures[0]->name); + self::assertSame(5, $event->exposures[0]->variant); + self::assertTrue($event->exposures[0]->overridden); + } + + public function testTreatmentQueuesExposureWithCustomAssignmentVariant(): void { + $context = $this->createReadyContext(); + + $context->setCustomAssignment('exp_test_ab', 2); + self::assertSame(2, $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('exp_test_ab', $event->exposures[0]->name); + self::assertSame(2, $event->exposures[0]->variant); + self::assertTrue($event->exposures[0]->custom); + } + + public function testVariableValueDefaultWhenUnassigned(): void { + $context = $this->createReadyContext(); + self::assertSame('default', $context->getVariableValue('nonexistent_variable', 'default')); + } + + public function testVariableValueWhenOverridden(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 1); + + self::assertSame(1, $context->getVariableValue('banner.border', 0)); + self::assertSame('large', $context->getVariableValue('banner.size', 'small')); + } + + public function testVariableValueQueuesExposureAfterPeekVariable(): void { + $context = $this->createReadyContext(); + + $context->peekVariableValue('banner.border', 0); + self::assertSame(0, $context->getPendingCount()); + + $context->getVariableValue('banner.border', 0); + self::assertSame(1, $context->getPendingCount()); + } + + public function testVariableValueQueuesExposureOnlyOnce(): void { + $context = $this->createReadyContext(); + + $context->getVariableValue('banner.border', 0); + $context->getVariableValue('banner.border', 0); + $context->getVariableValue('banner.size', 'small'); + + self::assertSame(1, $context->getPendingCount()); + } + + public function testVariableValueReturnsDefaultOnUnknownVariable(): void { + $context = $this->createReadyContext(); + self::assertSame(42, $context->getVariableValue('completely_unknown_var', 42)); + } + + public function testVariableValueThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $context->getVariableValue('banner.border', 0); + } + + public function testPeekVariableValueDefaultWhenUnassigned(): void { + $context = $this->createReadyContext(); + self::assertSame('default', $context->peekVariableValue('nonexistent_variable', 'default')); + } + + public function testPeekVariableValueWhenOverridden(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 1); + + self::assertSame(1, $context->peekVariableValue('banner.border', 0)); + self::assertSame('large', $context->peekVariableValue('banner.size', 'small')); + } + + public function testPeekVariableValueDoesNotQueueExposure(): void { + $context = $this->createReadyContext(); + + $context->peekVariableValue('banner.border', 0); + $context->peekVariableValue('banner.size', 'small'); + $context->peekVariableValue('button.color', 'blue'); + + self::assertSame(0, $context->getPendingCount()); + } + + public function testTrackQueuesGoals(): void { + $context = $this->createReadyContext(); + + $context->track('goal1', (object) ['amount' => 125]); + $context->track('goal2', (object) ['tries' => 7]); + + self::assertSame(2, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('goal1', $event->goals[0]->name); + self::assertSame('goal2', $event->goals[1]->name); + self::assertSame(125, $event->goals[0]->properties->amount); + self::assertSame(7, $event->goals[1]->properties->tries); + } + + public function testTrackDoesNotThrowWithNumberProperties(): void { + $context = $this->createReadyContext(); + + $context->track('goal1', (object) ['amount' => 125, 'hours' => 245.5]); + self::assertSame(1, $context->getPendingCount()); + } + + public function testTrackAcceptsNullProperties(): void { + $context = $this->createReadyContext(); + + $context->track('goal1'); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertNull($event->goals[0]->properties); + } + + public function testTrackCallableBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setUnits($this->units); + $context = $this->createContext($contextConfig); + + $context->track('goal1', (object) ['amount' => 100]); + self::assertSame(1, $context->getPendingCount()); + } + + public function testTrackThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $context->track('goal1'); + } + + public function testTrackQueuesGoalsWithTimestamp(): void { + $context = $this->createReadyContext(); + + $timeBefore = (int) (microtime(true) * 1000); + $context->track('goal1', (object) ['amount' => 100]); + $timeAfter = (int) (microtime(true) * 1000); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertGreaterThanOrEqual($timeBefore, $event->goals[0]->achievedAt); + self::assertLessThanOrEqual($timeAfter, $event->goals[0]->achievedAt); + } + + public function testPublishShouldNotCallClientPublishWhenQueueIsEmpty(): void { + $context = $this->createReadyContext(); + $context->publish(); + self::assertEmpty($this->eventHandler->submitted); + } + + public function testPublishShouldCallClientPublish(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->publish(); + + self::assertCount(1, $this->eventHandler->submitted); + self::assertNotEmpty($this->eventHandler->submitted[0]->exposures); + } + + public function testPublishShouldIncludeExposureData(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('exp_test_ab', $event->exposures[0]->name); + self::assertSame(1, $event->exposures[0]->variant); + } + + public function testPublishShouldIncludeGoalData(): void { + $context = $this->createReadyContext(); + + $context->track('test_goal', (object) ['revenue' => 99]); + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('test_goal', $event->goals[0]->name); + self::assertSame(99, $event->goals[0]->properties->revenue); + } + + public function testPublishShouldIncludeAttributeData(): void { + $context = $this->createReadyContext(); + + $context->setAttribute('user_type', 'premium'); + $context->getTreatment('exp_test_ab'); + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + $attributeNames = array_map(fn($attr) => $attr->name, $event->attributes); + self::assertContains('user_type', $attributeNames); + } + + public function testPublishShouldClearQueueOnSuccess(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + self::assertSame(0, $context->getPendingCount()); + } + + public function testPublishShouldNotClearQueueOnFailure(): void { + $context = $this->createReadyContext(); + $this->eventHandler->prerun = static function() { + throw new \RuntimeException('Publish failed'); + }; + + $context->track('goal1'); + $context->publish(); + + self::assertTrue($context->isFailed()); + } + + public function testPublishThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $context->publish(); + } + + public function testFinalizeShouldNotCallClientPublishWhenQueueIsEmpty(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertEmpty($this->eventHandler->submitted); + self::assertTrue($context->isClosed()); + } + + public function testFinalizeShouldCallClientPublish(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->close(); + + self::assertCount(1, $this->eventHandler->submitted); + } + + public function testFinalizeShouldIncludeExposureData(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->close(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('exp_test_ab', $event->exposures[0]->name); + } + + public function testFinalizeShouldIncludeGoalData(): void { + $context = $this->createReadyContext(); + + $context->track('goal1', (object) ['value' => 50]); + $context->close(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('goal1', $event->goals[0]->name); + } + + public function testFinalizeShouldIncludeAttributeData(): void { + $context = $this->createReadyContext(); + + $context->setAttribute('plan', 'pro'); + $context->getTreatment('exp_test_ab'); + $context->close(); + + $event = $this->eventHandler->submitted[0]; + $attributeNames = array_map(fn($attr) => $attr->name, $event->attributes); + self::assertContains('plan', $attributeNames); + } + + public function testFinalizeShouldClearQueueOnSuccess(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->close(); + + self::assertTrue($context->isClosed()); + } + + public function testOverrideCallableBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setOverride('exp_test', 5); + self::assertSame(5, $contextConfig->getOverride('exp_test')); + } + + public function testCustomAssignmentOverridesNaturalAssignment(): void { + $context = $this->createReadyContext(); + + self::assertSame($this->expectedVariants['exp_test_ab'], $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->setCustomAssignment('exp_test_ab', 2); + self::assertSame(2, $context->getTreatment('exp_test_ab')); + self::assertSame(2, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + $lastExposure = $event->exposures[count($event->exposures) - 1]; + self::assertSame('exp_test_ab', $lastExposure->name); + self::assertSame(2, $lastExposure->variant); + self::assertTrue($lastExposure->custom); + } + + public function testCustomAssignmentCallableBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setCustomAssignment('exp_test', 3); + self::assertSame(3, $contextConfig->getCustomAssignment('exp_test')); + } + + public function testCustomAssignmentAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $context->setCustomAssignment('exp_test', 1); + self::assertSame(1, $context->getCustomAssignment('exp_test')); + } + + public function testRefreshKeepsOverrides(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 5); + self::assertSame(5, $context->getTreatment('exp_test_ab')); + + $this->getContextData('refreshed.json'); + $context->refresh(); + + self::assertSame(5, $context->getTreatment('exp_test_ab')); + } + + public function testRefreshKeepsCustomAssignments(): void { + $context = $this->createReadyContext(); + + $context->setCustomAssignment('exp_test_ab', 2); + self::assertSame(2, $context->getTreatment('exp_test_ab')); + + $this->getContextData('refreshed.json'); + $context->refresh(); + + self::assertSame(2, $context->getTreatment('exp_test_ab')); + } + + public function testRefreshClearsAssignmentCacheForIterationChange(): void { + $context = $this->createReadyContext(); + self::assertTrue($context->isReady()); + + $experimentName = "exp_test_abc"; + + self::assertSame(2, $context->getTreatment($experimentName)); + self::assertSame(0, $context->getTreatment('not_found')); + self::assertSame(2, $context->getPendingCount()); + + $this->getContextData('refreshed_iteration.json'); + $context->refresh(); + + self::assertSame(2, $context->getTreatment($experimentName)); + self::assertSame(0, $context->getTreatment('not_found')); + + self::assertSame(3, $context->getPendingCount()); + } + + public function testRefreshThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $context->refresh(); + } + + public function testTreatmentThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $context->getTreatment('exp_test_ab'); + } + + public function testCustomFieldKeysReturnsKeys(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $experiment = $context->getExperiment('exp_test_ab'); + self::assertNotNull($experiment); + self::assertNotNull($experiment->data->customFieldValues); + } + + public function testCustomFieldValueReturnsStringField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'country'); + self::assertSame('US,UK,ES', $value); + } + + public function testCustomFieldValueReturnsTextField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'description'); + self::assertSame('Test experiment for AB testing', $value); + } + + public function testCustomFieldValueReturnsParsedJsonField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'config'); + self::assertIsArray($value); + self::assertSame('red', $value['color']); + self::assertSame(10, $value['size']); + } + + public function testCustomFieldValueReturnsNumberField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'min_age'); + self::assertSame(18, $value); + } + + public function testCustomFieldValueReturnsDecimalNumberField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'decimal_val'); + self::assertSame(3.14, $value); + } + + public function testCustomFieldValueReturnsBooleanField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'enabled'); + self::assertTrue($value); + } + + public function testCustomFieldValueReturnsNullForNonExistentField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'nonexistent_field'); + self::assertNull($value); + } + + public function testCustomFieldValueReturnsNullForExperimentsWithoutCustomFields(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_no_custom_fields', 'any_field'); + self::assertNull($value); + } + + public function testCustomFieldValueReturnsNullForNonExistentExperiment(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('nonexistent_experiment', 'any_field'); + self::assertNull($value); + } + + public function testGetTreatmentQueuesExposureWithAudienceMatchTrueOnAudienceMatch(): void { + $context = $this->createReadyContext('audience_context.json'); + $context->setAttribute('age', 21); + + self::assertSame(1, $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertFalse($event->exposures[0]->audienceMismatch); + } + + public function testGetTreatmentQueuesExposureWithAudienceMatchFalseOnAudienceMismatch(): void { + $context = $this->createReadyContext('audience_context.json'); + + self::assertSame(1, $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertTrue($event->exposures[0]->audienceMismatch); + } + + public function testGetTreatmentQueuesExposureWithAudienceMatchFalseAndControlVariantOnAudienceMismatchStrictMode(): void { + $context = $this->createReadyContext('audience_strict_context.json'); + + self::assertSame(0, $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame(0, $event->exposures[0]->variant); + self::assertTrue($event->exposures[0]->audienceMismatch); + } + + public function testVariableValueQueuesExposureWithAudienceMatchTrueOnMatch(): void { + $context = $this->createReadyContext('audience_context.json'); + $context->setAttribute('age', 21); + + self::assertSame('large', $context->getVariableValue('banner.size', 'small')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertFalse($event->exposures[0]->audienceMismatch); + } + + public function testVariableValueQueuesExposureWithAudienceMatchFalseOnMismatch(): void { + $context = $this->createReadyContext('audience_context.json'); + + self::assertSame('large', $context->getVariableValue('banner.size', 'small')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertTrue($event->exposures[0]->audienceMismatch); + } + + public function testVariableValueQueuesExposureWithAudienceMatchFalseAndControlOnMismatchStrictMode(): void { + $context = $this->createReadyContext('audience_strict_context.json'); + + self::assertSame('small', $context->getVariableValue('banner.size', 'small')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertTrue($event->exposures[0]->audienceMismatch); + } + + public function testPeekVariableValueReturnsAssignedOnAudienceMismatchNonStrict(): void { + $context = $this->createReadyContext('audience_context.json'); + self::assertSame('large', $context->peekVariableValue('banner.size', 'small')); + } + + public function testPeekVariableValueReturnsDefaultOnAudienceMismatchStrict(): void { + $context = $this->createReadyContext('audience_strict_context.json'); + self::assertSame('small', $context->peekVariableValue('banner.size', 'small')); + } + + public function testFinalizeCallsEventLoggerOnError(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + $this->eventHandler->prerun = static function() { + throw new \RuntimeException('Finalize failure'); + }; + + $context->track('goal1'); + $logger->clear(); + $context->close(); + + self::assertSame(ContextEventLoggerEvent::Error, $logger->events[0]->getEvent()); + } + + public function testFinalizeCallsEventLoggerOnSuccess(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + $logger->clear(); + + $context->close(); + + self::assertSame(1, $logger->called); + self::assertSame(ContextEventLoggerEvent::Finalize, $logger->events[0]->getEvent()); + } + + public function testFinalizePropagatesClientErrorMessage(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + $this->eventHandler->prerun = static function() { + throw new \RuntimeException('Server unavailable'); + }; + + $context->track('goal1'); + $logger->clear(); + $context->close(); + + $errorEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Error); + self::assertNotEmpty($errorEvents); + $errorEvent = array_values($errorEvents)[0]; + self::assertStringContainsString('Server unavailable', $errorEvent->getData()->getMessage()); + } + + public function testPublishPropagatesClientErrorMessage(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + $this->eventHandler->prerun = static function() { + throw new \RuntimeException('Connection refused'); + }; + + $context->track('goal1'); + $logger->clear(); + $context->publish(); + + $errorEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Error); + self::assertNotEmpty($errorEvents); + $errorEvent = array_values($errorEvents)[0]; + self::assertStringContainsString('Connection refused', $errorEvent->getData()->getMessage()); + } + + public function testRefreshShouldRejectOnError(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + $this->dataProvider->prerun = static function() { + throw new \RuntimeException('Refresh failed'); + }; + + $logger->clear(); + $context->refresh(); + + self::assertTrue($context->isFailed()); + $errorEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Error); + self::assertNotEmpty($errorEvents); + } + + public function testRefreshShouldNotCallClientPublishWhenFailed(): void { + $clientConfig = new ClientConfig('', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $eventHandler = new ContextEventHandlerMock($client); + $dataProvider = new ContextDataProviderMock($client); + $config->setContextDataProvider($dataProvider); + + $contextConfig = new ContextConfig(); + $contextConfig->setEventHandler($eventHandler); + $context = (new SDK($config))->createContext($contextConfig); + + self::assertTrue($context->isReady()); + + $dataProvider->prerun = static function() { + throw new \RuntimeException('Refresh error'); + }; + + $context->refresh(); + + self::assertTrue($context->isFailed()); + } + + public function testClosedContextRejectsOperations(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertTrue($context->isClosed()); + + $thrownForTreatment = false; + try { + $context->getTreatment('exp_test_ab'); + } catch (\ABSmartly\SDK\Exception\LogicException $e) { + $thrownForTreatment = true; + } + self::assertTrue($thrownForTreatment); + + $thrownForTrack = false; + try { + $context->track('goal1'); + } catch (\ABSmartly\SDK\Exception\LogicException $e) { + $thrownForTrack = true; + } + self::assertTrue($thrownForTrack); + + $thrownForPublish = false; + try { + $context->publish(); + } catch (\ABSmartly\SDK\Exception\LogicException $e) { + $thrownForPublish = true; + } + self::assertTrue($thrownForPublish); + + $thrownForRefresh = false; + try { + $context->refresh(); + } catch (\ABSmartly\SDK\Exception\LogicException $e) { + $thrownForRefresh = true; + } + self::assertTrue($thrownForRefresh); + } } diff --git a/tests/Fixtures/json/context_custom_fields.json b/tests/Fixtures/json/context_custom_fields.json new file mode 100644 index 0000000..5b3f7ca --- /dev/null +++ b/tests/Fixtures/json/context_custom_fields.json @@ -0,0 +1,93 @@ +{ + "experiments":[ + { + "id":1, + "name":"exp_test_ab", + "iteration":1, + "unitType":"session_id", + "seedHi":3603515, + "seedLo":233373850, + "split":[ + 0.5, + 0.5 + ], + "trafficSeedHi":449867249, + "trafficSeedLo":455443629, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"banner.border\":1,\"banner.size\":\"large\"}" + } + ], + "audience": null, + "customFieldValues": { + "country": "US,UK,ES", + "country_type": "string", + "description": "Test experiment for AB testing", + "description_type": "text", + "min_age": "18", + "min_age_type": "number", + "enabled": "true", + "enabled_type": "boolean", + "config": "{\"color\":\"red\",\"size\":10}", + "config_type": "json", + "decimal_val": "3.14", + "decimal_val_type": "number" + } + }, + { + "id":2, + "name":"exp_test_no_custom_fields", + "iteration":1, + "unitType":"session_id", + "seedHi":55006150, + "seedLo":47189152, + "split":[ + 0.34, + 0.33, + 0.33 + ], + "trafficSeedHi":705671872, + "trafficSeedLo":212903484, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"button.color\":\"blue\"}" + }, + { + "name":"C", + "config":"{\"button.color\":\"red\"}" + } + ], + "audience": "" + } + ] +} diff --git a/tests/Fixtures/json/refreshed_iteration.json b/tests/Fixtures/json/refreshed_iteration.json new file mode 100644 index 0000000..7ad3062 --- /dev/null +++ b/tests/Fixtures/json/refreshed_iteration.json @@ -0,0 +1,194 @@ +{ + "experiments":[ + { + "id":1, + "name":"exp_test_ab", + "iteration":1, + "unitType":"session_id", + "seedHi":3603515, + "seedLo":233373850, + "split":[ + 0.5, + 0.5 + ], + "trafficSeedHi":449867249, + "trafficSeedLo":455443629, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"banner.border\":1,\"banner.size\":\"large\"}" + } + ] + }, + { + "id":2, + "name":"exp_test_abc", + "iteration":2, + "unitType":"session_id", + "seedHi":55006150, + "seedLo":47189152, + "split":[ + 0.34, + 0.33, + 0.33 + ], + "trafficSeedHi":705671872, + "trafficSeedLo":212903484, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"button.color\":\"blue\"}" + }, + { + "name":"C", + "config":"{\"button.color\":\"red\"}" + } + ] + }, + { + "id":3, + "name":"exp_test_not_eligible", + "iteration":1, + "unitType":"user_id", + "seedHi":503266407, + "seedLo":144942754, + "split":[ + 0.34, + 0.33, + 0.33 + ], + "trafficSeedHi":87768905, + "trafficSeedLo":511357582, + "trafficSplit":[ + 0.99, + 0.01 + ], + "fullOnVariant":0, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"card.width\":\"80%\"}" + }, + { + "name":"C", + "config":"{\"card.width\":\"75%\"}" + } + ] + }, + { + "id":4, + "name":"exp_test_fullon", + "iteration":1, + "unitType":"session_id", + "seedHi":856061641, + "seedLo":990838475, + "split":[ + 0.25, + 0.25, + 0.25, + 0.25 + ], + "trafficSeedHi":360868579, + "trafficSeedLo":330937933, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":2, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"submit.color\":\"red\",\"submit.shape\":\"circle\"}" + }, + { + "name":"C", + "config":"{\"submit.color\":\"blue\",\"submit.shape\":\"rect\"}" + }, + { + "name":"D", + "config":"{\"submit.color\":\"green\",\"submit.shape\":\"square\"}" + } + ] + }, + { + "id":5, + "name":"exp_test_new", + "iteration":2, + "unitType":"session_id", + "seedHi":934590467, + "seedLo":714771373, + "split":[ + 0.5, + 0.5 + ], + "trafficSeedHi":940553836, + "trafficSeedLo":270705624, + "trafficSplit":[ + 0.0, + 1.0 + ], + "fullOnVariant":1, + "applications":[ + { + "name":"website" + } + ], + "variants":[ + { + "name":"A", + "config":null + }, + { + "name":"B", + "config":"{\"show-modal\":true}" + } + ] + } + ] +} diff --git a/tests/MD5Test.php b/tests/MD5Test.php new file mode 100644 index 0000000..d5073d3 --- /dev/null +++ b/tests/MD5Test.php @@ -0,0 +1,39 @@ + '-', + '/' => '_', + '=' => '', + ]); + self::assertSame($expectedHash, $base64url); + } +} diff --git a/tests/Murmur3Test.php b/tests/Murmur3Test.php new file mode 100644 index 0000000..b3a34e2 --- /dev/null +++ b/tests/Murmur3Test.php @@ -0,0 +1,83 @@ + $seed])); + } + + public static function murmur3Seed0Provider(): array { + return [ + ['', 0], + [' ', 2129959832], + ['t', 3397902157], + ['te', 3988319771], + ['tes', 196677210], + ['test', 3127628307], + ['testy', 1152353090], + ['testy1', 2316969018], + ['testy12', 2220122553], + ['testy123', 1197640388], + ['special characters açb↓c', 3196301632], + ['The quick brown fox jumps over the lazy dog', 776992547], + ]; + } + + public static function murmur3SeedDeadbeefProvider(): array { + return [ + ['', 233162409], + [' ', 632081987], + ['t', 991288568], + ['te', 2895647538], + ['tes', 3251080666], + ['test', 2854409242], + ['testy', 2230711843], + ['testy1', 166537449], + ['testy12', 575043637], + ['testy123', 3593668109], + ['special characters açb↓c', 4160608418], + ['The quick brown fox jumps over the lazy dog', 981155661], + ]; + } + + public static function murmur3Seed1Provider(): array { + return [ + ['', 1364076727], + [' ', 1326412082], + ['t', 1571914526], + ['te', 3527981870], + ['tes', 3560106868], + ['test', 2579507938], + ['testy', 3316833310], + ['testy1', 865230059], + ['testy12', 3643580195], + ['testy123', 1002533165], + ['special characters açb↓c', 691218357], + ['The quick brown fox jumps over the lazy dog', 2028379687], + ]; + } + + /** + * @dataProvider murmur3Seed0Provider + */ + public function testShouldMatchKnownHashesWithSeed0(string $input, int $expectedHash): void { + self::assertSame($expectedHash, $this->murmur3Hash($input, 0)); + } + + /** + * @dataProvider murmur3SeedDeadbeefProvider + */ + public function testShouldMatchKnownHashesWithSeedDeadbeef(string $input, int $expectedHash): void { + self::assertSame($expectedHash, $this->murmur3Hash($input, 0xdeadbeef)); + } + + /** + * @dataProvider murmur3Seed1Provider + */ + public function testShouldMatchKnownHashesWithSeed1(string $input, int $expectedHash): void { + self::assertSame($expectedHash, $this->murmur3Hash($input, 1)); + } +} diff --git a/tests/VariantAssignerTest.php b/tests/VariantAssignerTest.php index 8b127e2..e7b10f1 100644 --- a/tests/VariantAssignerTest.php +++ b/tests/VariantAssignerTest.php @@ -6,100 +6,60 @@ use PHPUnit\Framework\TestCase; class VariantAssignerTest extends TestCase { - public function getAssignmentTestValues(): array { + public static function assignmentProvider(): array { return [ - [ - "bleh@absmartly.com", - [ - [[0.5, 0.5], 0x00000000, 0x00000000, 0], - [[0.5, 0.5], 0x00000000, 0x00000001, 1], - [[0.5, 0.5], 0x8015406f, 0x7ef49b98, 0], - [[0.5, 0.5], 0x3b2e7d90, 0xca87df4d, 0], - [[0.5, 0.5], 0x52c1f657, 0xd248bb2e, 0], - [[0.5, 0.5], 0x865a84d0, 0xaa22d41a, 0], - [[0.5, 0.5], 0x27d1dc86, 0x845461b9, 1], - [[0.33, 0.33, 0.34], 0x00000000, 0x00000000, 0], - [[0.33, 0.33, 0.34], 0x00000000, 0x00000001, 2], - [[0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98, 0], - [[0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d, 0], - [[0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e, 0], - [[0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a, 1], - [[0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9, 1], - ], - ], - [ - "123456789", - [ - [[0.5, 0.5], 0x00000000, 0x00000000, 1], - [[0.5, 0.5], 0x00000000, 0x00000001, 0], - [[0.5, 0.5], 0x8015406f, 0x7ef49b98, 1], - [[0.5, 0.5], 0x3b2e7d90, 0xca87df4d, 1], - [[0.5, 0.5], 0x52c1f657, 0xd248bb2e, 1], - [[0.5, 0.5], 0x865a84d0, 0xaa22d41a, 0], - [[0.5, 0.5], 0x27d1dc86, 0x845461b9, 0], - [[0.33, 0.33, 0.34], 0x00000000, 0x00000000, 2], - [[0.33, 0.33, 0.34], 0x00000000, 0x00000001, 1], - [[0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98, 2], - [[0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d, 2], - [[0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e, 2], - [[0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a, 0], - [[0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9, 0], - ], - ], - [ - "e791e240fcd3df7d238cfc285f475e8152fcc0ec", - [ - [[0.5, 0.5], 0x00000000, 0x00000000, 1], - [[0.5, 0.5], 0x00000000, 0x00000001, 0], - [[0.5, 0.5], 0x8015406f, 0x7ef49b98, 1], - [[0.5, 0.5], 0x3b2e7d90, 0xca87df4d, 1], - [[0.5, 0.5], 0x52c1f657, 0xd248bb2e, 0], - [[0.5, 0.5], 0x865a84d0, 0xaa22d41a, 0], - [[0.5, 0.5], 0x27d1dc86, 0x845461b9, 0], - [[0.33, 0.33, 0.34], 0x00000000, 0x00000000, 2], - [[0.33, 0.33, 0.34], 0x00000000, 0x00000001, 0], - [[0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98, 2], - [[0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d, 1], - [[0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e, 0], - [[0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a, 0], - [[0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9, 1], - [[0.0, 0.01, 0.02], 0x27d1dc86, 0x845461b9, 2], - ], - ], + ['bleh@absmartly.com', [0.5, 0.5], 0x00000000, 0x00000000, 0], + ['bleh@absmartly.com', [0.5, 0.5], 0x00000000, 0x00000001, 1], + ['bleh@absmartly.com', [0.5, 0.5], 0x8015406f, 0x7ef49b98, 0], + ['bleh@absmartly.com', [0.5, 0.5], 0x3b2e7d90, 0xca87df4d, 0], + ['bleh@absmartly.com', [0.5, 0.5], 0x52c1f657, 0xd248bb2e, 0], + ['bleh@absmartly.com', [0.5, 0.5], 0x865a84d0, 0xaa22d41a, 0], + ['bleh@absmartly.com', [0.5, 0.5], 0x27d1dc86, 0x845461b9, 1], + ['bleh@absmartly.com', [0.33, 0.33, 0.34], 0x00000000, 0x00000000, 0], + ['bleh@absmartly.com', [0.33, 0.33, 0.34], 0x00000000, 0x00000001, 2], + ['bleh@absmartly.com', [0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98, 0], + ['bleh@absmartly.com', [0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d, 0], + ['bleh@absmartly.com', [0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e, 0], + ['bleh@absmartly.com', [0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a, 1], + ['bleh@absmartly.com', [0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9, 1], + ['123456789', [0.5, 0.5], 0x00000000, 0x00000000, 1], + ['123456789', [0.5, 0.5], 0x00000000, 0x00000001, 0], + ['123456789', [0.5, 0.5], 0x8015406f, 0x7ef49b98, 1], + ['123456789', [0.5, 0.5], 0x3b2e7d90, 0xca87df4d, 1], + ['123456789', [0.5, 0.5], 0x52c1f657, 0xd248bb2e, 1], + ['123456789', [0.5, 0.5], 0x865a84d0, 0xaa22d41a, 0], + ['123456789', [0.5, 0.5], 0x27d1dc86, 0x845461b9, 0], + ['123456789', [0.33, 0.33, 0.34], 0x00000000, 0x00000000, 2], + ['123456789', [0.33, 0.33, 0.34], 0x00000000, 0x00000001, 1], + ['123456789', [0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98, 2], + ['123456789', [0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d, 2], + ['123456789', [0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e, 2], + ['123456789', [0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a, 0], + ['123456789', [0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9, 0], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.5, 0.5], 0x00000000, 0x00000000, 1], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.5, 0.5], 0x00000000, 0x00000001, 0], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.5, 0.5], 0x8015406f, 0x7ef49b98, 1], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.5, 0.5], 0x3b2e7d90, 0xca87df4d, 1], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.5, 0.5], 0x52c1f657, 0xd248bb2e, 0], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.5, 0.5], 0x865a84d0, 0xaa22d41a, 0], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.5, 0.5], 0x27d1dc86, 0x845461b9, 0], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.33, 0.33, 0.34], 0x00000000, 0x00000000, 2], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.33, 0.33, 0.34], 0x00000000, 0x00000001, 0], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.33, 0.33, 0.34], 0x8015406f, 0x7ef49b98, 2], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.33, 0.33, 0.34], 0x3b2e7d90, 0xca87df4d, 1], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.33, 0.33, 0.34], 0x52c1f657, 0xd248bb2e, 0], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.33, 0.33, 0.34], 0x865a84d0, 0xaa22d41a, 0], + ['e791e240fcd3df7d238cfc285f475e8152fcc0ec', [0.33, 0.33, 0.34], 0x27d1dc86, 0x845461b9, 1], ]; } - public function getFailingAssignmentTestValues(): array { - return [ - [ - "e791e240fcd3df7d238cfc285f475e8152fcc0ec", - [ - [[0.0, 0.01, 0.02], 0x27d1dc86, 0x845461b9, 5], - ], - ], - ]; - } - - /** - * @dataProvider getAssignmentTestValues - */ - public function testVariantAssignerIsDeterministic(string $hash, array $testCases): void { - $assigner = new VariantAssigner($hash); - foreach ($testCases as $testCase) { - $value = $assigner->assign($testCase[0], $testCase[1], $testCase[2]); - static::assertSame($testCase[3], $value); - } - } - /** - * @dataProvider getFailingAssignmentTestValues + * @dataProvider assignmentProvider */ - public function testVariantAssignerMutation(string $hash, array $testCases): void { - $assigner = new VariantAssigner($hash); - foreach ($testCases as $testCase) { - $value = $assigner->assign($testCase[0], $testCase[1], $testCase[2]); - static::assertNotSame($testCase[3], $value); - } + public function testAssignShouldBeDeterministic(string $unit, array $split, int $seedHi, int $seedLo, int $expectedVariant): void { + $assigner = new VariantAssigner($unit); + $variant = $assigner->assign($split, $seedHi, $seedLo); + self::assertSame($expectedVariant, $variant); } public function testChooseVariantGenericValidation(): void { From 0d51ff6737143147f83d43ab461750128a011dcf Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Feb 2026 10:35:06 +0000 Subject: [PATCH 09/21] fix: audit fixes for operator correctness and async interface support Fix InOperator and MatchOperator for proper type handling. Add async interfaces for Client, HttpClient, ContextDataProvider, and ContextEventHandler. Add RuntimeException and ABsmartly facade class. --- .gitignore | 5 + composer.json | 15 +- src/ABsmartly.php | 109 ++++++++++++++ src/Assignment.php | 4 +- src/Client/AsyncClientInterface.php | 11 ++ src/Client/Client.php | 54 +++++-- src/Client/ClientConfig.php | 3 + src/Client/ClientInterface.php | 12 ++ src/Context/AsyncContextDataProvider.php | 19 +++ src/Context/AsyncContextEventHandler.php | 20 +++ src/Context/Context.php | 117 +++++++++++++-- src/Context/ContextConfig.php | 2 +- src/Context/ContextDataProvider.php | 6 +- src/Context/ContextEventHandler.php | 6 +- src/Exception/RuntimeException.php | 7 + src/Experiment.php | 11 ++ src/Http/AsyncHttpClientInterface.php | 11 ++ src/Http/HTTPClient.php | 52 ++++++- src/Http/HttpClientInterface.php | 10 ++ src/Http/ReactHttpClient.php | 95 ++++++++++++ src/JsonExpression/Operator/InOperator.php | 21 +-- src/JsonExpression/Operator/MatchOperator.php | 12 +- src/SDK.php | 70 +-------- src/VariableParser.php | 5 + tests/Client/ClientAsyncTest.php | 136 ++++++++++++++++++ tests/Client/ClientInterfaceTest.php | 57 ++++++++ .../Context/AsyncContextDataProviderTest.php | 67 +++++++++ .../Context/AsyncContextEventHandlerTest.php | 45 ++++++ tests/Context/ContextTest.php | 2 +- tests/Http/HttpClientInterfaceTest.php | 23 +++ .../Operator/InOperatorTest.php | 48 +++---- 31 files changed, 911 insertions(+), 144 deletions(-) create mode 100644 src/ABsmartly.php create mode 100644 src/Client/AsyncClientInterface.php create mode 100644 src/Client/ClientInterface.php create mode 100644 src/Context/AsyncContextDataProvider.php create mode 100644 src/Context/AsyncContextEventHandler.php create mode 100644 src/Exception/RuntimeException.php create mode 100644 src/Http/AsyncHttpClientInterface.php create mode 100644 src/Http/HttpClientInterface.php create mode 100644 src/Http/ReactHttpClient.php create mode 100644 tests/Client/ClientAsyncTest.php create mode 100644 tests/Client/ClientInterfaceTest.php create mode 100644 tests/Context/AsyncContextDataProviderTest.php create mode 100644 tests/Context/AsyncContextEventHandlerTest.php create mode 100644 tests/Http/HttpClientInterfaceTest.php diff --git a/.gitignore b/.gitignore index b126028..a7a081c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ composer.lock coverage.xml .phpunit.cache phpunit.xml + +.claude/ +.DS_Store +AUDIT_REPORT.md +FIXES_IMPLEMENTED.md diff --git a/composer.json b/composer.json index 91f5744..9255228 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,15 @@ "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^9.5.26" + "phpunit/phpunit": "^9.5.26", + "react/http": "^1.9", + "react/async": "^4.0", + "react/promise": "^3.0" + }, + "suggest": { + "react/http": "Required for async HTTP support with ReactPHP", + "react/async": "Required for sync-over-async operations with ReactPHP", + "react/promise": "Required for async operations" }, "license": "MIT", "autoload": { @@ -27,5 +35,8 @@ "email": "ayesh@aye.sh" } ], - "keywords": ["absmartly"] + "keywords": ["absmartly"], + "scripts": { + "test": "phpunit" + } } diff --git a/src/ABsmartly.php b/src/ABsmartly.php new file mode 100644 index 0000000..13f3053 --- /dev/null +++ b/src/ABsmartly.php @@ -0,0 +1,109 @@ +client = $config->getClient(); + $this->provider = $config->getContextDataProvider(); + $this->handler = $config->getContextEventHandler(); + } + + /** + * @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io". + * @param string $apiKey API key which can be found on the Web Console. + * @param string $environment Environment of the platform where the SDK is installed. Environments are created on + * the Web Console and should match the available environments in your infrastructure. + * @param string $application Name of the application where the SDK is installed. Applications are created on the + * Web Console and should match the applications where your experiments will be running. + * @param int $retries The number of retries before the SDK stops trying to connect. + * @param int $timeout Amount of time, in milliseconds, before the SDK will stop trying to connect. + * @param callable|null $eventLogger A callback function which runs after SDK events. + * @return ABsmartly ABsmartly instance created using the credentials and details above. + */ + public static function createWithDefaults( + string $endpoint, + string $apiKey, + string $environment, + string $application, + int $retries = 5, + int $timeout = 3000, + ?callable $eventLogger = null + ): ABsmartly { + + $clientConfig = new ClientConfig( + $endpoint, + $apiKey, + $application, + $environment, + ); + $clientConfig->setRetries($retries); + $clientConfig->setTimeout($timeout); + + $client = new Client($clientConfig, new HTTPClient()); + $sdkConfig = new Config($client); + if ($eventLogger !== null) { + $sdkConfig->setContextEventLogger(new \ABSmartly\SDK\Context\ContextEventLoggerCallback($eventLogger)); + } + return new ABsmartly($sdkConfig); + } + + public function createContext(ContextConfig $contextConfig): Context { + return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler); + } + + public function createContextWithData(ContextConfig $contextConfig, ContextData $contextData): Context { + return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler, $contextData); + } + + public function createContextAsync(ContextConfig $contextConfig): PromiseInterface { + if (!$this->provider instanceof AsyncContextDataProvider) { + return resolve($this->createContext($contextConfig)); + } + + return $this->provider->getContextDataAsync() + ->then(fn($data) => $this->createContextWithData($contextConfig, $data)); + } + + public function createContextPending(ContextConfig $contextConfig): array { + $context = Context::createPending($this, $contextConfig, $this->provider, $this->handler); + + if (!$this->provider instanceof AsyncContextDataProvider) { + $promise = resolve(null)->then(function() use ($context) { + $data = $this->provider->getContextData(); + $context->setContextData($data); + return $context; + }); + } else { + $promise = $this->provider->getContextDataAsync() + ->then(function($data) use ($context) { + $context->setContextData($data); + return $context; + }); + } + + return ['context' => $context, 'promise' => $promise]; + } + + public function close(): void { + $this->client->close(); + } +} diff --git a/src/Assignment.php b/src/Assignment.php index dd5c709..9c81b38 100644 --- a/src/Assignment.php +++ b/src/Assignment.php @@ -9,8 +9,8 @@ class Assignment { public int $iteration = 0; public int $fullOnVariant = 0; public string $name = ''; - public ?string $unitType; - public array $trafficSplit; + public ?string $unitType = null; + public array $trafficSplit = []; public int $variant = 0; public bool $assigned = false; public bool $overridden = false; diff --git a/src/Client/AsyncClientInterface.php b/src/Client/AsyncClientInterface.php new file mode 100644 index 0000000..5c37c3f --- /dev/null +++ b/src/Client/AsyncClientInterface.php @@ -0,0 +1,11 @@ +httpClient = $HTTPClient; - $this->httpClient->timeout = $clientConfig->getTimeout(); - $this->httpClient->retries = $clientConfig->getRetries(); + + if (property_exists($this->httpClient, 'timeout')) { + $this->httpClient->timeout = $clientConfig->getTimeout(); + } + if (property_exists($this->httpClient, 'retries')) { + $this->httpClient->retries = $clientConfig->getRetries(); + } $this->url = rtrim($clientConfig->getEndpoint(), '/') .'/context'; $this->query = [ @@ -45,14 +54,29 @@ public function __construct(ClientConfig $clientConfig, ?HTTPClient $HTTPClient private function authRequest(): void { - + if (empty($this->headers['X-API-Key'])) { + throw new \ABSmartly\SDK\Exception\RuntimeException( + 'API key is not configured. Please set a valid API key in ClientConfig.' + ); + } } public function getContextData(): ContextData { $this->authRequest(); $response = $this->httpClient->get($this->url, $this->query, $this->headers); - $response = $this->decode($response->content); - return new ContextData($response->experiments); + $decoded = $this->decode($response->content); + return new ContextData($decoded->experiments); + } + + public function getContextDataAsync(): PromiseInterface { + if (!$this->httpClient instanceof AsyncHttpClientInterface) { + return resolve($this->getContextData()); + } + + $this->authRequest(); + return $this->httpClient + ->getAsync($this->url, $this->query, $this->headers) + ->then(fn($response) => new ContextData($this->decode($response->content)->experiments)); } public function publish(PublishEvent $publishEvent): void { @@ -60,6 +84,16 @@ public function publish(PublishEvent $publishEvent): void { $this->httpClient->put($this->url, $this->query, $this->headers, $data); } + public function publishAsync(PublishEvent $publishEvent): PromiseInterface { + if (!$this->httpClient instanceof AsyncHttpClientInterface) { + $this->publish($publishEvent); + return resolve(null); + } + + $data = $this->encode($publishEvent); + return $this->httpClient->putAsync($this->url, $this->query, $this->headers, $data); + } + public function decode(string $jsonString): object { return json_decode($jsonString, false, 16, JSON_THROW_ON_ERROR); } @@ -71,4 +105,8 @@ public function encode(object $object): string { public function close(): void { $this->httpClient->close(); } + + public function isAsync(): bool { + return $this->httpClient instanceof AsyncHttpClientInterface; + } } diff --git a/src/Client/ClientConfig.php b/src/Client/ClientConfig.php index c0cef43..aee3938 100644 --- a/src/Client/ClientConfig.php +++ b/src/Client/ClientConfig.php @@ -23,6 +23,9 @@ public function __construct( string $application, string $environment ) { + if ($endpoint === '' && $apiKey !== '' || $endpoint !== '' && $apiKey === '') { + error_log('ABsmartly SDK Warning: ClientConfig created with empty endpoint or API key. This may cause runtime errors.'); + } $this->apiKey = $apiKey; $this->application = $application; diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php new file mode 100644 index 0000000..b03641c --- /dev/null +++ b/src/Client/ClientInterface.php @@ -0,0 +1,12 @@ +asyncClient = $client; + } + + public function getContextDataAsync(): PromiseInterface { + return $this->asyncClient->getContextDataAsync(); + } +} diff --git a/src/Context/AsyncContextEventHandler.php b/src/Context/AsyncContextEventHandler.php new file mode 100644 index 0000000..1ba327b --- /dev/null +++ b/src/Context/AsyncContextEventHandler.php @@ -0,0 +1,20 @@ +asyncClient = $client; + } + + public function publishAsync(PublishEvent $event): PromiseInterface { + return $this->asyncClient->publishAsync($event); + } +} diff --git a/src/Context/Context.php b/src/Context/Context.php index cb458fe..80e57d8 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -11,7 +11,7 @@ use ABSmartly\SDK\Exposure; use ABSmartly\SDK\GoalAchievement; use ABSmartly\SDK\PublishEvent; -use ABSmartly\SDK\SDK; +use ABSmartly\SDK\ABsmartly; use ABSmartly\SDK\VariableParser; use ABSmartly\SDK\VariantAssigner; @@ -29,7 +29,7 @@ class Context { - private SDK $sdk; + private ABsmartly $sdk; private ContextEventHandler $eventHandler; private ContextEventLogger $eventLogger; @@ -82,7 +82,7 @@ public static function getTime(): int { return (int) (microtime(true) * 1000); } - private function __construct(SDK $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ?ContextData $contextData = null) { + private function __construct(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ?ContextData $contextData = null, bool $pending = false) { $this->sdk = $sdk; $this->dataProvider = $dataProvider; $this->setUnits($contextConfig->getUnits()); @@ -97,6 +97,14 @@ private function __construct(SDK $sdk, ContextConfig $contextConfig, ContextData $this->audienceMatcher = new AudienceMatcher(); $this->variableParser = new VariableParser(); + if ($pending) { + $this->ready = false; + $this->data = null; + $this->index = []; + $this->indexVariables = []; + return; + } + try { $this->ready = true; if (!$contextData) { @@ -111,6 +119,12 @@ private function __construct(SDK $sdk, ContextConfig $contextConfig, ContextData } catch (Exception $exception) { $this->setDataFailed(); + error_log(sprintf( + 'ABsmartly SDK CRITICAL: Context initialization failed: %s in %s:%d. Context is in failed state.', + $exception->getMessage(), + $exception->getFile(), + $exception->getLine() + )); $this->logError($exception); } } @@ -164,7 +178,7 @@ private function setDataFailed(): void { $this->failed = true; } - public static function createFromContextConfig(SDK $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextEventHandler $handler, ?ContextData $contextData = null): Context { + public static function createFromContextConfig(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextEventHandler $handler, ?ContextData $contextData = null): Context { $context = new Context($sdk, $contextConfig, $dataProvider, $contextData); $context->setEventHandler($handler); @@ -175,6 +189,33 @@ public static function createFromContextConfig(SDK $sdk, ContextConfig $contextC return $context; } + public static function createPending(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextEventHandler $handler): Context { + $context = new Context($sdk, $contextConfig, $dataProvider, null, true); + $context->setEventHandler($handler); + + if ($logger = $contextConfig->getEventLogger()) { + $context->setEventLogger($logger); + } + + return $context; + } + + public function setContextData(ContextData $contextData): void { + if ($this->ready) { + return; + } + + try { + $this->data = $contextData; + $this->setData($contextData); + $this->ready = true; + $this->logEvent(ContextEventLoggerEvent::Ready, $contextData); + } catch (Exception $exception) { + $this->setDataFailed(); + $this->logError($exception); + } + } + private function checkReady(): void { if (!$this->isReady()) { throw new LogicException('ABSmartly Context is not yet ready'); @@ -207,7 +248,17 @@ public function customFieldValue(string $experimentName, string $fieldName) { $customFieldValues = $experiment->data->customFieldValues; if (is_string($customFieldValues)) { - $customFieldValues = json_decode($customFieldValues, true); + try { + $customFieldValues = json_decode($customFieldValues, true, 512, JSON_THROW_ON_ERROR); + } + catch (\JsonException $e) { + error_log(sprintf( + 'ABsmartly SDK Error: Failed to decode custom field values for experiment "%s": %s', + $experimentName, + $e->getMessage() + )); + return null; + } } if (is_object($customFieldValues)) { @@ -222,7 +273,18 @@ public function customFieldValue(string $experimentName, string $fieldName) { $type = $customFieldValues[$fieldName . '_type'] ?? null; if ($type === 'json' && is_string($value)) { - return json_decode($value, true); + try { + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } + catch (\JsonException $e) { + error_log(sprintf( + 'ABsmartly SDK Error: Failed to decode JSON custom field "%s" for experiment "%s": %s', + $fieldName, + $experimentName, + $e->getMessage() + )); + return null; + } } if ($type === 'number' && is_string($value)) { @@ -475,6 +537,12 @@ private function logEvent(string $event, ?object $data): void { private function logError(Throwable $throwable): void { if (!isset($this->eventLogger)) { + error_log(sprintf( + 'ABsmartly SDK Error: %s in %s:%d', + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine() + )); return; } @@ -600,12 +668,24 @@ public function getPendingCount(): int { private function checkNotClosed(): void { if ($this->isClosed()) { - throw new LogicException('ABSmartly Context is closed'); + throw new LogicException('ABSmartly Context is finalized'); } } public function flush(): void { if ($this->isFailed()) { + $errorMsg = sprintf( + 'ABsmartly SDK Warning: Discarding %d exposures and %d goals due to failed context state', + count($this->exposures), + count($this->achievements) + ); + error_log($errorMsg); + if (isset($this->eventLogger)) { + $this->eventLogger->handleEvent($this, new ContextEventLoggerEvent( + ContextEventLoggerEvent::Error, + new \RuntimeException($errorMsg) + )); + } $this->exposures = []; $this->achievements = []; $this->pendingCount = 0; @@ -622,10 +702,18 @@ public function flush(): void { try { $this->eventHandler->publish($event); $this->logEvent(ContextEventLoggerEvent::Publish, $event); + $this->exposures = []; + $this->achievements = []; $this->pendingCount = 0; } catch (Exception $exception) { $this->failed = true; + error_log(sprintf( + 'ABsmartly SDK Error: Failed to publish %d exposures and %d goals: %s. Data will be lost.', + count($this->exposures), + count($this->achievements), + $exception->getMessage() + )); $this->logError($exception); } } @@ -656,13 +744,25 @@ public function publish(): void { public function refresh(): void { $this->checkNotClosed(); + $oldData = $this->data; + $oldIndex = $this->index; + $oldIndexVariables = $this->indexVariables; try { $data = $this->dataProvider->getContextData(); $this->setData($data); $this->logEvent(ContextEventLoggerEvent::Refresh, $data); } catch (Exception $exception) { - $this->setDataFailed(); + $this->data = $oldData; + $this->index = $oldIndex; + $this->indexVariables = $oldIndexVariables; + $this->failed = true; + error_log(sprintf( + 'ABsmartly SDK Error: Failed to refresh context, keeping existing data: %s in %s:%d', + $exception->getMessage(), + $exception->getFile(), + $exception->getLine() + )); $this->logError($exception); } } @@ -677,7 +777,6 @@ public function close(): void { $this->logEvent(ContextEventLoggerEvent::Finalize, null); $this->closed = true; - $this->sdk->close(); } } diff --git a/src/Context/ContextConfig.php b/src/Context/ContextConfig.php index 82b5c66..3a2510b 100644 --- a/src/Context/ContextConfig.php +++ b/src/Context/ContextConfig.php @@ -2,7 +2,7 @@ namespace ABSmartly\SDK\Context; -use InvalidArgumentException; +use ABSmartly\SDK\Exception\InvalidArgumentException; use function gettype; use function is_int; diff --git a/src/Context/ContextDataProvider.php b/src/Context/ContextDataProvider.php index 068643b..aff6e48 100644 --- a/src/Context/ContextDataProvider.php +++ b/src/Context/ContextDataProvider.php @@ -2,12 +2,12 @@ namespace ABSmartly\SDK\Context; -use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Client\ClientInterface; class ContextDataProvider { - private Client $client; + private ClientInterface $client; - public function __construct(Client $client) { + public function __construct(ClientInterface $client) { $this->client = $client; } diff --git a/src/Context/ContextEventHandler.php b/src/Context/ContextEventHandler.php index c474b35..8a98ce6 100644 --- a/src/Context/ContextEventHandler.php +++ b/src/Context/ContextEventHandler.php @@ -2,13 +2,13 @@ namespace ABSmartly\SDK\Context; -use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Client\ClientInterface; use ABSmartly\SDK\PublishEvent; class ContextEventHandler { - private Client $client; + private ClientInterface $client; - public function __construct(Client $client) { + public function __construct(ClientInterface $client) { $this->client = $client; } diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 0000000..d139940 --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,7 @@ +audience)) { $this->audience = json_decode($data->audience, false, 512, JSON_THROW_ON_ERROR); } diff --git a/src/Http/AsyncHttpClientInterface.php b/src/Http/AsyncHttpClientInterface.php new file mode 100644 index 0000000..747418d --- /dev/null +++ b/src/Http/AsyncHttpClientInterface.php @@ -0,0 +1,11 @@ +curlHandle); - $this->throwOnError($returnedResponse); + $attempt = 0; + $lastException = null; - $response = new Response(); - $response->content = (string) $returnedResponse; - $response->status = (int) curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + while ($attempt < $this->retries) { + try { + $returnedResponse = curl_exec($this->curlHandle); + $this->throwOnError($returnedResponse); - return $response; + $response = new Response(); + $response->content = (string) $returnedResponse; + $response->status = (int) curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + + return $response; + } + catch (HttpClientError $e) { + $lastException = $e; + $httpCode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + $curlError = curl_errno($this->curlHandle); + + $isRetryable = ($curlError !== 0) || + ($httpCode >= 500 && $httpCode < 600) || + $httpCode === 408 || + $httpCode === 429; + + if (!$isRetryable || $attempt >= $this->retries - 1) { + throw $e; + } + + $attempt++; + $backoffMs = min(1000 * pow(2, $attempt - 1), 10000); + error_log(sprintf( + 'ABsmartly SDK: Retrying HTTP request (attempt %d/%d) after %dms due to error: %s', + $attempt, + $this->retries, + $backoffMs, + $e->getMessage() + )); + usleep($backoffMs * 1000); + } + } + + throw $lastException; } public function get(string $url, array $query = [], array $headers = []): Response { @@ -120,6 +154,10 @@ private function curlInit(): void { } $this->curlHandle = curl_init(); + if ($this->curlHandle === false) { + throw new HttpClientError('Failed to initialize cURL. Is the curl extension loaded?'); + } + // https://php.watch/articles/php-curl-security-hardening curl_setopt_array($this->curlHandle, [ CURLOPT_RETURNTRANSFER => true, diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php new file mode 100644 index 0000000..789713f --- /dev/null +++ b/src/Http/HttpClientInterface.php @@ -0,0 +1,10 @@ +browser = $browser ?? new Browser(); + } + + public function getAsync(string $url, array $query = [], array $headers = []): PromiseInterface { + $url = $this->buildUrl($url, $query); + return $this->browser + ->withTimeout($this->timeout / 1000) + ->get($url, $this->flattenHeaders($headers)) + ->then(fn($response) => $this->toResponse($response)); + } + + public function putAsync(string $url, array $query = [], array $headers = [], string $body = ''): PromiseInterface { + $url = $this->buildUrl($url, $query); + return $this->browser + ->withTimeout($this->timeout / 1000) + ->put($url, $this->flattenHeaders($headers), $body) + ->then(fn($response) => $this->toResponse($response)); + } + + public function postAsync(string $url, array $query = [], array $headers = [], string $body = ''): PromiseInterface { + $url = $this->buildUrl($url, $query); + return $this->browser + ->withTimeout($this->timeout / 1000) + ->post($url, $this->flattenHeaders($headers), $body) + ->then(fn($response) => $this->toResponse($response)); + } + + public function get(string $url, array $query = [], array $headers = []): Response { + return await($this->getAsync($url, $query, $headers)); + } + + public function put(string $url, array $query = [], array $headers = [], string $body = ''): Response { + return await($this->putAsync($url, $query, $headers, $body)); + } + + public function post(string $url, array $query = [], array $headers = [], string $body = ''): Response { + return await($this->postAsync($url, $query, $headers, $body)); + } + + public function close(): void { + } + + private function buildUrl(string $url, array $query): string { + if (!$query) { + return $url; + } + $queryParams = http_build_query($query); + return strpos($url, '?') === false + ? "$url?$queryParams" + : rtrim($url, '&') . "&$queryParams"; + } + + private function flattenHeaders(array $headers): array { + $flat = []; + foreach ($headers as $key => $value) { + $flat[] = "$key: $value"; + } + return $flat; + } + + private function toResponse($reactResponse): Response { + $response = new Response(); + $response->status = $reactResponse->getStatusCode(); + $response->content = (string) $reactResponse->getBody(); + + if ($response->status >= 400) { + throw new HttpClientError( + sprintf('HTTP Client returned an HTTP error %d: Response Body: %s', + $response->status, + $response->content + ) + ); + } + + return $response; + } +} diff --git a/src/JsonExpression/Operator/InOperator.php b/src/JsonExpression/Operator/InOperator.php index 10dcf38..0a8fe7f 100644 --- a/src/JsonExpression/Operator/InOperator.php +++ b/src/JsonExpression/Operator/InOperator.php @@ -15,27 +15,30 @@ class InOperator extends BinaryOperator { - public function binary(Evaluator $evaluator, $haystack, $needle): ?bool { - if ($needle === null) { + public function binary(Evaluator $evaluator, $lhs, $rhs): ?bool { + if ($lhs === null) { return null; } - if (is_array($haystack)) { - return in_array($needle, $haystack, true); + if (is_array($rhs)) { + return in_array($lhs, $rhs, false); } - if (is_string($haystack)) { + if (is_string($rhs)) { + if (!is_string($lhs)) { + return null; + } //@codeCoverageIgnoreStart // due to version-dependent code if (function_exists('str_contains')) { - return str_contains($haystack, $needle); // Allows empty strings + return str_contains($rhs, $lhs); } - return strpos($haystack, $needle) !== false; + return strpos($rhs, $lhs) !== false; // @codeCoverageIgnoreEnd } - if (is_object($haystack)) { - return property_exists($haystack, (string) $needle); // Not using isset() to account for possible null values. + if (is_object($rhs)) { + return property_exists($rhs, (string) $lhs); } return null; diff --git a/src/JsonExpression/Operator/MatchOperator.php b/src/JsonExpression/Operator/MatchOperator.php index 5dbf96a..8bf177f 100644 --- a/src/JsonExpression/Operator/MatchOperator.php +++ b/src/JsonExpression/Operator/MatchOperator.php @@ -25,15 +25,9 @@ public function binary(Evaluator $evaluator, $text, $pattern): ?bool { } private function runRegexBounded(string $text, string $pattern): ?bool { - /* - * If the user-provided $pattern has forward slash delimiters, accept them. Any other patterns will - * automatically get forward slashes as delimiters. - * - * This is not ideal, because unlike JS, regexps are strings, and working with user-provided patterns is - * prone to either security issues (too eager regexps, or simply Regexp errors), or having to enforce delimiters - * at source. - */ - $matches = preg_match('/'. trim($pattern, '/') . '/', $text); + $pattern = trim($pattern, '/'); + + $matches = @preg_match('~'. $pattern . '~', $text); if (preg_last_error() !== PREG_NO_ERROR) { return null; diff --git a/src/SDK.php b/src/SDK.php index f30b223..8374307 100644 --- a/src/SDK.php +++ b/src/SDK.php @@ -2,70 +2,8 @@ namespace ABSmartly\SDK; -use ABSmartly\SDK\Client\Client; -use ABSmartly\SDK\Client\ClientConfig; -use ABSmartly\SDK\Context\Context; -use ABSmartly\SDK\Context\ContextConfig; -use ABSmartly\SDK\Context\ContextData; -use ABSmartly\SDK\Context\ContextDataProvider; -use ABSmartly\SDK\Context\ContextEventHandler; -use ABSmartly\SDK\Http\HTTPClient; - -final class SDK { - - private Client $client; - private ContextEventHandler $handler; - private ContextDataProvider $provider; - - public function __construct(Config $config) { - $this->client = $config->getClient(); - $this->provider = $config->getContextDataProvider(); - $this->handler = $config->getContextEventHandler(); - } - - /** - * @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io". - * @param string $apiKey API key which can be found on the Web Console. - * @param string $environment Environment of the platform where the SDK is installed. Environments are created on - * the Web Console and should match the available environments in your infrastructure. - * @param string $application Name of the application where the SDK is installed. Applications are created on the - * Web Console and should match the applications where your experiments will be running. - * @param int $retries The number of retries before the SDK stops trying to connect. - * @param int $timeout Amount of time, in milliseconds, before the SDK will stop trying to connect. - * @param callable|null $eventLogger A callback function which runs after SDK events. - * @return SDK SDK instance created using the credentials and details above. - */ - public static function createWithDefaults( - string $endpoint, - string $apiKey, - string $environment, - string $application, - int $retries = 5, - int $timeout = 3000, - ?callable $eventLogger = null - ): SDK { - - $clientConfig = new ClientConfig( - $endpoint, - $apiKey, - $application, - $environment, - ); - - $client = new Client($clientConfig, new HTTPClient()); - $sdkConfig = new Config($client); - return new SDK($sdkConfig); - } - - public function createContext(ContextConfig $contextConfig): Context { - return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler); - } - - public function createContextWithData(ContextConfig $contextConfig, ContextData $contextData): Context { - return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler, $contextData); - } - - public function close(): void { - $this->client->close(); - } +/** + * @deprecated Use ABsmartly instead. This class will be removed in a future version. + */ +final class SDK extends ABsmartly { } diff --git a/src/VariableParser.php b/src/VariableParser.php index 274b604..ae12d81 100644 --- a/src/VariableParser.php +++ b/src/VariableParser.php @@ -14,6 +14,11 @@ public function parse(string $experimentName, string $config): ?object { return json_decode($config, false, 512, JSON_THROW_ON_ERROR); } catch (Exception $exception) { + error_log(sprintf( + 'ABsmartly SDK Error: Failed to parse variant config for experiment "%s": %s', + $experimentName, + $exception->getMessage() + )); return null; } } diff --git a/tests/Client/ClientAsyncTest.php b/tests/Client/ClientAsyncTest.php new file mode 100644 index 0000000..8bbdb44 --- /dev/null +++ b/tests/Client/ClientAsyncTest.php @@ -0,0 +1,136 @@ +createMock(AsyncHttpClientInterface::class); + return $mock; + } + + private function createMockSyncHttpClient(): HttpClientInterface { + $mock = $this->createMock(HttpClientInterface::class); + return $mock; + } + + private function createContextDataResponse(): Response { + $response = new Response(); + $response->status = 200; + $response->content = json_encode([ + 'experiments' => [ + [ + 'id' => 1, + 'name' => 'test_exp', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 0, + 'seedLo' => 0, + 'split' => [0.5, 0.5], + 'trafficSeedHi' => 0, + 'trafficSeedLo' => 0, + 'trafficSplit' => [0.0, 1.0], + 'fullOnVariant' => 0, + 'applications' => [['name' => 'app']], + 'variants' => [[], []], + 'audienceStrict' => false, + 'audience' => null + ] + ] + ]); + return $response; + } + + public function testGetContextDataAsyncWithAsyncClient(): void { + $httpClient = $this->createMockAsyncHttpClient(); + $response = $this->createContextDataResponse(); + + $httpClient->method('getAsync') + ->willReturn(resolve($response)); + + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config, $httpClient); + + self::assertTrue($client->isAsync()); + + $promise = $client->getContextDataAsync(); + self::assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testGetContextDataAsyncWithSyncClientReturnsResolvedPromise(): void { + $httpClient = $this->createMockSyncHttpClient(); + $response = $this->createContextDataResponse(); + + $httpClient->method('get') + ->willReturn($response); + + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config, $httpClient); + + self::assertFalse($client->isAsync()); + + $promise = $client->getContextDataAsync(); + self::assertInstanceOf(PromiseInterface::class, $promise); + + $result = null; + $promise->then(function($data) use (&$result) { + $result = $data; + }); + + self::assertInstanceOf(ContextData::class, $result); + } + + public function testPublishAsyncWithAsyncClient(): void { + $httpClient = $this->createMockAsyncHttpClient(); + $response = new Response(); + $response->status = 200; + $response->content = '{}'; + + $httpClient->method('putAsync') + ->willReturn(resolve($response)); + + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config, $httpClient); + + $publishEvent = new PublishEvent(); + $promise = $client->publishAsync($publishEvent); + + self::assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testPublishAsyncWithSyncClientReturnsResolvedPromise(): void { + $httpClient = $this->createMockSyncHttpClient(); + $response = new Response(); + $response->status = 200; + $response->content = '{}'; + + $httpClient->method('put') + ->willReturn($response); + + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config, $httpClient); + + $publishEvent = new PublishEvent(); + $promise = $client->publishAsync($publishEvent); + + self::assertInstanceOf(PromiseInterface::class, $promise); + + $resolved = false; + $promise->then(function() use (&$resolved) { + $resolved = true; + }); + + self::assertTrue($resolved); + } +} diff --git a/tests/Client/ClientInterfaceTest.php b/tests/Client/ClientInterfaceTest.php new file mode 100644 index 0000000..1470f26 --- /dev/null +++ b/tests/Client/ClientInterfaceTest.php @@ -0,0 +1,57 @@ +hasMethod('getContextData')); + self::assertTrue($reflection->hasMethod('publish')); + self::assertTrue($reflection->hasMethod('close')); + } + + public function testAsyncClientInterfaceDefinesRequiredMethods(): void { + $reflection = new \ReflectionClass(AsyncClientInterface::class); + + self::assertTrue($reflection->hasMethod('getContextDataAsync')); + self::assertTrue($reflection->hasMethod('publishAsync')); + } + + public function testClientHasIsAsyncMethod(): void { + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config); + + self::assertFalse($client->isAsync()); + } + + public function testClientAcceptsHttpClientInterface(): void { + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $httpClient = new HTTPClient(); + $client = new Client($config, $httpClient); + + self::assertInstanceOf(Client::class, $client); + self::assertFalse($client->isAsync()); + } +} diff --git a/tests/Context/AsyncContextDataProviderTest.php b/tests/Context/AsyncContextDataProviderTest.php new file mode 100644 index 0000000..b790a12 --- /dev/null +++ b/tests/Context/AsyncContextDataProviderTest.php @@ -0,0 +1,67 @@ +createMock(AsyncClientInterface::class); + $provider = new AsyncContextDataProvider($client); + + self::assertInstanceOf(ContextDataProvider::class, $provider); + } + + public function testGetContextDataAsyncReturnsPromise(): void { + $client = $this->createMock(AsyncClientInterface::class); + $contextData = new ContextData(); + + $client->method('getContextDataAsync') + ->willReturn(resolve($contextData)); + + $provider = new AsyncContextDataProvider($client); + $promise = $provider->getContextDataAsync(); + + self::assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testGetContextDataAsyncResolvesToContextData(): void { + $client = $this->createMock(AsyncClientInterface::class); + $contextData = new ContextData(); + $contextData->experiments = []; + + $client->method('getContextDataAsync') + ->willReturn(resolve($contextData)); + + $provider = new AsyncContextDataProvider($client); + $promise = $provider->getContextDataAsync(); + + $result = null; + $promise->then(function($data) use (&$result) { + $result = $data; + }); + + self::assertSame($contextData, $result); + } + + public function testGetContextDataSyncStillWorks(): void { + $client = $this->createMock(AsyncClientInterface::class); + $contextData = new ContextData(); + $contextData->experiments = []; + + $client->method('getContextData') + ->willReturn($contextData); + + $provider = new AsyncContextDataProvider($client); + $result = $provider->getContextData(); + + self::assertSame($contextData, $result); + } +} diff --git a/tests/Context/AsyncContextEventHandlerTest.php b/tests/Context/AsyncContextEventHandlerTest.php new file mode 100644 index 0000000..63a869b --- /dev/null +++ b/tests/Context/AsyncContextEventHandlerTest.php @@ -0,0 +1,45 @@ +createMock(AsyncClientInterface::class); + $handler = new AsyncContextEventHandler($client); + + self::assertInstanceOf(ContextEventHandler::class, $handler); + } + + public function testPublishAsyncReturnsPromise(): void { + $client = $this->createMock(AsyncClientInterface::class); + + $client->method('publishAsync') + ->willReturn(resolve(null)); + + $handler = new AsyncContextEventHandler($client); + $event = new PublishEvent(); + $promise = $handler->publishAsync($event); + + self::assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testPublishSyncStillWorks(): void { + $client = $this->createMock(AsyncClientInterface::class); + + $client->expects($this->once()) + ->method('publish'); + + $handler = new AsyncContextEventHandler($client); + $event = new PublishEvent(); + $handler->publish($event); + } +} diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index f5b5d45..46a59ec 100644 --- a/tests/Context/ContextTest.php +++ b/tests/Context/ContextTest.php @@ -811,7 +811,7 @@ public function testPublishResetsInternalQueuesAndKeepsAttributesOverridesAndCus $event = $this->eventHandler->submitted[1]; self::assertSame(2, $event->attributes[1]->value); self::assertSame(245, $event->goals[0]->properties->hours); - self::assertSame('not_found', $event->exposures[2]->name); + self::assertFalse(property_exists($event, 'exposures'), 'Second publish should not have exposures - they were already sent in first publish'); self::assertSame(0, $context->getPendingCount()); diff --git a/tests/Http/HttpClientInterfaceTest.php b/tests/Http/HttpClientInterfaceTest.php new file mode 100644 index 0000000..445dd59 --- /dev/null +++ b/tests/Http/HttpClientInterfaceTest.php @@ -0,0 +1,23 @@ +hasMethod('get')); + self::assertTrue($reflection->hasMethod('put')); + self::assertTrue($reflection->hasMethod('post')); + self::assertTrue($reflection->hasMethod('close')); + } +} diff --git a/tests/JsonExpression/Operator/InOperatorTest.php b/tests/JsonExpression/Operator/InOperatorTest.php index dda4a9d..13ed6aa 100644 --- a/tests/JsonExpression/Operator/InOperatorTest.php +++ b/tests/JsonExpression/Operator/InOperatorTest.php @@ -19,20 +19,20 @@ public function setUp(): void { } public function testStringInString(): void { - self::assertTrue($this->operator->evaluate($this->evaluator, ["abcdefghijk", "abc"])); - self::assertTrue($this->operator->evaluate($this->evaluator, ["abcdefghijk", "def"])); - self::assertFalse($this->operator->evaluate($this->evaluator, ["abcdefghijk", "xxx"])); + self::assertTrue($this->operator->evaluate($this->evaluator, ["abc", "abcdefghijk"])); + self::assertTrue($this->operator->evaluate($this->evaluator, ["def", "abcdefghijk"])); + self::assertFalse($this->operator->evaluate($this->evaluator, ["xxx", "abcdefghijk"])); - self::assertNull($this->operator->evaluate($this->evaluator, ["abcdefghijk", null])); + self::assertNull($this->operator->evaluate($this->evaluator, [null, "abcdefghijk"])); } public function testReturnFalseOnEmptyArray(): void { - self::assertFalse($this->operator->evaluate($this->evaluator, [[], false])); - self::assertFalse($this->operator->evaluate($this->evaluator, [[], "1"])); - self::assertFalse($this->operator->evaluate($this->evaluator, [[], true])); - self::assertFalse($this->operator->evaluate($this->evaluator, [[], false])); + self::assertFalse($this->operator->evaluate($this->evaluator, [false, []])); + self::assertFalse($this->operator->evaluate($this->evaluator, ["1", []])); + self::assertFalse($this->operator->evaluate($this->evaluator, [true, []])); + self::assertFalse($this->operator->evaluate($this->evaluator, [false, []])); - self::assertNull($this->operator->evaluate($this->evaluator, [[], null])); + self::assertNull($this->operator->evaluate($this->evaluator, [null, []])); } public function testArrayContainsValue(): void { @@ -40,29 +40,29 @@ public function testArrayContainsValue(): void { $haystack12 = [1, 2]; $haystackabKeys = ['a' => 5, 'b' => 6]; - self::assertFalse($this->operator->evaluate($this->evaluator, [$haystack01, 2])); - self::assertFalse($this->operator->evaluate($this->evaluator, [$haystack12, 0])); - self::assertTrue($this->operator->evaluate($this->evaluator, [$haystack12, 1])); - self::assertTrue($this->operator->evaluate($this->evaluator, [$haystack12, 2])); - self::assertFalse($this->operator->evaluate($this->evaluator, [$haystackabKeys, 'a'])); - self::assertFalse($this->operator->evaluate($this->evaluator, [$haystackabKeys, 'b'])); - self::assertTrue($this->operator->evaluate($this->evaluator, [$haystackabKeys, 5])); - self::assertTrue($this->operator->evaluate($this->evaluator, [$haystackabKeys, 6])); - self::assertFalse($this->operator->evaluate($this->evaluator, [$haystackabKeys, 7])); + self::assertFalse($this->operator->evaluate($this->evaluator, [2, $haystack01])); + self::assertFalse($this->operator->evaluate($this->evaluator, [0, $haystack12])); + self::assertTrue($this->operator->evaluate($this->evaluator, [1, $haystack12])); + self::assertTrue($this->operator->evaluate($this->evaluator, [2, $haystack12])); + self::assertFalse($this->operator->evaluate($this->evaluator, ['a', $haystackabKeys])); + self::assertFalse($this->operator->evaluate($this->evaluator, ['b', $haystackabKeys])); + self::assertTrue($this->operator->evaluate($this->evaluator, [5, $haystackabKeys])); + self::assertTrue($this->operator->evaluate($this->evaluator, [6, $haystackabKeys])); + self::assertFalse($this->operator->evaluate($this->evaluator, [7, $haystackabKeys])); } public function testObjectContainsProperty(): void { $haystackab = (object) ['a' => 1, 'b' => 2 ]; $haystackbc = (object) ['b' => 2, 'c' => 3, 0 => 100]; - self::assertFalse($this->operator->evaluate($this->evaluator, [$haystackab, 'c'])); - self::assertTrue($this->operator->evaluate($this->evaluator, [$haystackab, 'b'])); - self::assertTrue($this->operator->evaluate($this->evaluator, [$haystackbc, 'b'])); - self::assertTrue($this->operator->evaluate($this->evaluator, [$haystackbc, 0])); + self::assertFalse($this->operator->evaluate($this->evaluator, ['c', $haystackab])); + self::assertTrue($this->operator->evaluate($this->evaluator, ['b', $haystackab])); + self::assertTrue($this->operator->evaluate($this->evaluator, ['b', $haystackbc])); + self::assertTrue($this->operator->evaluate($this->evaluator, [0, $haystackbc])); } public function testArrayDiffNull(): void { - self::assertFalse($this->operator->evaluate($this->evaluator, [[1, 2, 3], [2, 3]])); - self::assertFalse($this->operator->evaluate($this->evaluator, [[1, 2, 3], [5, 6]])); + self::assertFalse($this->operator->evaluate($this->evaluator, [[2, 3], [1, 2, 3]])); + self::assertFalse($this->operator->evaluate($this->evaluator, [[5, 6], [1, 2, 3]])); } } From e01fbbe602667e83f21749d9892ccaaa0a42a7db Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 24 Feb 2026 20:06:58 +0000 Subject: [PATCH 10/21] docs: restructure README to match standard SDK documentation structure --- README.md | 346 +++++++++++++++++++++++++----------------------------- 1 file changed, 160 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index aa12d8f..7fbc027 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# A/B Smartly SDK +# A/B Smartly PHP SDK -A/B Smartly PHP SDK +A/B Smartly - PHP SDK ## Compatibility @@ -10,9 +10,7 @@ The A/B Smartly PHP SDK is compatible with PHP versions 7.4 and later. For the b The main SDK class has been renamed from `SDK` to `ABsmartly` to standardize naming across all ABSmartly SDKs. The old `SDK` class name is still available as a deprecated alias for backwards compatibility, but it is recommended to migrate to the new `ABsmartly` class name in new projects. -## Getting Started - -### Install the SDK +## Installation A/B Smartly PHP SDK can be installed with [`composer`](https://getcomposer.org): @@ -20,7 +18,13 @@ A/B Smartly PHP SDK can be installed with [`composer`](https://getcomposer.org): composer require absmartly/php-sdk ``` -### Import and Initialize the SDK +## Getting Started + +Please follow the [installation](#installation) instructions before trying the following code. + +### Initialization + +This example assumes an API Key, an Application, and an Environment have been created in the A/B Smartly web console. #### Recommended: Simple API @@ -57,23 +61,12 @@ use ABSmartly\SDK\Client\ClientConfig; use ABSmartly\SDK\Client\Client; use ABSmartly\SDK\Config; use ABSmartly\SDK\ABsmartly; -use ABSmartly\SDK\Context\ContextConfig; -use ABSmartly\SDK\Context\ContextEventLoggerCallback; $clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); $client = new Client($clientConfig); $config = new Config($client); $sdk = new ABsmartly($config); - -$contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(new ContextEventLoggerCallback( - function (string $event, ?object $data) { - // Custom callback - } -)); - -$context = $sdk->createContext($contextConfig); ``` #### Using Async HTTP Client @@ -111,79 +104,11 @@ The async HTTP client uses ReactPHP promises and allows for non-blocking I/O ope | `application` | `string` | ✅ | `null` | The name of the application where the SDK is installed. Applications are created on the Web Console and should match the applications where your experiments will be running. | | `retries` | `int` | ❌ | `5` | The number of retries before the SDK stops trying to connect. | | `timeout` | `int` | ❌ | `3000` | An amount of time, in milliseconds, before the SDK will stop trying to connect. | -| `eventLogger` | `ContextEventLogger` | ❌ | `null` | A callback function which runs after SDK events. See [Using a Custom Event Logger](#using-a-custom-event-logger) below. | +| `eventLogger` | `ContextEventLogger` | ❌ | `null` | A callback function which runs after SDK events. See [Custom Event Logger](#custom-event-logger) below. | | `contextDataProvider` | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage) | | `contextEventHandler` | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage) | -### Using a Custom Event Logger - -The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context in the `ContextConfig`. - -#### Simple Callback Approach - -```php -use ABSmartly\SDK\Context\ContextConfig; -use ABSmartly\SDK\Context\ContextEventLoggerCallback; - -$contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(new ContextEventLoggerCallback( - function (string $event, ?object $data) { - // Custom callback - if ($event === 'Error') { - error_log('ABSmartly Error: ' . print_r($data, true)); - } - } -)); -``` - -#### Interface Implementation Approach - -Alternatively, you can implement the `ContextEventLogger` interface with a `handleEvent()` method that receives the `Context` object itself, along with a `ContextEventLoggerEvent` object: - -```php -use ABSmartly\SDK\Context\Context; -use ABSmartly\SDK\Context\ContextEventLogger; -use ABSmartly\SDK\Context\ContextEventLoggerEvent; - -class CustomLogger implements ContextEventLogger { - public function handleEvent(Context $context, ContextEventLoggerEvent $event): void { - $eventName = $event->getEvent(); - $eventData = $event->getData(); - - // Process the log event - switch ($eventName) { - case 'Exposure': - // Log exposure event - break; - case 'Goal': - // Log goal achievement - break; - case 'Error': - error_log('ABSmartly Error: ' . print_r($eventData, true)); - break; - } - } -} - -$contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(new CustomLogger()); -``` - -**Event Types** - -The data parameter depends on the type of event. Currently, the SDK logs the following events: - -| Event | When | Data | -| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------- | -| `Error` | `Context` receives an error | `Exception` object thrown | -| `Ready` | `Context` turns ready | `ContextData` object used to initialize the context | -| `Refresh` | `Context->refresh()` method succeeds | `ContextData` used to refresh the context | -| `Publish` | `Context->publish()` method succeeds | `PublishEvent` data sent to the A/B Smartly event collector| -| `Exposure` | `Context->getTreatment()` method succeeds on first exposure | `Exposure` data enqueued for publishing | -| `Goal` | `Context->track()` method succeeds | `GoalAchievement` goal data enqueued for publishing | -| `Close` | `Context->close()` method succeeds the first time | `null` | - -## Create a New Context Request +## Creating a New Context ### Synchronously @@ -209,22 +134,19 @@ $contextConfig->setUnit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8' $context = $sdk->createContext($contextConfig); -// Use promises for async operations $context->ready()->then( function($context) { - // Context is ready $treatment = $context->getTreatment('exp_test_experiment'); }, function($error) { - // Handle error error_log('Context failed: ' . $error->getMessage()); } ); ``` -### With Prefetched Data +### With Pre-fetched Data -To avoid repeating the round-trip on the client-side, you can initialize a context with pre-fetched data from a previous context: +When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. Creating a context involves a round-trip to the A/B Smartly event collector. We can avoid repeating the round-trip on the client-side by re-using data previously retrieved. ```php use ABSmartly\SDK\Context\ContextConfig; @@ -238,7 +160,6 @@ $anotherContextConfig = new ContextConfig(); $anotherContextConfig->setUnit('session_id', 'another-user-id'); $anotherContext = $sdk->createContextWithData($anotherContextConfig, $context->getContextData()); -// No need to wait - context is immediately ready ``` ### Refreshing the Context with Fresh Experiment Data @@ -260,14 +181,13 @@ You can add additional units to a context by calling the `Context->setUnit()` or ```php $context->setUnit('user_id', 143432); -// Or set multiple units at once $context->setUnits([ 'user_id' => 143432, 'db_user_id' => 1000013 ]); ``` -> **Note:** You cannot override an already set unit type as that would be a change of identity and would throw an exception. In this case, you must create a new context instead. The `Context->setUnit()` and `Context->setUnits()` methods can be called before the context is ready. +> **Note:** You cannot override an already set unit type as that would be a change of identity and would throw an exception. In this case, you must create a new context instead. The `Context->setUnit()` and `Context->setUnits()` methods can be called before the context is ready. ## Basic Usage @@ -321,7 +241,150 @@ $context->setOverrides([ 'exp_test_experiment' => 1, 'exp_another_experiment' => 0, ]); -``` +``` + +## Advanced + +### Context Attributes + +Attributes are used to pass meta-data about the user and/or the request. They can be used later in the Web Console to create segments or audiences. They can be set using the `Context->setAttribute()` or `Context->setAttributes()` methods, before or after the context is ready. + +```php +$context->setAttribute('user_agent', $_SERVER['HTTP_USER_AGENT']); + +$context->setAttributes([ + 'customer_age' => 'new_customer', + 'session_id' => session_id() +]); +``` + +### Custom Assignments + +Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `Context->setCustomAssignment()` method. + +```php +$chosenVariant = 1; +$context->setCustomAssignment('experiment_name', $chosenVariant); +``` + +If you are running multiple experiments and need to choose different custom assignments for each one, you can do so using the `Context->setCustomAssignments()` method. + +```php +$assignments = [ + 'experiment_name' => 1, + 'another_experiment_name' => 0, + 'a_third_experiment_name' => 2 +]; + +$context->setCustomAssignments($assignments); +``` + +### Tracking Goals + +Goals are created in the A/B Smartly Web Console. + +```php +$context->track('payment', (object) [ + 'item_count' => 1, + 'total_amount' => 1999.99 +]); +``` + +### Publishing Pending Data + +Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector before proceeding. You can explicitly call the `Context->publish()` method. + +```php +$context->publish(); +``` + +With async HTTP client: + +```php +$context->publish()->then(function() { + header('Location: https://www.absmartly.com'); +}); +``` + +### Finalizing + +The `close()` method will ensure all events have been published to the A/B Smartly collector, like `Context->publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. + +```php +$context->close(); +``` + +With async HTTP client: + +```php +$context->close()->then(function() { + header('Location: https://www.absmartly.com'); +}); +``` + +### Custom Event Logger + +The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context in the `ContextConfig`. + +#### Simple Callback Approach + +```php +use ABSmartly\SDK\Context\ContextConfig; +use ABSmartly\SDK\Context\ContextEventLoggerCallback; + +$contextConfig = new ContextConfig(); +$contextConfig->setEventLogger(new ContextEventLoggerCallback( + function (string $event, ?object $data) { + if ($event === 'Error') { + error_log('ABSmartly Error: ' . print_r($data, true)); + } + } +)); +``` + +#### Interface Implementation Approach + +Alternatively, you can implement the `ContextEventLogger` interface with a `handleEvent()` method that receives the `Context` object itself, along with a `ContextEventLoggerEvent` object: + +```php +use ABSmartly\SDK\Context\Context; +use ABSmartly\SDK\Context\ContextEventLogger; +use ABSmartly\SDK\Context\ContextEventLoggerEvent; + +class CustomLogger implements ContextEventLogger { + public function handleEvent(Context $context, ContextEventLoggerEvent $event): void { + $eventName = $event->getEvent(); + $eventData = $event->getData(); + + switch ($eventName) { + case 'Exposure': + break; + case 'Goal': + break; + case 'Error': + error_log('ABSmartly Error: ' . print_r($eventData, true)); + break; + } + } +} + +$contextConfig = new ContextConfig(); +$contextConfig->setEventLogger(new CustomLogger()); +``` + +**Event Types** + +The data parameter depends on the type of event. Currently, the SDK logs the following events: + +| Event | When | Data | +| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------- | +| `Error` | `Context` receives an error | `Exception` object thrown | +| `Ready` | `Context` turns ready | `ContextData` object used to initialize the context | +| `Refresh` | `Context->refresh()` method succeeds | `ContextData` used to refresh the context | +| `Publish` | `Context->publish()` method succeeds | `PublishEvent` data sent to the A/B Smartly event collector | +| `Exposure` | `Context->getTreatment()` method succeeds on first exposure | `Exposure` data enqueued for publishing | +| `Goal` | `Context->track()` method succeeds | `GoalAchievement` goal data enqueued for publishing | +| `Close` | `Context->close()` method succeeds the first time | `null` | ## Platform-Specific Examples @@ -586,87 +649,6 @@ $context->ready()->then( ); ``` -## Advanced - -### Context Attributes - -Attributes are used to pass meta-data about the user and/or the request. They can be used later in the Web Console to create segments or audiences. They can be set using the `Context->setAttribute()` or `Context->setAttributes()` methods, before or after the context is ready. - -```php -$context->setAttribute('user_agent', $_SERVER['HTTP_USER_AGENT']); - -$context->setAttributes([ - 'customer_age' => 'new_customer', - 'session_id' => session_id() -]); -``` - -### Custom Assignments - -Sometimes it may be necessary to override the automatic selection of a variant. For example, if you wish to have your variant chosen based on data from an API call. This can be accomplished using the `Context->setCustomAssignment()` method. - -```php -$chosenVariant = 1; -$context->setCustomAssignment('experiment_name', $chosenVariant); -``` - -If you are running multiple experiments and need to choose different custom assignments for each one, you can do so using the `Context->setCustomAssignments()` method. - -```php -$assignments = [ - 'experiment_name' => 1, - 'another_experiment_name' => 0, - 'a_third_experiment_name' => 2 -]; - -$context->setCustomAssignments($assignments); -``` - -### Tracking Goals - -Goals are created in the A/B Smartly Web Console. - -```php -$context->track('payment', (object) [ - 'item_count' => 1, - 'total_amount' => 1999.99 -]); -``` - -### Publish - -Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector before proceeding. You can explicitly call the `Context->publish()` method. - -```php -$context->publish(); -``` - -With async HTTP client: - -```php -$context->publish()->then(function() { - // All events published - header('Location: https://www.absmartly.com'); -}); -``` - -### Finalize - -The `close()` method will ensure all events have been published to the A/B Smartly collector, like `Context->publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. - -```php -$context->close(); -``` - -With async HTTP client: - -```php -$context->close()->then(function() { - // Context closed and all events published - header('Location: https://www.absmartly.com'); -}); -``` - ## About A/B Smartly **A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. @@ -676,7 +658,7 @@ A/B Smartly's real-time analytics helps engineering and product teams ensure tha - [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) - [Java SDK](https://www.github.com/absmartly/java-sdk) -- [PHP SDK](https://www.github.com/absmartly/php-sdk) +- [PHP SDK](https://www.github.com/absmartly/php-sdk) (this package) - [Swift SDK](https://www.github.com/absmartly/swift-sdk) - [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) - [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) @@ -687,11 +669,3 @@ A/B Smartly's real-time analytics helps engineering and product teams ensure tha - [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) - [Dart SDK](https://www.github.com/absmartly/dart-sdk) - [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) - -## Documentation - -- [Full Documentation](https://docs.absmartly.com/) - -## License - -MIT License - see [LICENSE](LICENSE) for details. From d82b09ee5a06851e57d65610b9ea15a68f53dcac Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Thu, 26 Feb 2026 22:50:08 +0000 Subject: [PATCH 11/21] feat: add createSimple() with correct parameter ordering createWithDefaults() had swapped environment/application params. Add createSimple() with correct order matching ClientConfig constructor. Deprecate createWithDefaults() for backward compatibility. --- README.md | 8 ++++---- src/ABsmartly.php | 42 ++++++++++++++++++++++++++++++++++++++++ tests/ABsmartlyTest.php | 43 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 tests/ABsmartlyTest.php diff --git a/README.md b/README.md index 7fbc027..44a5a98 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ use ABSmartly\SDK\Client\Client; use ABSmartly\SDK\Config; use ABSmartly\SDK\ABsmartly; -$clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); +$clientConfig = new ClientConfig($endpoint, $apiKey, $application, $environment); $client = new Client($clientConfig); $config = new Config($client); @@ -80,7 +80,7 @@ use ABSmartly\SDK\Http\ReactHttpClient; use ABSmartly\SDK\Config; use ABSmartly\SDK\ABsmartly; -$clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); +$clientConfig = new ClientConfig($endpoint, $apiKey, $application, $environment); $reactHttpClient = new ReactHttpClient(); $reactHttpClient->timeout = 3000; @@ -594,7 +594,7 @@ use ABSmartly\SDK\Context\ContextConfig; $httpClient = new HTTPClient(); $httpClient->timeout = 1500; -$clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); +$clientConfig = new ClientConfig($endpoint, $apiKey, $application, $environment); $client = new Client($clientConfig, $httpClient); $config = new Config($client); @@ -622,7 +622,7 @@ use React\EventLoop\Loop; $reactHttpClient = new ReactHttpClient(); $reactHttpClient->timeout = 1500; -$clientConfig = new ClientConfig($endpoint, $apiKey, $environment, $application); +$clientConfig = new ClientConfig($endpoint, $apiKey, $application, $environment); $client = new Client($clientConfig, $reactHttpClient); $config = new Config($client); diff --git a/src/ABsmartly.php b/src/ABsmartly.php index 13f3053..ee9fa11 100644 --- a/src/ABsmartly.php +++ b/src/ABsmartly.php @@ -28,6 +28,48 @@ public function __construct(Config $config) { } /** + * @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io". + * @param string $apiKey API key which can be found on the Web Console. + * @param string $application Name of the application where the SDK is installed. Applications are created on the + * Web Console and should match the applications where your experiments will be running. + * @param string $environment Environment of the platform where the SDK is installed. Environments are created on + * the Web Console and should match the available environments in your infrastructure. + * @param int $retries The number of retries before the SDK stops trying to connect. + * @param int $timeout Amount of time, in milliseconds, before the SDK will stop trying to connect. + * @param callable|null $eventLogger A callback function which runs after SDK events. + * @return ABsmartly ABsmartly instance created using the credentials and details above. + */ + public static function createSimple( + string $endpoint, + string $apiKey, + string $application, + string $environment, + int $retries = 5, + int $timeout = 3000, + ?callable $eventLogger = null + ): ABsmartly { + $clientConfig = new ClientConfig( + $endpoint, + $apiKey, + $application, + $environment, + ); + $clientConfig->setRetries($retries); + $clientConfig->setTimeout($timeout); + + $client = new Client($clientConfig, new HTTPClient()); + $sdkConfig = new Config($client); + if ($eventLogger !== null) { + $sdkConfig->setContextEventLogger(new \ABSmartly\SDK\Context\ContextEventLoggerCallback($eventLogger)); + } + return new ABsmartly($sdkConfig); + } + + /** + * @deprecated Use createSimple() instead. This method has $environment and $application in the wrong order + * relative to ClientConfig::__construct(). createSimple() fixes this with the correct order: + * endpoint, apiKey, application, environment. + * * @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io". * @param string $apiKey API key which can be found on the Web Console. * @param string $environment Environment of the platform where the SDK is installed. Environments are created on diff --git a/tests/ABsmartlyTest.php b/tests/ABsmartlyTest.php new file mode 100644 index 0000000..95bb6d7 --- /dev/null +++ b/tests/ABsmartlyTest.php @@ -0,0 +1,43 @@ +getParameters(); + + self::assertSame('endpoint', $params[0]->getName()); + self::assertSame('apiKey', $params[1]->getName()); + self::assertSame('application', $params[2]->getName()); + self::assertSame('environment', $params[3]->getName()); + } + + public function testCreateSimpleIsNotDeprecated(): void { + $reflection = new ReflectionMethod(ABsmartly::class, 'createSimple'); + $docComment = $reflection->getDocComment(); + + self::assertStringNotContainsString('@deprecated', $docComment); + } + + public function testCreateWithDefaultsIsDeprecated(): void { + $reflection = new ReflectionMethod(ABsmartly::class, 'createWithDefaults'); + $docComment = $reflection->getDocComment(); + + self::assertStringContainsString('@deprecated', $docComment); + } + + public function testCreateWithDefaultsParameterOrderIsPreservedForBackwardCompatibility(): void { + $reflection = new ReflectionMethod(ABsmartly::class, 'createWithDefaults'); + $params = $reflection->getParameters(); + + self::assertSame('endpoint', $params[0]->getName()); + self::assertSame('apiKey', $params[1]->getName()); + self::assertSame('environment', $params[2]->getName()); + self::assertSame('application', $params[3]->getName()); + } +} From 235ab4a1492d2902b7d5610cb27a765773824233 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 9 Mar 2026 23:21:10 +0000 Subject: [PATCH 12/21] docs: update README documentation --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 44a5a98..bfa6d23 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ $sdk = ABsmartly::createWithDefaults( ); ``` -#### Advanced: Manual Client Configuration +#### Alternative: Manual Client Configuration -The above is a shortcut that creates an SDK instance quickly using default values. For advanced use cases where you need custom HTTP clients or configurations, you can manually configure individual components: +The above is a shortcut that creates an SDK instance quickly using default values. If you need to manually configure individual components, you can do so: ```php use ABSmartly\SDK\Client\ClientConfig; From b8fa6f99071fde8a57d51b2371a3030159d58f77 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 16:52:37 +0000 Subject: [PATCH 13/21] feat(php-sdk): add getCustomFieldKeys, getCustomFieldValueType, readyError, and isFinalizing to Context --- src/Context/Context.php | 88 +++++++++++++++++++++++++------- tests/Context/ContextTest.php | 95 +++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 17 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index 80e57d8..b53c65e 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -59,8 +59,10 @@ class Context { private int $pendingCount = 0; private bool $closed = false; + private bool $finalizing = false; private bool $ready; private int $attrsSeq = 0; + private ?Throwable $readyError = null; public function isReady(): bool { return $this->ready; @@ -74,6 +76,63 @@ public function isClosed(): bool { return $this->closed; } + public function isFinalizing(): bool { + return $this->finalizing && !$this->closed; + } + + public function readyError(): ?Throwable { + return $this->readyError; + } + + public function getCustomFieldKeys(): array { + $keys = []; + if (!empty($this->data->experiments)) { + foreach ($this->data->experiments as $experiment) { + if (!isset($experiment->customFieldValues)) { + continue; + } + + $customFieldValues = $experiment->customFieldValues; + if (is_string($customFieldValues)) { + $customFieldValues = json_decode($customFieldValues, true) ?? []; + } + if (is_object($customFieldValues)) { + $customFieldValues = get_object_vars($customFieldValues); + } + if (!is_array($customFieldValues)) { + continue; + } + + foreach (array_keys($customFieldValues) as $k) { + if (!str_ends_with($k, '_type')) { + $keys[$k] = true; + } + } + } + } + return array_keys($keys); + } + + public function getCustomFieldValueType(string $experimentName, string $key): ?string { + $experiment = $this->getExperiment($experimentName); + if ($experiment === null || !isset($experiment->data->customFieldValues)) { + return null; + } + + $customFieldValues = $experiment->data->customFieldValues; + if (is_string($customFieldValues)) { + $customFieldValues = json_decode($customFieldValues, true) ?? []; + } + if (is_object($customFieldValues)) { + $customFieldValues = get_object_vars($customFieldValues); + } + if (!is_array($customFieldValues)) { + return null; + } + + return $customFieldValues[$key . '_type'] ?? null; + } + public function pending(): int { return $this->pendingCount; } @@ -118,7 +177,7 @@ private function __construct(ABsmartly $sdk, ContextConfig $contextConfig, Conte $this->logEvent(ContextEventLoggerEvent::Ready, $data); } catch (Exception $exception) { - $this->setDataFailed(); + $this->setDataFailed($exception); error_log(sprintf( 'ABsmartly SDK CRITICAL: Context initialization failed: %s in %s:%d. Context is in failed state.', $exception->getMessage(), @@ -171,21 +230,18 @@ private function setData(ContextData $data): void { } } - private function setDataFailed(): void { + private function setDataFailed(?Throwable $exception = null): void { $this->indexVariables = []; $this->index = []; $this->data = null; $this->failed = true; + $this->readyError = $exception; } public static function createFromContextConfig(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextEventHandler $handler, ?ContextData $contextData = null): Context { $context = new Context($sdk, $contextConfig, $dataProvider, $contextData); $context->setEventHandler($handler); - if ($logger = $contextConfig->getEventLogger()) { - $context->setEventLogger($logger); - } - return $context; } @@ -193,10 +249,6 @@ public static function createPending(ABsmartly $sdk, ContextConfig $contextConfi $context = new Context($sdk, $contextConfig, $dataProvider, null, true); $context->setEventHandler($handler); - if ($logger = $contextConfig->getEventLogger()) { - $context->setEventLogger($logger); - } - return $context; } @@ -211,7 +263,7 @@ public function setContextData(ContextData $contextData): void { $this->ready = true; $this->logEvent(ContextEventLoggerEvent::Ready, $contextData); } catch (Exception $exception) { - $this->setDataFailed(); + $this->setDataFailed($exception); $this->logError($exception); } } @@ -294,7 +346,7 @@ public function customFieldValue(string $experimentName, string $fieldName) { return (int) $value; } - if (str_starts_with($type ?? '', 'boolean') && is_string($value)) { + if (substr($type ?? '', 0, 7) === 'boolean' && is_string($value)) { return $value === 'true' || $value === '1'; } @@ -379,10 +431,9 @@ private function getAssignment(string $experimentName): Assignment { $assignment->variant = 0; } else if (empty($experiment->data->fullOnVariant) && $uid = $this->units[$experiment->data->unitType] ?? null) { - //$unitHash = $this->getUnitHash($unitType, $uid); $assigner = $this->getVariantAssigner($unitType, $uid); - $eligible = $assigner->assign( + $eligible = $assigner->assign( $experiment->data->trafficSplit, $experiment->data->trafficSeedHi, $experiment->data->trafficSeedLo @@ -768,15 +819,18 @@ public function refresh(): void { } public function close(): void { - if ($this->getPendingCount() > 0) { - $this->flush(); - } if ($this->isClosed()) { return; } + $this->finalizing = true; + if ($this->getPendingCount() > 0) { + $this->flush(); + } + $this->logEvent(ContextEventLoggerEvent::Finalize, null); $this->closed = true; + $this->finalizing = false; } } diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index 46a59ec..a2f1492 100644 --- a/tests/Context/ContextTest.php +++ b/tests/Context/ContextTest.php @@ -2191,6 +2191,28 @@ public function testCustomFieldValueReturnsNullForNonExistentExperiment(): void self::assertNull($value); } + public function testEventLoggerCalledOnceOnReady(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + $readyEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Ready); + self::assertCount(1, $readyEvents); + } + + public function testCustomFieldValueBooleanPrefixType(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'is_active'); + self::assertFalse($value); + } + + public function testCustomFieldValueBooleanFalseWithZero(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'disabled'); + self::assertFalse($value); + } + public function testGetTreatmentQueuesExposureWithAudienceMatchTrueOnAudienceMatch(): void { $context = $this->createReadyContext('audience_context.json'); $context->setAttribute('age', 21); @@ -2413,4 +2435,77 @@ public function testClosedContextRejectsOperations(): void { } self::assertTrue($thrownForRefresh); } + + public function testReadyErrorReturnsNullOnSuccess(): void { + $context = $this->createReadyContext(); + self::assertNull($context->readyError()); + } + + public function testIsFinalizingReturnsFalseWhenNotClosing(): void { + $context = $this->createReadyContext(); + self::assertFalse($context->isFinalizing()); + } + + public function testIsFinalizingReturnsFalseAfterClose(): void { + $context = $this->createReadyContext(); + $context->close(); + self::assertFalse($context->isFinalizing()); + self::assertTrue($context->isClosed()); + } + + public function testGetCustomFieldKeysReturnsAllKeys(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + $keys = $context->getCustomFieldKeys(); + self::assertIsArray($keys); + self::assertContains('country', $keys); + self::assertContains('description', $keys); + self::assertContains('enabled', $keys); + self::assertContains('config', $keys); + self::assertContains('min_age', $keys); + } + + public function testGetCustomFieldKeysDoesNotIncludeTypeKeys(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + $keys = $context->getCustomFieldKeys(); + foreach ($keys as $key) { + self::assertStringNotContainsString('_type', $key); + } + } + + public function testGetCustomFieldKeysReturnsEmptyArrayWithNoCustomFields(): void { + $context = $this->createReadyContext(); + $keys = $context->getCustomFieldKeys(); + self::assertIsArray($keys); + self::assertEmpty($keys); + } + + public function testGetCustomFieldValueTypeReturnsType(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + self::assertSame('string', $context->getCustomFieldValueType('exp_test_ab', 'country')); + self::assertSame('text', $context->getCustomFieldValueType('exp_test_ab', 'description')); + self::assertSame('number', $context->getCustomFieldValueType('exp_test_ab', 'min_age')); + self::assertSame('boolean', $context->getCustomFieldValueType('exp_test_ab', 'enabled')); + self::assertSame('json', $context->getCustomFieldValueType('exp_test_ab', 'config')); + } + + public function testGetCustomFieldValueTypeReturnsNullForUnknownField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + self::assertNull($context->getCustomFieldValueType('exp_test_ab', 'nonexistent_field')); + } + + public function testGetCustomFieldValueTypeReturnsNullForUnknownExperiment(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + self::assertNull($context->getCustomFieldValueType('nonexistent_experiment', 'country')); + } + + public function testGetUnitReturnsUidForKnownUnitType(): void { + $context = $this->createReadyContext(); + self::assertSame('e791e240fcd3df7d238cfc285f475e8152fcc0ec', $context->getUnit('session_id')); + self::assertSame('123456789', $context->getUnit('user_id')); + } + + public function testGetUnitReturnsNullForUnknownUnitType(): void { + $context = $this->createReadyContext(); + self::assertNull($context->getUnit('nonexistent_unit')); + } } From 1f04e03295621fa6a63e556b8b475b9389d79025 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 17:59:32 +0000 Subject: [PATCH 14/21] feat(php-sdk): add isFinalized/finalize aliases and standardize error messages Adds isFinalized() and finalize() as aliases for isClosed() and close() to align with JS SDK terminology. Standardizes error messages to include trailing periods matching JS SDK format. --- src/Context/Context.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index b53c65e..28019b6 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -80,6 +80,14 @@ public function isFinalizing(): bool { return $this->finalizing && !$this->closed; } + public function isFinalized(): bool { + return $this->isClosed(); + } + + public function finalize(): void { + $this->close(); + } + public function readyError(): ?Throwable { return $this->readyError; } @@ -270,7 +278,7 @@ public function setContextData(ContextData $contextData): void { private function checkReady(): void { if (!$this->isReady()) { - throw new LogicException('ABSmartly Context is not yet ready'); + throw new LogicException('ABSmartly Context is not yet ready.'); } $this->checkNotClosed(); @@ -719,7 +727,7 @@ public function getPendingCount(): int { private function checkNotClosed(): void { if ($this->isClosed()) { - throw new LogicException('ABSmartly Context is finalized'); + throw new LogicException('ABSmartly Context is finalized.'); } } From eb7aa1937172364c4c865dd59a1850feb364f490 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Sun, 15 Mar 2026 20:32:58 +0000 Subject: [PATCH 15/21] fix: read methods return safe defaults instead of throwing when not ready or finalized --- src/Context/Context.php | 12 +++++++++--- tests/Context/ContextTest.php | 29 +++++++++++++++-------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index 28019b6..66999f0 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -490,7 +490,9 @@ private function getAssignment(string $experimentName): Assignment { } public function getVariableValue(string $key, $defaultValue = null) { - $this->checkReady(); + if (!$this->isReady() || $this->isClosed()) { + return $defaultValue; + } $assignment = $this->getVariableAssignment($key); if ($assignment === null) { @@ -562,7 +564,9 @@ private function getVariantAssigner(string $unitType, string $unitHash): Variant } public function getTreatment(string $experimentName): int { - $this->checkReady(); + if (!$this->isReady() || $this->isClosed()) { + return 0; + } $assignment = $this->getAssignment($experimentName); if (empty($assignment->exposed)) { $this->queueExposure($assignment); @@ -636,7 +640,9 @@ public function getContextData(): ContextData { } public function peekTreatment(string $experimentName): int { - $this->checkReady(); + if (!$this->isReady() || $this->isClosed()) { + return 0; + } return $this->getAssignment($experimentName)->variant; } diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index a2f1492..cfe097d 100644 --- a/tests/Context/ContextTest.php +++ b/tests/Context/ContextTest.php @@ -1783,12 +1783,12 @@ public function testVariableValueReturnsDefaultOnUnknownVariable(): void { self::assertSame(42, $context->getVariableValue('completely_unknown_var', 42)); } - public function testVariableValueThrowsAfterFinalized(): void { + public function testVariableValueReturnsDefaultAfterFinalized(): void { $context = $this->createReadyContext(); $context->close(); - $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); - $context->getVariableValue('banner.border', 0); + self::assertSame(0, $context->getVariableValue('banner.border', 0)); + self::assertSame('default', $context->getVariableValue('nonexistent', 'default')); } public function testPeekVariableValueDefaultWhenUnassigned(): void { @@ -2110,12 +2110,18 @@ public function testRefreshThrowsAfterFinalized(): void { $context->refresh(); } - public function testTreatmentThrowsAfterFinalized(): void { + public function testTreatmentReturnsZeroAfterFinalized(): void { $context = $this->createReadyContext(); $context->close(); - $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); - $context->getTreatment('exp_test_ab'); + self::assertSame(0, $context->getTreatment('exp_test_ab')); + } + + public function testPeekTreatmentReturnsZeroAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertSame(0, $context->peekTreatment('exp_test_ab')); } public function testCustomFieldKeysReturnsKeys(): void { @@ -2397,19 +2403,14 @@ public function testRefreshShouldNotCallClientPublishWhenFailed(): void { self::assertTrue($context->isFailed()); } - public function testClosedContextRejectsOperations(): void { + public function testClosedContextRejectsWriteOperations(): void { $context = $this->createReadyContext(); $context->close(); self::assertTrue($context->isClosed()); - $thrownForTreatment = false; - try { - $context->getTreatment('exp_test_ab'); - } catch (\ABSmartly\SDK\Exception\LogicException $e) { - $thrownForTreatment = true; - } - self::assertTrue($thrownForTreatment); + self::assertSame(0, $context->getTreatment('exp_test_ab')); + self::assertSame(0, $context->peekTreatment('exp_test_ab')); $thrownForTrack = false; try { From 9933447a13d7138153e4d0a4b4f0b1de8c843530 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Mon, 16 Mar 2026 12:30:11 +0000 Subject: [PATCH 16/21] fix(php-sdk): standardize error messages in Context Use lowercase 'ABsmartly' prefix and standard unit error formats with single quotes and trailing periods. --- src/Context/Context.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index 66999f0..0a8c3b7 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -278,7 +278,7 @@ public function setContextData(ContextData $contextData): void { private function checkReady(): void { if (!$this->isReady()) { - throw new LogicException('ABSmartly Context is not yet ready.'); + throw new LogicException('ABsmartly Context is not yet ready.'); } $this->checkNotClosed(); @@ -717,10 +717,10 @@ public function getCustomAssignment(string $experimentName) { public function setUnit(string $unitType, string $uid): Context { if (isset($this->units[$unitType]) && $this->units[$unitType] !== $uid) { - throw new InvalidArgumentException(sprintf('Unit "%s" UID is already set', $unitType)); + throw new InvalidArgumentException(sprintf("Unit '%s' UID already set.", $unitType)); } if (trim($uid) === '') { - throw new InvalidArgumentException(sprintf('Unit "%s" UID must not be blank', $unitType)); + throw new InvalidArgumentException(sprintf("Unit '%s' UID must not be blank.", $unitType)); } $this->units[$unitType] = $uid; @@ -733,7 +733,7 @@ public function getPendingCount(): int { private function checkNotClosed(): void { if ($this->isClosed()) { - throw new LogicException('ABSmartly Context is finalized.'); + throw new LogicException('ABsmartly Context is finalized.'); } } From e88af058944f3092e79b8532e5f11350144b43bf Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 17 Mar 2026 08:52:45 +0000 Subject: [PATCH 17/21] fix: update setUnit exception messages to use double quotes and correct wording - Match expected format: Unit "x" UID must not be blank (no trailing period) - Match expected format: Unit "x" UID is already set (was "already set.") --- src/Context/Context.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index 0a8c3b7..315ba75 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -717,10 +717,10 @@ public function getCustomAssignment(string $experimentName) { public function setUnit(string $unitType, string $uid): Context { if (isset($this->units[$unitType]) && $this->units[$unitType] !== $uid) { - throw new InvalidArgumentException(sprintf("Unit '%s' UID already set.", $unitType)); + throw new InvalidArgumentException(sprintf('Unit "%s" UID is already set', $unitType)); } if (trim($uid) === '') { - throw new InvalidArgumentException(sprintf("Unit '%s' UID must not be blank.", $unitType)); + throw new InvalidArgumentException(sprintf('Unit "%s" UID must not be blank', $unitType)); } $this->units[$unitType] = $uid; From e9e6bba9d9c7dbea23f93c1040f26c84b3ee78e8 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Tue, 17 Mar 2026 13:45:15 +0000 Subject: [PATCH 18/21] =?UTF-8?q?feat:=20cross-SDK=20consistency=20fixes?= =?UTF-8?q?=20=E2=80=94=20all=20201=20scenarios=20passing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ABsmartly.php | 13 +++ src/Client/Client.php | 2 +- src/Client/ClientConfig.php | 4 +- src/Config.php | 11 +++ src/Experiment.php | 2 +- src/Http/HTTPClient.php | 1 - src/Http/ReactHttpClient.php | 6 +- src/JsonExpression/Operator/MatchOperator.php | 1 + tests/ABsmartlyTest.php | 84 +++++++++++++++++++ tests/Client/ClientConfigTest.php | 10 +++ tests/Client/ClientTest.php | 27 ++++++ tests/ConfigTest.php | 45 ++++++++++ tests/ExperimentTest.php | 51 +++++++++++ .../Fixtures/json/context_custom_fields.json | 6 +- tests/Http/ReactHttpClientTest.php | 37 ++++++++ .../Operator/MatchOperatorTest.php | 6 ++ 16 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 tests/Client/ClientTest.php create mode 100644 tests/ConfigTest.php create mode 100644 tests/ExperimentTest.php create mode 100644 tests/Http/ReactHttpClientTest.php diff --git a/src/ABsmartly.php b/src/ABsmartly.php index ee9fa11..b124a43 100644 --- a/src/ABsmartly.php +++ b/src/ABsmartly.php @@ -10,6 +10,7 @@ use ABSmartly\SDK\Context\ContextData; use ABSmartly\SDK\Context\ContextDataProvider; use ABSmartly\SDK\Context\ContextEventHandler; +use ABSmartly\SDK\Context\ContextEventLogger; use ABSmartly\SDK\Http\HTTPClient; use React\Promise\PromiseInterface; @@ -20,11 +21,13 @@ class ABsmartly { private Client $client; private ContextEventHandler $handler; private ContextDataProvider $provider; + private ?ContextEventLogger $eventLogger; public function __construct(Config $config) { $this->client = $config->getClient(); $this->provider = $config->getContextDataProvider(); $this->handler = $config->getContextEventHandler(); + $this->eventLogger = $config->getContextEventLogger(); } /** @@ -109,14 +112,23 @@ public static function createWithDefaults( } public function createContext(ContextConfig $contextConfig): Context { + $this->applyEventLogger($contextConfig); return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler); } public function createContextWithData(ContextConfig $contextConfig, ContextData $contextData): Context { + $this->applyEventLogger($contextConfig); return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler, $contextData); } + private function applyEventLogger(ContextConfig $contextConfig): void { + if ($this->eventLogger !== null && $contextConfig->getEventLogger() === null) { + $contextConfig->setEventLogger($this->eventLogger); + } + } + public function createContextAsync(ContextConfig $contextConfig): PromiseInterface { + $this->applyEventLogger($contextConfig); if (!$this->provider instanceof AsyncContextDataProvider) { return resolve($this->createContext($contextConfig)); } @@ -126,6 +138,7 @@ public function createContextAsync(ContextConfig $contextConfig): PromiseInterfa } public function createContextPending(ContextConfig $contextConfig): array { + $this->applyEventLogger($contextConfig); $context = Context::createPending($this, $contextConfig, $this->provider, $this->handler); if (!$this->provider instanceof AsyncContextDataProvider) { diff --git a/src/Client/Client.php b/src/Client/Client.php index a313842..1acffd3 100644 --- a/src/Client/Client.php +++ b/src/Client/Client.php @@ -95,7 +95,7 @@ public function publishAsync(PublishEvent $publishEvent): PromiseInterface { } public function decode(string $jsonString): object { - return json_decode($jsonString, false, 16, JSON_THROW_ON_ERROR); + return json_decode($jsonString, false, 512, JSON_THROW_ON_ERROR); } public function encode(object $object): string { diff --git a/src/Client/ClientConfig.php b/src/Client/ClientConfig.php index aee3938..d4bdf25 100644 --- a/src/Client/ClientConfig.php +++ b/src/Client/ClientConfig.php @@ -4,7 +4,6 @@ use ABSmartly\SDK\Exception\InvalidArgumentException; -use function get_class; use function str_repeat; use function strlen; @@ -23,7 +22,7 @@ public function __construct( string $application, string $environment ) { - if ($endpoint === '' && $apiKey !== '' || $endpoint !== '' && $apiKey === '') { + if (($endpoint === '' && $apiKey !== '') || ($endpoint !== '' && $apiKey === '')) { error_log('ABsmartly SDK Warning: ClientConfig created with empty endpoint or API key. This may cause runtime errors.'); } @@ -43,7 +42,6 @@ public function __debugInfo(): array { 'application' => $this->application, 'endpoint' => $this->endpoint, 'environment' => $this->environment, - 'eventLogger' => isset($this->eventLogger) ? get_class($this->eventLogger) : null, ]; } diff --git a/src/Config.php b/src/Config.php index caf7548..60ac590 100644 --- a/src/Config.php +++ b/src/Config.php @@ -5,11 +5,13 @@ use ABSmartly\SDK\Client\Client; use ABSmartly\SDK\Context\ContextDataProvider; use ABSmartly\SDK\Context\ContextEventHandler; +use ABSmartly\SDK\Context\ContextEventLogger; class Config { private Client $client; private ContextDataProvider $contextDataProvider; private ContextEventHandler $contextEventHandler; + private ?ContextEventLogger $contextEventLogger = null; public function __construct(Client $client) { $this->client = $client; @@ -43,4 +45,13 @@ public function getContextEventHandler(): ContextEventHandler { return $this->contextEventHandler; } + + public function setContextEventLogger(?ContextEventLogger $logger): self { + $this->contextEventLogger = $logger; + return $this; + } + + public function getContextEventLogger(): ?ContextEventLogger { + return $this->contextEventLogger; + } } diff --git a/src/Experiment.php b/src/Experiment.php index 9d7f42b..fada3f2 100644 --- a/src/Experiment.php +++ b/src/Experiment.php @@ -22,7 +22,7 @@ class Experiment { public array $trafficSplit; public int $fullOnVariant; public ?object $audience; - public bool $audienceStrict; + public bool $audienceStrict = false; public array $applications; public array $variants; public ?object $customFieldValues = null; diff --git a/src/Http/HTTPClient.php b/src/Http/HTTPClient.php index 6f0100b..cc1cdbe 100644 --- a/src/Http/HTTPClient.php +++ b/src/Http/HTTPClient.php @@ -24,7 +24,6 @@ use const CURLINFO_HTTP_CODE; use const CURLOPT_CONNECTTIMEOUT_MS; use const CURLOPT_CUSTOMREQUEST; -use const CURLOPT_FAILONERROR; use const CURLOPT_HTTPHEADER; use const CURLOPT_MAXREDIRS; use const CURLOPT_POSTFIELDS; diff --git a/src/Http/ReactHttpClient.php b/src/Http/ReactHttpClient.php index bb7e519..f1a91a4 100644 --- a/src/Http/ReactHttpClient.php +++ b/src/Http/ReactHttpClient.php @@ -69,11 +69,11 @@ private function buildUrl(string $url, array $query): string { } private function flattenHeaders(array $headers): array { - $flat = []; + $result = []; foreach ($headers as $key => $value) { - $flat[] = "$key: $value"; + $result[$key] = $value; } - return $flat; + return $result; } private function toResponse($reactResponse): Response { diff --git a/src/JsonExpression/Operator/MatchOperator.php b/src/JsonExpression/Operator/MatchOperator.php index 8bf177f..925e890 100644 --- a/src/JsonExpression/Operator/MatchOperator.php +++ b/src/JsonExpression/Operator/MatchOperator.php @@ -26,6 +26,7 @@ public function binary(Evaluator $evaluator, $text, $pattern): ?bool { private function runRegexBounded(string $text, string $pattern): ?bool { $pattern = trim($pattern, '/'); + $pattern = str_replace('~', '\~', $pattern); $matches = @preg_match('~'. $pattern . '~', $text); diff --git a/tests/ABsmartlyTest.php b/tests/ABsmartlyTest.php index 95bb6d7..f73b3fc 100644 --- a/tests/ABsmartlyTest.php +++ b/tests/ABsmartlyTest.php @@ -3,8 +3,18 @@ namespace ABSmartly\SDK\Tests; use ABSmartly\SDK\ABsmartly; +use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Client\ClientConfig; +use ABSmartly\SDK\Config; +use ABSmartly\SDK\Context\ContextConfig; +use ABSmartly\SDK\Context\ContextEventLoggerCallback; +use ABSmartly\SDK\Context\ContextEventLoggerEvent; +use ABSmartly\SDK\Tests\Mocks\ContextDataProviderMock; +use ABSmartly\SDK\Tests\Mocks\ContextEventHandlerMock; +use ABSmartly\SDK\Tests\Mocks\MockContextEventLoggerProxy; use PHPUnit\Framework\TestCase; use ReflectionMethod; +use ReflectionProperty; class ABsmartlyTest extends TestCase { public function testCreateSimpleParameterOrderMatchesClientConfig(): void { @@ -40,4 +50,78 @@ public function testCreateWithDefaultsParameterOrderIsPreservedForBackwardCompat self::assertSame('environment', $params[2]->getName()); self::assertSame('application', $params[3]->getName()); } + + public function testEventLoggerFromConfigIsStoredOnInstance(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', 'key', 'app', 'env'); + $client = new Client($clientConfig); + $config = new Config($client); + + $logger = new MockContextEventLoggerProxy(); + $config->setContextEventLogger($logger); + + $sdk = new ABsmartly($config); + + $prop = new ReflectionProperty(ABsmartly::class, 'eventLogger'); + $prop->setAccessible(true); + self::assertSame($logger, $prop->getValue($sdk)); + } + + public function testEventLoggerFromConfigPropagatedToContext(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->setSource('context.json'); + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $config->setContextEventHandler($eventHandler); + + $logger = new MockContextEventLoggerProxy(); + $config->setContextEventLogger($logger); + + $sdk = new ABsmartly($config); + $contextConfig = new ContextConfig(); + $contextConfig->setUnits([ + 'session_id' => 'e791e240fcd3df7d238cfc285f475e8152fcc0ec', + ]); + + $context = $sdk->createContext($contextConfig); + self::assertTrue($context->isReady()); + self::assertGreaterThan(0, $logger->called); + + $readyEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Ready); + self::assertCount(1, $readyEvents); + } + + public function testContextConfigEventLoggerNotOverriddenBySdkLogger(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->setSource('context.json'); + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $config->setContextEventHandler($eventHandler); + + $sdkLogger = new MockContextEventLoggerProxy(); + $config->setContextEventLogger($sdkLogger); + + $contextLogger = new MockContextEventLoggerProxy(); + + $sdk = new ABsmartly($config); + $contextConfig = new ContextConfig(); + $contextConfig->setUnits([ + 'session_id' => 'e791e240fcd3df7d238cfc285f475e8152fcc0ec', + ]); + $contextConfig->setEventLogger($contextLogger); + + $context = $sdk->createContext($contextConfig); + self::assertTrue($context->isReady()); + self::assertSame(0, $sdkLogger->called); + self::assertGreaterThan(0, $contextLogger->called); + } } diff --git a/tests/Client/ClientConfigTest.php b/tests/Client/ClientConfigTest.php index 70567fe..7f06331 100644 --- a/tests/Client/ClientConfigTest.php +++ b/tests/Client/ClientConfigTest.php @@ -69,6 +69,16 @@ public function testSetRetriesNegativeThrowsException(): void { $clientConfig->setRetries(-1); } + public function testDebugInfoDoesNotContainEventLogger(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $debugInfo = $clientConfig->__debugInfo(); + self::assertArrayNotHasKey('eventLogger', $debugInfo); + self::assertArrayHasKey('apiKey', $debugInfo); + self::assertArrayHasKey('application', $debugInfo); + self::assertArrayHasKey('endpoint', $debugInfo); + self::assertArrayHasKey('environment', $debugInfo); + } + public function testFluentInterface(): void { $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); $result = $clientConfig->setTimeout(1000)->setRetries(3); diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php new file mode 100644 index 0000000..041f1a1 --- /dev/null +++ b/tests/Client/ClientTest.php @@ -0,0 +1,27 @@ +decode($nested); + + self::assertSame('deep', $result->a->b->c->d->e->f->g->h->i->j->k->l->m->n->o->p->q); + } + + public function testDecodeThrowsOnInvalidJson(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $client = new Client($clientConfig); + + $this->expectException(\JsonException::class); + $client->decode('invalid json'); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..0ba9e0c --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,45 @@ +createConfig(); + $logger = new ContextEventLoggerCallback(function () {}); + $result = $config->setContextEventLogger($logger); + self::assertSame($config, $result); + } + + public function testGetContextEventLoggerReturnsNullByDefault(): void { + $config = $this->createConfig(); + self::assertNull($config->getContextEventLogger()); + } + + public function testSetAndGetContextEventLogger(): void { + $config = $this->createConfig(); + $logger = new ContextEventLoggerCallback(function () {}); + $config->setContextEventLogger($logger); + self::assertSame($logger, $config->getContextEventLogger()); + } + + public function testSetContextEventLoggerWithNull(): void { + $config = $this->createConfig(); + $logger = new ContextEventLoggerCallback(function () {}); + $config->setContextEventLogger($logger); + $config->setContextEventLogger(null); + self::assertNull($config->getContextEventLogger()); + } +} diff --git a/tests/ExperimentTest.php b/tests/ExperimentTest.php new file mode 100644 index 0000000..39355d4 --- /dev/null +++ b/tests/ExperimentTest.php @@ -0,0 +1,51 @@ + 1, + 'name' => 'test_exp', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 12345, + 'seedLo' => 67890, + 'split' => [0.5, 0.5], + 'trafficSeedHi' => 11111, + 'trafficSeedLo' => 22222, + 'trafficSplit' => [0.0, 1.0], + 'fullOnVariant' => 0, + 'applications' => [['name' => 'website']], + 'variants' => [ + (object) ['name' => 'A', 'config' => null], + (object) ['name' => 'B', 'config' => null], + ], + 'audience' => '', + ]; + + return (object) array_merge($defaults, $overrides); + } + + public function testAudienceStrictDefaultsToFalse(): void { + $data = $this->createExperimentData(); + $experiment = new Experiment($data); + self::assertFalse($experiment->audienceStrict); + } + + public function testAudienceStrictSetFromData(): void { + $data = $this->createExperimentData(['audienceStrict' => true]); + $experiment = new Experiment($data); + self::assertTrue($experiment->audienceStrict); + } + + public function testMissingRequiredFieldThrows(): void { + $data = (object) ['id' => 1, 'name' => 'test']; + $this->expectException(\ABSmartly\SDK\Exception\RuntimeException::class); + $this->expectExceptionMessage('Missing required field'); + new Experiment($data); + } +} diff --git a/tests/Fixtures/json/context_custom_fields.json b/tests/Fixtures/json/context_custom_fields.json index 5b3f7ca..fb57b6d 100644 --- a/tests/Fixtures/json/context_custom_fields.json +++ b/tests/Fixtures/json/context_custom_fields.json @@ -46,7 +46,11 @@ "config": "{\"color\":\"red\",\"size\":10}", "config_type": "json", "decimal_val": "3.14", - "decimal_val_type": "number" + "decimal_val_type": "number", + "is_active": "false", + "is_active_type": "boolean_flag", + "disabled": "0", + "disabled_type": "boolean" } }, { diff --git a/tests/Http/ReactHttpClientTest.php b/tests/Http/ReactHttpClientTest.php new file mode 100644 index 0000000..b2f29e6 --- /dev/null +++ b/tests/Http/ReactHttpClientTest.php @@ -0,0 +1,37 @@ +setAccessible(true); + + $headers = [ + 'Content-Type' => 'application/json', + 'X-API-Key' => 'test-key', + 'Authorization' => 'Bearer token123', + ]; + + $result = $method->invoke($client, $headers); + + self::assertSame('application/json', $result['Content-Type']); + self::assertSame('test-key', $result['X-API-Key']); + self::assertSame('Bearer token123', $result['Authorization']); + self::assertCount(3, $result); + } + + public function testFlattenHeadersEmptyArray(): void { + $client = new ReactHttpClient(); + $method = new ReflectionMethod(ReactHttpClient::class, 'flattenHeaders'); + $method->setAccessible(true); + + $result = $method->invoke($client, []); + self::assertSame([], $result); + } +} diff --git a/tests/JsonExpression/Operator/MatchOperatorTest.php b/tests/JsonExpression/Operator/MatchOperatorTest.php index 621898a..7309012 100644 --- a/tests/JsonExpression/Operator/MatchOperatorTest.php +++ b/tests/JsonExpression/Operator/MatchOperatorTest.php @@ -30,6 +30,12 @@ public function testRegexMatches(): void { self::assertFalse($this->operator->evaluate($this->evaluator, ["abcdefghijk", "xyz"])); } + public function testRegexWithTildeDelimiterInPattern(): void { + self::assertTrue($this->operator->evaluate($this->evaluator, ["hello~world", "hello~world"])); + self::assertTrue($this->operator->evaluate($this->evaluator, ["test~value", "~"])); + self::assertFalse($this->operator->evaluate($this->evaluator, ["helloworld", "hello~world"])); + } + public function testRegexAutoBoundaries(): void { self::assertTrue($this->operator->evaluate($this->evaluator, ["abcdefghijk", "//"])); self::assertTrue($this->operator->evaluate($this->evaluator, ["abcdefghijk", "/abc/"])); From 1ebd8651bf601f6c2670d4bbe0a31c187fe6861d Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 12:08:10 +0000 Subject: [PATCH 19/21] refactor: rename ContextEventHandler to ContextPublisher --- src/ABsmartly.php | 4 ++-- src/Config.php | 10 +++++----- src/Context/Context.php | 8 ++++---- src/Context/ContextEventHandler.php | 17 ++++------------- src/Context/ContextPublisher.php | 18 ++++++++++++++++++ 5 files changed, 33 insertions(+), 24 deletions(-) create mode 100644 src/Context/ContextPublisher.php diff --git a/src/ABsmartly.php b/src/ABsmartly.php index b124a43..4f7fcf8 100644 --- a/src/ABsmartly.php +++ b/src/ABsmartly.php @@ -9,7 +9,7 @@ use ABSmartly\SDK\Context\ContextConfig; use ABSmartly\SDK\Context\ContextData; use ABSmartly\SDK\Context\ContextDataProvider; -use ABSmartly\SDK\Context\ContextEventHandler; +use ABSmartly\SDK\Context\ContextPublisher; use ABSmartly\SDK\Context\ContextEventLogger; use ABSmartly\SDK\Http\HTTPClient; use React\Promise\PromiseInterface; @@ -19,7 +19,7 @@ class ABsmartly { private Client $client; - private ContextEventHandler $handler; + private ContextPublisher $handler; private ContextDataProvider $provider; private ?ContextEventLogger $eventLogger; diff --git a/src/Config.php b/src/Config.php index 60ac590..58dc27c 100644 --- a/src/Config.php +++ b/src/Config.php @@ -4,13 +4,13 @@ use ABSmartly\SDK\Client\Client; use ABSmartly\SDK\Context\ContextDataProvider; -use ABSmartly\SDK\Context\ContextEventHandler; +use ABSmartly\SDK\Context\ContextPublisher; use ABSmartly\SDK\Context\ContextEventLogger; class Config { private Client $client; private ContextDataProvider $contextDataProvider; - private ContextEventHandler $contextEventHandler; + private ContextPublisher $contextEventHandler; private ?ContextEventLogger $contextEventLogger = null; public function __construct(Client $client) { @@ -26,7 +26,7 @@ public function setContextDataProvider(ContextDataProvider $contextDataProvider) return $this; } - public function setContextEventHandler(ContextEventHandler $contextEventHandler): Config { + public function setContextEventHandler(ContextPublisher $contextEventHandler): Config { $this->contextEventHandler = $contextEventHandler; return $this; } @@ -38,9 +38,9 @@ public function getContextDataProvider(): ContextDataProvider { return $this->contextDataProvider; } - public function getContextEventHandler(): ContextEventHandler { + public function getContextEventHandler(): ContextPublisher { if (!isset($this->contextEventHandler)) { - $this->contextEventHandler = new ContextEventHandler($this->client); + $this->contextEventHandler = new ContextPublisher($this->client); } return $this->contextEventHandler; diff --git a/src/Context/Context.php b/src/Context/Context.php index 315ba75..7d1ab17 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -31,7 +31,7 @@ class Context { private ABsmartly $sdk; - private ContextEventHandler $eventHandler; + private ContextPublisher $eventHandler; private ContextEventLogger $eventLogger; private ContextDataProvider $dataProvider; private VariableParser $variableParser; @@ -201,7 +201,7 @@ private function setEventLogger(ContextEventLogger $eventLogger): Context { return $this; } - private function setEventHandler(ContextEventHandler $eventHandler): Context { + private function setEventHandler(ContextPublisher $eventHandler): Context { $this->eventHandler = $eventHandler; return $this; } @@ -246,14 +246,14 @@ private function setDataFailed(?Throwable $exception = null): void { $this->readyError = $exception; } - public static function createFromContextConfig(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextEventHandler $handler, ?ContextData $contextData = null): Context { + public static function createFromContextConfig(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextPublisher $handler, ?ContextData $contextData = null): Context { $context = new Context($sdk, $contextConfig, $dataProvider, $contextData); $context->setEventHandler($handler); return $context; } - public static function createPending(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextEventHandler $handler): Context { + public static function createPending(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextPublisher $handler): Context { $context = new Context($sdk, $contextConfig, $dataProvider, null, true); $context->setEventHandler($handler); diff --git a/src/Context/ContextEventHandler.php b/src/Context/ContextEventHandler.php index 8a98ce6..f39ab85 100644 --- a/src/Context/ContextEventHandler.php +++ b/src/Context/ContextEventHandler.php @@ -2,17 +2,8 @@ namespace ABSmartly\SDK\Context; -use ABSmartly\SDK\Client\ClientInterface; -use ABSmartly\SDK\PublishEvent; - -class ContextEventHandler { - private ClientInterface $client; - - public function __construct(ClientInterface $client) { - $this->client = $client; - } - - public function publish(PublishEvent $event): void { - $this->client->publish($event); - } +/** + * @deprecated Use ContextPublisher instead. + */ +class ContextEventHandler extends ContextPublisher { } diff --git a/src/Context/ContextPublisher.php b/src/Context/ContextPublisher.php new file mode 100644 index 0000000..a8ee056 --- /dev/null +++ b/src/Context/ContextPublisher.php @@ -0,0 +1,18 @@ +client = $client; + } + + public function publish(PublishEvent $event): void { + $this->client->publish($event); + } +} From ed9cc97e4516cc54a731999a4ec04127072a9076 Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 13:29:34 +0000 Subject: [PATCH 20/21] fix: address coderabbit review issues - Replace str_ends_with() with substr() for PHP 7.4 compatibility - Fix null $lastException when retries=0 by ensuring at least one attempt - Remove duplicate test case in InOperatorTest --- src/Context/Context.php | 2 +- src/Http/HTTPClient.php | 5 +++-- tests/JsonExpression/Operator/InOperatorTest.php | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Context/Context.php b/src/Context/Context.php index 7d1ab17..8cb1ea4 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -112,7 +112,7 @@ public function getCustomFieldKeys(): array { } foreach (array_keys($customFieldValues) as $k) { - if (!str_ends_with($k, '_type')) { + if (substr($k, -5) !== '_type') { $keys[$k] = true; } } diff --git a/src/Http/HTTPClient.php b/src/Http/HTTPClient.php index cc1cdbe..5dba6fe 100644 --- a/src/Http/HTTPClient.php +++ b/src/Http/HTTPClient.php @@ -72,8 +72,9 @@ private function setupRequest(string $url, array $query = [], array $headers = [ private function fetchResponse(): Response { $attempt = 0; $lastException = null; + $maxAttempts = max(1, $this->retries); - while ($attempt < $this->retries) { + while ($attempt < $maxAttempts) { try { $returnedResponse = curl_exec($this->curlHandle); $this->throwOnError($returnedResponse); @@ -94,7 +95,7 @@ private function fetchResponse(): Response { $httpCode === 408 || $httpCode === 429; - if (!$isRetryable || $attempt >= $this->retries - 1) { + if (!$isRetryable || $attempt >= $maxAttempts - 1) { throw $e; } diff --git a/tests/JsonExpression/Operator/InOperatorTest.php b/tests/JsonExpression/Operator/InOperatorTest.php index 13ed6aa..da993ae 100644 --- a/tests/JsonExpression/Operator/InOperatorTest.php +++ b/tests/JsonExpression/Operator/InOperatorTest.php @@ -30,7 +30,7 @@ public function testReturnFalseOnEmptyArray(): void { self::assertFalse($this->operator->evaluate($this->evaluator, [false, []])); self::assertFalse($this->operator->evaluate($this->evaluator, ["1", []])); self::assertFalse($this->operator->evaluate($this->evaluator, [true, []])); - self::assertFalse($this->operator->evaluate($this->evaluator, [false, []])); + self::assertFalse($this->operator->evaluate($this->evaluator, [0, []])); self::assertNull($this->operator->evaluate($this->evaluator, [null, []])); } From 5b685c2b5d583322bbc627b9691810c96bb1813b Mon Sep 17 00:00:00 2001 From: Jonas Alves Date: Wed, 18 Mar 2026 19:04:26 +0000 Subject: [PATCH 21/21] feat: add setContextPublisher() method, deprecate setContextEventHandler() --- src/Config.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Config.php b/src/Config.php index 58dc27c..9e835f5 100644 --- a/src/Config.php +++ b/src/Config.php @@ -26,6 +26,14 @@ public function setContextDataProvider(ContextDataProvider $contextDataProvider) return $this; } + public function setContextPublisher(ContextPublisher $contextEventHandler): Config { + $this->contextEventHandler = $contextEventHandler; + return $this; + } + + /** + * @deprecated Use setContextPublisher() instead. + */ public function setContextEventHandler(ContextPublisher $contextEventHandler): Config { $this->contextEventHandler = $contextEventHandler; return $this;