diff --git a/.gitignore b/.gitignore index b126028..a7a081c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,8 @@ composer.lock coverage.xml .phpunit.cache phpunit.xml + +.claude/ +.DS_Store +AUDIT_REPORT.md +FIXES_IMPLEMENTED.md diff --git a/README.md b/README.md index 1e5aaea..bfa6d23 100644 --- a/README.md +++ b/README.md @@ -1,199 +1,213 @@ -# A/B Smartly SDK +# A/B Smartly PHP SDK -A/B Smartly PHP SDK +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. -## Getting Started +### 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. -### Install the SDK +## Installation 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 +## Getting Started -Once the SDK is installed, it can be initialized in your project. +Please follow the [installation](#installation) instructions before trying the following code. -You can create an SDK instance using the API key, application name, environment, and the endpoint URL obtained from A/B Smartly. +### Initialization -```php -use \ABSmartly\SDK\SDK; +This example assumes an API Key, an Application, and an Environment have been created in the A/B Smartly web console. -$sdk = SDK::createWithDefaults( +#### Recommended: Simple API + +Once the SDK is installed, it can be initialized in your project using the simple API: + +```php +use ABSmartly\SDK\ABsmartly; + +$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 +); +``` -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: +#### Alternative: Manual Client Configuration -```php -use ABSmartly\SDK\Client\ClientConfig; -use ABSmartly\SDK\Client\Client; -use ABSmartly\SDK\Config; -use ABSmartly\SDK\SDK; -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); - -$contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(new ContextEventLoggerCallback( - function (string $event, ?object $data) { - // Custom callback - } -)); +The above is a shortcut that creates an SDK instance quickly using default values. If you need to manually configure individual components, you can do so: -$context = $sdk->createContext($contextConfig); -``` +```php +use ABSmartly\SDK\Client\ClientConfig; +use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Config; +use ABSmartly\SDK\ABsmartly; -**SDK Options** +$clientConfig = new ClientConfig($endpoint, $apiKey, $application, $environment); +$client = new Client($clientConfig); +$config = new Config($client); -| 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. +$sdk = new ABsmartly($config); +``` -### Using a Custom Event Logger +#### Using Async HTTP Client -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`. +For non-blocking operations, you can use the ReactPHP-based async HTTP client: -```php +```php use ABSmartly\SDK\Client\ClientConfig; -use ABSmartly\SDK\Context\ContextEventLoggerCallback; +use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Http\ReactHttpClient; +use ABSmartly\SDK\Config; +use ABSmartly\SDK\ABsmartly; -$contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(new ContextEventLoggerCallback( - function (string $event, ?object $data) { - // Custom callback - } -)); -``` +$clientConfig = new ClientConfig($endpoint, $apiKey, $application, $environment); -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: +$reactHttpClient = new ReactHttpClient(); +$reactHttpClient->timeout = 3000; +$reactHttpClient->retries = 5; -```php -use \ABSmartly\SDK\Context\ContextEventLoggerCallback; - -class CustomLogger implements ContextEventLogger { - public function handleEvent(Context $context, ContextEventLoggerEvent $event): void { - // Process the log event - // e.g - // myLogFunction($event->getEvent(), $event->getData()); - } -} - -$contextConfig = new ContextConfig(); -$contextConfig->setEventLogger(CustomLogger()); -``` +$client = new Client($clientConfig, $reactHttpClient); +$config = new Config($client); -The data parameter depends on the type of event. Currently, the SDK logs the following events: +$sdk = new ABsmartly($config); +``` -| 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` | +The async HTTP client uses ReactPHP promises and allows for non-blocking I/O operations. -## Create a New Context Request +**SDK Options** -**Synchronously** +| 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 [Custom Event Logger](#custom-event-logger) below. | +| `contextDataProvider` | `ContextDataProvider` | ❌ | auto | Custom provider for context data (advanced usage) | +| `contextEventHandler` | `ContextEventHandler` | ❌ | auto | Custom handler for publishing events (advanced usage) | + +## Creating a New Context + +### 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); -``` +``` -**With Prefetched Data** +### Asynchronously (with ReactPHP) + +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); + +$context->ready()->then( + function($context) { + $treatment = $context->getTreatment('exp_test_experiment'); + }, + function($error) { + error_log('Context failed: ' . $error->getMessage()); + } +); +``` + +### With Pre-fetched Data + +When doing full-stack experimentation with A/B Smartly, we recommend creating a context only once on the server-side. Creating a context involves a round-trip to the A/B Smartly event collector. We can avoid repeating the round-trip on the client-side by re-using data previously retrieved. + +```php +use ABSmartly\SDK\Context\ContextConfig; + +$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()); -``` +``` -**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); -``` + +$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,81 +219,94 @@ $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, +]); +``` ## 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); ``` -### Publish +### Tracking Goals -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. +Goals are created in the A/B Smartly Web Console. + +```php +$context->track('payment', (object) [ + 'item_count' => 1, + 'total_amount' => 1999.99 +]); +``` + +### Publishing Pending Data + +Sometimes it is necessary to ensure all events have been published to the A/B Smartly collector before proceeding. You can explicitly call the `Context->publish()` method. ```php $context->publish(); -``` +``` -### Finalize +With async HTTP client: + +```php +$context->publish()->then(function() { + header('Location: https://www.absmartly.com'); +}); +``` + +### Finalizing The `close()` method will ensure all events have been published to the A/B Smartly collector, like `Context->publish()`, and will also "seal" the context, throwing an error if any method that could generate an event is called. @@ -287,11 +314,358 @@ 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->close()->then(function() { + header('Location: https://www.absmartly.com'); +}); +``` + +### Custom Event Logger + +The A/B Smartly SDK can be instantiated with an event logger used for all contexts. In addition, an event logger can be specified when creating a particular context in the `ContextConfig`. + +#### Simple Callback Approach + +```php +use ABSmartly\SDK\Context\ContextConfig; +use ABSmartly\SDK\Context\ContextEventLoggerCallback; + +$contextConfig = new ContextConfig(); +$contextConfig->setEventLogger(new ContextEventLoggerCallback( + function (string $event, ?object $data) { + if ($event === 'Error') { + error_log('ABSmartly Error: ' . print_r($data, true)); + } + } +)); +``` + +#### Interface Implementation Approach + +Alternatively, you can implement the `ContextEventLogger` interface with a `handleEvent()` method that receives the `Context` object itself, along with a `ContextEventLoggerEvent` object: + +```php +use ABSmartly\SDK\Context\Context; +use ABSmartly\SDK\Context\ContextEventLogger; +use ABSmartly\SDK\Context\ContextEventLoggerEvent; + +class CustomLogger implements ContextEventLogger { + public function handleEvent(Context $context, ContextEventLoggerEvent $event): void { + $eventName = $event->getEvent(); + $eventData = $event->getData(); + + switch ($eventName) { + case 'Exposure': + break; + case 'Goal': + break; + case 'Error': + error_log('ABSmartly Error: ' . print_r($eventData, true)); + break; + } + } +} + +$contextConfig = new ContextConfig(); +$contextConfig->setEventLogger(new CustomLogger()); +``` + +**Event Types** + +The data parameter depends on the type of event. Currently, the SDK logs the following events: + +| Event | When | Data | +| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------- | +| `Error` | `Context` receives an error | `Exception` object thrown | +| `Ready` | `Context` turns ready | `ContextData` object used to initialize the context | +| `Refresh` | `Context->refresh()` method succeeds | `ContextData` used to refresh the context | +| `Publish` | `Context->publish()` method succeeds | `PublishEvent` data sent to the A/B Smartly event collector | +| `Exposure` | `Context->getTreatment()` method succeeds on first exposure | `Exposure` data enqueued for publishing | +| `Goal` | `Context->track()` method succeeds | `GoalAchievement` goal data enqueued for publishing | +| `Close` | `Context->close()` method succeeds the first time | `null` | + +## Platform-Specific Examples + +### 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 -$context->track( - 'payment', - (object) ['item_count' => 1, 'total_amount' => 1999.99] +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, $application, $environment); +$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, $application, $environment); +$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"; + } ); ``` + +## 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) (this package) +- [Swift SDK](https://www.github.com/absmartly/swift-sdk) +- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) +- [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) diff --git a/composer.json b/composer.json index 91f5744..9255228 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,15 @@ "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^9.5.26" + "phpunit/phpunit": "^9.5.26", + "react/http": "^1.9", + "react/async": "^4.0", + "react/promise": "^3.0" + }, + "suggest": { + "react/http": "Required for async HTTP support with ReactPHP", + "react/async": "Required for sync-over-async operations with ReactPHP", + "react/promise": "Required for async operations" }, "license": "MIT", "autoload": { @@ -27,5 +35,8 @@ "email": "ayesh@aye.sh" } ], - "keywords": ["absmartly"] + "keywords": ["absmartly"], + "scripts": { + "test": "phpunit" + } } diff --git a/src/ABsmartly.php b/src/ABsmartly.php new file mode 100644 index 0000000..4f7fcf8 --- /dev/null +++ b/src/ABsmartly.php @@ -0,0 +1,164 @@ +client = $config->getClient(); + $this->provider = $config->getContextDataProvider(); + $this->handler = $config->getContextEventHandler(); + $this->eventLogger = $config->getContextEventLogger(); + } + + /** + * @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io". + * @param string $apiKey API key which can be found on the Web Console. + * @param string $application Name of the application where the SDK is installed. Applications are created on the + * Web Console and should match the applications where your experiments will be running. + * @param string $environment Environment of the platform where the SDK is installed. Environments are created on + * the Web Console and should match the available environments in your infrastructure. + * @param int $retries The number of retries before the SDK stops trying to connect. + * @param int $timeout Amount of time, in milliseconds, before the SDK will stop trying to connect. + * @param callable|null $eventLogger A callback function which runs after SDK events. + * @return ABsmartly ABsmartly instance created using the credentials and details above. + */ + public static function createSimple( + string $endpoint, + string $apiKey, + string $application, + string $environment, + int $retries = 5, + int $timeout = 3000, + ?callable $eventLogger = null + ): ABsmartly { + $clientConfig = new ClientConfig( + $endpoint, + $apiKey, + $application, + $environment, + ); + $clientConfig->setRetries($retries); + $clientConfig->setTimeout($timeout); + + $client = new Client($clientConfig, new HTTPClient()); + $sdkConfig = new Config($client); + if ($eventLogger !== null) { + $sdkConfig->setContextEventLogger(new \ABSmartly\SDK\Context\ContextEventLoggerCallback($eventLogger)); + } + return new ABsmartly($sdkConfig); + } + + /** + * @deprecated Use createSimple() instead. This method has $environment and $application in the wrong order + * relative to ClientConfig::__construct(). createSimple() fixes this with the correct order: + * endpoint, apiKey, application, environment. + * + * @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io". + * @param string $apiKey API key which can be found on the Web Console. + * @param string $environment Environment of the platform where the SDK is installed. Environments are created on + * the Web Console and should match the available environments in your infrastructure. + * @param string $application Name of the application where the SDK is installed. Applications are created on the + * Web Console and should match the applications where your experiments will be running. + * @param int $retries The number of retries before the SDK stops trying to connect. + * @param int $timeout Amount of time, in milliseconds, before the SDK will stop trying to connect. + * @param callable|null $eventLogger A callback function which runs after SDK events. + * @return ABsmartly ABsmartly instance created using the credentials and details above. + */ + public static function createWithDefaults( + string $endpoint, + string $apiKey, + string $environment, + string $application, + int $retries = 5, + int $timeout = 3000, + ?callable $eventLogger = null + ): ABsmartly { + + $clientConfig = new ClientConfig( + $endpoint, + $apiKey, + $application, + $environment, + ); + $clientConfig->setRetries($retries); + $clientConfig->setTimeout($timeout); + + $client = new Client($clientConfig, new HTTPClient()); + $sdkConfig = new Config($client); + if ($eventLogger !== null) { + $sdkConfig->setContextEventLogger(new \ABSmartly\SDK\Context\ContextEventLoggerCallback($eventLogger)); + } + return new ABsmartly($sdkConfig); + } + + public function createContext(ContextConfig $contextConfig): Context { + $this->applyEventLogger($contextConfig); + return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler); + } + + public function createContextWithData(ContextConfig $contextConfig, ContextData $contextData): Context { + $this->applyEventLogger($contextConfig); + return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler, $contextData); + } + + private function applyEventLogger(ContextConfig $contextConfig): void { + if ($this->eventLogger !== null && $contextConfig->getEventLogger() === null) { + $contextConfig->setEventLogger($this->eventLogger); + } + } + + public function createContextAsync(ContextConfig $contextConfig): PromiseInterface { + $this->applyEventLogger($contextConfig); + if (!$this->provider instanceof AsyncContextDataProvider) { + return resolve($this->createContext($contextConfig)); + } + + return $this->provider->getContextDataAsync() + ->then(fn($data) => $this->createContextWithData($contextConfig, $data)); + } + + public function createContextPending(ContextConfig $contextConfig): array { + $this->applyEventLogger($contextConfig); + $context = Context::createPending($this, $contextConfig, $this->provider, $this->handler); + + if (!$this->provider instanceof AsyncContextDataProvider) { + $promise = resolve(null)->then(function() use ($context) { + $data = $this->provider->getContextData(); + $context->setContextData($data); + return $context; + }); + } else { + $promise = $this->provider->getContextDataAsync() + ->then(function($data) use ($context) { + $context->setContextData($data); + return $context; + }); + } + + return ['context' => $context, 'promise' => $promise]; + } + + public function close(): void { + $this->client->close(); + } +} diff --git a/src/Assignment.php b/src/Assignment.php index 0296899..9c81b38 100644 --- a/src/Assignment.php +++ b/src/Assignment.php @@ -9,8 +9,8 @@ class Assignment { public int $iteration = 0; public int $fullOnVariant = 0; public string $name = ''; - public ?string $unitType; - public array $trafficSplit; + public ?string $unitType = null; + public array $trafficSplit = []; public int $variant = 0; public bool $assigned = false; public bool $overridden = false; @@ -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/Client/AsyncClientInterface.php b/src/Client/AsyncClientInterface.php new file mode 100644 index 0000000..5c37c3f --- /dev/null +++ b/src/Client/AsyncClientInterface.php @@ -0,0 +1,11 @@ +httpClient = $HTTPClient; - $this->httpClient->timeout = $clientConfig->getTimeout(); - $this->httpClient->retries = $clientConfig->getRetries(); + + if (property_exists($this->httpClient, 'timeout')) { + $this->httpClient->timeout = $clientConfig->getTimeout(); + } + if (property_exists($this->httpClient, 'retries')) { + $this->httpClient->retries = $clientConfig->getRetries(); + } $this->url = rtrim($clientConfig->getEndpoint(), '/') .'/context'; $this->query = [ @@ -45,14 +54,29 @@ public function __construct(ClientConfig $clientConfig, ?HTTPClient $HTTPClient private function authRequest(): void { - + if (empty($this->headers['X-API-Key'])) { + throw new \ABSmartly\SDK\Exception\RuntimeException( + 'API key is not configured. Please set a valid API key in ClientConfig.' + ); + } } public function getContextData(): ContextData { $this->authRequest(); $response = $this->httpClient->get($this->url, $this->query, $this->headers); - $response = $this->decode($response->content); - return new ContextData($response->experiments); + $decoded = $this->decode($response->content); + return new ContextData($decoded->experiments); + } + + public function getContextDataAsync(): PromiseInterface { + if (!$this->httpClient instanceof AsyncHttpClientInterface) { + return resolve($this->getContextData()); + } + + $this->authRequest(); + return $this->httpClient + ->getAsync($this->url, $this->query, $this->headers) + ->then(fn($response) => new ContextData($this->decode($response->content)->experiments)); } public function publish(PublishEvent $publishEvent): void { @@ -60,8 +84,18 @@ public function publish(PublishEvent $publishEvent): void { $this->httpClient->put($this->url, $this->query, $this->headers, $data); } + public function publishAsync(PublishEvent $publishEvent): PromiseInterface { + if (!$this->httpClient instanceof AsyncHttpClientInterface) { + $this->publish($publishEvent); + return resolve(null); + } + + $data = $this->encode($publishEvent); + return $this->httpClient->putAsync($this->url, $this->query, $this->headers, $data); + } + public function decode(string $jsonString): object { - return json_decode($jsonString, false, 16, JSON_THROW_ON_ERROR); + return json_decode($jsonString, false, 512, JSON_THROW_ON_ERROR); } public function encode(object $object): string { @@ -71,4 +105,8 @@ public function encode(object $object): string { public function close(): void { $this->httpClient->close(); } + + public function isAsync(): bool { + return $this->httpClient instanceof AsyncHttpClientInterface; + } } diff --git a/src/Client/ClientConfig.php b/src/Client/ClientConfig.php index c0cef43..d4bdf25 100644 --- a/src/Client/ClientConfig.php +++ b/src/Client/ClientConfig.php @@ -4,7 +4,6 @@ use ABSmartly\SDK\Exception\InvalidArgumentException; -use function get_class; use function str_repeat; use function strlen; @@ -23,6 +22,9 @@ public function __construct( string $application, string $environment ) { + if (($endpoint === '' && $apiKey !== '') || ($endpoint !== '' && $apiKey === '')) { + error_log('ABsmartly SDK Warning: ClientConfig created with empty endpoint or API key. This may cause runtime errors.'); + } $this->apiKey = $apiKey; $this->application = $application; @@ -40,7 +42,6 @@ public function __debugInfo(): array { 'application' => $this->application, 'endpoint' => $this->endpoint, 'environment' => $this->environment, - 'eventLogger' => isset($this->eventLogger) ? get_class($this->eventLogger) : null, ]; } diff --git a/src/Client/ClientInterface.php b/src/Client/ClientInterface.php new file mode 100644 index 0000000..b03641c --- /dev/null +++ b/src/Client/ClientInterface.php @@ -0,0 +1,12 @@ +client = $client; @@ -24,7 +26,15 @@ public function setContextDataProvider(ContextDataProvider $contextDataProvider) return $this; } - public function setContextEventHandler(ContextEventHandler $contextEventHandler): Config { + public function setContextPublisher(ContextPublisher $contextEventHandler): Config { + $this->contextEventHandler = $contextEventHandler; + return $this; + } + + /** + * @deprecated Use setContextPublisher() instead. + */ + public function setContextEventHandler(ContextPublisher $contextEventHandler): Config { $this->contextEventHandler = $contextEventHandler; return $this; } @@ -36,11 +46,20 @@ public function getContextDataProvider(): ContextDataProvider { return $this->contextDataProvider; } - public function getContextEventHandler(): ContextEventHandler { + public function getContextEventHandler(): ContextPublisher { if (!isset($this->contextEventHandler)) { - $this->contextEventHandler = new ContextEventHandler($this->client); + $this->contextEventHandler = new ContextPublisher($this->client); } return $this->contextEventHandler; } + + public function setContextEventLogger(?ContextEventLogger $logger): self { + $this->contextEventLogger = $logger; + return $this; + } + + public function getContextEventLogger(): ?ContextEventLogger { + return $this->contextEventLogger; + } } diff --git a/src/Context/AsyncContextDataProvider.php b/src/Context/AsyncContextDataProvider.php new file mode 100644 index 0000000..702915c --- /dev/null +++ b/src/Context/AsyncContextDataProvider.php @@ -0,0 +1,19 @@ +asyncClient = $client; + } + + public function getContextDataAsync(): PromiseInterface { + return $this->asyncClient->getContextDataAsync(); + } +} diff --git a/src/Context/AsyncContextEventHandler.php b/src/Context/AsyncContextEventHandler.php new file mode 100644 index 0000000..1ba327b --- /dev/null +++ b/src/Context/AsyncContextEventHandler.php @@ -0,0 +1,20 @@ +asyncClient = $client; + } + + public function publishAsync(PublishEvent $event): PromiseInterface { + return $this->asyncClient->publishAsync($event); + } +} diff --git a/src/Context/Context.php b/src/Context/Context.php index d70888a..8cb1ea4 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -11,7 +11,7 @@ use ABSmartly\SDK\Exposure; use ABSmartly\SDK\GoalAchievement; use ABSmartly\SDK\PublishEvent; -use ABSmartly\SDK\SDK; +use ABSmartly\SDK\ABsmartly; use ABSmartly\SDK\VariableParser; use ABSmartly\SDK\VariantAssigner; @@ -29,9 +29,9 @@ class Context { - private SDK $sdk; + private ABsmartly $sdk; - private ContextEventHandler $eventHandler; + private ContextPublisher $eventHandler; private ContextEventLogger $eventLogger; private ContextDataProvider $dataProvider; private VariableParser $variableParser; @@ -59,7 +59,10 @@ class Context { private int $pendingCount = 0; private bool $closed = false; + private bool $finalizing = false; private bool $ready; + private int $attrsSeq = 0; + private ?Throwable $readyError = null; public function isReady(): bool { return $this->ready; @@ -73,11 +76,80 @@ public function isClosed(): bool { return $this->closed; } + public function isFinalizing(): bool { + return $this->finalizing && !$this->closed; + } + + public function isFinalized(): bool { + return $this->isClosed(); + } + + public function finalize(): void { + $this->close(); + } + + public function readyError(): ?Throwable { + return $this->readyError; + } + + public function getCustomFieldKeys(): array { + $keys = []; + if (!empty($this->data->experiments)) { + foreach ($this->data->experiments as $experiment) { + if (!isset($experiment->customFieldValues)) { + continue; + } + + $customFieldValues = $experiment->customFieldValues; + if (is_string($customFieldValues)) { + $customFieldValues = json_decode($customFieldValues, true) ?? []; + } + if (is_object($customFieldValues)) { + $customFieldValues = get_object_vars($customFieldValues); + } + if (!is_array($customFieldValues)) { + continue; + } + + foreach (array_keys($customFieldValues) as $k) { + if (substr($k, -5) !== '_type') { + $keys[$k] = true; + } + } + } + } + return array_keys($keys); + } + + public function getCustomFieldValueType(string $experimentName, string $key): ?string { + $experiment = $this->getExperiment($experimentName); + if ($experiment === null || !isset($experiment->data->customFieldValues)) { + return null; + } + + $customFieldValues = $experiment->data->customFieldValues; + if (is_string($customFieldValues)) { + $customFieldValues = json_decode($customFieldValues, true) ?? []; + } + if (is_object($customFieldValues)) { + $customFieldValues = get_object_vars($customFieldValues); + } + if (!is_array($customFieldValues)) { + return null; + } + + return $customFieldValues[$key . '_type'] ?? null; + } + + public function pending(): int { + return $this->pendingCount; + } + public static function getTime(): int { return (int) (microtime(true) * 1000); } - private function __construct(SDK $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ?ContextData $contextData = null) { + private function __construct(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ?ContextData $contextData = null, bool $pending = false) { $this->sdk = $sdk; $this->dataProvider = $dataProvider; $this->setUnits($contextConfig->getUnits()); @@ -92,6 +164,14 @@ private function __construct(SDK $sdk, ContextConfig $contextConfig, ContextData $this->audienceMatcher = new AudienceMatcher(); $this->variableParser = new VariableParser(); + if ($pending) { + $this->ready = false; + $this->data = null; + $this->index = []; + $this->indexVariables = []; + return; + } + try { $this->ready = true; if (!$contextData) { @@ -105,7 +185,13 @@ private function __construct(SDK $sdk, ContextConfig $contextConfig, ContextData $this->logEvent(ContextEventLoggerEvent::Ready, $data); } catch (Exception $exception) { - $this->setDataFailed(); + $this->setDataFailed($exception); + error_log(sprintf( + 'ABsmartly SDK CRITICAL: Context initialization failed: %s in %s:%d. Context is in failed state.', + $exception->getMessage(), + $exception->getFile(), + $exception->getLine() + )); $this->logError($exception); } } @@ -115,7 +201,7 @@ private function setEventLogger(ContextEventLogger $eventLogger): Context { return $this; } - private function setEventHandler(ContextEventHandler $eventHandler): Context { + private function setEventHandler(ContextPublisher $eventHandler): Context { $this->eventHandler = $eventHandler; return $this; } @@ -152,27 +238,47 @@ private function setData(ContextData $data): void { } } - private function setDataFailed(): void { + private function setDataFailed(?Throwable $exception = null): void { $this->indexVariables = []; $this->index = []; $this->data = null; $this->failed = true; + $this->readyError = $exception; } - public static function createFromContextConfig(SDK $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextEventHandler $handler, ?ContextData $contextData = null): Context { + public static function createFromContextConfig(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextPublisher $handler, ?ContextData $contextData = null): Context { $context = new Context($sdk, $contextConfig, $dataProvider, $contextData); $context->setEventHandler($handler); - if ($logger = $contextConfig->getEventLogger()) { - $context->setEventLogger($logger); - } + return $context; + } + + public static function createPending(ABsmartly $sdk, ContextConfig $contextConfig, ContextDataProvider $dataProvider, ContextPublisher $handler): Context { + $context = new Context($sdk, $contextConfig, $dataProvider, null, true); + $context->setEventHandler($handler); return $context; } + public function setContextData(ContextData $contextData): void { + if ($this->ready) { + return; + } + + try { + $this->data = $contextData; + $this->setData($contextData); + $this->ready = true; + $this->logEvent(ContextEventLoggerEvent::Ready, $contextData); + } catch (Exception $exception) { + $this->setDataFailed($exception); + $this->logError($exception); + } + } + private function checkReady(): void { if (!$this->isReady()) { - throw new LogicException('ABSmartly Context is not yet ready'); + throw new LogicException('ABsmartly Context is not yet ready.'); } $this->checkNotClosed(); @@ -194,6 +300,67 @@ 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)) { + try { + $customFieldValues = json_decode($customFieldValues, true, 512, JSON_THROW_ON_ERROR); + } + catch (\JsonException $e) { + error_log(sprintf( + 'ABsmartly SDK Error: Failed to decode custom field values for experiment "%s": %s', + $experimentName, + $e->getMessage() + )); + return null; + } + } + + if (is_object($customFieldValues)) { + $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)) { + try { + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } + catch (\JsonException $e) { + error_log(sprintf( + 'ABsmartly SDK Error: Failed to decode JSON custom field "%s" for experiment "%s": %s', + $fieldName, + $experimentName, + $e->getMessage() + )); + return null; + } + } + + if ($type === 'number' && is_string($value)) { + if (strpos($value, '.') !== false) { + return (float) $value; + } + return (int) $value; + } + + if (substr($type ?? '', 0, 7) === '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 +369,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 +404,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 +435,16 @@ 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( $experiment->data->trafficSplit, - $experiment->data->seedHi, - $experiment->data->seedLo + $experiment->data->trafficSeedHi, + $experiment->data->trafficSeedLo ); if ($eligible === 1) { $custom = $this->cassignments[$experimentName] ?? null; @@ -296,26 +480,36 @@ 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; } public function getVariableValue(string $key, $defaultValue = null) { - $this->checkReady(); + if (!$this->isReady() || $this->isClosed()) { + return $defaultValue; + } $assignment = $this->getVariableAssignment($key); if ($assignment === 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 +521,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; } @@ -369,7 +564,9 @@ private function getVariantAssigner(string $unitType, string $unitHash): Variant } public function getTreatment(string $experimentName): int { - $this->checkReady(); + if (!$this->isReady() || $this->isClosed()) { + return 0; + } $assignment = $this->getAssignment($experimentName); if (empty($assignment->exposed)) { $this->queueExposure($assignment); @@ -403,6 +600,12 @@ private function logEvent(string $event, ?object $data): void { private function logError(Throwable $throwable): void { if (!isset($this->eventLogger)) { + error_log(sprintf( + 'ABsmartly SDK Error: %s in %s:%d', + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine() + )); return; } @@ -437,7 +640,9 @@ public function getContextData(): ContextData { } public function peekTreatment(string $experimentName): int { - $this->checkReady(); + if (!$this->isReady() || $this->isClosed()) { + return 0; + } return $this->getAssignment($experimentName)->variant; } @@ -457,6 +662,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,12 +733,24 @@ public function getPendingCount(): int { private function checkNotClosed(): void { if ($this->isClosed()) { - throw new LogicException('ABSmartly Context is closed'); + throw new LogicException('ABsmartly Context is finalized.'); } } public function flush(): void { if ($this->isFailed()) { + $errorMsg = sprintf( + 'ABsmartly SDK Warning: Discarding %d exposures and %d goals due to failed context state', + count($this->exposures), + count($this->achievements) + ); + error_log($errorMsg); + if (isset($this->eventLogger)) { + $this->eventLogger->handleEvent($this, new ContextEventLoggerEvent( + ContextEventLoggerEvent::Error, + new \RuntimeException($errorMsg) + )); + } $this->exposures = []; $this->achievements = []; $this->pendingCount = 0; @@ -542,10 +767,18 @@ public function flush(): void { try { $this->eventHandler->publish($event); $this->logEvent(ContextEventLoggerEvent::Publish, $event); + $this->exposures = []; + $this->achievements = []; $this->pendingCount = 0; } catch (Exception $exception) { $this->failed = true; + error_log(sprintf( + 'ABsmartly SDK Error: Failed to publish %d exposures and %d goals: %s. Data will be lost.', + count($this->exposures), + count($this->achievements), + $exception->getMessage() + )); $this->logError($exception); } } @@ -576,28 +809,42 @@ public function publish(): void { public function refresh(): void { $this->checkNotClosed(); + $oldData = $this->data; + $oldIndex = $this->index; + $oldIndexVariables = $this->indexVariables; try { $data = $this->dataProvider->getContextData(); $this->setData($data); $this->logEvent(ContextEventLoggerEvent::Refresh, $data); } catch (Exception $exception) { - $this->setDataFailed(); + $this->data = $oldData; + $this->index = $oldIndex; + $this->indexVariables = $oldIndexVariables; + $this->failed = true; + error_log(sprintf( + 'ABsmartly SDK Error: Failed to refresh context, keeping existing data: %s in %s:%d', + $exception->getMessage(), + $exception->getFile(), + $exception->getLine() + )); $this->logError($exception); } } public function close(): void { - if ($this->getPendingCount() > 0) { - $this->flush(); - } if ($this->isClosed()) { return; } - $this->logEvent(ContextEventLoggerEvent::Close, null); + $this->finalizing = true; + if ($this->getPendingCount() > 0) { + $this->flush(); + } + + $this->logEvent(ContextEventLoggerEvent::Finalize, null); $this->closed = true; - $this->sdk->close(); + $this->finalizing = false; } } diff --git a/src/Context/ContextConfig.php b/src/Context/ContextConfig.php index 82b5c66..3a2510b 100644 --- a/src/Context/ContextConfig.php +++ b/src/Context/ContextConfig.php @@ -2,7 +2,7 @@ namespace ABSmartly\SDK\Context; -use InvalidArgumentException; +use ABSmartly\SDK\Exception\InvalidArgumentException; use function gettype; use function is_int; diff --git a/src/Context/ContextDataProvider.php b/src/Context/ContextDataProvider.php index 068643b..aff6e48 100644 --- a/src/Context/ContextDataProvider.php +++ b/src/Context/ContextDataProvider.php @@ -2,12 +2,12 @@ namespace ABSmartly\SDK\Context; -use ABSmartly\SDK\Client\Client; +use ABSmartly\SDK\Client\ClientInterface; class ContextDataProvider { - private Client $client; + private ClientInterface $client; - public function __construct(Client $client) { + public function __construct(ClientInterface $client) { $this->client = $client; } diff --git a/src/Context/ContextEventHandler.php b/src/Context/ContextEventHandler.php index c474b35..f39ab85 100644 --- a/src/Context/ContextEventHandler.php +++ b/src/Context/ContextEventHandler.php @@ -2,17 +2,8 @@ namespace ABSmartly\SDK\Context; -use ABSmartly\SDK\Client\Client; -use ABSmartly\SDK\PublishEvent; - -class ContextEventHandler { - private Client $client; - - public function __construct(Client $client) { - $this->client = $client; - } - - public function publish(PublishEvent $event): void { - $this->client->publish($event); - } +/** + * @deprecated Use ContextPublisher instead. + */ +class ContextEventHandler extends ContextPublisher { } diff --git a/src/Context/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/Context/ContextPublisher.php b/src/Context/ContextPublisher.php new file mode 100644 index 0000000..a8ee056 --- /dev/null +++ b/src/Context/ContextPublisher.php @@ -0,0 +1,18 @@ +client = $client; + } + + public function publish(PublishEvent $event): void { + $this->client->publish($event); + } +} diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 0000000..d139940 --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,7 @@ +audience)) { $this->audience = json_decode($data->audience, false, 512, JSON_THROW_ON_ERROR); } diff --git a/src/Http/AsyncHttpClientInterface.php b/src/Http/AsyncHttpClientInterface.php new file mode 100644 index 0000000..747418d --- /dev/null +++ b/src/Http/AsyncHttpClientInterface.php @@ -0,0 +1,11 @@ +curlInit(); $flatHeaders = []; foreach ($headers as $header => $value) { @@ -71,14 +70,49 @@ private function setupRequest(string $url, array $query = [], array $headers = [ } private function fetchResponse(): Response { - $returnedResponse = curl_exec($this->curlHandle); - $this->throwOnError($returnedResponse); + $attempt = 0; + $lastException = null; + $maxAttempts = max(1, $this->retries); - $response = new Response(); - $response->content = (string) $returnedResponse; - $response->status = (int) curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + while ($attempt < $maxAttempts) { + try { + $returnedResponse = curl_exec($this->curlHandle); + $this->throwOnError($returnedResponse); - return $response; + $response = new Response(); + $response->content = (string) $returnedResponse; + $response->status = (int) curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + + return $response; + } + catch (HttpClientError $e) { + $lastException = $e; + $httpCode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + $curlError = curl_errno($this->curlHandle); + + $isRetryable = ($curlError !== 0) || + ($httpCode >= 500 && $httpCode < 600) || + $httpCode === 408 || + $httpCode === 429; + + if (!$isRetryable || $attempt >= $maxAttempts - 1) { + throw $e; + } + + $attempt++; + $backoffMs = min(1000 * pow(2, $attempt - 1), 10000); + error_log(sprintf( + 'ABsmartly SDK: Retrying HTTP request (attempt %d/%d) after %dms due to error: %s', + $attempt, + $this->retries, + $backoffMs, + $e->getMessage() + )); + usleep($backoffMs * 1000); + } + } + + throw $lastException; } public function get(string $url, array $query = [], array $headers = []): Response { @@ -120,6 +154,10 @@ private function curlInit(): void { } $this->curlHandle = curl_init(); + if ($this->curlHandle === false) { + throw new HttpClientError('Failed to initialize cURL. Is the curl extension loaded?'); + } + // https://php.watch/articles/php-curl-security-hardening curl_setopt_array($this->curlHandle, [ CURLOPT_RETURNTRANSFER => true, diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php new file mode 100644 index 0000000..789713f --- /dev/null +++ b/src/Http/HttpClientInterface.php @@ -0,0 +1,10 @@ +browser = $browser ?? new Browser(); + } + + public function getAsync(string $url, array $query = [], array $headers = []): PromiseInterface { + $url = $this->buildUrl($url, $query); + return $this->browser + ->withTimeout($this->timeout / 1000) + ->get($url, $this->flattenHeaders($headers)) + ->then(fn($response) => $this->toResponse($response)); + } + + public function putAsync(string $url, array $query = [], array $headers = [], string $body = ''): PromiseInterface { + $url = $this->buildUrl($url, $query); + return $this->browser + ->withTimeout($this->timeout / 1000) + ->put($url, $this->flattenHeaders($headers), $body) + ->then(fn($response) => $this->toResponse($response)); + } + + public function postAsync(string $url, array $query = [], array $headers = [], string $body = ''): PromiseInterface { + $url = $this->buildUrl($url, $query); + return $this->browser + ->withTimeout($this->timeout / 1000) + ->post($url, $this->flattenHeaders($headers), $body) + ->then(fn($response) => $this->toResponse($response)); + } + + public function get(string $url, array $query = [], array $headers = []): Response { + return await($this->getAsync($url, $query, $headers)); + } + + public function put(string $url, array $query = [], array $headers = [], string $body = ''): Response { + return await($this->putAsync($url, $query, $headers, $body)); + } + + public function post(string $url, array $query = [], array $headers = [], string $body = ''): Response { + return await($this->postAsync($url, $query, $headers, $body)); + } + + public function close(): void { + } + + private function buildUrl(string $url, array $query): string { + if (!$query) { + return $url; + } + $queryParams = http_build_query($query); + return strpos($url, '?') === false + ? "$url?$queryParams" + : rtrim($url, '&') . "&$queryParams"; + } + + private function flattenHeaders(array $headers): array { + $result = []; + foreach ($headers as $key => $value) { + $result[$key] = $value; + } + return $result; + } + + private function toResponse($reactResponse): Response { + $response = new Response(); + $response->status = $reactResponse->getStatusCode(); + $response->content = (string) $reactResponse->getBody(); + + if ($response->status >= 400) { + throw new HttpClientError( + sprintf('HTTP Client returned an HTTP error %d: Response Body: %s', + $response->status, + $response->content + ) + ); + } + + return $response; + } +} diff --git a/src/JsonExpression/Operator/InOperator.php b/src/JsonExpression/Operator/InOperator.php index 10dcf38..0a8fe7f 100644 --- a/src/JsonExpression/Operator/InOperator.php +++ b/src/JsonExpression/Operator/InOperator.php @@ -15,27 +15,30 @@ class InOperator extends BinaryOperator { - public function binary(Evaluator $evaluator, $haystack, $needle): ?bool { - if ($needle === null) { + public function binary(Evaluator $evaluator, $lhs, $rhs): ?bool { + if ($lhs === null) { return null; } - if (is_array($haystack)) { - return in_array($needle, $haystack, true); + if (is_array($rhs)) { + return in_array($lhs, $rhs, false); } - if (is_string($haystack)) { + if (is_string($rhs)) { + if (!is_string($lhs)) { + return null; + } //@codeCoverageIgnoreStart // due to version-dependent code if (function_exists('str_contains')) { - return str_contains($haystack, $needle); // Allows empty strings + return str_contains($rhs, $lhs); } - return strpos($haystack, $needle) !== false; + return strpos($rhs, $lhs) !== false; // @codeCoverageIgnoreEnd } - if (is_object($haystack)) { - return property_exists($haystack, (string) $needle); // Not using isset() to account for possible null values. + if (is_object($rhs)) { + return property_exists($rhs, (string) $lhs); } return null; diff --git a/src/JsonExpression/Operator/MatchOperator.php b/src/JsonExpression/Operator/MatchOperator.php index 5dbf96a..925e890 100644 --- a/src/JsonExpression/Operator/MatchOperator.php +++ b/src/JsonExpression/Operator/MatchOperator.php @@ -25,15 +25,10 @@ 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, '/'); + $pattern = str_replace('~', '\~', $pattern); + + $matches = @preg_match('~'. $pattern . '~', $text); if (preg_last_error() !== PREG_NO_ERROR) { return null; diff --git a/src/SDK.php b/src/SDK.php index f30b223..8374307 100644 --- a/src/SDK.php +++ b/src/SDK.php @@ -2,70 +2,8 @@ namespace ABSmartly\SDK; -use ABSmartly\SDK\Client\Client; -use ABSmartly\SDK\Client\ClientConfig; -use ABSmartly\SDK\Context\Context; -use ABSmartly\SDK\Context\ContextConfig; -use ABSmartly\SDK\Context\ContextData; -use ABSmartly\SDK\Context\ContextDataProvider; -use ABSmartly\SDK\Context\ContextEventHandler; -use ABSmartly\SDK\Http\HTTPClient; - -final class SDK { - - private Client $client; - private ContextEventHandler $handler; - private ContextDataProvider $provider; - - public function __construct(Config $config) { - $this->client = $config->getClient(); - $this->provider = $config->getContextDataProvider(); - $this->handler = $config->getContextEventHandler(); - } - - /** - * @param string $endpoint URL to your API endpoint. Most commonly "your-company.absmartly.io". - * @param string $apiKey API key which can be found on the Web Console. - * @param string $environment Environment of the platform where the SDK is installed. Environments are created on - * the Web Console and should match the available environments in your infrastructure. - * @param string $application Name of the application where the SDK is installed. Applications are created on the - * Web Console and should match the applications where your experiments will be running. - * @param int $retries The number of retries before the SDK stops trying to connect. - * @param int $timeout Amount of time, in milliseconds, before the SDK will stop trying to connect. - * @param callable|null $eventLogger A callback function which runs after SDK events. - * @return SDK SDK instance created using the credentials and details above. - */ - public static function createWithDefaults( - string $endpoint, - string $apiKey, - string $environment, - string $application, - int $retries = 5, - int $timeout = 3000, - ?callable $eventLogger = null - ): SDK { - - $clientConfig = new ClientConfig( - $endpoint, - $apiKey, - $application, - $environment, - ); - - $client = new Client($clientConfig, new HTTPClient()); - $sdkConfig = new Config($client); - return new SDK($sdkConfig); - } - - public function createContext(ContextConfig $contextConfig): Context { - return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler); - } - - public function createContextWithData(ContextConfig $contextConfig, ContextData $contextData): Context { - return Context::createFromContextConfig($this, $contextConfig, $this->provider, $this->handler, $contextData); - } - - public function close(): void { - $this->client->close(); - } +/** + * @deprecated Use ABsmartly instead. This class will be removed in a future version. + */ +final class SDK extends ABsmartly { } diff --git a/src/VariableParser.php b/src/VariableParser.php index 274b604..ae12d81 100644 --- a/src/VariableParser.php +++ b/src/VariableParser.php @@ -14,6 +14,11 @@ public function parse(string $experimentName, string $config): ?object { return json_decode($config, false, 512, JSON_THROW_ON_ERROR); } catch (Exception $exception) { + error_log(sprintf( + 'ABsmartly SDK Error: Failed to parse variant config for experiment "%s": %s', + $experimentName, + $exception->getMessage() + )); return null; } } diff --git a/tests/ABsmartlyTest.php b/tests/ABsmartlyTest.php new file mode 100644 index 0000000..f73b3fc --- /dev/null +++ b/tests/ABsmartlyTest.php @@ -0,0 +1,127 @@ +getParameters(); + + self::assertSame('endpoint', $params[0]->getName()); + self::assertSame('apiKey', $params[1]->getName()); + self::assertSame('application', $params[2]->getName()); + self::assertSame('environment', $params[3]->getName()); + } + + public function testCreateSimpleIsNotDeprecated(): void { + $reflection = new ReflectionMethod(ABsmartly::class, 'createSimple'); + $docComment = $reflection->getDocComment(); + + self::assertStringNotContainsString('@deprecated', $docComment); + } + + public function testCreateWithDefaultsIsDeprecated(): void { + $reflection = new ReflectionMethod(ABsmartly::class, 'createWithDefaults'); + $docComment = $reflection->getDocComment(); + + self::assertStringContainsString('@deprecated', $docComment); + } + + public function testCreateWithDefaultsParameterOrderIsPreservedForBackwardCompatibility(): void { + $reflection = new ReflectionMethod(ABsmartly::class, 'createWithDefaults'); + $params = $reflection->getParameters(); + + self::assertSame('endpoint', $params[0]->getName()); + self::assertSame('apiKey', $params[1]->getName()); + self::assertSame('environment', $params[2]->getName()); + self::assertSame('application', $params[3]->getName()); + } + + public function testEventLoggerFromConfigIsStoredOnInstance(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', 'key', 'app', 'env'); + $client = new Client($clientConfig); + $config = new Config($client); + + $logger = new MockContextEventLoggerProxy(); + $config->setContextEventLogger($logger); + + $sdk = new ABsmartly($config); + + $prop = new ReflectionProperty(ABsmartly::class, 'eventLogger'); + $prop->setAccessible(true); + self::assertSame($logger, $prop->getValue($sdk)); + } + + public function testEventLoggerFromConfigPropagatedToContext(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->setSource('context.json'); + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $config->setContextEventHandler($eventHandler); + + $logger = new MockContextEventLoggerProxy(); + $config->setContextEventLogger($logger); + + $sdk = new ABsmartly($config); + $contextConfig = new ContextConfig(); + $contextConfig->setUnits([ + 'session_id' => 'e791e240fcd3df7d238cfc285f475e8152fcc0ec', + ]); + + $context = $sdk->createContext($contextConfig); + self::assertTrue($context->isReady()); + self::assertGreaterThan(0, $logger->called); + + $readyEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Ready); + self::assertCount(1, $readyEvents); + } + + public function testContextConfigEventLoggerNotOverriddenBySdkLogger(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + + $dataProvider = new ContextDataProviderMock($client); + $dataProvider->setSource('context.json'); + $config->setContextDataProvider($dataProvider); + + $eventHandler = new ContextEventHandlerMock($client); + $config->setContextEventHandler($eventHandler); + + $sdkLogger = new MockContextEventLoggerProxy(); + $config->setContextEventLogger($sdkLogger); + + $contextLogger = new MockContextEventLoggerProxy(); + + $sdk = new ABsmartly($config); + $contextConfig = new ContextConfig(); + $contextConfig->setUnits([ + 'session_id' => 'e791e240fcd3df7d238cfc285f475e8152fcc0ec', + ]); + $contextConfig->setEventLogger($contextLogger); + + $context = $sdk->createContext($contextConfig); + self::assertTrue($context->isReady()); + self::assertSame(0, $sdkLogger->called); + self::assertGreaterThan(0, $contextLogger->called); + } +} diff --git a/tests/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/ClientAsyncTest.php b/tests/Client/ClientAsyncTest.php new file mode 100644 index 0000000..8bbdb44 --- /dev/null +++ b/tests/Client/ClientAsyncTest.php @@ -0,0 +1,136 @@ +createMock(AsyncHttpClientInterface::class); + return $mock; + } + + private function createMockSyncHttpClient(): HttpClientInterface { + $mock = $this->createMock(HttpClientInterface::class); + return $mock; + } + + private function createContextDataResponse(): Response { + $response = new Response(); + $response->status = 200; + $response->content = json_encode([ + 'experiments' => [ + [ + 'id' => 1, + 'name' => 'test_exp', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 0, + 'seedLo' => 0, + 'split' => [0.5, 0.5], + 'trafficSeedHi' => 0, + 'trafficSeedLo' => 0, + 'trafficSplit' => [0.0, 1.0], + 'fullOnVariant' => 0, + 'applications' => [['name' => 'app']], + 'variants' => [[], []], + 'audienceStrict' => false, + 'audience' => null + ] + ] + ]); + return $response; + } + + public function testGetContextDataAsyncWithAsyncClient(): void { + $httpClient = $this->createMockAsyncHttpClient(); + $response = $this->createContextDataResponse(); + + $httpClient->method('getAsync') + ->willReturn(resolve($response)); + + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config, $httpClient); + + self::assertTrue($client->isAsync()); + + $promise = $client->getContextDataAsync(); + self::assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testGetContextDataAsyncWithSyncClientReturnsResolvedPromise(): void { + $httpClient = $this->createMockSyncHttpClient(); + $response = $this->createContextDataResponse(); + + $httpClient->method('get') + ->willReturn($response); + + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config, $httpClient); + + self::assertFalse($client->isAsync()); + + $promise = $client->getContextDataAsync(); + self::assertInstanceOf(PromiseInterface::class, $promise); + + $result = null; + $promise->then(function($data) use (&$result) { + $result = $data; + }); + + self::assertInstanceOf(ContextData::class, $result); + } + + public function testPublishAsyncWithAsyncClient(): void { + $httpClient = $this->createMockAsyncHttpClient(); + $response = new Response(); + $response->status = 200; + $response->content = '{}'; + + $httpClient->method('putAsync') + ->willReturn(resolve($response)); + + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config, $httpClient); + + $publishEvent = new PublishEvent(); + $promise = $client->publishAsync($publishEvent); + + self::assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testPublishAsyncWithSyncClientReturnsResolvedPromise(): void { + $httpClient = $this->createMockSyncHttpClient(); + $response = new Response(); + $response->status = 200; + $response->content = '{}'; + + $httpClient->method('put') + ->willReturn($response); + + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config, $httpClient); + + $publishEvent = new PublishEvent(); + $promise = $client->publishAsync($publishEvent); + + self::assertInstanceOf(PromiseInterface::class, $promise); + + $resolved = false; + $promise->then(function() use (&$resolved) { + $resolved = true; + }); + + self::assertTrue($resolved); + } +} diff --git a/tests/Client/ClientConfigTest.php b/tests/Client/ClientConfigTest.php index 3a27731..7f06331 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,70 @@ 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 testDebugInfoDoesNotContainEventLogger(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $debugInfo = $clientConfig->__debugInfo(); + self::assertArrayNotHasKey('eventLogger', $debugInfo); + self::assertArrayHasKey('apiKey', $debugInfo); + self::assertArrayHasKey('application', $debugInfo); + self::assertArrayHasKey('endpoint', $debugInfo); + self::assertArrayHasKey('environment', $debugInfo); + } + + public function testFluentInterface(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $result = $clientConfig->setTimeout(1000)->setRetries(3); + self::assertSame($clientConfig, $result); + self::assertSame(1000, $clientConfig->getTimeout()); + self::assertSame(3, $clientConfig->getRetries()); + } } diff --git a/tests/Client/ClientInterfaceTest.php b/tests/Client/ClientInterfaceTest.php new file mode 100644 index 0000000..1470f26 --- /dev/null +++ b/tests/Client/ClientInterfaceTest.php @@ -0,0 +1,57 @@ +hasMethod('getContextData')); + self::assertTrue($reflection->hasMethod('publish')); + self::assertTrue($reflection->hasMethod('close')); + } + + public function testAsyncClientInterfaceDefinesRequiredMethods(): void { + $reflection = new \ReflectionClass(AsyncClientInterface::class); + + self::assertTrue($reflection->hasMethod('getContextDataAsync')); + self::assertTrue($reflection->hasMethod('publishAsync')); + } + + public function testClientHasIsAsyncMethod(): void { + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $client = new Client($config); + + self::assertFalse($client->isAsync()); + } + + public function testClientAcceptsHttpClientInterface(): void { + $config = new ClientConfig('https://example.com', 'api-key', 'app', 'env'); + $httpClient = new HTTPClient(); + $client = new Client($config, $httpClient); + + self::assertInstanceOf(Client::class, $client); + self::assertFalse($client->isAsync()); + } +} diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php new file mode 100644 index 0000000..041f1a1 --- /dev/null +++ b/tests/Client/ClientTest.php @@ -0,0 +1,27 @@ +decode($nested); + + self::assertSame('deep', $result->a->b->c->d->e->f->g->h->i->j->k->l->m->n->o->p->q); + } + + public function testDecodeThrowsOnInvalidJson(): void { + $clientConfig = new ClientConfig('endpoint', 'key', 'app', 'env'); + $client = new Client($clientConfig); + + $this->expectException(\JsonException::class); + $client->decode('invalid json'); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..0ba9e0c --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,45 @@ +createConfig(); + $logger = new ContextEventLoggerCallback(function () {}); + $result = $config->setContextEventLogger($logger); + self::assertSame($config, $result); + } + + public function testGetContextEventLoggerReturnsNullByDefault(): void { + $config = $this->createConfig(); + self::assertNull($config->getContextEventLogger()); + } + + public function testSetAndGetContextEventLogger(): void { + $config = $this->createConfig(); + $logger = new ContextEventLoggerCallback(function () {}); + $config->setContextEventLogger($logger); + self::assertSame($logger, $config->getContextEventLogger()); + } + + public function testSetContextEventLoggerWithNull(): void { + $config = $this->createConfig(); + $logger = new ContextEventLoggerCallback(function () {}); + $config->setContextEventLogger($logger); + $config->setContextEventLogger(null); + self::assertNull($config->getContextEventLogger()); + } +} diff --git a/tests/Context/AsyncContextDataProviderTest.php b/tests/Context/AsyncContextDataProviderTest.php new file mode 100644 index 0000000..b790a12 --- /dev/null +++ b/tests/Context/AsyncContextDataProviderTest.php @@ -0,0 +1,67 @@ +createMock(AsyncClientInterface::class); + $provider = new AsyncContextDataProvider($client); + + self::assertInstanceOf(ContextDataProvider::class, $provider); + } + + public function testGetContextDataAsyncReturnsPromise(): void { + $client = $this->createMock(AsyncClientInterface::class); + $contextData = new ContextData(); + + $client->method('getContextDataAsync') + ->willReturn(resolve($contextData)); + + $provider = new AsyncContextDataProvider($client); + $promise = $provider->getContextDataAsync(); + + self::assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testGetContextDataAsyncResolvesToContextData(): void { + $client = $this->createMock(AsyncClientInterface::class); + $contextData = new ContextData(); + $contextData->experiments = []; + + $client->method('getContextDataAsync') + ->willReturn(resolve($contextData)); + + $provider = new AsyncContextDataProvider($client); + $promise = $provider->getContextDataAsync(); + + $result = null; + $promise->then(function($data) use (&$result) { + $result = $data; + }); + + self::assertSame($contextData, $result); + } + + public function testGetContextDataSyncStillWorks(): void { + $client = $this->createMock(AsyncClientInterface::class); + $contextData = new ContextData(); + $contextData->experiments = []; + + $client->method('getContextData') + ->willReturn($contextData); + + $provider = new AsyncContextDataProvider($client); + $result = $provider->getContextData(); + + self::assertSame($contextData, $result); + } +} diff --git a/tests/Context/AsyncContextEventHandlerTest.php b/tests/Context/AsyncContextEventHandlerTest.php new file mode 100644 index 0000000..63a869b --- /dev/null +++ b/tests/Context/AsyncContextEventHandlerTest.php @@ -0,0 +1,45 @@ +createMock(AsyncClientInterface::class); + $handler = new AsyncContextEventHandler($client); + + self::assertInstanceOf(ContextEventHandler::class, $handler); + } + + public function testPublishAsyncReturnsPromise(): void { + $client = $this->createMock(AsyncClientInterface::class); + + $client->method('publishAsync') + ->willReturn(resolve(null)); + + $handler = new AsyncContextEventHandler($client); + $event = new PublishEvent(); + $promise = $handler->publishAsync($event); + + self::assertInstanceOf(PromiseInterface::class, $promise); + } + + public function testPublishSyncStillWorks(): void { + $client = $this->createMock(AsyncClientInterface::class); + + $client->expects($this->once()) + ->method('publish'); + + $handler = new AsyncContextEventHandler($client); + $event = new PublishEvent(); + $handler->publish($event); + } +} diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index 78bbe92..cfe097d 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,9 +809,9 @@ 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); + self::assertFalse(property_exists($event, 'exposures'), 'Second publish should not have exposures - they were already sent in first publish'); self::assertSame(0, $context->getPendingCount()); @@ -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,1373 @@ 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 testVariableValueReturnsDefaultAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertSame(0, $context->getVariableValue('banner.border', 0)); + self::assertSame('default', $context->getVariableValue('nonexistent', 'default')); + } + + 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 testTreatmentReturnsZeroAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertSame(0, $context->getTreatment('exp_test_ab')); + } + + public function testPeekTreatmentReturnsZeroAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertSame(0, $context->peekTreatment('exp_test_ab')); + } + + public function testCustomFieldKeysReturnsKeys(): void { + $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 testEventLoggerCalledOnceOnReady(): void { + $logger = new MockContextEventLoggerProxy(); + $context = $this->createReadyContext('context.json', true, $logger); + + $readyEvents = array_filter($logger->events, fn($e) => $e->getEvent() === ContextEventLoggerEvent::Ready); + self::assertCount(1, $readyEvents); + } + + public function testCustomFieldValueBooleanPrefixType(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'is_active'); + self::assertFalse($value); + } + + public function testCustomFieldValueBooleanFalseWithZero(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + + $value = $context->customFieldValue('exp_test_ab', 'disabled'); + self::assertFalse($value); + } + + public function testGetTreatmentQueuesExposureWithAudienceMatchTrueOnAudienceMatch(): void { + $context = $this->createReadyContext('audience_context.json'); + $context->setAttribute('age', 21); + + 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 testClosedContextRejectsWriteOperations(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertTrue($context->isClosed()); + + self::assertSame(0, $context->getTreatment('exp_test_ab')); + self::assertSame(0, $context->peekTreatment('exp_test_ab')); + + $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); + } + + public function testReadyErrorReturnsNullOnSuccess(): void { + $context = $this->createReadyContext(); + self::assertNull($context->readyError()); + } + + public function testIsFinalizingReturnsFalseWhenNotClosing(): void { + $context = $this->createReadyContext(); + self::assertFalse($context->isFinalizing()); + } + + public function testIsFinalizingReturnsFalseAfterClose(): void { + $context = $this->createReadyContext(); + $context->close(); + self::assertFalse($context->isFinalizing()); + self::assertTrue($context->isClosed()); + } + + public function testGetCustomFieldKeysReturnsAllKeys(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + $keys = $context->getCustomFieldKeys(); + self::assertIsArray($keys); + self::assertContains('country', $keys); + self::assertContains('description', $keys); + self::assertContains('enabled', $keys); + self::assertContains('config', $keys); + self::assertContains('min_age', $keys); + } + + public function testGetCustomFieldKeysDoesNotIncludeTypeKeys(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + $keys = $context->getCustomFieldKeys(); + foreach ($keys as $key) { + self::assertStringNotContainsString('_type', $key); + } + } + + public function testGetCustomFieldKeysReturnsEmptyArrayWithNoCustomFields(): void { + $context = $this->createReadyContext(); + $keys = $context->getCustomFieldKeys(); + self::assertIsArray($keys); + self::assertEmpty($keys); + } + + public function testGetCustomFieldValueTypeReturnsType(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + self::assertSame('string', $context->getCustomFieldValueType('exp_test_ab', 'country')); + self::assertSame('text', $context->getCustomFieldValueType('exp_test_ab', 'description')); + self::assertSame('number', $context->getCustomFieldValueType('exp_test_ab', 'min_age')); + self::assertSame('boolean', $context->getCustomFieldValueType('exp_test_ab', 'enabled')); + self::assertSame('json', $context->getCustomFieldValueType('exp_test_ab', 'config')); + } + + public function testGetCustomFieldValueTypeReturnsNullForUnknownField(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + self::assertNull($context->getCustomFieldValueType('exp_test_ab', 'nonexistent_field')); + } + + public function testGetCustomFieldValueTypeReturnsNullForUnknownExperiment(): void { + $context = $this->createReadyContext('context_custom_fields.json'); + self::assertNull($context->getCustomFieldValueType('nonexistent_experiment', 'country')); + } + + public function testGetUnitReturnsUidForKnownUnitType(): void { + $context = $this->createReadyContext(); + self::assertSame('e791e240fcd3df7d238cfc285f475e8152fcc0ec', $context->getUnit('session_id')); + self::assertSame('123456789', $context->getUnit('user_id')); + } + + public function testGetUnitReturnsNullForUnknownUnitType(): void { + $context = $this->createReadyContext(); + self::assertNull($context->getUnit('nonexistent_unit')); + } } diff --git a/tests/ExperimentTest.php b/tests/ExperimentTest.php new file mode 100644 index 0000000..39355d4 --- /dev/null +++ b/tests/ExperimentTest.php @@ -0,0 +1,51 @@ + 1, + 'name' => 'test_exp', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 12345, + 'seedLo' => 67890, + 'split' => [0.5, 0.5], + 'trafficSeedHi' => 11111, + 'trafficSeedLo' => 22222, + 'trafficSplit' => [0.0, 1.0], + 'fullOnVariant' => 0, + 'applications' => [['name' => 'website']], + 'variants' => [ + (object) ['name' => 'A', 'config' => null], + (object) ['name' => 'B', 'config' => null], + ], + 'audience' => '', + ]; + + return (object) array_merge($defaults, $overrides); + } + + public function testAudienceStrictDefaultsToFalse(): void { + $data = $this->createExperimentData(); + $experiment = new Experiment($data); + self::assertFalse($experiment->audienceStrict); + } + + public function testAudienceStrictSetFromData(): void { + $data = $this->createExperimentData(['audienceStrict' => true]); + $experiment = new Experiment($data); + self::assertTrue($experiment->audienceStrict); + } + + public function testMissingRequiredFieldThrows(): void { + $data = (object) ['id' => 1, 'name' => 'test']; + $this->expectException(\ABSmartly\SDK\Exception\RuntimeException::class); + $this->expectExceptionMessage('Missing required field'); + new Experiment($data); + } +} diff --git a/tests/Fixtures/json/context_custom_fields.json b/tests/Fixtures/json/context_custom_fields.json new file mode 100644 index 0000000..fb57b6d --- /dev/null +++ b/tests/Fixtures/json/context_custom_fields.json @@ -0,0 +1,97 @@ +{ + "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", + "is_active": "false", + "is_active_type": "boolean_flag", + "disabled": "0", + "disabled_type": "boolean" + } + }, + { + "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/Http/HttpClientInterfaceTest.php b/tests/Http/HttpClientInterfaceTest.php new file mode 100644 index 0000000..445dd59 --- /dev/null +++ b/tests/Http/HttpClientInterfaceTest.php @@ -0,0 +1,23 @@ +hasMethod('get')); + self::assertTrue($reflection->hasMethod('put')); + self::assertTrue($reflection->hasMethod('post')); + self::assertTrue($reflection->hasMethod('close')); + } +} diff --git a/tests/Http/ReactHttpClientTest.php b/tests/Http/ReactHttpClientTest.php new file mode 100644 index 0000000..b2f29e6 --- /dev/null +++ b/tests/Http/ReactHttpClientTest.php @@ -0,0 +1,37 @@ +setAccessible(true); + + $headers = [ + 'Content-Type' => 'application/json', + 'X-API-Key' => 'test-key', + 'Authorization' => 'Bearer token123', + ]; + + $result = $method->invoke($client, $headers); + + self::assertSame('application/json', $result['Content-Type']); + self::assertSame('test-key', $result['X-API-Key']); + self::assertSame('Bearer token123', $result['Authorization']); + self::assertCount(3, $result); + } + + public function testFlattenHeadersEmptyArray(): void { + $client = new ReactHttpClient(); + $method = new ReflectionMethod(ReactHttpClient::class, 'flattenHeaders'); + $method->setAccessible(true); + + $result = $method->invoke($client, []); + self::assertSame([], $result); + } +} diff --git a/tests/JsonExpression/Operator/InOperatorTest.php b/tests/JsonExpression/Operator/InOperatorTest.php index dda4a9d..da993ae 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, [0, []])); - 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/JsonExpression/Operator/MatchOperatorTest.php b/tests/JsonExpression/Operator/MatchOperatorTest.php index 621898a..7309012 100644 --- a/tests/JsonExpression/Operator/MatchOperatorTest.php +++ b/tests/JsonExpression/Operator/MatchOperatorTest.php @@ -30,6 +30,12 @@ public function testRegexMatches(): void { self::assertFalse($this->operator->evaluate($this->evaluator, ["abcdefghijk", "xyz"])); } + public function testRegexWithTildeDelimiterInPattern(): void { + self::assertTrue($this->operator->evaluate($this->evaluator, ["hello~world", "hello~world"])); + self::assertTrue($this->operator->evaluate($this->evaluator, ["test~value", "~"])); + self::assertFalse($this->operator->evaluate($this->evaluator, ["helloworld", "hello~world"])); + } + public function testRegexAutoBoundaries(): void { self::assertTrue($this->operator->evaluate($this->evaluator, ["abcdefghijk", "//"])); self::assertTrue($this->operator->evaluate($this->evaluator, ["abcdefghijk", "/abc/"])); 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 {