diff --git a/README.md b/README.md index 1e5aaea..aa12d8f 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\HTTPClient; +use ABSmartly\SDK\Config; +use ABSmartly\SDK\ABsmartly; +use ABSmartly\SDK\Context\ContextConfig; + +$httpClient = new HTTPClient(); +$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. 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..14421d2 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,17 +313,17 @@ 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) { //$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; @@ -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,17 @@ public function getVariableValue(string $key, $defaultValue = null) { return $defaultValue; } - if (empty($assignment->exposed)) { - $this->queueExposure($assignment); + if ($assignment->variables !== null) { + if (empty($assignment->exposed)) { + $this->queueExposure($assignment); + } + + if (isset($assignment->variables->{$key}) && ($assignment->assigned || $assignment->overridden)) { + return $assignment->variables->{$key}; + } } - return $assignment->variables->{$key} ?? $defaultValue; + return $defaultValue; } public function getVariableKeys(): array { @@ -327,12 +398,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 +529,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) { @@ -520,7 +600,7 @@ 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'); } } @@ -595,7 +675,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)) { 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/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/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/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 78bbe92..f5b5d45 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 { @@ -1140,4 +1140,1277 @@ 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); + } + + 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/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]])); } } 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 {