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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..95f5bd5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## 2.0.0 (unreleased) + +### BREAKING + +- **Units keep their type.** `setUnit`/`setUnits` (Context and ContextConfig) accept strings or numbers and + `getUnit`/`getUnits` return the value as given (old: numbers were cast to strings). A float unit is hashed as + its value rounded to an integer, like the JavaScript SDK. String UIDs are trimmed. + Unit error messages now match the other SDKs: `Unit '' UID already set.`, + `Unit '' UID must not be blank.`, `Unit '' must be a string or a number.` +- **`track()` validates goal properties.** Anything other than null, a plain object or an associative array + raises `Goal '' properties must be of type object.` (old: PHP `TypeError` or silently accepted). +- **A failed context load no longer rejects.** `createContextAsync()`/`createContextPending()` resolve with a + context that is ready and failed, has empty data, stores `readyError()` and logs an `Error` event. +- **`close()` rethrows a failed final publish.** The events stay queued and the context stays open so `close()` + can be retried (old: the error was swallowed and the context closed). +- New `ABSmartly\SDK\Utils` helpers (`hashUnit`, `base64UrlNoPadding`, `utf8Bytes`, `isObject`, `isNumeric`, + `isPromise`). + +- **Context reads throw before ready and after finalize** (parity with the JavaScript SDK `_checkReady`). + The following methods now throw `ABSmartly\SDK\Exception\LogicException`: + `getTreatment`, `peekTreatment`, `getVariableValue`, `peekVariableValue`, `getVariableKeys`, + `getExperiments`, `getCustomFieldKeys`, `customFieldValue`, `getCustomFieldValueType`. + - Before ready (e.g. a context from `createContextPending()` whose data has not arrived): + message `ABsmartly Context is not yet ready.` + Old: `peekVariableValue`/`getVariableKeys`/`getExperiments` returned defaults or empty arrays. + - After `close()`/`finalize()`: message `ABsmartly Context is finalized.` + Old: `peekVariableValue` and `getVariableKeys` kept returning values. + `getExperiments()` is still allowed after finalize (as in JS). + - The not-ready message changed from `ABSmartly Context is not yet ready` to + `ABsmartly Context is not yet ready.` Code matching on the message text must be updated. diff --git a/README.md b/README.md index 1e5aaea..e0c444e 100644 --- a/README.md +++ b/README.md @@ -1,199 +1,215 @@ -# 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. + +### Migrating to 2.0 (breaking) -### Install the SDK +Context read methods (`getTreatment`, `peekTreatment`, `getVariableValue`, `peekVariableValue`, `getVariableKeys`, `getExperiments`, `getCustomFieldKeys`, `customFieldValue`, `getCustomFieldValueType`) now throw `ABSmartly\SDK\Exception\LogicException` when the context is not ready (`ABsmartly Context is not yet ready.`) or has been finalized (`ABsmartly Context is finalized.`; `getExperiments` is still allowed after finalize). Previously some of them returned a default value. Check `$context->isReady()` / `$context->isFinalized()` or catch the exception. See [CHANGELOG.md](CHANGELOG.md). + +## Installation A/B Smartly PHP SDK can be installed with [`composer`](https://getcomposer.org): -```bash +```bash composer require absmartly/php-sdk -``` +``` + +## Getting Started + +Please follow the [installation](#installation) instructions before trying the following code. + +### Initialization -## Import and Initialize the SDK +This example assumes an API Key, an Application, and an Environment have been created in the A/B Smartly web console. -Once the SDK is installed, it can be initialized in your project. +#### Recommended: Simple API -You can create an SDK instance using the API key, application name, environment, and the endpoint URL obtained from A/B Smartly. +Once the SDK is installed, it can be initialized in your project using the simple API: -```php -use \ABSmartly\SDK\SDK; +```php +use ABSmartly\SDK\ABsmartly; -$sdk = SDK::createWithDefaults( +$sdk = ABsmartly::createWithDefaults( endpoint: $endpoint, apiKey: $apiKey, environment: $environment, application: $application ); -``` +``` Note that the above example uses named parameters introduced in PHP 8.0. Although it is strongly recommended to use the latest PHP version, PHP 7.4 is supported as well. On PHP 7.4, parameters are only passed in their order, as named parameters are not supported. -Example: +Example for PHP 7.4: ```php -use \ABSmartly\SDK\SDK; +use ABSmartly\SDK\ABsmartly; -$sdk = SDK::createWithDefaults( - $endpoint, $apiKey, $environment, $application, -); -``` +$sdk = ABsmartly::createWithDefaults( + $endpoint, $apiKey, $environment, $application +); +``` -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); +``` + +The async HTTP client uses ReactPHP promises and allows for non-blocking I/O operations. + +**SDK Options** -| 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` | +| 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) | -## Create a New Context Request +## Creating a New Context -**Synchronously** +### Synchronously ```php -$contextConfig = new ContextConfig(); -$contextConfig->setUnit('session_id', 'session_id5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); // a unique id identifying the user +use ABSmartly\SDK\Context\ContextConfig; + +$contextConfig = new ContextConfig(); +$contextConfig->setUnit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); $context = $sdk->createContext($contextConfig); -``` +``` + +### Asynchronously (with ReactPHP) + +When using the async HTTP client, context creation is non-blocking: + +```php +use ABSmartly\SDK\Context\ContextConfig; +use React\Promise\PromiseInterface; + +$contextConfig = new ContextConfig(); +$contextConfig->setUnit('session_id', '5ebf06d8cb5d8137290c4abb64155584fbdb64d8'); + +$sdk->createContextAsync($contextConfig)->then( + function($context) { + $treatment = $context->getTreatment('exp_test_experiment'); + }, + function($error) { + error_log('Context failed: ' . $error->getMessage()); + } +); +``` -**With Prefetched Data** +### 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 -$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); $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 +221,86 @@ $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); +``` + +### Tracking Goals + +Goals are created in the A/B Smartly Web Console. + +```php +$context->track('payment', (object) [ + 'item_count' => 1, + 'total_amount' => 1999.99 +]); ``` -### Publish +### 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. +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 +### 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 +308,348 @@ The `close()` method will ensure all events have been published to the A/B Smart $context->close(); ``` -### Tracking Goals +### 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 | +| `Finalize` | `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 +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 -$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\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'); + +$timeout = Loop::addTimer(1.5, function() { + echo "Context creation timed out\n"; +}); + +$sdk->createContextAsync($contextConfig)->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..3bd0d1e 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,10 @@ "php-64bit": "^7.4 || ^8.0", "lastguest/murmurhash": "~2.1.1", "ext-curl": "*", - "ext-json": "*" + "ext-json": "*", + "react/http": "^1.9", + "react/async": "^3.0 || ^4.0", + "react/promise": "^3.0" }, "require-dev": { "phpunit/phpunit": "^9.5.26" @@ -27,5 +30,10 @@ "email": "ayesh@aye.sh" } ], - "keywords": ["absmartly"] + "keywords": [ + "absmartly" + ], + "scripts": { + "test": "phpunit" + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 876e106..4f042e1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,7 @@ convertDeprecationsToExceptions="true" failOnRisky="true" failOnWarning="true" + defaultTestSuite="default" verbose="true"> diff --git a/src/ABsmartly.php b/src/ABsmartly.php new file mode 100644 index 0000000..6147a28 --- /dev/null +++ b/src/ABsmartly.php @@ -0,0 +1,174 @@ +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 { + return new ABsmartly(static::buildConfig($endpoint, $apiKey, $application, $environment, $retries, $timeout, $eventLogger)); + } + + /** + * @deprecated Use createSimple(); this legacy signature takes environment before application. + * + * @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 { + return new ABsmartly(static::buildConfig($endpoint, $apiKey, $application, $environment, $retries, $timeout, $eventLogger)); + } + + protected static function buildConfig( + string $endpoint, + string $apiKey, + string $application, + string $environment, + int $retries, + int $timeout, + ?callable $eventLogger + ): Config { + $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 $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) { + try { + return resolve($this->createContext($contextConfig)); + } catch (\Throwable $e) { + return reject($e); + } + } + + return $this->createContextPending($contextConfig)['promise']; + } + + 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) { + try { + $data = $this->provider->getContextData(); + } catch (\Throwable $error) { + $context->setContextDataFailed($error); + return $context; + } + $context->setContextData($data); + return $context; + }); + } else { + $promise = $this->provider->getContextDataAsync() + ->then( + function($data) use ($context) { + $context->setContextData($data); + return $context; + }, + function(\Throwable $error) use ($context) { + $context->setContextDataFailed($error); + 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/AudienceMatcher.php b/src/AudienceMatcher.php index ec2962e..9ae8a6e 100644 --- a/src/AudienceMatcher.php +++ b/src/AudienceMatcher.php @@ -17,6 +17,10 @@ public function evaluate(stdClass $audience, array $attributes): ?bool { return null; } + if (!is_array($audience->filter) && !is_object($audience->filter)) { + return null; + } + return $this->expression->evaluateBooleanExpr($audience->filter, (object) $attributes); } } 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,23 +55,66 @@ 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) { + try { + return resolve($this->getContextData()); + } catch (\Throwable $e) { + return reject($e); + } + } + + try { + $this->authRequest(); + } catch (\Throwable $e) { + return reject($e); + } + 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 { + $this->authRequest(); $data = $this->encode($publishEvent); $this->httpClient->put($this->url, $this->query, $this->headers, $data); } + public function publishAsync(PublishEvent $publishEvent): PromiseInterface { + if (!$this->httpClient instanceof AsyncHttpClientInterface) { + try { + $this->publish($publishEvent); + return resolve(null); + } catch (\Throwable $e) { + return reject($e); + } + } + + try { + $this->authRequest(); + $data = $this->encode($publishEvent); + } catch (\Throwable $e) { + return reject($e); + } + 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 +124,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,23 +27,41 @@ 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 { + return $this->setContextPublisher($contextEventHandler); + } + public function getContextDataProvider(): ContextDataProvider { if (!isset($this->contextDataProvider)) { - $this->contextDataProvider = new ContextDataProvider($this->client); + $this->contextDataProvider = $this->client->isAsync() + ? new AsyncContextDataProvider($this->client) + : new ContextDataProvider($this->client); } 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..c7270d5 100644 --- a/src/Context/Context.php +++ b/src/Context/Context.php @@ -11,7 +11,8 @@ use ABSmartly\SDK\Exposure; use ABSmartly\SDK\GoalAchievement; use ABSmartly\SDK\PublishEvent; -use ABSmartly\SDK\SDK; +use ABSmartly\SDK\Utils; +use ABSmartly\SDK\ABsmartly; use ABSmartly\SDK\VariableParser; use ABSmartly\SDK\VariantAssigner; @@ -29,9 +30,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 +60,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 +77,67 @@ 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 { + $this->checkReady(); + $keys = []; + if (!empty($this->data->experiments)) { + foreach ($this->data->experiments as $experiment) { + if (empty($experiment->customFieldValues)) { + continue; + } + + // customFieldValues is the collector's array of {name,type,value} objects. + foreach ($experiment->customFieldValues as $field) { + if (isset($field->name)) { + $keys[$field->name] = true; + } + } + } + } + return array_keys($keys); + } + + public function getCustomFieldValueType(string $experimentName, string $key): ?string { + $this->checkReady(); + $experiment = $this->getExperiment($experimentName); + if ($experiment === null || empty($experiment->data->customFieldValues)) { + return null; + } + + foreach ($experiment->data->customFieldValues as $field) { + if (isset($field->name) && $field->name === $key) { + return $field->type ?? null; + } + } + + return 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,20 +152,34 @@ 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) { - $data = $this->data = $this->dataProvider->getContextData(); + $data = $this->dataProvider->getContextData(); } else { - $data = $this->data = $contextData; + $data = $contextData; } $this->setData($data); $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 +189,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,30 +226,67 @@ 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->data = new ContextData(); $this->failed = true; + $this->ready = 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; } - private function checkReady(): void { + public function setContextDataFailed(Throwable $error): void { + if ($this->ready) { + return; + } + + $this->setDataFailed($error); + $this->logError($error); + } + + public function setContextData(ContextData $contextData): void { + if ($this->ready) { + return; + } + + try { + $this->setData($contextData); + $this->failed = false; + $this->ready = true; + $this->logEvent(ContextEventLoggerEvent::Ready, $contextData); + } catch (Exception $exception) { + $this->setDataFailed($exception); + $this->logError($exception); + } + } + + /** + * Mirrors the JS SDK `_checkReady(expectNotFinalized)`: reads throw before + * the context is ready and, unless $expectNotFinalized is false, after it + * has been finalized. + */ + private function checkReady(bool $expectNotFinalized = true): void { if (!$this->isReady()) { - throw new LogicException('ABSmartly Context is not yet ready'); + throw new LogicException('ABsmartly Context is not yet ready.'); } - $this->checkNotClosed(); + if ($expectNotFinalized) { + $this->checkNotClosed(); + } } public function getExperiment(string $experimentName): ?ExperimentVariables { @@ -183,6 +294,7 @@ public function getExperiment(string $experimentName): ?ExperimentVariables { } public function getExperiments(): array { + $this->checkReady(false); $return = []; if (!empty($this->data->experiments)) { @@ -194,6 +306,58 @@ public function getExperiments(): array { return $return; } + public function customFieldValue(string $experimentName, string $fieldName) { + $this->checkReady(); + $experiment = $this->getExperiment($experimentName); + if ($experiment === null || empty($experiment->data->customFieldValues)) { + return null; + } + + // customFieldValues is the collector's array of {name,type,value} objects. + $field = null; + foreach ($experiment->data->customFieldValues as $candidate) { + if (isset($candidate->name) && $candidate->name === $fieldName) { + $field = $candidate; + break; + } + } + + if ($field === null || !isset($field->value)) { + return null; + } + + $value = $field->value; + $type = $field->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 +366,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 !== null ? !$result : $assignment->audienceMismatch; + + 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; @@ -218,8 +400,8 @@ private function getAssignment(string $experimentName): Assignment { // previously not-running experiment return $assignment; } - } else if (!isset($this->cassignments[$experimentName]) || $this->cassignments[$experimentName] === $assignment->variant) { - if ($this->experimentMatches($experiment->data, $assignment)) { + } else if (!array_key_exists($experimentName, $this->cassignments) || ($assignment->custom && $this->cassignments[$experimentName] === $assignment->variant)) { + if ($this->experimentMatches($experiment->data, $assignment) && $this->audienceMatches($experiment->data, $assignment)) { // assignment up-to-date return $assignment; } @@ -247,20 +429,21 @@ private function getAssignment(string $experimentName): Assignment { $attrs = $this->getAttributes(); $result = $this->audienceMatcher->evaluate($experiment->data->audience, $attrs); - $assignment->audienceMismatch = !$result; + if (is_bool($result)) { + $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); + else if (empty($experiment->data->fullOnVariant) && ($uid = $this->units[$experiment->data->unitType] ?? null) !== null) { $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,10 +479,12 @@ private function getAssignment(string $experimentName): Assignment { $assignment->fullOnVariant = $experiment->data->fullOnVariant; } - if (($experiment !== null) && ($assignment->variant < count($experiment->data->variants))) { + if (($experiment !== null) && $assignment->variant >= 0 && ($assignment->variant < count($experiment->data->variants))) { $assignment->variables = $experiment->variables[$assignment->variant]; } + $assignment->attrsSeq = $this->attrsSeq; + return $assignment; } @@ -311,14 +496,21 @@ public function getVariableValue(string $key, $defaultValue = null) { return $defaultValue; } - if (empty($assignment->exposed)) { - $this->queueExposure($assignment); + if ($assignment->variables !== null) { + if (empty($assignment->exposed)) { + $this->queueExposure($assignment); + } + + if (isset($assignment->variables->{$key}) && ($assignment->assigned || $assignment->overridden)) { + return $assignment->variables->{$key}; + } } - return $assignment->variables->{$key} ?? $defaultValue; + return $defaultValue; } public function getVariableKeys(): array { + $this->checkReady(); $return = []; foreach ($this->indexVariables as $variable => $experimentVars) { $return[$variable] = $experimentVars->data->name; @@ -327,12 +519,14 @@ public function getVariableKeys(): array { return $return; } - public function setAttribute(string $name, string $value): Context { + public function setAttribute(string $name, $value): Context { + $this->checkNotClosed(); $this->attributes[] = (object) [ 'name' => $name, 'value' => $value, 'setAt' => self::getTime(), ]; + ++$this->attrsSeq; return $this; } @@ -364,7 +558,10 @@ public function getAttributes(): array { return $result; } - private function getVariantAssigner(string $unitType, string $unitHash): VariantAssigner { + /** + * @param string|int|float $unitHash + */ + private function getVariantAssigner(string $unitType, $unitHash): VariantAssigner { return $this->assigners[$unitType] ?? ($this->assigners[$unitType] = new VariantAssigner($unitHash)); } @@ -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; } @@ -411,6 +614,7 @@ private function logError(Throwable $throwable): void { public function peekVariableValue(string $key, $default) { + $this->checkReady(); $assignment = $this->getVariableAssignment($key); if ($assignment && isset($assignment->variables->{$key})) { return $assignment->variables->{$key}; @@ -442,21 +646,25 @@ public function peekTreatment(string $experimentName): int { } + /** + * @param array $units + */ public function setUnits(array $units): Context { - // It could have been possible to simply array_merge here, but we need - // to verify strict-types, hence the foreach loop. - foreach ($units as $key => $value) { - if (!is_string($value)) { - throw new InvalidArgumentException( - sprintf('Unit set value with key "%s" must be of type string, %s passed', $key, gettype($value))); - } - - $this->setUnit($key, $value); + foreach ($units as $unitType => $uid) { + $this->setUnit((string) $unitType, $uid); } 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) { @@ -502,30 +710,87 @@ public function getCustomAssignment(string $experimentName) { return $this->cassignments[$experimentName] ?? null; } - public function setUnit(string $unitType, string $uid): Context { - if (isset($this->units[$unitType]) && $this->units[$unitType] !== $uid) { - throw new InvalidArgumentException(sprintf('Unit "%s" UID is already set', $unitType)); - } - if (trim($uid) === '') { - throw new InvalidArgumentException(sprintf('Unit "%s" UID must not be blank', $unitType)); + /** + * Units may be strings or numbers; the value is kept as given, like the + * JavaScript SDK. String UIDs are trimmed. + * + * @param string|int|float $uid + */ + public function setUnit(string $unitType, $uid): Context { + $this->checkNotClosed(); + $uid = self::validateUnit($unitType, $uid); + + if (array_key_exists($unitType, $this->units)) { + if (!self::sameUnit($this->units[$unitType], $uid)) { + throw new InvalidArgumentException(sprintf("Unit '%s' UID already set.", $unitType)); + } + return $this; } $this->units[$unitType] = $uid; return $this; } + /** + * Numbers that are the same value and hash the same compare equal (125 + * equals 125.0, as JavaScript has a single number type); distinct integers + * and strings vs numbers never do. + * + * @param string|int|float $a + * @param string|int|float $b + */ + private static function sameUnit($a, $b): bool { + if (is_string($a) || is_string($b)) { + return $a === $b; + } + + return $a == $b && Utils::unitToString($a) === Utils::unitToString($b); + } + + /** + * @param mixed $uid + * @return string|int|float + */ + public static function validateUnit(string $unitType, $uid) { + if (is_string($uid)) { + $uid = trim($uid); + if ($uid === '') { + throw new InvalidArgumentException(sprintf("Unit '%s' UID must not be blank.", $unitType)); + } + return $uid; + } + + if (is_int($uid) || is_float($uid)) { + return $uid; + } + + throw new InvalidArgumentException(sprintf("Unit '%s' must be a string or a number.", $unitType)); + } + public function getPendingCount(): int { return $this->pendingCount; } 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,11 +807,19 @@ 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. Events preserved for retry.', + count($this->exposures), + count($this->achievements), + $exception->getMessage() + )); $this->logError($exception); + throw $exception; } } @@ -560,8 +833,13 @@ private function buildPublishEvent(): PublishEvent { return $event; } - public function track(string $goalName, ?object $properties = null): void { + /** + * @param object|array|null $properties a plain object (stdClass) or an + * associative array; any other value raises an error. + */ + public function track(string $goalName, $properties = null): void { $this->checkNotClosed(); + $properties = $this->validateGoal($goalName, $properties); $achievement = new GoalAchievement($goalName, static::getTime(), $properties); $this->achievements[] = $achievement; ++$this->pendingCount; @@ -569,6 +847,25 @@ public function track(string $goalName, ?object $properties = null): void { $this->logEvent(ContextEventLoggerEvent::Goal, $achievement); } + /** + * @param mixed $properties + */ + private function validateGoal(string $goalName, $properties): ?object { + if ($properties === null) { + return null; + } + + if (Utils::isObject($properties)) { + return clone $properties; + } + + if (is_array($properties) && $properties !== [] && array_keys($properties) !== range(0, count($properties) - 1)) { + return (object) $properties; + } + + throw new InvalidArgumentException(sprintf("Goal '%s' properties must be of type object.", $goalName)); + } + public function publish(): void { $this->checkNotClosed(); $this->flush(); @@ -576,28 +873,51 @@ public function publish(): void { public function refresh(): void { $this->checkNotClosed(); + if ($this->failed) { + return; + } + $oldData = $this->data; + $oldIndex = $this->index; + $oldIndexVariables = $this->indexVariables; try { $data = $this->dataProvider->getContextData(); $this->setData($data); + $this->failed = false; $this->logEvent(ContextEventLoggerEvent::Refresh, $data); } catch (Exception $exception) { - $this->setDataFailed(); + $this->data = $oldData; + $this->index = $oldIndex; + $this->indexVariables = $oldIndexVariables; + 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) { + try { + $this->flush(); + } + catch (Exception $exception) { + $this->finalizing = false; + throw $exception; + } + } + + $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..bd9be83 100644 --- a/src/Context/ContextConfig.php +++ b/src/Context/ContextConfig.php @@ -2,11 +2,10 @@ namespace ABSmartly\SDK\Context; -use InvalidArgumentException; +use ABSmartly\SDK\Exception\InvalidArgumentException; use function gettype; use function is_int; -use function is_scalar; use function sprintf; class ContextConfig { @@ -55,31 +54,29 @@ public function setEventHandler(ContextEventHandler $eventHandler): ContextConfi return $this; } - public function setUnit(string $unitType, string $uid): ContextConfig { - if ($uid === '') { - throw new InvalidArgumentException(sprintf('Unit "%s" UID must not be blank', $unitType)); - } - - $this->units[$unitType] = $uid; + /** + * @param string|int|float $uid + */ + public function setUnit(string $unitType, $uid): ContextConfig { + $this->units[$unitType] = Context::validateUnit($unitType, $uid); return $this; } + /** + * @param array $units + */ public function setUnits(array $units): ContextConfig { - // It could have been possible to simply array_merge here, but we need - // to verify strict-types, hence the foreach loop. foreach ($units as $unitType => $uid) { - if (!is_scalar($uid)) { - throw new InvalidArgumentException( - sprintf('Unit set value with key "%s" must be of type string, %s passed', $unitType, gettype($uid))); - } - - $this->setUnit($unitType, (string) $uid); + $this->setUnit((string) $unitType, $uid); } return $this; } - public function getUnit(string $unitType): ?string { + /** + * @return string|int|float|null + */ + public function getUnit(string $unitType) { return $this->units[$unitType] ?? null; } 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->withRetries(fn() => $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->withRetries(fn() => $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->withRetries(fn() => $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)); + } + + private function withRetries(callable $request): PromiseInterface { + $maxAttempts = max(1, $this->retries); + $current = null; + $timer = null; + $cancelled = false; + + return new Promise( + function($resolve, $reject) use ($request, $maxAttempts, &$current, &$timer, &$cancelled) { + $attempt = function(int $n) use (&$attempt, $request, $maxAttempts, $resolve, $reject, &$current, &$timer, &$cancelled) { + if ($cancelled) { + return; + } + $current = $request(); + $current->then($resolve, function($error) use (&$attempt, $n, $maxAttempts, $reject, &$timer, &$cancelled) { + if ($cancelled) { + return; + } + if (!$this->isRetryable($error) || $n >= $maxAttempts - 1) { + $reject($error); + return; + } + $backoffMs = min(1000 * pow(2, $n), 10000); + error_log(sprintf( + 'ABsmartly SDK: Retrying HTTP request (attempt %d/%d) after %dms due to error: %s', + $n + 1, + $maxAttempts, + $backoffMs, + $error->getMessage() + )); + $timer = Loop::addTimer($backoffMs / 1000, function() use (&$attempt, $n, &$timer) { + $timer = null; + $attempt($n + 1); + }); + }); + }; + $attempt(0); + }, + function() use (&$current, &$timer, &$cancelled) { + $cancelled = true; + if ($timer !== null) { + Loop::cancelTimer($timer); + $timer = null; + } + if ($current !== null) { + $current->cancel(); + } + throw new \RuntimeException('HTTP request cancelled'); + } + ); + } + + private function isRetryable(\Throwable $error): bool { + if ($error instanceof ResponseException) { + $code = $error->getCode(); + return ($code >= 500 && $code < 600) || $code === 408 || $code === 429; + } + if ($error instanceof HttpClientError) { + return preg_match('/HTTP error (5\d\d|408|429)\b/', $error->getMessage()) === 1; + } + return true; + } + + 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 { + return $headers; + } + + 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/Evaluator.php b/src/JsonExpression/Evaluator.php index fdf594b..3502e94 100644 --- a/src/JsonExpression/Evaluator.php +++ b/src/JsonExpression/Evaluator.php @@ -19,7 +19,10 @@ use function is_resource; use function is_scalar; use function property_exists; -use function serialize; +use function array_keys; +use function range; +use function is_float; +use function is_int; use function strcmp; class Evaluator { @@ -206,38 +209,60 @@ private static function compareAndReturn($lhs, $rhsValue): ?int { return $lhs > $rhsValue ? 1 : -1; } - protected static function isEqualsDeep($a, $b): ?bool { - if ($a === $b) { - return true; + protected static function isEqualsDeep($a, $b): bool { + if (is_object($a)) { + $a = get_object_vars($a); + if (is_object($b)) { + return self::isEqualsDeepMap($a, get_object_vars($b)); + } + if (!is_array($b) || self::isList($b)) { + return false; + } + return self::isEqualsDeepMap($a, $b); + } + if (is_object($b)) { + return self::isEqualsDeep($b, $a); } - if (gettype($a) !== gettype($b)) { - return false; + if (is_array($a) || is_array($b)) { + if (!is_array($a) || !is_array($b)) { + return false; + } + if (count($a) !== count($b)) { + return false; + } + $aIsList = self::isList($a); + $bIsList = self::isList($b); + if ($aIsList !== $bIsList) { + return false; + } + return self::isEqualsDeepMap($a, $b); } - switch (gettype($a)) { - case 'boolean': - case 'string': - return $a === $b; // Always false - case 'integer': - case 'double': - if (is_nan($a) || is_nan($b)) { - return false; - } + if (is_float($a) && is_nan($a)) { + return is_float($b) && is_nan($b); + } - return $a === $b; // Always false. - case 'array': - if (count($a) !== count($b)) { - return false; - } - return serialize($a) === serialize($b); - case 'NULL': // Making this clause visible. - return false; - case 'object': - return serialize($a) === serialize($b); + if ((is_int($a) || is_float($a)) && (is_int($b) || is_float($b))) { + return (float) $a === (float) $b; + } - default: + return $a === $b; + } + + private static function isEqualsDeepMap(array $a, array $b): bool { + if (count($a) !== count($b)) { + return false; + } + foreach ($a as $key => $value) { + if (!array_key_exists($key, $b) || !self::isEqualsDeep($value, $b[$key])) { return false; + } } + return true; + } + + private static function isList(array $value): bool { + return $value === [] || array_keys($value) === range(0, count($value) - 1); } } diff --git a/src/JsonExpression/Operator/BinaryOperator.php b/src/JsonExpression/Operator/BinaryOperator.php index 67b36dc..6856905 100644 --- a/src/JsonExpression/Operator/BinaryOperator.php +++ b/src/JsonExpression/Operator/BinaryOperator.php @@ -16,7 +16,14 @@ public function evaluate(Evaluator $evaluator, $args = null): ?bool { } $lhs = $evaluator->evaluate($args[0]); + if ($lhs === null) { + return null; + } + $rhs = $evaluator->evaluate($args[1]); + if ($rhs === null) { + return null; + } return $this->binary($evaluator, $lhs, $rhs); } 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/PublishEvent.php b/src/PublishEvent.php index c7c2415..138a039 100644 --- a/src/PublishEvent.php +++ b/src/PublishEvent.php @@ -18,17 +18,11 @@ public function __construct() { $this->publishedAt = Context::getTime(); } - public function hashUnit(string $unit): string { - $hash = hash('md5', $unit, true); - - // Removing padding and +/ characters in the base64 encoded string. - $hash = strtr(base64_encode($hash), [ - '+' => '-', - '/' => '_', - '=' => '', - ]); - - return substr($hash, 0, 22); + /** + * @param string|int|float $unit + */ + public function hashUnit($unit): string { + return Utils::hashUnit($unit); } public function setUnits(array $units): void { diff --git a/src/SDK.php b/src/SDK.php index f30b223..ab96e41 100644 --- a/src/SDK.php +++ b/src/SDK.php @@ -2,38 +2,12 @@ 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(); - } - +/** + * @deprecated Use ABsmartly instead. This class will be removed in a future version. + */ +final class SDK extends ABsmartly { /** - * @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. + * @deprecated Use ABsmartly::createSimple() instead. */ public static function createWithDefaults( string $endpoint, @@ -44,28 +18,6 @@ public static function createWithDefaults( 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(); + return new SDK(static::buildConfig($endpoint, $apiKey, $application, $environment, $retries, $timeout, $eventLogger)); } } diff --git a/src/Utils.php b/src/Utils.php new file mode 100644 index 0000000..7d69b72 --- /dev/null +++ b/src/Utils.php @@ -0,0 +1,136 @@ + '-', + '/' => '_', + '=' => '', + ]); + } + + /** + * Returns the UTF-8 bytes of a string. PHP strings are byte strings, so a + * UTF-8 encoded string maps directly to its bytes. + * + * @return int[] + */ + public static function utf8Bytes(string $value): array { + if ($value === '') { + return []; + } + + return array_values(unpack('C*', $value)); + } + + /** + * Units may be strings or numbers. A number is hashed as its value rounded + * to an integer, like the JavaScript SDK's `value.toFixed(0)`. + * + * @param string|int|float $unit + */ + public static function unitToString($unit): string { + if (is_float($unit)) { + return self::toFixed0($unit); + } + + return (string) $unit; + } + + /** + * JavaScript `Number.prototype.toFixed(0)`: halves round away from zero, + * negative values keep their sign ("-0"), and magnitudes of 1e21 or more + * use JavaScript's exponent notation ("1e+21"). + */ + private static function toFixed0(float $value): string { + if (is_nan($value)) { + return 'NaN'; + } + + $sign = $value < 0 ? '-' : ''; + $abs = abs($value); + if (is_infinite($abs)) { + return $sign . 'Infinity'; + } + + if ($abs >= 1e21) { + // Shortest round-trip form, like JavaScript, whatever the host's + // serialize_precision setting is. + $previous = ini_set('serialize_precision', '-1'); + try { + $repr = strtolower(var_export($abs, true)); + } finally { + ini_set('serialize_precision', (string) $previous); + } + [$mantissa, $exponent] = explode('e', $repr); + if (substr($mantissa, -2) === '.0') { + $mantissa = substr($mantissa, 0, -2); + } + return $sign . $mantissa . 'e' . ($exponent[0] === '-' ? '' : '+') . ltrim($exponent, '+'); + } + + $floor = floor($abs); + $rounded = ($abs - $floor) >= 0.5 ? $floor + 1 : $floor; + + return $sign . sprintf('%.0f', $rounded); + } + + /** + * @param string|int|float $unit + */ + public static function hashUnit($unit): string { + return self::base64UrlNoPadding(hash('md5', self::unitToString($unit), true)); + } + + /** + * @param mixed $value + */ + public static function isNumeric($value): bool { + return is_int($value) || is_float($value); + } + + /** + * True for plain objects (stdClass, e.g. a decoded JSON object). + * + * @param mixed $value + */ + public static function isObject($value): bool { + return is_object($value) && get_class($value) === \stdClass::class; + } + + /** + * @param mixed $value + */ + public static function isPromise($value): bool { + return $value instanceof PromiseInterface; + } +} 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/src/VariantAssigner.php b/src/VariantAssigner.php index b377a52..733ca1c 100644 --- a/src/VariantAssigner.php +++ b/src/VariantAssigner.php @@ -27,17 +27,11 @@ final class VariantAssigner { */ private int $unitHash; - public function __construct(string $unit) { - $hash = hash('md5', $unit, true); - - // Removing padding and +/ characters in the base64 encoded string. - $hash = strtr(base64_encode($hash), [ - '+' => '-', - '/' => '_', - '=' => '', - ]); - - $this->unitHash = $this->digest($hash); + /** + * @param string|int|float $unit + */ + public function __construct($unit) { + $this->unitHash = $this->digest(Utils::hashUnit($unit)); } /** 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..5de16b1 --- /dev/null +++ b/tests/AudienceMatcherTest.php @@ -0,0 +1,45 @@ +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, [])); + + self::assertNull($this->matcher->evaluate((object) ['filter' => 'invalid'], [])); + self::assertNull($this->matcher->evaluate((object) ['filter' => 1], [])); + + $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..bb10eb6 --- /dev/null +++ b/tests/Client/ClientAsyncTest.php @@ -0,0 +1,147 @@ +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->expects(self::once())->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); + + $result = null; + $promise->then(function($data) use (&$result) { + $result = $data; + }); + self::assertInstanceOf(ContextData::class, $result); + } + + 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->expects(self::once())->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); + $settled = false; + $promise->then(function() use (&$settled) { + $settled = true; + }); + self::assertTrue($settled); + } + + 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/ConfigAsyncProviderTest.php b/tests/ConfigAsyncProviderTest.php new file mode 100644 index 0000000..c2f5e35 --- /dev/null +++ b/tests/ConfigAsyncProviderTest.php @@ -0,0 +1,23 @@ +createMock(AsyncHttpClientInterface::class)); + self::assertInstanceOf(AsyncContextDataProvider::class, (new Config($client))->getContextDataProvider()); + } + + public function testSyncTransportKeepsSyncProvider(): void { + $client = new Client(new ClientConfig('https://example.com', 'key', 'app', 'env'), $this->createMock(HttpClientInterface::class)); + self::assertNotInstanceOf(AsyncContextDataProvider::class, (new Config($client))->getContextDataProvider()); + } +} 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/ContextConfigTest.php b/tests/Context/ContextConfigTest.php index ec3aa04..aa55998 100644 --- a/tests/Context/ContextConfigTest.php +++ b/tests/Context/ContextConfigTest.php @@ -15,13 +15,13 @@ public function testInitParams(): void { public function testUnits(): void { $contextConfig = new ContextConfig(); $contextConfig->setUnit('foo', 'foo-value'); // Value retention - $contextConfig->setUnit('bar', 2); // Automatic casting to string + $contextConfig->setUnit('bar', 2); $contextConfig->setUnit('baz', 'old-baz-value'); // Old value $contextConfig->setUnit('baz', 'new-baz-value'); // New value is overwritten $contextConfig->setUnit('baz', 'new-baz-value'); // New value is overwritten self::assertSame('foo-value', $contextConfig->getUnit('foo')); - self::assertSame('2', $contextConfig->getUnit('bar')); + self::assertSame(2, $contextConfig->getUnit('bar')); self::assertSame('new-baz-value', $contextConfig->getUnit('baz')); self::assertNull($contextConfig->getUnit('does-not-exist')); @@ -37,11 +37,11 @@ public function testUnits(): void { self::assertCount(5, $contextConfig->getUnits()); - self::assertSame('1', $contextConfig->getUnit('x')); + self::assertSame(1, $contextConfig->getUnit('x')); self::assertSame('mango', $contextConfig->getUnit('y')); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unit set value with key "x" must be of type string, array passed'); + $this->expectExceptionMessage("Unit 'x' must be a string or a number."); $contextConfig->setUnits( [ diff --git a/tests/Context/ContextConformanceTest.php b/tests/Context/ContextConformanceTest.php new file mode 100644 index 0000000..3988df9 --- /dev/null +++ b/tests/Context/ContextConformanceTest.php @@ -0,0 +1,245 @@ +setContextDataProvider($provider); + $config->setContextEventHandler(new ContextEventHandlerMock($client)); + return new SDK($config); + } + + private function context(?MockContextEventLoggerProxy $logger = null): Context { + $cfg = new ContextConfig(); + if ($logger) { + $cfg->setEventLogger($logger); + } + $cfg->setUnit('session_id', 'abc'); + return $this->sdk(new EmptyProvider())->createContext($cfg); + } + + private function settle(PromiseInterface $promise) { + $out = null; + $promise->then(function($v) use (&$out) { $out = $v; }, function($e) use (&$out) { $out = $e; }); + return $out; + } + + public function testSetUnitAlreadySetMessageMatchesOtherSdks(): void { + $context = $this->context(); + $this->expectExceptionMessage("Unit 'session_id' UID already set."); + $context->setUnit('session_id', 'other'); + } + + public function testNumericUnitsKeepTheirType(): void { + $cfg = new ContextConfig(); + $cfg->setUnits(['user_id' => 125, 'float_id' => 125.75, 'session_id' => 'abc']); + $context = $this->sdk(new EmptyProvider())->createContext($cfg); + $context->setUnit('other_id', 12345); + + self::assertSame(125, $context->getUnit('user_id')); + self::assertSame(125.75, $context->getUnit('float_id')); + self::assertSame(12345, $context->getUnit('other_id')); + self::assertSame('abc', $context->getUnit('session_id')); + } + + public function testStringUnitThatLooksNumericStaysAString(): void { + $context = $this->context(); + $context->setUnit('user_id', '007'); + self::assertSame('007', $context->getUnit('user_id')); + } + + public function testNumericUnitHashesLikeItsRoundedIntegerString(): void { + // Expected values are the JavaScript SDK's hashUnit() output. + self::assertSame('BpBZt--EDwx0qBTskje27A', Utils::hashUnit(125.75)); + self::assertSame('BpBZt--EDwx0qBTskje27A', Utils::hashUnit('126')); + self::assertSame('815ubasUW2p7sFI440lKaw', Utils::hashUnit(-0.1)); + self::assertSame('BpBZt--EDwx0qBTskje27A', (new PublishEvent())->hashUnit(125.75)); + self::assertNotSame(Utils::hashUnit(125), Utils::hashUnit(125.75)); + } + + public function testNumericUnitStringFormMatchesJavascriptToFixed(): void { + self::assertSame('126', Utils::unitToString(125.75)); + self::assertSame('125', Utils::unitToString(125)); + self::assertSame('-0', Utils::unitToString(-0.1)); + self::assertSame('0', Utils::unitToString(-0.0)); + self::assertSame('-1', Utils::unitToString(-0.5)); + self::assertSame('1', Utils::unitToString(0.5)); + self::assertSame('1e+21', Utils::unitToString(1e21)); + self::assertSame('1.5e+21', Utils::unitToString(1.5e21)); + self::assertSame('-1e+21', Utils::unitToString(-1e21)); + self::assertSame('999999999999999868928', Utils::unitToString(999999999999999868928.0)); + } + + public function testLargeNumericUnitIgnoresSerializePrecision(): void { + $previous = ini_get('serialize_precision'); + try { + foreach (['17', '14', '-1'] as $precision) { + ini_set('serialize_precision', $precision); + self::assertSame('1e+23', Utils::unitToString(1e23)); + self::assertSame('C2kIOZMVyzHIQ9hfVjhzvw', Utils::hashUnit(1e23)); + self::assertSame($precision, ini_get('serialize_precision')); + } + } finally { + ini_set('serialize_precision', $previous); + } + } + + public function testEqualNumericUnitsAreNotAConflict(): void { + $cfg = new ContextConfig(); + $cfg->setUnit('user_id', 125); + $context = $this->sdk(new EmptyProvider())->createContext($cfg); + $context->setUnit('user_id', 125.0); + self::assertSame(125, $context->getUnit('user_id')); + + $this->expectExceptionMessage("Unit 'user_id' UID already set."); + $context->setUnit('user_id', '125'); + } + + public function testDistinctLargeIntegerUnitsConflict(): void { + $context = $this->context(); + $context->setUnit('user_id', 9007199254740992); + $this->expectExceptionMessage("Unit 'user_id' UID already set."); + $context->setUnit('user_id', 9007199254740993); + } + + public function testInvalidUnitTypeIsRejected(): void { + $context = $this->context(); + $this->expectExceptionMessage("Unit 'x' must be a string or a number."); + $context->setUnit('x', true); + } + + public function testTrackRejectsNonObjectProperties(): void { + foreach ([125.0, 'text', true, [1, 2], []] as $bad) { + $context = $this->context(); + try { + $context->track('goal', $bad); + self::fail('expected an error for ' . json_encode($bad)); + } catch (\InvalidArgumentException $e) { + self::assertSame("Goal 'goal' properties must be of type object.", $e->getMessage()); + } + self::assertSame(0, $context->getPendingCount()); + } + } + + public function testTrackAcceptsObjectsAndAssociativeArrays(): void { + $context = $this->context(); + $context->track('a', (object) ['x' => 1]); + $context->track('b', ['x' => 1]); + $context->track('d'); + self::assertSame(3, $context->getPendingCount()); + } + + public function testRejectedAsyncLoadLeavesContextReadyAndFailed(): void { + $logger = new MockContextEventLoggerProxy(); + $cfg = new ContextConfig(); + $cfg->setUnit('session_id', 'abc'); + $cfg->setEventLogger($logger); + $result = $this->sdk(new RejectingAsyncProvider())->createContextPending($cfg); + $settled = $this->settle($result['promise']); + + $context = $result['context']; + self::assertSame($context, $settled); + self::assertTrue($context->isReady()); + self::assertTrue($context->isFailed()); + self::assertSame('Context load failed', $context->readyError()->getMessage()); + self::assertSame(0, $context->getTreatment('any')); + self::assertSame(ContextEventLoggerEvent::Error, $logger->events[0]->getEvent()); + } + + public function testRejectedAsyncLoadResolvesCreateContextAsyncWithFailedContext(): void { + $logger = new MockContextEventLoggerProxy(); + $cfg = new ContextConfig(); + $cfg->setUnit('session_id', 'abc'); + $cfg->setEventLogger($logger); + $context = $this->settle($this->sdk(new RejectingAsyncProvider())->createContextAsync($cfg)); + + self::assertInstanceOf(Context::class, $context); + self::assertTrue($context->isReady()); + self::assertTrue($context->isFailed()); + self::assertSame(ContextEventLoggerEvent::Error, $logger->events[0]->getEvent()); + } + + public function testUtilsMatchJavascriptHelpers(): void { + self::assertSame([97, 195, 167, 98, 226, 134, 147, 99], Utils::utf8Bytes("a\u{e7}b\u{2193}c")); + self::assertSame([], Utils::utf8Bytes('')); + self::assertSame('AQID', Utils::base64UrlNoPadding("\x01\x02\x03")); + self::assertSame('-_8', Utils::base64UrlNoPadding("\xfb\xff")); + self::assertTrue(Utils::isNumeric(1)); + self::assertTrue(Utils::isNumeric(1.5)); + self::assertFalse(Utils::isNumeric('1')); + self::assertTrue(Utils::isObject(new \stdClass())); + self::assertFalse(Utils::isObject([])); + self::assertFalse(Utils::isObject(new \ArrayObject())); + self::assertTrue(Utils::isPromise(resolve(1))); + self::assertFalse(Utils::isPromise((object) ['then' => 'noop'])); + } + + public function testFailedFinalPublishKeepsContextOpenAndRetryable(): void { + $client = new Client(new ClientConfig('https://example.com', 'k', 'a', 'e')); + $config = new Config($client); + $config->setContextDataProvider(new EmptyProvider()); + $handler = new ContextEventHandlerMock($client); + $config->setContextEventHandler($handler); + $cfg = new ContextConfig(); + $cfg->setUnit('session_id', 'abc'); + $context = (new SDK($config))->createContext($cfg); + + $handler->prerun = static function() { throw new \RuntimeException('Publish failed'); }; + $context->track('goal'); + try { + $context->close(); + self::fail('expected the publish error'); + } catch (\RuntimeException $e) { + self::assertSame('Publish failed', $e->getMessage()); + } + self::assertFalse($context->isClosed()); + self::assertFalse($context->isFinalizing()); + self::assertSame(1, $context->getPendingCount()); + + $handler->prerun = null; + $context->close(); + self::assertTrue($context->isClosed()); + self::assertSame(0, $context->getPendingCount()); + } +} diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index 78bbe92..21436d1 100644 --- a/tests/Context/ContextTest.php +++ b/tests/Context/ContextTest.php @@ -278,7 +278,7 @@ public function testSetUnitEmpty(): void { $context = $this->createReadyContext(); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unit "db_user_id" UID must not be blank'); + $this->expectExceptionMessage("Unit 'db_user_id' UID must not be blank."); $context->setUnit('db_user_id', ''); } @@ -289,7 +289,7 @@ public function testSetUnitThrowsOnAlreadySet(): void { $context->setUnit('session_id', $this->units['session_id']); // This should be allowed $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unit "session_id" UID is already set'); + $this->expectExceptionMessage("Unit 'session_id' UID already set."); $context->setUnit('session_id', 'new-uid'); } @@ -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); } @@ -472,6 +472,22 @@ public function testGetVariableValueQueuesExposureWithAudienceMismatchTrueOnAudi self::assertTrue($this->eventHandler->submitted[0]->exposures[0]->audienceMismatch); } + public function testInvalidAudienceFilterDoesNotFlagAudienceMismatch(): void { + // The matcher returns null (undecided) for a non-object/array filter; JS + // context.ts only sets audienceMismatch for a boolean result. + $context = $this->createReadyContext('audience_invalid_filter_context.json'); + + self::assertSame("large", $context->getVariableValue('banner.size', 'small')); + + $context->setAttribute('age', 21); + self::assertSame("large", $context->getVariableValue('banner.size', 'small')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + self::assertSame('exp_test_ab', $this->eventHandler->submitted[0]->exposures[0]->name); + self::assertFalse($this->eventHandler->submitted[0]->exposures[0]->audienceMismatch); + } + // Function name yikes! public function testGetVariableValueQueuesExposureWithAudienceMismatchFalseAndControlVariantOnAudienceMismatchInStrictMode(): void { $context = $this->createReadyContext('audience_strict_context.json'); @@ -634,7 +650,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); } @@ -762,7 +778,12 @@ public function testPublishCallsEventLoggerOnError(): void { $logger->clear(); - $context->publish(); + try { + $context->publish(); + self::fail('Expected publish() to throw'); + } catch (\RuntimeException $exception) { + self::assertSame('Trigger failure', $exception->getMessage()); + } self::assertSame(ContextEventLoggerEvent::Error, $logger->events[0]->getEvent()); } @@ -792,7 +813,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 +830,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 +891,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 +903,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 { @@ -896,7 +917,13 @@ public function testCloseCallsEventLoggerOnError(): void { $logger->clear(); - $context->close(); + try { + $context->close(); + self::fail('close() must surface the publish error'); + } catch (\RuntimeException $e) { + } + self::assertFalse($context->isClosed()); + self::assertSame(1, $context->getPendingCount()); self::assertSame(ContextEventLoggerEvent::Error, $logger->events[0]->getEvent()); } @@ -927,7 +954,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 +1167,1530 @@ 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); + + // Refresh on a failed context is a successful no-op (matches JS SDK). + $context->refresh(); + self::assertSame(1, $callCount); + self::assertTrue($context->isFailed()); + self::assertTrue($context->isReady()); + } + + public function testFailedContextIsReadyWithEmptyData(): 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); + $contextConfig->setUnits($this->units); + $context = (new SDK($config))->createContext($contextConfig); + + self::assertTrue($context->isReady()); + self::assertTrue($context->isFailed()); + self::assertInstanceOf(\ABSmartly\SDK\Context\ContextData::class, $context->getContextData()); + self::assertSame([], $context->getContextData()->experiments); + self::assertSame(0, $context->getTreatment('exp_test_ab')); + self::assertSame(17, $context->getVariableValue('banner.size', 17)); + } + + public function testPendingContextFailureBecomesReady(): void { + $clientConfig = new ClientConfig('https://demo.absmartly.io/v1', '', '', ''); + $client = new Client($clientConfig); + $config = new Config($client); + $dataProvider = new ContextDataProviderMock($client); + $eventHandler = new ContextEventHandlerMock($client); + $contextConfig = new ContextConfig(); + $contextConfig->setUnits($this->units); + $context = \ABSmartly\SDK\Context\Context::createPending(new SDK($config), $contextConfig, $dataProvider, $eventHandler); + self::assertFalse($context->isReady()); + + $method = new \ReflectionMethod($context, 'setDataFailed'); + $method->setAccessible(true); + $method->invoke($context, new \RuntimeException('boom')); + + self::assertTrue($context->isReady()); + self::assertTrue($context->isFailed()); + self::assertSame(0, $context->getTreatment('exp_test_ab')); + } + + /* + * ============================================================================= + * 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'); + + try { + $context->publish(); + self::fail('Expected publish() to throw'); + } catch (\RuntimeException $exception) { + self::assertSame('Network timeout', $exception->getMessage()); + } + + self::assertFalse($context->isFailed()); + self::assertSame(1, $context->getPendingCount()); + + $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'); + + try { + $context->publish(); + self::fail('Expected publish() to throw'); + } catch (\RuntimeException $exception) { + self::assertSame('Handler error', $exception->getMessage()); + } + + $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); + $context->getTreatment('exp_test_ab'); + + $context->publish(); + $this->eventHandler->submitted = []; + + $context->setAttribute('age', 25); + $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(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('ABsmartly Context is finalized.'); + $context->setUnit('new_unit', 'value'); + } + + public function testSetAttributeAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('ABsmartly Context is finalized.'); + $context->setAttribute('attr1', 'value1'); + } + + public function testSetAttributeBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setAttribute('attr1', 'value1'); + self::assertSame('value1', $contextConfig->getAttribute('attr1')); + } + + public function testPeekTreatmentDoesNotQueueExposures(): void { + $context = $this->createReadyContext(); + + foreach ($context->getContextData()->experiments as $experiment) { + $context->peekTreatment($experiment->name); + } + + $context->peekTreatment('not_found'); + self::assertSame(0, $context->getPendingCount()); + } + + public function testTreatmentQueuesExposureAfterPeek(): void { + $context = $this->createReadyContext(); + + $context->peekTreatment('exp_test_ab'); + self::assertSame(0, $context->getPendingCount()); + + $context->getTreatment('exp_test_ab'); + self::assertSame(1, $context->getPendingCount()); + } + + public function testTreatmentQueuesExposureOnlyOnce(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->getTreatment('exp_test_ab'); + $context->getTreatment('exp_test_ab'); + + self::assertSame(1, $context->getPendingCount()); + } + + public function testTreatmentQueuesExposureWithBaseVariantOnUnknownExperiment(): void { + $context = $this->createReadyContext(); + + self::assertSame(0, $context->getTreatment('unknown_experiment')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('unknown_experiment', $event->exposures[0]->name); + self::assertSame(0, $event->exposures[0]->variant); + self::assertFalse($event->exposures[0]->assigned); + } + + public function testTreatmentDoesNotReQueueExposureOnUnknownExperiment(): void { + $context = $this->createReadyContext(); + + self::assertSame(0, $context->getTreatment('unknown_experiment')); + self::assertSame(0, $context->getTreatment('unknown_experiment')); + self::assertSame(1, $context->getPendingCount()); + } + + public function testTreatmentQueuesExposureWithOverrideVariant(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 5); + self::assertSame(5, $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('exp_test_ab', $event->exposures[0]->name); + self::assertSame(5, $event->exposures[0]->variant); + self::assertTrue($event->exposures[0]->overridden); + } + + public function testTreatmentQueuesExposureWithCustomAssignmentVariant(): void { + $context = $this->createReadyContext(); + + $context->setCustomAssignment('exp_test_ab', 2); + self::assertSame(2, $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('exp_test_ab', $event->exposures[0]->name); + self::assertSame(2, $event->exposures[0]->variant); + self::assertTrue($event->exposures[0]->custom); + } + + public function testVariableValueDefaultWhenUnassigned(): void { + $context = $this->createReadyContext(); + self::assertSame('default', $context->getVariableValue('nonexistent_variable', 'default')); + } + + public function testVariableValueWhenOverridden(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 1); + + self::assertSame(1, $context->getVariableValue('banner.border', 0)); + self::assertSame('large', $context->getVariableValue('banner.size', 'small')); + } + + public function testVariableValueQueuesExposureAfterPeekVariable(): void { + $context = $this->createReadyContext(); + + $context->peekVariableValue('banner.border', 0); + self::assertSame(0, $context->getPendingCount()); + + $context->getVariableValue('banner.border', 0); + self::assertSame(1, $context->getPendingCount()); + } + + public function testVariableValueQueuesExposureOnlyOnce(): void { + $context = $this->createReadyContext(); + + $context->getVariableValue('banner.border', 0); + $context->getVariableValue('banner.border', 0); + $context->getVariableValue('banner.size', 'small'); + + self::assertSame(1, $context->getPendingCount()); + } + + public function testVariableValueReturnsDefaultOnUnknownVariable(): void { + $context = $this->createReadyContext(); + self::assertSame(42, $context->getVariableValue('completely_unknown_var', 42)); + } + + public function testVariableValueThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $this->expectExceptionMessage('ABsmartly Context is finalized.'); + $context->getVariableValue('banner.border', 0); + } + + public function testPeekVariableValueDefaultWhenUnassigned(): void { + $context = $this->createReadyContext(); + self::assertSame('default', $context->peekVariableValue('nonexistent_variable', 'default')); + } + + public function testPeekVariableValueWhenOverridden(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 1); + + self::assertSame(1, $context->peekVariableValue('banner.border', 0)); + self::assertSame('large', $context->peekVariableValue('banner.size', 'small')); + } + + public function testPeekVariableValueDoesNotQueueExposure(): void { + $context = $this->createReadyContext(); + + $context->peekVariableValue('banner.border', 0); + $context->peekVariableValue('banner.size', 'small'); + $context->peekVariableValue('button.color', 'blue'); + + self::assertSame(0, $context->getPendingCount()); + } + + public function testTrackQueuesGoals(): void { + $context = $this->createReadyContext(); + + $context->track('goal1', (object) ['amount' => 125]); + $context->track('goal2', (object) ['tries' => 7]); + + self::assertSame(2, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('goal1', $event->goals[0]->name); + self::assertSame('goal2', $event->goals[1]->name); + self::assertSame(125, $event->goals[0]->properties->amount); + self::assertSame(7, $event->goals[1]->properties->tries); + } + + public function testTrackDoesNotThrowWithNumberProperties(): void { + $context = $this->createReadyContext(); + + $context->track('goal1', (object) ['amount' => 125, 'hours' => 245.5]); + self::assertSame(1, $context->getPendingCount()); + } + + public function testTrackAcceptsNullProperties(): void { + $context = $this->createReadyContext(); + + $context->track('goal1'); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertNull($event->goals[0]->properties); + } + + public function testTrackCallableBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setUnits($this->units); + $context = $this->createContext($contextConfig); + + $context->track('goal1', (object) ['amount' => 100]); + self::assertSame(1, $context->getPendingCount()); + } + + public function testTrackThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $context->track('goal1'); + } + + public function testTrackQueuesGoalsWithTimestamp(): void { + $context = $this->createReadyContext(); + + $timeBefore = (int) (microtime(true) * 1000); + $context->track('goal1', (object) ['amount' => 100]); + $timeAfter = (int) (microtime(true) * 1000); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertGreaterThanOrEqual($timeBefore, $event->goals[0]->achievedAt); + self::assertLessThanOrEqual($timeAfter, $event->goals[0]->achievedAt); + } + + public function testPublishShouldNotCallClientPublishWhenQueueIsEmpty(): void { + $context = $this->createReadyContext(); + $context->publish(); + self::assertEmpty($this->eventHandler->submitted); + } + + public function testPublishShouldCallClientPublish(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->publish(); + + self::assertCount(1, $this->eventHandler->submitted); + self::assertNotEmpty($this->eventHandler->submitted[0]->exposures); + } + + public function testPublishShouldIncludeExposureData(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('exp_test_ab', $event->exposures[0]->name); + self::assertSame(1, $event->exposures[0]->variant); + } + + public function testPublishShouldIncludeGoalData(): void { + $context = $this->createReadyContext(); + + $context->track('test_goal', (object) ['revenue' => 99]); + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('test_goal', $event->goals[0]->name); + self::assertSame(99, $event->goals[0]->properties->revenue); + } + + public function testPublishShouldIncludeAttributeData(): void { + $context = $this->createReadyContext(); + + $context->setAttribute('user_type', 'premium'); + $context->getTreatment('exp_test_ab'); + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + $attributeNames = array_map(fn($attr) => $attr->name, $event->attributes); + self::assertContains('user_type', $attributeNames); + } + + public function testPublishShouldClearQueueOnSuccess(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + self::assertSame(1, $context->getPendingCount()); + + $context->publish(); + self::assertSame(0, $context->getPendingCount()); + } + + public function testPublishShouldNotClearQueueOnFailure(): void { + $context = $this->createReadyContext(); + $this->eventHandler->prerun = static function() { + throw new \RuntimeException('Publish failed'); + }; + + $context->track('goal1'); + + try { + $context->publish(); + self::fail('Expected publish() to throw'); + } catch (\RuntimeException $exception) { + self::assertSame('Publish failed', $exception->getMessage()); + } + + self::assertSame(1, $context->getPendingCount()); + } + + public function testPublishThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $context->publish(); + } + + public function testFinalizeShouldNotCallClientPublishWhenQueueIsEmpty(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertEmpty($this->eventHandler->submitted); + self::assertTrue($context->isClosed()); + } + + public function testFinalizeShouldCallClientPublish(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->close(); + + self::assertCount(1, $this->eventHandler->submitted); + } + + public function testFinalizeShouldIncludeExposureData(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->close(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('exp_test_ab', $event->exposures[0]->name); + } + + public function testFinalizeShouldIncludeGoalData(): void { + $context = $this->createReadyContext(); + + $context->track('goal1', (object) ['value' => 50]); + $context->close(); + + $event = $this->eventHandler->submitted[0]; + self::assertSame('goal1', $event->goals[0]->name); + } + + public function testFinalizeShouldIncludeAttributeData(): void { + $context = $this->createReadyContext(); + + $context->setAttribute('plan', 'pro'); + $context->getTreatment('exp_test_ab'); + $context->close(); + + $event = $this->eventHandler->submitted[0]; + $attributeNames = array_map(fn($attr) => $attr->name, $event->attributes); + self::assertContains('plan', $attributeNames); + } + + public function testFinalizeShouldClearQueueOnSuccess(): void { + $context = $this->createReadyContext(); + + $context->getTreatment('exp_test_ab'); + $context->close(); + + self::assertTrue($context->isClosed()); + } + + public function testOverrideCallableBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setOverride('exp_test', 5); + self::assertSame(5, $contextConfig->getOverride('exp_test')); + } + + public function testCustomAssignmentOverridesNaturalAssignment(): void { + $context = $this->createReadyContext(); + + self::assertSame($this->expectedVariants['exp_test_ab'], $context->getTreatment('exp_test_ab')); + self::assertSame(1, $context->getPendingCount()); + + $context->setCustomAssignment('exp_test_ab', 2); + self::assertSame(2, $context->getTreatment('exp_test_ab')); + self::assertSame(2, $context->getPendingCount()); + + $context->publish(); + + $event = $this->eventHandler->submitted[0]; + $lastExposure = $event->exposures[count($event->exposures) - 1]; + self::assertSame('exp_test_ab', $lastExposure->name); + self::assertSame(2, $lastExposure->variant); + self::assertTrue($lastExposure->custom); + } + + public function testCustomAssignmentCallableBeforeReady(): void { + $contextConfig = new ContextConfig(); + $contextConfig->setCustomAssignment('exp_test', 3); + self::assertSame(3, $contextConfig->getCustomAssignment('exp_test')); + } + + public function testCustomAssignmentAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $context->setCustomAssignment('exp_test', 1); + self::assertSame(1, $context->getCustomAssignment('exp_test')); + } + + public function testRefreshKeepsOverrides(): void { + $context = $this->createReadyContext(); + + $context->setOverride('exp_test_ab', 5); + self::assertSame(5, $context->getTreatment('exp_test_ab')); + + $this->getContextData('refreshed.json'); + $context->refresh(); + + self::assertSame(5, $context->getTreatment('exp_test_ab')); + } + + public function testRefreshKeepsCustomAssignments(): void { + $context = $this->createReadyContext(); + + $context->setCustomAssignment('exp_test_ab', 2); + self::assertSame(2, $context->getTreatment('exp_test_ab')); + + $this->getContextData('refreshed.json'); + $context->refresh(); + + self::assertSame(2, $context->getTreatment('exp_test_ab')); + } + + public function testRefreshClearsAssignmentCacheForIterationChange(): void { + $context = $this->createReadyContext(); + self::assertTrue($context->isReady()); + + $experimentName = "exp_test_abc"; + + self::assertSame(2, $context->getTreatment($experimentName)); + self::assertSame(0, $context->getTreatment('not_found')); + self::assertSame(2, $context->getPendingCount()); + + $this->getContextData('refreshed_iteration.json'); + $context->refresh(); + + self::assertSame(2, $context->getTreatment($experimentName)); + self::assertSame(0, $context->getTreatment('not_found')); + + self::assertSame(3, $context->getPendingCount()); + } + + public function testRefreshThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $context->refresh(); + } + + public function testTreatmentThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $this->expectExceptionMessage('ABsmartly Context is finalized.'); + $context->getTreatment('exp_test_ab'); + } + + public function testPeekTreatmentThrowsAfterFinalized(): void { + $context = $this->createReadyContext(); + $context->close(); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $this->expectExceptionMessage('ABsmartly Context is finalized.'); + $context->peekTreatment('exp_test_ab'); + } + + /** + * D1: reads before ready throw, matching JS context.ts _checkReady. + * @dataProvider readMethodsProvider + */ + public function testReadsThrowBeforeReady(string $method, array $args): void { + $client = new Client(new ClientConfig('https://demo.absmartly.io/v1', '', '', '')); + $config = new Config($client); + $dataProvider = new ContextDataProviderMock($client); + $eventHandler = new ContextEventHandlerMock($client); + $config->setContextDataProvider($dataProvider); + $config->setContextEventHandler($eventHandler); + $context = Context::createPending(new SDK($config), new ContextConfig(), $dataProvider, $eventHandler); + self::assertFalse($context->isReady()); + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $this->expectExceptionMessage('ABsmartly Context is not yet ready.'); + $context->{$method}(...$args); + } + + /** + * D1: reads after finalize throw, except getExperiments (JS experiments() uses _checkReady() without finalize check). + * @dataProvider readMethodsProvider + */ + public function testReadsThrowAfterFinalized(string $method, array $args): void { + $context = $this->createReadyContext('context_custom_fields.json'); + $context->close(); + + if ($method === 'getExperiments') { + self::assertIsArray($context->getExperiments()); + return; + } + + $this->expectException(\ABSmartly\SDK\Exception\LogicException::class); + $this->expectExceptionMessage('ABsmartly Context is finalized.'); + $context->{$method}(...$args); + } + + public function readMethodsProvider(): array { + return [ + 'getTreatment' => ['getTreatment', ['exp_test_ab']], + 'peekTreatment' => ['peekTreatment', ['exp_test_ab']], + 'getVariableValue' => ['getVariableValue', ['banner.border', 0]], + 'peekVariableValue' => ['peekVariableValue', ['banner.border', 0]], + 'getVariableKeys' => ['getVariableKeys', []], + 'getExperiments' => ['getExperiments', []], + 'getCustomFieldKeys' => ['getCustomFieldKeys', []], + 'customFieldValue' => ['customFieldValue', ['exp_test_ab', 'country']], + 'getCustomFieldValueType' => ['getCustomFieldValueType', ['exp_test_ab', 'country']], + ]; + } + + 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(); + try { + $context->close(); + self::fail('close() must surface the publish error'); + } catch (\RuntimeException $e) { + } + self::assertFalse($context->isClosed()); + self::assertSame(1, $context->getPendingCount()); + + 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(); + try { + $context->close(); + self::fail('close() must surface the publish error'); + } catch (\RuntimeException $e) { + } + self::assertFalse($context->isClosed()); + self::assertSame(1, $context->getPendingCount()); + + $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(); + + try { + $context->publish(); + self::fail('Expected publish() to throw'); + } catch (\RuntimeException $exception) { + self::assertSame('Connection refused', $exception->getMessage()); + } + + $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::assertFalse($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::assertFalse($context->isFailed()); + self::assertTrue($context->isReady()); + } + + public function testClosedContextRejectsWriteOperations(): void { + $context = $this->createReadyContext(); + $context->close(); + + self::assertTrue($context->isClosed()); + + $thrownForTreatment = false; + try { + $context->getTreatment('exp_test_ab'); + } catch (\ABSmartly\SDK\Exception\LogicException $e) { + $thrownForTreatment = true; + } + self::assertTrue($thrownForTreatment); + + $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/audience_invalid_filter_context.json b/tests/Fixtures/json/audience_invalid_filter_context.json new file mode 100644 index 0000000..aca276c --- /dev/null +++ b/tests/Fixtures/json/audience_invalid_filter_context.json @@ -0,0 +1,39 @@ +{ + "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": "{\"filter\":\"invalid\"}" + } + ] +} diff --git a/tests/Fixtures/json/context_custom_fields.json b/tests/Fixtures/json/context_custom_fields.json new file mode 100644 index 0000000..3ac170e --- /dev/null +++ b/tests/Fixtures/json/context_custom_fields.json @@ -0,0 +1,121 @@ +{ + "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": [ + { + "name": "country", + "type": "string", + "value": "US,UK,ES" + }, + { + "name": "description", + "type": "text", + "value": "Test experiment for AB testing" + }, + { + "name": "min_age", + "type": "number", + "value": "18" + }, + { + "name": "enabled", + "type": "boolean", + "value": "true" + }, + { + "name": "config", + "type": "json", + "value": "{\"color\":\"red\",\"size\":10}" + }, + { + "name": "decimal_val", + "type": "number", + "value": "3.14" + }, + { + "name": "is_active", + "type": "boolean_flag", + "value": "false" + }, + { + "name": "disabled", + "type": "boolean", + "value": "0" + } + ] + }, + { + "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": "" + } + ] +} \ No newline at end of file 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/LocalServerIntegrationTest.php b/tests/Http/LocalServerIntegrationTest.php new file mode 100644 index 0000000..3d319c8 --- /dev/null +++ b/tests/Http/LocalServerIntegrationTest.php @@ -0,0 +1,147 @@ +logFile = tempnam(sys_get_temp_dir(), 'absmartly_reqs_'); + file_put_contents($this->logFile, ''); + + $router = __DIR__ . '/local_server_router.php'; + $this->port = $this->findFreePort(); + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + // Array form avoids a cmd.exe wrapper on Windows; getenv() (not $_ENV, often + // empty) keeps SystemRoot etc., without which the server can't open sockets. + $cmd = [PHP_BINARY, '-S', "127.0.0.1:{$this->port}", $router]; + $env = ['ABSMARTLY_TEST_LOG' => $this->logFile] + getenv(); + $this->serverProcess = proc_open($cmd, $descriptors, $this->pipes, null, $env); + self::assertIsResource($this->serverProcess, 'failed to start PHP built-in server'); + + $deadline = microtime(true) + 15.0; + while (microtime(true) < $deadline) { + $conn = @stream_socket_client("tcp://127.0.0.1:{$this->port}", $errno, $errstr, 0.2); + if (is_resource($conn)) { + fclose($conn); + return; + } + usleep(50000); + } + self::fail('local server did not become ready'); + } + + protected function tearDown(): void { + foreach ($this->pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + if (is_resource($this->serverProcess)) { + proc_terminate($this->serverProcess); + proc_close($this->serverProcess); + } + if (isset($this->logFile) && is_file($this->logFile)) { + unlink($this->logFile); + } + } + + private function recordedRequests(): array { + $lines = array_filter(explode("\n", (string) file_get_contents($this->logFile))); + return array_map(static fn($l) => json_decode($l, true, 512, JSON_THROW_ON_ERROR), $lines); + } + + public function testRealGetAndPutContext(): void { + $endpoint = "http://127.0.0.1:{$this->port}"; + + $clientConfig = new ClientConfig($endpoint, 'test-api-key', 'website', 'dev'); + $client = new Client($clientConfig, new ReactHttpClient()); + $sdk = new ABsmartly(new Config($client)); + + $contextConfig = new ContextConfig(); + $contextConfig->setUnit('user_id', '123456789'); + + $context = $sdk->createContext($contextConfig); + self::assertTrue($context->isReady()); + + $requests = $this->recordedRequests(); + $get = null; + foreach ($requests as $r) { + if ($r['method'] === 'GET') { + $get = $r; + break; + } + } + self::assertNotNull($get, 'expected a GET /context'); + self::assertSame('/context', $get['path']); + self::assertSame('website', $get['query']['application'] ?? null); + self::assertSame('dev', $get['query']['environment'] ?? null); + + $context->track('payment', (object) ['value' => 99]); + $context->publish(); + + $requests = $this->recordedRequests(); + $put = null; + foreach ($requests as $r) { + if ($r['method'] === 'PUT') { + $put = $r; + break; + } + } + self::assertNotNull($put, 'expected a PUT /context'); + self::assertSame('/context', $put['path']); + + // --- headers (server lowercases header names) --- + $h = $put['headers']; + self::assertSame('test-api-key', $h['x-api-key'] ?? null); + self::assertSame('website', $h['x-application'] ?? null); + self::assertSame('dev', $h['x-environment'] ?? null); + self::assertSame('0', $h['x-application-version'] ?? null); + self::assertNotEmpty($h['x-agent'] ?? null); + self::assertStringContainsString('application/json', $h['content-type'] ?? ''); + + $body = json_decode($put['body'], true, 512, JSON_THROW_ON_ERROR); + self::assertArrayHasKey('hashed', $body); + self::assertIsArray($body['units']); + self::assertNotEmpty($body['units']); + self::assertArrayHasKey('type', $body['units'][0]); + self::assertArrayHasKey('uid', $body['units'][0]); + self::assertArrayHasKey('publishedAt', $body); + self::assertIsInt($body['publishedAt']); + self::assertArrayHasKey('goals', $body); + self::assertNotEmpty($body['goals']); + self::assertSame('payment', $body['goals'][0]['name']); + + $sdk->close(); + } +} diff --git a/tests/Http/ReactHttpClientRetryTest.php b/tests/Http/ReactHttpClientRetryTest.php new file mode 100644 index 0000000..27bb053 --- /dev/null +++ b/tests/Http/ReactHttpClientRetryTest.php @@ -0,0 +1,64 @@ +createMock(Browser::class); + $browser->method('withTimeout')->willReturnSelf(); + $browser->expects(self::exactly(count($results)))->method('get') + ->willReturnOnConsecutiveCalls(...$results); + return $browser; + } + + public function testRetriesNetworkFailureThenSucceeds(): void { + $client = new ReactHttpClient($this->browser([ + reject(new \RuntimeException('Connection refused')), + resolve(new ReactResponse(200, [], 'ok')), + ])); + $client->retries = 2; + + $result = null; + $client->getAsync('http://x')->then(function($r) use (&$result) { $result = $r; }); + \React\EventLoop\Loop::run(); + + self::assertSame('ok', $result->content); + } + + public function testDoesNotRetryClientErrors(): void { + $client = new ReactHttpClient($this->browser([ + resolve(new ReactResponse(404, [], 'nf')), + ])); + $client->retries = 3; + + $error = null; + $client->getAsync('http://x')->then(null, function($e) use (&$error) { $error = $e; }); + + self::assertInstanceOf(\ABSmartly\SDK\Exception\HttpClientError::class, $error); + } + + public function testCancellingDuringBackoffPreventsFurtherRequests(): void { + $client = new ReactHttpClient($this->browser([ + reject(new \RuntimeException('Connection refused')), + ])); + $client->retries = 3; + + $promise = $client->getAsync('http://x'); + $error = null; + $promise->then(null, function($e) use (&$error) { $error = $e; }); + $promise->cancel(); + + \React\EventLoop\Loop::addTimer(1.3, fn() => \React\EventLoop\Loop::stop()); + \React\EventLoop\Loop::run(); + + self::assertInstanceOf(\RuntimeException::class, $error); + } +} 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/Http/local_server_router.php b/tests/Http/local_server_router.php new file mode 100644 index 0000000..0ced278 --- /dev/null +++ b/tests/Http/local_server_router.php @@ -0,0 +1,40 @@ + $value) { + if (strpos($key, 'HTTP_') === 0) { + $name = strtolower(str_replace('_', '-', substr($key, 5))); + $headers[$name] = $value; + } +} +if (isset($_SERVER['CONTENT_TYPE'])) { + $headers['content-type'] = $_SERVER['CONTENT_TYPE']; +} + +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); +parse_str($_SERVER['QUERY_STRING'] ?? '', $query); +$body = file_get_contents('php://input'); + +if ($logFile) { + $record = json_encode([ + 'method' => $method, + 'path' => $path, + 'query' => $query, + 'headers' => $headers, + 'body' => $body, + ]); + file_put_contents($logFile, $record . "\n", FILE_APPEND | LOCK_EX); +} + +header('Content-Type: application/json'); +if ($method === 'GET') { + echo '{"experiments":[]}'; +} else { + echo '{}'; +} diff --git a/tests/JsonExpression/EvaluatorComparisonTest.php b/tests/JsonExpression/EvaluatorComparisonTest.php index e457885..aef6917 100644 --- a/tests/JsonExpression/EvaluatorComparisonTest.php +++ b/tests/JsonExpression/EvaluatorComparisonTest.php @@ -124,5 +124,28 @@ public function testCompareNumbers(): void { self::assertSame(1, $this->evaluator->compare(9007199254740991.0, 0)); self::assertSame(1, $this->evaluator->compare(9007199254740991.0, 0.0)); } -} + public function testCompareDeepEqualsCollections(): void { + self::assertSame(0, $this->evaluator->compare([1, [2, 3], 4], [1, [2, 3], 4])); + self::assertSame(0, $this->evaluator->compare((object) ['b' => 2, 'a' => 1], (object) ['a' => 1, 'b' => 2])); + self::assertSame(0, $this->evaluator->compare(['b' => 2, 'a' => 1], (object) ['a' => 1, 'b' => 2])); + self::assertSame(0, $this->evaluator->compare( + (object) ['a' => (object) ['x' => 1], 'b' => [1, 2]], + ['a' => ['x' => 1], 'b' => [1, 2]] + )); + self::assertSame(0, $this->evaluator->compare([1, 2.0], [1.0, 2])); + self::assertNull($this->evaluator->compare([1, 2], [2, 1])); + self::assertNull($this->evaluator->compare((object) ['a' => 1], (object) ['a' => 2])); + } + + public function testCompareNumericKeyObjects(): void { + self::assertSame(0, $this->evaluator->compare(json_decode('{"0":"x"}'), json_decode('{"0":"x"}'))); + self::assertSame(0, $this->evaluator->compare(json_decode('{"0":"x","1":"y"}'), json_decode('{"1":"y","0":"x"}'))); + self::assertNull($this->evaluator->compare(json_decode('{"0":"x"}'), json_decode('["x"]'))); + self::assertNull($this->evaluator->compare(json_decode('["x"]'), json_decode('{"0":"x"}'))); + self::assertNull($this->evaluator->compare(json_decode('{}'), json_decode('[]'))); + self::assertNull($this->evaluator->compare(json_decode('[]'), json_decode('{}'))); + self::assertNull($this->evaluator->compare(json_decode('{"items":{}}'), json_decode('{"items":[]}'))); + self::assertSame(0, $this->evaluator->compare(json_decode('{}'), json_decode('{}'))); + } +} diff --git a/tests/JsonExpression/Operator/EqOperatorTest.php b/tests/JsonExpression/Operator/EqOperatorTest.php index 4807662..6fd9881 100644 --- a/tests/JsonExpression/Operator/EqOperatorTest.php +++ b/tests/JsonExpression/Operator/EqOperatorTest.php @@ -22,7 +22,7 @@ public function testEvaluation(): void { self::assertTrue($this->operator->evaluate($this->evaluator, [0, 0])); self::assertFalse($this->operator->evaluate($this->evaluator, [1, 0])); self::assertFalse($this->operator->evaluate($this->evaluator, [0, 1])); - self::assertTrue($this->operator->evaluate($this->evaluator, [null, null])); /** @todo Deviation */ + self::assertNull($this->operator->evaluate($this->evaluator, [null, null])); self::assertTrue($this->operator->evaluate($this->evaluator, [[1, 2], [1, 2]])); self::assertNull($this->operator->evaluate($this->evaluator, [[1, 2], [3, 4]])); self::assertTrue($this->operator->evaluate($this->evaluator, [(object)['a' => 1, 'b' => 2], (object)['a' => 1, 'b' => 2]])); diff --git a/tests/JsonExpression/Operator/GtOperatorTest.php b/tests/JsonExpression/Operator/GtOperatorTest.php index 7c9abf5..30f65ce 100644 --- a/tests/JsonExpression/Operator/GtOperatorTest.php +++ b/tests/JsonExpression/Operator/GtOperatorTest.php @@ -22,7 +22,7 @@ public function testEvaluation(): void { self::assertFalse($this->operator->evaluate($this->evaluator, [0, 0])); self::assertTrue($this->operator->evaluate($this->evaluator, [1, 0])); self::assertFalse($this->operator->evaluate($this->evaluator, [0, 1])); - self::assertFalse($this->operator->evaluate($this->evaluator, [null, null])); /** @todo Deviation */ + self::assertNull($this->operator->evaluate($this->evaluator, [null, null])); self::assertNull($this->operator->evaluate($this->evaluator, [null, 1])); self::assertNull($this->operator->evaluate($this->evaluator, [1, null])); diff --git a/tests/JsonExpression/Operator/GteOperatorTest.php b/tests/JsonExpression/Operator/GteOperatorTest.php index 54b4721..d8e14bf 100644 --- a/tests/JsonExpression/Operator/GteOperatorTest.php +++ b/tests/JsonExpression/Operator/GteOperatorTest.php @@ -22,7 +22,7 @@ public function testEvaluation(): void { self::assertTrue($this->operator->evaluate($this->evaluator, [0, 0])); self::assertTrue($this->operator->evaluate($this->evaluator, [1, 0])); self::assertFalse($this->operator->evaluate($this->evaluator, [0, 1])); - self::assertTrue($this->operator->evaluate($this->evaluator, [null, null])); /** @todo Deviation */ + self::assertNull($this->operator->evaluate($this->evaluator, [null, null])); self::assertNull($this->operator->evaluate($this->evaluator, [null, 1])); self::assertNull($this->operator->evaluate($this->evaluator, [1, null])); /** @todo Deviation */ self::assertNull($this->operator->evaluate($this->evaluator, [0, null])); /** @todo Deviation */ diff --git a/tests/JsonExpression/Operator/InOperatorTest.php b/tests/JsonExpression/Operator/InOperatorTest.php index dda4a9d..eb261ab 100644 --- a/tests/JsonExpression/Operator/InOperatorTest.php +++ b/tests/JsonExpression/Operator/InOperatorTest.php @@ -30,7 +30,6 @@ 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::assertNull($this->operator->evaluate($this->evaluator, [[], null])); } diff --git a/tests/JsonExpression/Operator/LtOperatorTest.php b/tests/JsonExpression/Operator/LtOperatorTest.php index 51fdf93..1fcf25f 100644 --- a/tests/JsonExpression/Operator/LtOperatorTest.php +++ b/tests/JsonExpression/Operator/LtOperatorTest.php @@ -22,7 +22,7 @@ public function testEvaluation(): void { self::assertFalse($this->operator->evaluate($this->evaluator, [0, 0])); self::assertFalse($this->operator->evaluate($this->evaluator, [1, 0])); self::assertTrue($this->operator->evaluate($this->evaluator, [0, 1])); - self::assertFalse($this->operator->evaluate($this->evaluator, [null, null])); /** @todo Deviation */ + self::assertNull($this->operator->evaluate($this->evaluator, [null, null])); self::assertNull($this->operator->evaluate($this->evaluator, [null, 1])); self::assertNull($this->operator->evaluate($this->evaluator, [1, null])); diff --git a/tests/JsonExpression/Operator/LteOperatorTest.php b/tests/JsonExpression/Operator/LteOperatorTest.php index 914531b..5ec5303 100644 --- a/tests/JsonExpression/Operator/LteOperatorTest.php +++ b/tests/JsonExpression/Operator/LteOperatorTest.php @@ -22,7 +22,7 @@ public function testEvaluation(): void { self::assertTrue($this->operator->evaluate($this->evaluator, [0, 0])); self::assertFalse($this->operator->evaluate($this->evaluator, [1, 0])); self::assertTrue($this->operator->evaluate($this->evaluator, [0, 1])); - self::assertTrue($this->operator->evaluate($this->evaluator, [null, null])); /** @todo Deviation */ + self::assertNull($this->operator->evaluate($this->evaluator, [null, null])); self::assertNull($this->operator->evaluate($this->evaluator, [null, 1])); self::assertNull($this->operator->evaluate($this->evaluator, [1, null])); 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..5936808 --- /dev/null +++ b/tests/Murmur3Test.php @@ -0,0 +1,87 @@ += 80100) { + return hexdec(hash('murmur3a', $input, false, ['seed' => $seed])); + } + + return \lastguest\Murmur::hash3_int($input, $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/SDKLegacyFactoryTest.php b/tests/SDKLegacyFactoryTest.php new file mode 100644 index 0000000..fbdc4f8 --- /dev/null +++ b/tests/SDKLegacyFactoryTest.php @@ -0,0 +1,13 @@ +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 {