diff --git a/README.md b/README.md index f1e4ebb..594860c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Bucket visitors into experiment variations, resolve feature flags with typed var - [Visitor Context](#visitor-context) - [Experience Bucketing](#experience-bucketing) - [Feature Flags](#feature-flags) +- [Experiment Preview](#experiment-preview) - [Conversion Tracking](#conversion-tracking) - [Revenue Reporting](#revenue-reporting) - [Force Multiple Transactions](#force-multiple-transactions) @@ -154,6 +155,24 @@ $sdk = ConvertSDK::create([ Pass any PSR-16 `CacheInterface`. When omitted, an in-memory `ArrayCache` is used (no persistence between requests). +### QA debug token + +For QA and preview scenarios where you need the freshest possible config, pass a `debugToken`: + +```php +$sdk = ConvertSDK::create([ + 'sdkKey' => 'your-sdk-key', + 'debugToken' => 'your-qa-debug-token', +]); +``` + +When set, the SDK: + +- appends `debug_token=` and forces `_conv_low_cache=1` on every config fetch, regardless of the project's cache level; +- bypasses the PSR-16 config cache entirely — every request fetches config live from origin (the config cache entry is neither read nor written); +- redacts the token from all log output (including PSR-18 client exception messages); +- never sends the token to the tracking endpoint. + **Important:** The PSR-16 cache also serves as the visitor data store. When you provide a persistent cache (Redis, Memcached, filesystem), the SDK automatically persists visitor bucketing decisions across HTTP requests. This enables conversion tracking in later requests to be correctly attributed to experiment variations. See [Data Persistence](#data-persistence) for details. ### Full configuration options @@ -167,6 +186,7 @@ $sdk = ConvertSDK::create([ 'dataStore' => $customStore, // Custom data store (overrides cache for visitor data) 'dataRefreshInterval' => 300000, // Config cache TTL in milliseconds (default: 300000 = 5 min) 'environment' => 'production', // Environment targeting + 'debugToken' => 'qa-debug-token', // QA/preview: bypass config cache + force fresh fetch (see QA debug token) ]); ``` @@ -341,6 +361,54 @@ foreach ($features as $feature) { **Returns:** `BucketedFeature[]` — an array of all resolved features. +## Experiment Preview + +Force a visitor context to decide a specific variation of a specific experience, bypassing every normal gate — audiences, segments, locations, environment, experience/variation status, traffic allocation, stored decisions, and the bucketing hash. This is how you render a QA/preview of a variation that a real visitor would not otherwise be bucketed into. + +```php +$context = $sdk->createContext('qa-visitor'); + +// Force the experience whose id is 100200 to decide variation 300400 +$context->setPreview('100200', '300400'); + +// When 'homepage-redesign' is the key of experience 100200, the forced +// variation is returned regardless of targeting or bucketing +$variation = $context->runExperience('homepage-redesign'); +``` + +**Parameters:** + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `$experienceId` | `string` | Yes | The experience id (numeric string) to preview. | +| `$variationId` | `string` | Yes | The variation id (numeric string) to force. | + +**Returns:** `void` + +### Zero-trace guarantee + +Once a preview target resolves successfully, the context becomes **zero-trace for its entire lifetime**: no tracking events are sent and no visitor state is persisted for **any** experience, feature, or conversion run through that context — not just the previewed one. Preview never pollutes real experiment data, including on the shutdown flush. + +### Inert on bad input + +If the experience or variation id cannot be resolved (unknown experience, unknown variation), `setPreview()` is a no-op and the context behaves fully normally — normal bucketing and tracking resume. A preview target absent from the current config is fetched on demand via a single-experience config request (`?exp=`) and memoized for 60 seconds. + +### Preview links + +The canonical preview link format is `convert_preview={experienceId}.{variationId}`. Your application extracts the raw query-string value and parses it with `PreviewParam` — the SDK never reads request superglobals directly: + +```php +use ConvertSdk\Preview\PreviewParam; + +$parsed = PreviewParam::parse($_GET['convert_preview'] ?? ''); + +if ($parsed !== null) { + $context->setPreview($parsed['experienceId'], $parsed['variationId']); +} +``` + +`PreviewParam::parse()` returns `['experienceId' => string, 'variationId' => string]` for a well-formed value, or `null` when the value is malformed. + ## Conversion Tracking Track a goal conversion for the current visitor: diff --git a/demo/laravel/.env.example b/demo/laravel/.env.example index d5e2eac..4e832dc 100644 --- a/demo/laravel/.env.example +++ b/demo/laravel/.env.example @@ -21,3 +21,6 @@ CONVERT_FEATURE_KEY_PRICING=feature-5 CONVERT_FEATURE_KEY_STATS=feature-4 CONVERT_GOAL_KEY=button-primary-click CONVERT_SEGMENT_KEY=test-segment-1 +# Optional QA debug token (24h TTL) — widens the fetched config to draft/paused +# statuses and disables the SDK config cache while set. Redacted from logs. +# CONVERT_DEBUG_TOKEN= diff --git a/demo/laravel/README.md b/demo/laravel/README.md index 176381d..18c119d 100644 --- a/demo/laravel/README.md +++ b/demo/laravel/README.md @@ -35,6 +35,38 @@ Visit [http://localhost:8080](http://localhost:8080). | `/statistics` | Multiple experiments and feature flag (different key) | | `POST /api/buy` | Conversion tracking (`trackConversion`) with goal data (amount, products count) | +## Preview Links & QA + +The demo wires up the two SDK QA/preview capabilities so a stakeholder or tester can exercise them without touching code. + +### Preview links (`?convert_preview=`) + +A preview link renders **one specific variation server-side** — bypassing bucketing, audiences, segments, locations, the environment check, experience/variation status, and stored decisions — with **zero tracking events** and **zero visitor-state persistence** (cache/dataStore) for that request. + +Append `?convert_preview={experienceId}.{variationId}` to **any** demo page, e.g.: + +``` +http://localhost:8000/events?convert_preview=123456.789012 +``` + +Where `experienceId`/`variationId` are the **numeric** ids of the experience/variation to force — copy them from the Convert app's per-variation "Copy preview link" action, or from the ids of the experience already configured via `CONVERT_EXPERIENCE_KEY` in `config/convert.php`. + +Preview auto-fetches the target experience via the serving `?exp=` param when it isn't already present in the loaded config, so you can preview a **draft or paused** experience with no token needed at all. + +To confirm zero-trace behavior: +- Watch the app logs for `[ConvertSDK] Preview active — experienceId=... variationId=...` when a preview request comes in. +- No request reaches the tracking endpoint and no cache/dataStore entry is written for that request — every other page you load in the same session still buckets and persists normally, so you can compare side-by-side. + +### `debugToken` — QA config access + +Set `CONVERT_DEBUG_TOKEN` in `.env` to have every config fetch pull the **full, fresh** config — including draft and paused experiences — with the SDK's config cache disabled for as long as the token is set (every request fetches live from origin). The token has a 24-hour TTL on the backend, is redacted from all SDK logs, and is never sent to the tracking endpoint. + +```env +CONVERT_DEBUG_TOKEN=your-qa-debug-token +``` + +Generate a token from the Convert app for the project configured via `CONVERT_SDK_KEY`. Leave it unset for normal (production-like) demo behavior. + ## Configuration Override the default Convert project keys via `.env`: @@ -48,17 +80,21 @@ CONVERT_FEATURE_KEY_PRICING=feature-5 CONVERT_FEATURE_KEY_STATS=feature-4 CONVERT_GOAL_KEY=button-primary-click CONVERT_SEGMENT_KEY=test-segment-1 +CONVERT_DEBUG_TOKEN= ``` +See [Preview Links & QA](#preview-links--qa) above for what `CONVERT_DEBUG_TOKEN` does. + ## Architecture ``` Request → ConvertContext middleware - ├ Read/generate userId cookie (1-hour expiry) + ├ Read/generate userId cookie (1-hour expiry, skipped while previewing) ├ Resolve SDK singleton (ConvertServiceProvider) ├ Create visitor context with attributes - └ Set default segments + ├ Set default segments + └ Parse ?convert_preview= and setPreview() when present → Controller ├ runExperience / runExperiences / runFeature ├ setCustomSegments / trackConversion @@ -75,10 +111,10 @@ grep -r '\[ConvertSDK\]' app/ ``` **Key files:** -- `app/Providers/ConvertServiceProvider.php` — SDK singleton with PSR-16 filesystem cache -- `app/Http/Middleware/ConvertContext.php` — Per-request visitor context creation +- `app/Providers/ConvertServiceProvider.php` — SDK singleton with PSR-16 filesystem cache; conditionally wires `debugToken` +- `app/Http/Middleware/ConvertContext.php` — Per-request visitor context creation; parses `?convert_preview=` via `PreviewParam::parse()` and calls `$context->setPreview()` - `app/Http/Controllers/` — SDK method calls per route -- `config/convert.php` — All Convert keys (env-configurable) +- `config/convert.php` — All Convert keys (env-configurable), including `debug_token` ## Links diff --git a/demo/laravel/app/Http/Middleware/ConvertContext.php b/demo/laravel/app/Http/Middleware/ConvertContext.php index 17d3d6d..78a0c74 100644 --- a/demo/laravel/app/Http/Middleware/ConvertContext.php +++ b/demo/laravel/app/Http/Middleware/ConvertContext.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use Closure; +use ConvertSdk\Preview\PreviewParam; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpFoundation\Response; @@ -20,6 +21,8 @@ public function handle(Request $request, Closure $next): Response $newVisitor = true; } + $previewActive = false; + // [ConvertSDK] Resolve SDK singleton from container try { $sdk = app('convert.sdk'); @@ -31,6 +34,28 @@ public function handle(Request $request, Closure $next): Response if ($context) { // [ConvertSDK] Set default segments matching JS demo $context->setDefaultSegments(['country' => 'US']); + + // [ConvertSDK] qs-16 preview link — ?convert_preview={experienceId}.{variationId} + // forces that exact variation server-side with zero tracking/persistence for + // the rest of this context's lifetime. Inert (no-op) on a missing/malformed + // param — PreviewParam::parse() returns null and we simply skip setPreview(). + $previewParam = $request->query('convert_preview'); + + if (is_string($previewParam)) { + $parsed = PreviewParam::parse($previewParam); + + if ($parsed !== null) { + $context->setPreview($parsed['experienceId'], $parsed['variationId']); + $previewActive = true; + + Log::info(sprintf( + '[ConvertSDK] Preview active — experienceId=%s variationId=%s (zero-trace context)', + $parsed['experienceId'], + $parsed['variationId'] + )); + } + } + $request->attributes->set('sdkContext', $context); } } else { @@ -42,8 +67,12 @@ public function handle(Request $request, Closure $next): Response $response = $next($request); - // Set visitor ID cookie on response if newly generated (1-hour expiry) - if ($newVisitor) { + // Set visitor ID cookie on response if newly generated (1-hour expiry). + // [ConvertSDK] Skip the cookie write while previewing so a stakeholder preview + // request stays stateless on the demo side too — the SDK-level zero-trace + // guarantee already covers cache/dataStore; this just avoids issuing a new + // visitor identity cookie for a request that was never really "visited". + if ($newVisitor && !$previewActive) { $response->headers->setCookie( cookie('userId', $userId, 60) // 60 minutes ); diff --git a/demo/laravel/app/Providers/ConvertServiceProvider.php b/demo/laravel/app/Providers/ConvertServiceProvider.php index 14f0400..611fc56 100644 --- a/demo/laravel/app/Providers/ConvertServiceProvider.php +++ b/demo/laravel/app/Providers/ConvertServiceProvider.php @@ -21,7 +21,7 @@ public function register(): void directory: storage_path('framework/cache/convert'), )); - return ConvertSDK::create([ + $sdkConfig = [ 'sdkKey' => config('convert.sdk_key'), // [ConvertSDK] 'cache' => $cache, // [ConvertSDK] 'environment' => config('convert.environment'), // [ConvertSDK] @@ -29,7 +29,17 @@ public function register(): void 'logLevel' => LogLevel::Trace, 'customLoggers' => [$app->make(LoggerInterface::class)], ], - ]); + ]; + + // [ConvertSDK] qs-16 QA capability — only pass debugToken when a non-empty + // token is configured; passing null/empty would needlessly disable the + // config cache (Core::fetchConfig() treats any non-empty string as "skip cache"). + $debugToken = config('convert.debug_token'); + if (is_string($debugToken) && $debugToken !== '') { + $sdkConfig['debugToken'] = $debugToken; // [ConvertSDK] + } + + return ConvertSDK::create($sdkConfig); }); } } diff --git a/demo/laravel/config/convert.php b/demo/laravel/config/convert.php index 2965e87..bc80ab4 100644 --- a/demo/laravel/config/convert.php +++ b/demo/laravel/config/convert.php @@ -9,4 +9,5 @@ 'feature_key_stats' => env('CONVERT_FEATURE_KEY_STATS', 'feature-4'), 'goal_key' => env('CONVERT_GOAL_KEY', 'button-primary-click'), 'segment_key' => env('CONVERT_SEGMENT_KEY', 'test-segment-1'), + 'debug_token' => env('CONVERT_DEBUG_TOKEN'), ]; diff --git a/packages/Api/src/ApiManager.php b/packages/Api/src/ApiManager.php index 7b3d4b1..5becfc2 100644 --- a/packages/Api/src/ApiManager.php +++ b/packages/Api/src/ApiManager.php @@ -111,6 +111,14 @@ class ApiManager implements ApiManagerInterface /** @var string Cache level setting */ private string $cacheLevel; + /** + * Optional QA/preview debug token (qs-02 capability A). When set, forces + * `debug_token=` and `_conv_low_cache=1` onto every config-fetch + * URL, regardless of `network.cacheLevel`. Never sent to the track + * endpoint; redacted from log output via {@see redactDebugTokenForLog()}. + */ + private ?string $debugToken = null; + /** @var callable Mapper function for data transformation */ private mixed $mapper; @@ -188,6 +196,7 @@ public function __construct( $this->cacheLevel = $config && $config->getNetwork() && isset($config->getNetwork()['cacheLevel']) ? (string) $config->getNetwork()['cacheLevel'] : ''; + $this->debugToken = $config ? $config->getDebugToken() : null; $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); @@ -446,30 +455,75 @@ public function getBatchSize(): int } /** - * Get configuration data + * Redact the `debug_token` query-param value (qs-02 AC3 — token hygiene) + * from a log-only string, e.g. a config-fetch endpoint URL or an + * exception message that carries `debug_token=` in its query + * string. Encoding-agnostic: matches the value regardless of whether it + * was produced by `urlencode()`, `rawurlencode()`, left decoded, or + * mangled by an arbitrary PSR-18 client — the exception message passed + * in here originates from whatever HTTP client is plugged in, which is + * not guaranteed to encode (or even include) the URL the same way this + * SDK built it. Only used for values passed to the logger/rethrown + * exception — never affects the actual request URL. * - * @return ConfigResponseData + * @param string $value The string to redact before logging + * @return string The value with the token value masked, if present */ - public function getConfig(): ConfigResponseData + private function redactDebugTokenForLog(string $value): string { - if ($this->loggerManager && method_exists($this->loggerManager, 'trace')) { - $this->loggerManager->trace('ApiManager.getConfig()'); + if ($this->debugToken === null || $this->debugToken === '') { + return $value; } - $query = ''; - if ($this->cacheLevel === 'low' || $this->environment) { - $query = '?'; - } + return (string) preg_replace( + '/(debug_token=)[^&\s]*/i', + '${1}***REDACTED***', + $value + ); + } + + /** + * Build the query string for a config-fetch request, applying the shared + * environment/debug_token/_conv_low_cache rules (qs-02 AC1) plus any + * caller-supplied extra params (e.g. `exp=` for the preview fetch). + * + * @param array $additionalParams Extra key=>value params to append + * @param bool $forceLowCache Force `_conv_low_cache=1` regardless of cacheLevel/debugToken + * @return string The query string including the leading `?`, or '' if empty + */ + private function buildConfigQueryString(array $additionalParams = [], bool $forceLowCache = false): string + { + $hasDebugToken = $this->debugToken !== null && $this->debugToken !== ''; + + $params = []; if ($this->environment) { - $query .= 'environment=' . urlencode($this->environment); + $params[] = 'environment=' . urlencode($this->environment); } - if ($this->cacheLevel === 'low') { - if ($query !== '?') { - $query .= '&'; - } - $query .= '_conv_low_cache=1'; + foreach ($additionalParams as $key => $value) { + $params[] = $key . '=' . urlencode((string) $value); + } + if ($hasDebugToken) { + // Forced regardless of network.cacheLevel — qs-02 AC1. + $params[] = 'debug_token=' . urlencode((string) $this->debugToken); + } + if ($forceLowCache || $this->cacheLevel === 'low' || $hasDebugToken) { + $params[] = '_conv_low_cache=1'; } + return $params !== [] ? '?' . implode('&', $params) : ''; + } + + /** + * Shared GET/parse/log/error-handling body for the two config-fetch entry + * points ({@see getConfig()} and {@see getConfigForExperience()}) — only + * the query string and the log context label differ between them. + * + * @param string $query The query string (including leading `?`, or '') + * @param string $logContext Log context label (e.g. 'ApiManager.getConfig()') + * @return ConfigResponseData + */ + private function fetchConfigFromEndpoint(string $query, string $logContext): ConfigResponseData + { try { $response = $this->request( 'GET', @@ -483,8 +537,8 @@ public function getConfig(): ConfigResponseData if ($statusCode < 200 || $statusCode >= 300) { $url = $this->configEndpoint . "/config/{$this->sdkKey}"; if ($this->loggerManager) { - $this->loggerManager->error('ApiManager.getConfig()', [ - 'endpoint' => $url . $query, + $this->loggerManager->error($logContext, [ + 'endpoint' => $this->redactDebugTokenForLog($url . $query), 'status' => 'error', 'httpStatus' => $statusCode, 'error' => "HTTP {$statusCode}", @@ -505,8 +559,8 @@ public function getConfig(): ConfigResponseData if ($this->loggerManager) { $project = $configData->getProject(); - $this->loggerManager->debug('ApiManager.getConfig()', [ - 'endpoint' => $this->configEndpoint . "/config/{$this->sdkKey}" . $query, + $this->loggerManager->debug($logContext, [ + 'endpoint' => $this->redactDebugTokenForLog($this->configEndpoint . "/config/{$this->sdkKey}" . $query), 'status' => 'success', 'httpStatus' => $statusCode, 'accountId' => $configData->getAccountId() ?? 'unknown', @@ -517,20 +571,65 @@ public function getConfig(): ConfigResponseData return $configData; } catch (ClientExceptionInterface $e) { + // qs-02 AC3 — PSR-18 client exceptions (e.g. Guzzle's ConnectException + // on DNS/TLS/timeout failures) commonly append " for " to + // getMessage(), which carries the same debug_token= query + // param as the sibling `endpoint` log field below. Redact once here + // so neither the log entry nor the rethrown RuntimeException leaks it. + $safeMessage = $this->redactDebugTokenForLog($e->getMessage()); + if ($this->loggerManager) { - $this->loggerManager->error('ApiManager.getConfig()', [ - 'endpoint' => $this->configEndpoint . "/config/{$this->sdkKey}" . $query, + $this->loggerManager->error($logContext, [ + 'endpoint' => $this->redactDebugTokenForLog($this->configEndpoint . "/config/{$this->sdkKey}" . $query), 'status' => 'error', - 'error' => $e->getMessage(), + 'error' => $safeMessage, 'code' => method_exists($e, 'getCode') ? $e->getCode() : null, ]); } throw new \RuntimeException( - "Failed to fetch config from {$this->configEndpoint}/config/{$this->sdkKey}: HTTP error - {$e->getMessage()}", + "Failed to fetch config from {$this->configEndpoint}/config/{$this->sdkKey}: HTTP error - {$safeMessage}", (int)$e->getCode(), $e ); } } + + /** + * Get configuration data + * + * @return ConfigResponseData + */ + public function getConfig(): ConfigResponseData + { + if ($this->loggerManager && method_exists($this->loggerManager, 'trace')) { + $this->loggerManager->trace('ApiManager.getConfig()'); + } + + $query = $this->buildConfigQueryString(); + + return $this->fetchConfigFromEndpoint($query, 'ApiManager.getConfig()'); + } + + /** + * Get configuration data scoped to a single experience (qs-02 capability B + * preview input — AC4). Forces `exp={experienceId}` and `_conv_low_cache=1` + * onto the config-fetch URL regardless of `network.cacheLevel`, plus + * `debug_token=` when configured — this is how the SDK resolves a preview + * target that is absent from the current config (draft/paused/other + * environment). + * + * @param string $experienceId The experience id to inject via `exp=` + * @return ConfigResponseData + */ + public function getConfigForExperience(string $experienceId): ConfigResponseData + { + if ($this->loggerManager && method_exists($this->loggerManager, 'trace')) { + $this->loggerManager->trace('ApiManager.getConfigForExperience()', ['experienceId' => $experienceId]); + } + + $query = $this->buildConfigQueryString(['exp' => $experienceId], true); + + return $this->fetchConfigFromEndpoint($query, 'ApiManager.getConfigForExperience()'); + } } diff --git a/packages/Api/src/Interfaces/ApiManagerInterface.php b/packages/Api/src/Interfaces/ApiManagerInterface.php index 27dbe2f..d3ee75b 100644 --- a/packages/Api/src/Interfaces/ApiManagerInterface.php +++ b/packages/Api/src/Interfaces/ApiManagerInterface.php @@ -79,4 +79,14 @@ public function setData(ConfigResponseData $data): void; * @return ConfigResponseData */ public function getConfig(): ConfigResponseData; + + /** + * Get configuration data scoped to a single experience via `exp=` (qs-02 + * capability B preview input — AC4). Forces `_conv_low_cache=1` regardless + * of `network.cacheLevel`, plus `debug_token=` when configured. + * + * @param string $experienceId The experience id to inject via `exp=` + * @return ConfigResponseData + */ + public function getConfigForExperience(string $experienceId): ConfigResponseData; } diff --git a/packages/Api/tests/ApiManagerDebugTokenTest.php b/packages/Api/tests/ApiManagerDebugTokenTest.php new file mode 100644 index 0000000..12c17c6 --- /dev/null +++ b/packages/Api/tests/ApiManagerDebugTokenTest.php @@ -0,0 +1,486 @@ +` AND + * `_conv_low_cache=1` when set, forced regardless of `network.cacheLevel`; + * neither when unset (unless cacheLevel=low already adds the low-cache flag). + * - AC3 (token hygiene): the token never reaches the track-endpoint payload + * nor any log call in clear text. + * + * @see ../../../../ai-driven-product-dev/_bmad-output/planning-artifacts/2026-03-13-convert-php-sdk/qs-02-experiment-preview.md + */ +class ApiManagerDebugTokenTest extends TestCase +{ + private const HOST = 'http://localhost'; + private const PORT = 8091; + private const BATCH_SIZE = 2; + private const SECRET_TOKEN = 'qa-debug-token-xyz789'; + + /** + * Contains a space so its urlencode() and rawurlencode() representations + * genuinely differ (`+` vs `%20`) — required to prove the encoding-agnostic + * redaction fix, since the old `str_replace('debug_token=' . urlencode(...))` + * approach only ever matched the urlencode() shape. + */ + private const ENCODING_TEST_TOKEN = 'qa debug token xyz'; + + private MockHttpClient $mockHttpClient; + private Psr17Factory $psr17Factory; + /** @var EventManagerInterface&\PHPUnit\Framework\MockObject\MockObject */ + private $eventManagerMock; + /** @var LogManagerInterface&\PHPUnit\Framework\MockObject\MockObject */ + private $loggerManagerMock; + + protected function setUp(): void + { + $this->mockHttpClient = new MockHttpClient(); + $this->psr17Factory = new Psr17Factory(); + $this->eventManagerMock = $this->createMock(EventManagerInterface::class); + $this->loggerManagerMock = $this->createMock(LogManagerInterface::class); + } + + /** + * Build a Config merging the shared test fixture + SDK defaults + per-test + * overrides, mirroring ApiManagerTest's fixture assembly so the two test + * classes stay consistent. + * + * @param array $overrides + */ + private function buildConfig(array $overrides = []): Config + { + $testConfig = json_decode((string) file_get_contents(__DIR__ . '/test-config.json'), true); + $defaultConfig = DefaultConfig::getDefault(); + $mergedConfig = ObjectUtils::objectDeepMerge($testConfig, $defaultConfig); + + $baseOverrides = [ + 'api' => [ + 'endpoint' => [ + 'config' => self::HOST . ':' . self::PORT, + 'track' => self::HOST . ':' . self::PORT, + ], + ], + 'events' => [ + 'batch_size' => self::BATCH_SIZE, + ], + 'mapper' => null, + ]; + $layeredOverrides = ObjectUtils::objectDeepMerge($baseOverrides, $overrides); + $finalConfig = ObjectUtils::objectDeepMerge($mergedConfig, $layeredOverrides); + + if (isset($finalConfig['sdkKey'])) { + unset($finalConfig['sdkKey']); + } + $finalConfig['data'] = new ConfigResponseData($finalConfig['data']); + + return new Config($finalConfig); + } + + /** + * @param array $overrides + */ + private function buildApiManager(array $overrides = []): ApiManager + { + return new ApiManager( + $this->buildConfig($overrides), + $this->eventManagerMock, + $this->loggerManagerMock, + $this->mockHttpClient, + $this->psr17Factory, + $this->psr17Factory + ); + } + + private function queueSuccessfulConfigResponse(): void + { + $this->mockHttpClient->addResponse( + new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([ + 'data' => [ + 'account_id' => '999', + 'project' => ['id' => '888', 'key' => 'test-project'], + 'experiences' => [], + 'features' => [], + 'segments' => [], + 'audiences' => [], + 'goals' => [], + 'locations' => [], + ], + ])) + ); + } + + /** + * Stub every logger method to capture its arguments into $captured, + * so AC3 tests can assert the secret never appears in any log call + * regardless of level. + * + * @param array}> $captured + */ + private function spyAllLogCalls(array &$captured): void + { + foreach (['trace', 'debug', 'info', 'warn', 'error'] as $method) { + $this->loggerManagerMock->method($method) + ->willReturnCallback(function (...$args) use (&$captured, $method): void { + $captured[] = [$method, $args]; + }); + } + } + + /** + * @param array}> $captured + */ + private function assertNoSecretLeak(array $captured, string $secret): void + { + $serialized = json_encode($captured, JSON_PARTIAL_OUTPUT_ON_ERROR); + $this->assertIsString($serialized); + $this->assertStringNotContainsString($secret, $serialized, 'Secret token leaked into a log payload'); + } + + /** + * @return array + */ + public static function debugTokenUrlProvider(): array + { + return [ + 'debugToken set, default cacheLevel — both params forced' => [ + self::SECRET_TOKEN, 'default', true, true, + ], + 'debugToken unset, default cacheLevel — neither param' => [ + null, 'default', false, false, + ], + 'debugToken unset, cacheLevel=low — only low-cache param (regression, today\'s behavior)' => [ + null, 'low', false, true, + ], + 'debugToken set, cacheLevel=low — both, forced regardless' => [ + self::SECRET_TOKEN, 'low', true, true, + ], + ]; + } + + /** + * AC1 — debugToken transport. + */ + #[DataProvider('debugTokenUrlProvider')] + public function testDebugTokenUrlTransport( + ?string $debugToken, + string $cacheLevel, + bool $expectDebugTokenParam, + bool $expectLowCacheParam + ): void { + $overrides = ['network' => ['cacheLevel' => $cacheLevel]]; + if ($debugToken !== null) { + $overrides['debugToken'] = $debugToken; + } + + $apiManager = $this->buildApiManager($overrides); + $this->queueSuccessfulConfigResponse(); + + $apiManager->getConfig(); + + $sentUri = (string) $this->mockHttpClient->getLastRequest()->getUri(); + + if ($expectDebugTokenParam) { + $this->assertStringContainsString('debug_token=' . $debugToken, $sentUri); + } else { + $this->assertStringNotContainsString('debug_token=', $sentUri); + } + + if ($expectLowCacheParam) { + $this->assertStringContainsString('_conv_low_cache=1', $sentUri); + } else { + $this->assertStringNotContainsString('_conv_low_cache=1', $sentUri); + } + } + + /** + * AC3 — token hygiene: never sent to the track endpoint. + */ + #[Test] + public function debugTokenNeverAppearsInTrackEndpointPayload(): void + { + $apiManager = $this->buildApiManager(['debugToken' => self::SECRET_TOKEN]); + + $requestData = new VisitorTrackingEvents([ + 'eventType' => 'bucketing', + 'data' => ['experienceId' => '11', 'variationId' => '12'], + ]); + + $this->mockHttpClient->addResponse( + new Response(200, ['Content-Type' => 'application/json'], '{}') + ); + + for ($i = 1; $i <= self::BATCH_SIZE; $i++) { + $apiManager->enqueue("VID$i", $requestData); + } + + $sentRequest = $this->mockHttpClient->getLastRequest(); + $this->assertNotNull($sentRequest); + $this->assertStringContainsString('/track/', (string) $sentRequest->getUri()); + + $body = (string) $sentRequest->getBody(); + $this->assertStringNotContainsString(self::SECRET_TOKEN, $body); + $this->assertStringNotContainsString('debug_token', $body); + } + + /** + * AC3 — token hygiene: never logged in clear on a successful config fetch. + */ + #[Test] + public function debugTokenNeverAppearsInConfigFetchSuccessLogs(): void + { + $captured = []; + $this->spyAllLogCalls($captured); + + $apiManager = $this->buildApiManager(['debugToken' => self::SECRET_TOKEN]); + $this->queueSuccessfulConfigResponse(); + + $apiManager->getConfig(); + + $this->assertNotEmpty($captured, 'Expected at least one log call to inspect'); + $this->assertNoSecretLeak($captured, self::SECRET_TOKEN); + } + + /** + * AC3 — token hygiene: never logged in clear when the config fetch fails + * (bad HTTP status) — this is the codepath most likely to leak the raw + * query string via the 'endpoint' log field. + */ + #[Test] + public function debugTokenNeverAppearsInConfigFetchBadStatusLogs(): void + { + $captured = []; + $this->spyAllLogCalls($captured); + + $apiManager = $this->buildApiManager(['debugToken' => self::SECRET_TOKEN]); + $this->mockHttpClient->addResponse( + new Response(500, ['Content-Type' => 'application/json'], '{"error":"internal"}') + ); + + try { + $apiManager->getConfig(); + } catch (\RuntimeException $e) { + // Expected — the failure path also logs; the assertion below still applies. + } + + $this->assertNotEmpty($captured, 'Expected at least one log call to inspect'); + $this->assertNoSecretLeak($captured, self::SECRET_TOKEN); + } + + /** + * Build a PSR-18 network-level exception whose message embeds the full + * config-fetch URL — including `debug_token=` — mirroring how + * Guzzle's ConnectException/RequestException append `" for "` to + * connection/DNS/TLS/timeout failures. This is the shape that actually + * exercises the AC3 leak: a message-less exception (e.g. bare + * "Connection refused") never touches the token and passes trivially + * regardless of whether redaction is applied. + */ + private function buildNetworkExceptionWithLeakingUrl(): \Http\Client\Exception\NetworkException + { + $leakingUrl = self::HOST . ':' . self::PORT + . '/config/?environment=staging&debug_token=' . self::SECRET_TOKEN . '&_conv_low_cache=1'; + + return new \Http\Client\Exception\NetworkException( + 'cURL error 6: Could not resolve host: localhost for ' . $leakingUrl, + $this->psr17Factory->createRequest('GET', $leakingUrl) + ); + } + + /** + * AC3 — token hygiene: never logged in clear, and never present in the + * rethrown exception's message, when the HTTP client throws a + * network-level exception whose own message embeds the full config URL + * (Guzzle's ConnectException/RequestException behavior). This is the + * codepath that leaked before the fix: `redactDebugTokenForLog()` was + * applied to the sibling `endpoint` log field but not to `$e->getMessage()` + * itself, which is both logged raw and interpolated raw into the + * rethrown RuntimeException. + */ + #[Test] + public function debugTokenNeverAppearsInConfigFetchNetworkErrorLogs(): void + { + $captured = []; + $this->spyAllLogCalls($captured); + + $apiManager = $this->buildApiManager(['debugToken' => self::SECRET_TOKEN]); + $this->mockHttpClient->addException($this->buildNetworkExceptionWithLeakingUrl()); + + $thrown = null; + try { + $apiManager->getConfig(); + } catch (\RuntimeException $e) { + $thrown = $e; + } + + $this->assertNotNull($thrown, 'Expected getConfig() to rethrow a RuntimeException'); + $this->assertNotEmpty($captured, 'Expected at least one log call to inspect'); + $this->assertNoSecretLeak($captured, self::SECRET_TOKEN); + $this->assertStringNotContainsString( + self::SECRET_TOKEN, + $thrown->getMessage(), + 'Secret token leaked into the rethrown exception message' + ); + } + + /** + * AC3/AC4 symmetry — the preview-fetch entry point + * (`getConfigForExperience()`, used by `PreviewResolver`) shares the same + * `fetchConfigFromEndpoint()` error-handling body as `getConfig()`, so it + * must be equally immune to the network-exception leak. + */ + #[Test] + public function debugTokenNeverAppearsInConfigForExperienceNetworkErrorLogs(): void + { + $captured = []; + $this->spyAllLogCalls($captured); + + $apiManager = $this->buildApiManager(['debugToken' => self::SECRET_TOKEN]); + $this->mockHttpClient->addException($this->buildNetworkExceptionWithLeakingUrl()); + + $thrown = null; + try { + $apiManager->getConfigForExperience('exp-1'); + } catch (\RuntimeException $e) { + $thrown = $e; + } + + $this->assertNotNull($thrown, 'Expected getConfigForExperience() to rethrow a RuntimeException'); + $this->assertNotEmpty($captured, 'Expected at least one log call to inspect'); + $this->assertNoSecretLeak($captured, self::SECRET_TOKEN); + $this->assertStringNotContainsString( + self::SECRET_TOKEN, + $thrown->getMessage(), + 'Secret token leaked into the rethrown exception message' + ); + } + + /** + * Build a PSR-18 network-level exception whose message embeds + * `debug_token=&other=param` — the caller controls + * exactly how the token is represented in the message (rawurlencode()'d, + * fully decoded/un-encoded, etc.) so the redaction fix can be proven + * encoding-agnostic rather than coupled to `urlencode()` output. + */ + private function buildNetworkExceptionWithEncodedDebugToken( + string $tokenAsItAppearsInUrl + ): \Http\Client\Exception\NetworkException { + $leakingUrl = self::HOST . ':' . self::PORT + . '/config/?environment=staging&debug_token=' . $tokenAsItAppearsInUrl . '&other=param'; + + return new \Http\Client\Exception\NetworkException( + 'cURL error 6: Could not resolve host: localhost for ' . $leakingUrl, + $this->psr17Factory->createRequest('GET', self::HOST . ':' . self::PORT . '/config/') + ); + } + + /** + * Two representations of {@see ENCODING_TEST_TOKEN} that a plugged-in + * PSR-18 client could plausibly embed in an exception message: Guzzle + * (and most HTTP clients) rawurlencode() query values, while a client + * that logs/presents a decoded URL for readability would show the value + * fully un-encoded. Both must be redacted regardless of which shows up. + * + * @return array + */ + public static function debugTokenEncodingProvider(): array + { + return [ + 'rawurlencoded (spaces as %20)' => [rawurlencode(self::ENCODING_TEST_TOKEN)], + 'un-encoded / decoded' => [self::ENCODING_TEST_TOKEN], + ]; + } + + /** + * AC3 — token hygiene must hold regardless of how the plugged-in PSR-18 + * client encoded (or didn't encode) the token in its own exception + * message. Before the fix, `redactDebugTokenForLog()` matched only the + * exact `urlencode($this->debugToken)` byte sequence — since + * `urlencode('qa debug token xyz')` produces `qa+debug+token+xyz`, it + * would silently no-op against a rawurlencode()'d (`%20`) or fully + * decoded (raw space) representation, leaking the token verbatim into + * both the log payload and the rethrown RuntimeException. The + * regex-based fix redacts `debug_token=` regardless of encoding, + * while leaving the surrounding message (prefix and the trailing + * `&other=param`) untouched. + */ + #[DataProvider('debugTokenEncodingProvider')] + public function testDebugTokenRedactionIsEncodingAgnostic(string $tokenAsItAppearsInUrl): void + { + $captured = []; + $this->spyAllLogCalls($captured); + + $apiManager = $this->buildApiManager(['debugToken' => self::ENCODING_TEST_TOKEN]); + $this->mockHttpClient->addException( + $this->buildNetworkExceptionWithEncodedDebugToken($tokenAsItAppearsInUrl) + ); + + $thrown = null; + try { + $apiManager->getConfig(); + } catch (\RuntimeException $e) { + $thrown = $e; + } + + $this->assertNotNull($thrown, 'Expected getConfig() to rethrow a RuntimeException'); + $this->assertNotEmpty($captured, 'Expected at least one log call to inspect'); + + $serializedLogs = (string) json_encode($captured, JSON_PARTIAL_OUTPUT_ON_ERROR); + + // The exact on-the-wire representation the exception message carried + // must be gone — this is what proves the fix works regardless of + // encoding (for the decoded row, this representation IS the full + // secret; for the rawurlencoded row, it's the %20-encoded value). + $this->assertStringNotContainsString( + $tokenAsItAppearsInUrl, + $serializedLogs, + 'Debug token representation leaked into a log payload' + ); + $this->assertStringNotContainsString( + $tokenAsItAppearsInUrl, + $thrown->getMessage(), + 'Debug token representation leaked into the rethrown exception message' + ); + + // The full configured secret must never appear verbatim either way. + $this->assertNoSecretLeak($captured, self::ENCODING_TEST_TOKEN); + $this->assertStringNotContainsString( + self::ENCODING_TEST_TOKEN, + $thrown->getMessage(), + 'Full secret token leaked into the rethrown exception message' + ); + + // Surrounding message content — before `debug_token=` and after the + // redacted value — must be preserved untouched. + $this->assertStringContainsString( + 'Could not resolve host', + $thrown->getMessage(), + 'Message content preceding debug_token= must be preserved' + ); + $this->assertStringContainsString( + '&other=param', + $thrown->getMessage(), + 'Message content following the redacted value must be preserved' + ); + } +} diff --git a/packages/Api/tests/ApiManagerPreviewFetchTest.php b/packages/Api/tests/ApiManagerPreviewFetchTest.php new file mode 100644 index 0000000..daad65e --- /dev/null +++ b/packages/Api/tests/ApiManagerPreviewFetchTest.php @@ -0,0 +1,150 @@ +mockHttpClient = new MockHttpClient(); + $this->psr17Factory = new Psr17Factory(); + } + + private function buildApiManager(?string $debugToken = null): ApiManager + { + $config = [ + 'sdkKey' => 'test-sdk-key', + 'environment' => 'production', + 'api' => [ + 'endpoint' => [ + 'config' => self::HOST . ':' . self::PORT, + 'track' => self::HOST . ':' . self::PORT, + ], + ], + ]; + if ($debugToken !== null) { + $config['debugToken'] = $debugToken; + } + + return new ApiManager( + new Config($config), + $this->createMock(EventManagerInterface::class), + null, + $this->mockHttpClient, + $this->psr17Factory, + $this->psr17Factory + ); + } + + /** + * Response body shape verified against the backend OpenAPI contract + * (`backend/apiDoc/serving/src/responses/index.yaml` — `ProjectConfigResponse` + * resolves directly to the `ConfigResponseData` schema, with no enclosing + * envelope) and against the JS SDK's own real-HTTP-server integration test + * for `getConfigByExperience()` + * (`javascript-sdk/packages/api/tests/api-manager-config-by-experience.tests.ts`), + * whose mock server returns the config fields at the top level of the + * response body. + */ + private function queueExpConfigResponse(): void + { + $this->mockHttpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([ + 'account_id' => '999', + 'project' => ['id' => '888'], + 'experiences' => [ + ['id' => self::EXPERIENCE_ID, 'key' => 'preview-target', 'status' => 'draft', 'variations' => []], + ], + ]))); + } + + /** + * @return array + */ + public static function debugTokenProvider(): array + { + return [ + 'no debugToken configured' => [null], + 'debugToken configured' => [self::DEBUG_TOKEN], + ]; + } + + /** + * AC4 (fetch contract): the exp= fetch always forces `exp={id}` and + * `_conv_low_cache=1`, plus `debug_token=` only when configured — reusing + * the same URL surface PHP-1 built for the debugToken config-fetch path. + */ + #[DataProvider('debugTokenProvider')] + public function testExpFetchUrlCarriesExperienceIdLowCacheAndOptionalDebugToken(?string $debugToken): void + { + $apiManager = $this->buildApiManager($debugToken); + $this->queueExpConfigResponse(); + + $apiManager->getConfigForExperience(self::EXPERIENCE_ID); + + $sentUri = (string) $this->mockHttpClient->getLastRequest()->getUri(); + + $this->assertStringContainsString('exp=' . self::EXPERIENCE_ID, $sentUri); + $this->assertStringContainsString('_conv_low_cache=1', $sentUri); + + if ($debugToken !== null) { + $this->assertStringContainsString('debug_token=' . $debugToken, $sentUri); + } else { + $this->assertStringNotContainsString('debug_token=', $sentUri); + } + } + + #[Test] + public function getConfigForExperienceReturnsParsedConfigResponseData(): void + { + $apiManager = $this->buildApiManager(); + $this->queueExpConfigResponse(); + + $result = $apiManager->getConfigForExperience(self::EXPERIENCE_ID); + + $this->assertInstanceOf(ConfigResponseData::class, $result); + $experiences = $result->getExperiences() ?? []; + $this->assertNotEmpty($experiences, 'exp= fetch response must be parsed into ConfigResponseData with the injected experience'); + $this->assertSame(self::EXPERIENCE_ID, $experiences[0]['id'] ?? null); + } +} diff --git a/packages/Bucketing/src/BucketingManager.php b/packages/Bucketing/src/BucketingManager.php index caadfb9..b9da19f 100644 --- a/packages/Bucketing/src/BucketingManager.php +++ b/packages/Bucketing/src/BucketingManager.php @@ -137,4 +137,120 @@ public function getBucketForVisitor(array $buckets, string $visitorId, ?array $o 'bucketingAllocation' => $value, ]; } + + /** + * Build the anchored bucket layout for a set of variation allocations (qs-01). + * + * Anchors are computed over the total weight of ALL entries (active and inactive) so + * that raising an experience's total allocation only ever grows arms (superset + * property) and never reshuffles an already-bucketed visitor into a different arm. + * Inactive (or explicit zero-allocation) entries keep their weight for anchor + * stability but get a zero-width range so they can never be selected. + * + * @param array $allocations Variation allocations in config order + * @return array + */ + public function getBucketRanges(array $allocations): array + { + $totalWeight = array_reduce( + $allocations, + fn (float $sum, array $allocation) => $sum + $allocation['allocation'], + 0.0 + ); + + $ranges = []; + + if ($totalWeight <= 0) { + if ($this->logManager) { + $this->logManager->debug('BucketingManager.getBucketRanges()', [ + 'allocations' => $allocations, + 'totalWeight' => $totalWeight, + ]); + } + + return $ranges; + } + + $cumWeight = 0.0; + foreach ($allocations as $allocation) { + $anchor = ($cumWeight / $totalWeight) * $this->maxTraffic; + $width = $allocation['active'] ? $allocation['allocation'] * 100 : 0.0; + $ranges[] = [ + 'id' => $allocation['id'], + 'anchor' => $anchor, + 'width' => $width, + ]; + $cumWeight += $allocation['allocation']; + } + + if ($this->logManager) { + $this->logManager->debug('BucketingManager.getBucketRanges()', [ + 'allocations' => $allocations, + 'totalWeight' => $totalWeight, + ], ['ranges' => $ranges]); + } + + return $ranges; + } + + /** + * Select the variation whose anchored range contains the provided value. + * + * @param array $ranges Anchored bucket ranges (see getBucketRanges()) + * @param float $value A normalized bucket value in [0, maxTraffic) + * @return string|null The selected variation ID, or null if no match + */ + public function selectBucketAnchored(array $ranges, float $value): ?string + { + $variation = null; + + foreach ($ranges as $range) { + if ($value >= $range['anchor'] && $value < $range['anchor'] + $range['width']) { + $variation = $range['id']; + break; + } + } + + if ($this->logManager) { + $this->logManager->debug('BucketingManager.selectBucketAnchored()', [ + 'ranges' => $ranges, + 'value' => $value, + ], ['variation' => $variation]); + } + + return $variation; + } + + /** + * Get an anchored bucket for the visitor (qs-01). Reuses the existing + * visitor-based hash value unchanged, then resolves it through the anchored layout. + * + * @param array $allocations Variation allocations in config order + * @param string $visitorId The visitor's unique identifier + * @param array{seed?: int, experienceId?: string}|null $options Optional overrides + * @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null + */ + public function getBucketForVisitorAnchored(array $allocations, string $visitorId, ?array $options = null): ?array + { + $value = $this->getValueVisitorBased($visitorId, $options); + $selectedBucket = $this->selectBucketAnchored($this->getBucketRanges($allocations), (float)$value); + + if ($this->logManager) { + $this->logManager->debug('BucketingManager.getBucketForVisitorAnchored()', [ + 'visitorId' => $visitorId, + 'experienceId' => $options['experienceId'] ?? '', + 'bucketValue' => $value, + 'selectedVariationId' => $selectedBucket, + ]); + } + + if (!$selectedBucket) { + return null; + } + + return [ + 'variationId' => $selectedBucket, + 'bucketingAllocation' => $value, + ]; + } } diff --git a/packages/Bucketing/src/Interfaces/BucketingManagerInterface.php b/packages/Bucketing/src/Interfaces/BucketingManagerInterface.php index 0315194..181f19b 100644 --- a/packages/Bucketing/src/Interfaces/BucketingManagerInterface.php +++ b/packages/Bucketing/src/Interfaces/BucketingManagerInterface.php @@ -40,4 +40,38 @@ public function getValueVisitorBased(string $visitorId, ?array $options = null): * @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null */ public function getBucketForVisitor(array $buckets, string $visitorId, ?array $options = null): ?array; + + /** + * Build the anchored bucket layout for a set of variation allocations (qs-01). + * + * Anchors are computed over the total weight of ALL entries (active and inactive) so + * that raising an experience's total allocation only ever grows arms (superset + * property) and never reshuffles an already-bucketed visitor into a different arm. + * Inactive (or explicit zero-allocation) entries keep their weight for anchor + * stability but get a zero-width range so they can never be selected. + * + * @param array $allocations Variation allocations in config order + * @return array + */ + public function getBucketRanges(array $allocations): array; + + /** + * Select the variation whose anchored range contains the provided value. + * + * @param array $ranges Anchored bucket ranges (see getBucketRanges()) + * @param float $value A normalized bucket value in [0, maxTraffic) + * @return string|null The selected variation ID, or null if no match + */ + public function selectBucketAnchored(array $ranges, float $value): ?string; + + /** + * Get an anchored bucket for the visitor (qs-01). Reuses the existing + * visitor-based hash value unchanged, then resolves it through the anchored layout. + * + * @param array $allocations Variation allocations in config order + * @param string $visitorId The visitor's unique identifier + * @param array{seed?: int, experienceId?: string}|null $options Optional overrides + * @return array{variationId: string, bucketingAllocation: int}|null Assignment result or null + */ + public function getBucketForVisitorAnchored(array $allocations, string $visitorId, ?array $options = null): ?array; } diff --git a/packages/Data/src/DataManager.php b/packages/Data/src/DataManager.php index 8440d7b..ccbf744 100644 --- a/packages/Data/src/DataManager.php +++ b/packages/Data/src/DataManager.php @@ -16,6 +16,7 @@ use ConvertSdk\Enums\ErrorMessages; use ConvertSdk\Enums\Messages; use ConvertSdk\Enums\RuleError; +use ConvertSdk\Enums\RuleType; use ConvertSdk\Enums\SystemEvents; use ConvertSdk\Event\Interfaces\EventManagerInterface; use ConvertSdk\Interfaces\ApiManagerInterface; @@ -281,6 +282,10 @@ public function matchRulesByField( $locationProperties = $attributes->locationProperties ?? null; $ignoreLocationProperties = $attributes->ignoreLocationProperties ?? false; $environment = $attributes->environment ?? $this->_environment; + // qs-02 capability (B) preview input — per-context suppression signal, + // forwarded to selectLocations() below so a preview context's "other + // experiences evaluate normally" location matching never persists. + $suppressPersistence = $attributes->suppressPersistence ?? false; // Log trace information $this->_loggerManager?->trace( @@ -354,6 +359,7 @@ public function matchRulesByField( $matchedLocations = $this->selectLocations($visitorId, $locations, new LocationAttributes([ 'locationProperties' => $locationProperties, 'identityField' => $identityField, + 'suppressPersistence' => $suppressPersistence, ])); $matchedErrors = array_filter($matchedLocations, fn ($match) => $match instanceof RuleError); if (count($matchedErrors) > 0) { @@ -400,10 +406,23 @@ public function matchRulesByField( $segmentsMatched = false; if (isset($experience['audiences']) && is_array($experience['audiences']) && count($experience['audiences']) > 0) { + // Hoisted above the visitorProperties gate below: getItemsByIds() is a + // pure config lookup (no side effects), so fetching it unconditionally + // lets us inspect the fetched audiences' rule trees for a + // bucketed_into_experience_key rule (qs-03) before deciding whether the + // empty-visitorProperties gate applies. + $audiences = $this->getItemsByIds($experience['audiences'], 'audiences'); + // qs-03 (mutual-exclusion audience rule): a bucketed_into_experience_key + // rule resolves against SDK-stored visitor bucketing state, not + // caller-supplied visitor properties, so it must still be evaluated when + // $visitorProperties is empty (AC4 "zero new application inputs"). + $hasBucketingExclusionAudience = $this->_audiencesContainBucketedIntoExperienceKeyRule($audiences); // In PHP, an empty array [] is falsy (unlike JS where {} is truthy). - // This check correctly requires non-empty visitorProperties to evaluate audience rules. - if ($visitorProperties) { - $audiences = $this->getItemsByIds($experience['audiences'], 'audiences'); + // This check correctly requires non-empty visitorProperties to evaluate audience rules + // -- UNLESS the audience carries a bucketed_into_experience_key rule, which reads + // stored bucketing state instead of visitor properties (qs-03 AC4). Generic-only + // audiences (no such rule) keep today's exact gate behavior (AC7). + if ($visitorProperties || $hasBucketingExclusionAudience) { $audiencesToCheck = array_filter( $audiences, fn ($audience) => !($isBucketed && $audience['type'] === ConfigAudienceTypes::PERMANENT) @@ -411,9 +430,16 @@ public function matchRulesByField( if (count($audiencesToCheck) > 0) { $matchedAudiences = $this->filterMatchedRecordsWithRule( $audiencesToCheck, - $visitorProperties, + // qs-03 gate widening (line 425) can reach this call with + // $visitorProperties still null (no caller-supplied + // properties at all). Coerce to [] here: the exclusion + // path reads stored bucketing state via getData(), not + // visitorProperties content, so an empty array is a safe, + // behavior-preserving default (Gemini review R1). + $visitorProperties ?? [], 'audience', - $identityField + $identityField, + $visitorId ); $matchedErrors = array_filter($matchedAudiences, fn ($match) => $match instanceof RuleError); if (count($matchedErrors) > 0) { @@ -438,8 +464,9 @@ public function matchRulesByField( ); } } - // If visitorProperties is null/empty and experience has audiences, - // audiencesMatched stays false — can't evaluate without properties + // If visitorProperties is null/empty, no bucketed_into_experience_key + // audience is present, and the experience has (other) audiences, + // audiencesMatched stays false — can't evaluate without properties. } else { // No audiences on experience — all visitors qualify $audiencesMatched = true; @@ -531,6 +558,8 @@ private function _getBucketingByField( $enableTracking = $attributes->enableTracking ?? true; $ignoreLocationProperties = $attributes->ignoreLocationProperties ?? false; $environment = $attributes->environment ?? $this->_environment; + // qs-02 capability (B) preview input — per-context suppression signal. + $suppressPersistence = $attributes->suppressPersistence ?? false; // Log trace information $this->_loggerManager?->trace( 'DataManager._getBucketingByField()', @@ -557,6 +586,7 @@ private function _getBucketingByField( 'locationProperties' => $locationProperties, 'ignoreLocationProperties' => $ignoreLocationProperties, 'environment' => $environment, + 'suppressPersistence' => $suppressPersistence, ]) ); if ($experience) { @@ -569,13 +599,94 @@ private function _getBucketingByField( $updateVisitorProperties, new ConfigExperience($experience), $forceVariationId, - $enableTracking + $enableTracking, + $suppressPersistence ); } return null; } + /** + * Shared "running + non-zero-traffic" active predicate used by BOTH the packed + * (buildPackedBuckets) and anchored (buildVariationAllocations) layout builders, + * so both layouts agree on activeness. + * + * @param array $variation + */ + private function isVariationActive(array $variation): bool + { + return (isset($variation['status']) ? $variation['status'] === VariationStatuses::RUNNING : true) && + (array_key_exists('traffic_allocation', $variation) ? + ($variation['traffic_allocation'] > 0 || !is_numeric($variation['traffic_allocation'])) : + true); + } + + /** + * Build buckets where key is variation id and value is traffic distribution + * (existing packed layout, experience version <= 11, missing, or non-numeric; + * byte-for-byte unchanged). Version 11 is the version stamped on every experience + * currently served in production (backend CURRENT_EXPERIENCE_VERSION), so this is + * the active path for all currently-running experiments. + * + * @param array> $variations + * @return array + * @private + */ + private function buildPackedBuckets(array $variations): array + { + return array_reduce( + array_filter( + $variations, + fn ($variation) => $this->isVariationActive($variation) + ), + function ($carry, $variation) { + if (!empty($variation['id'])) { + $carry[$variation['id']] = $variation['traffic_allocation'] ?? 100.0; + } + return $carry; + }, + [] + ); + } + + /** + * Build variation allocations for the anchored layout (qs-01, contract v12). + * Activates only once the served experience version is > 11 (i.e. >= 12, once the + * backend bumps CURRENT_EXPERIENCE_VERSION past its current value of 11). + * Inactive arms (stopped, or explicit zero traffic_allocation) keep their weight for + * anchor stability but are marked inactive so BucketingManager::getBucketRanges() + * gives them zero width. Uses the shared isVariationActive() predicate, also used + * by buildPackedBuckets(), so both layouts agree on activeness. See + * qs-01-anchored-bucketing-layout.md "The contract (normative)". + * + * @param array> $variations + * @return array + * @private + */ + private function buildVariationAllocations(array $variations): array + { + $allocations = []; + + foreach ($variations as $variation) { + if (empty($variation['id'])) { + continue; + } + + $trafficAllocation = array_key_exists('traffic_allocation', $variation) + ? $variation['traffic_allocation'] + : null; + + $allocations[] = [ + 'id' => (string)$variation['id'], + 'allocation' => is_numeric($trafficAllocation) ? (float)$trafficAllocation : 100.0, + 'active' => $this->isVariationActive($variation), + ]; + } + + return $allocations; + } + /** * Retrieve variation for visitor * @@ -585,6 +696,9 @@ private function _getBucketingByField( * @param ConfigExperience $experience * @param ?string $forceVariationId * @param bool $enableTracking Defaults to true + * @param bool $suppressPersistence qs-02 capability (B) preview input — when true, + * suppresses the stored-decision write AND the bucketing-event enqueue + * regardless of $enableTracking. Defaults to false. * @return mixed BucketedVariation array or BucketingError or null * @private */ @@ -594,7 +708,8 @@ private function _retrieveBucketing( ?bool $updateVisitorProperties, ConfigExperience $experience, ?string $forceVariationId = null, - bool $enableTracking = true + bool $enableTracking = true, + bool $suppressPersistence = false ): array|BucketingError|null { // Initial validation if (empty($visitorId) || $experience === null || empty($experience->getId())) { @@ -651,33 +766,35 @@ private function _retrieveBucketing( ) ); } else { - // Build buckets from variations - $buckets = array_reduce( - array_filter( - $experience->getVariations(), - fn ($variation) => - (isset($variation['status']) ? $variation['status'] === VariationStatuses::RUNNING : true) && - (array_key_exists('traffic_allocation', $variation) ? - ($variation['traffic_allocation'] > 0 || !is_numeric($variation['traffic_allocation'])) : - true) - ), - function ($carry, $variation) { - if (!empty($variation['id'])) { - $carry[$variation['id']] = $variation['traffic_allocation'] ?? 100.0; - } - return $carry; - }, - [] - ); + // qs-01: anchored-vs-packed GATE. `experience.version > 11` runs the anchored + // layout (contract v12); version <= 11, missing, or non-numeric keeps the + // existing packed cumulative walk unchanged -- this is every currently-served + // production experience (backend CURRENT_EXPERIENCE_VERSION = 11). The SDK + // must never infer the layout from anything but this field. See + // qs-01-anchored-bucketing-layout.md "The contract (normative)". + $version = $experience->getVersion(); + $isAnchoredLayout = is_numeric($version) && (float)$version > 11; + // Determine bucket for visitor $bucketingParams = $this->_config->bucketing->excludeExperienceIdHash ?? false ? null : ['experienceId' => (string)$experience->getId()]; - $bucketing = $this->_bucketingManager->getBucketForVisitor( - $buckets, - $visitorId, - $bucketingParams - ); + + if ($isAnchoredLayout) { + $buckets = $this->buildVariationAllocations($experience->getVariations()); + $bucketing = $this->_bucketingManager->getBucketForVisitorAnchored( + $buckets, + $visitorId, + $bucketingParams + ); + } else { + $buckets = $this->buildPackedBuckets($experience->getVariations()); + $bucketing = $this->_bucketingManager->getBucketForVisitor( + $buckets, + $visitorId, + $bucketingParams + ); + } $variationId = $variationId ?? $bucketing['variationId'] ?? null; $bucketingAllocation = $bucketing['bucketingAllocation'] ?? null; @@ -708,9 +825,12 @@ function ($carry, $variation) { if ($updateVisitorProperties && !empty($visitorProperties)) { $storeDataObj['segments'] = $visitorProperties; } - $this->putData($visitorId, $storeDataObj); + // qs-02: suppressed for a preview context — zero-trace, regardless of enableTracking. + if (!$suppressPersistence) { + $this->putData($visitorId, $storeDataObj); + } // Track bucketing event if enabled - if ($enableTracking) { + if ($enableTracking && !$suppressPersistence) { $bucketingEvent = [ 'experienceId' => (string)$experience->getId(), 'variationId' => (string)$variationId, @@ -775,6 +895,66 @@ private function retrieveVariation( return $subItem !== null ? new ExperienceVariationConfig($subItem) : null; } + /** + * Build a bucketed-variation array for a preview forced decision (qs-02 + * capability B preview input), bypassing every normal gate — audiences, + * segments, locations, the environment check, experience status, variation + * status/traffic filters, stored decisions, and the bucketing hash. Pure: + * never calls putData() and never enqueues a tracking event, so it stays + * entirely per-context regardless of whether $experienceData came from the + * current shared config or from a one-off `?exp=` fetch — and never + * touches the shared entity list either way. + * + * @param array $experienceData The experience data (from the current + * config, or from a `?exp=` fetch response — same raw shape either way) + * @param string $variationId The variation id to force + * @return array|null Same shape as a normal bucketed decision (see + * _retrieveBucketing()), or null when $variationId does not exist on the given + * experience — the caller (Context) treats this as inert bad input. + */ + public function buildPreviewDecision(array $experienceData, string $variationId): ?array + { + $variationData = null; + foreach ($experienceData['variations'] ?? [] as $candidate) { + if (is_array($candidate) && (string)($candidate['id'] ?? '') === $variationId) { + $variationData = $candidate; + break; + } + } + + if ($variationData === null) { + $this->_loggerManager?->warn( + 'DataManager.buildPreviewDecision()', + Messages::PREVIEW_VARIATION_NOT_FOUND, + LogUtils::toLoggable(($this->_mapper)([ + 'experienceId' => $experienceData['id'] ?? null, + 'variationId' => $variationId, + ])) + ); + return null; + } + + $experience = new ConfigExperience($experienceData); + $variation = new ExperienceVariationConfig($variationData); + + return array_merge( + [ + 'experienceId' => $experience->getId(), + 'experienceName' => $experience->getName(), + 'experienceKey' => $experience->getKey(), + ], + ['bucketingAllocation' => null], + [ + 'id' => $variation->getId(), + 'name' => $variation->getName(), + 'key' => $variation->getKey(), + 'traffic_allocation' => $variation->getTrafficAllocation(), + 'status' => $variation->getStatus(), + 'changes' => $variation->getChanges(), + ] + ); + } + /** * Reset the bucketed visitors map. * @@ -903,6 +1083,8 @@ public function selectLocations(string $visitorId, array $items, LocationAttribu $locationProperties = $attributes->getLocationProperties(); $identityField = $attributes->getIdentityField() ?? 'key'; $forceEvent = $attributes->getForceEvent(); + // qs-02 capability (B) preview input — per-context suppression signal. + $suppressPersistence = $attributes->getSuppressPersistence() ?? false; $this->_loggerManager?->trace( 'DataManager.selectLocations()', @@ -936,7 +1118,16 @@ public function selectLocations(string $visitorId, array $items, LocationAttribu str_replace('#', "#{$identity}", Messages::LOCATION_MATCH) ); - if (!in_array($identity, $locations, true) || $forceEvent) { + if ((!in_array($identity, $locations, true) || $forceEvent) && !$suppressPersistence) { + // qs-16 correction: JS mirrors this with a distinct `suppressEvents` + // flag (data-manager.ts selectLocations(), gating only the + // LOCATION_ACTIVATED/LOCATION_DEACTIVATED fires, independent of its + // `enableStorage`). PHP's `suppressPersistence` is contractually + // preview-exclusive (see LocationAttributes::$suppressPersistence + // docblock — "never exposed as a public per-call override"), so it is + // reused here to gate both event fires as well as the persistence + // write, achieving the same zero-trace behavior with one flag instead + // of two. Location matching/bookkeeping above is never gated. $this->_eventManager->fire( SystemEvents::LocationActivated, [ @@ -964,33 +1155,40 @@ public function selectLocations(string $visitorId, array $items, LocationAttribu // Catch rule errors $matchedRecords[] = $match; } elseif ($match === false && in_array($identity, $locations, true)) { - $this->_eventManager->fire( - SystemEvents::LocationDeactivated, - [ - 'visitorId' => $visitorId, - 'location' => [ - 'id' => $item['id'] ?? null, - 'key' => $item['key'] ?? null, - 'name' => $item['name'] ?? null, + // qs-16 correction: gated on the same preview-exclusive + // $suppressPersistence signal as LocationActivated above — mirrors JS's + // separate `suppressEvents` flag (data-manager.ts selectLocations()). + if (!$suppressPersistence) { + $this->_eventManager->fire( + SystemEvents::LocationDeactivated, + [ + 'visitorId' => $visitorId, + 'location' => [ + 'id' => $item['id'] ?? null, + 'key' => $item['key'] ?? null, + 'name' => $item['name'] ?? null, + ], ], - ], - null, - true - ); + null, + true + ); + $this->_loggerManager?->info( + 'DataManager.selectLocations()', + str_replace('#', "#{$identity}", Messages::LOCATION_DEACTIVATED) + ); + } $locationIndex = array_search($identity, $locations, true); if ($locationIndex !== false) { array_splice($locations, $locationIndex, 1); } - $this->_loggerManager?->info( - 'DataManager.selectLocations()', - str_replace('#', "#{$identity}", Messages::LOCATION_DEACTIVATED) - ); } } } - // Store the data - $this->putData($visitorId, ['locations' => $locations]); + // Store the data (qs-02: suppressed for a preview context — zero-trace) + if (!$suppressPersistence) { + $this->putData($visitorId, ['locations' => $locations]); + } $this->_loggerManager?->debug( 'DataManager.selectLocations()', @@ -1038,6 +1236,9 @@ public function getBucketingById(string $visitorId, string $id, BucketingAttribu * @param array|null $goalData Optional array of associative arrays containing goal data * @param VisitorSegments|null $segments Optional visitor segments object * @param array|null $conversionSetting Optional associative array of conversion settings + * @param bool $suppressPersistence qs-02 capability (B) preview input — when true, + * suppresses the goal-triggered write AND the conversion/transaction + * enqueue. Defaults to false. * @return bool|RuleError Returns true on success, or a RuleError instance on failure */ public function convert( @@ -1046,7 +1247,8 @@ public function convert( ?array $goalRule = null, ?array $goalData = null, ?VisitorSegments $segments = null, - ?array $conversionSetting = null + ?array $conversionSetting = null, + bool $suppressPersistence = false ): bool|RuleError { // Retrieve the goal based on goalId type $goal = is_string($goalId) @@ -1105,15 +1307,17 @@ public function convert( } } - // Store the goal as triggered - $this->putData($visitorId, ['goals' => [$goalId => true]]); + // Store the goal as triggered (qs-02: suppressed for a preview context — zero-trace) + if (!$suppressPersistence) { + $this->putData($visitorId, ['goals' => [$goalId => true]]); + } // Send conversion event if goal wasn't previously triggered - if (!$goalTriggered) { + if (!$goalTriggered && !$suppressPersistence) { $this->sendConversion($visitorId, $goal['id'], $bucketingData, $segments); } // Send transaction event if goalData exists and conditions are met - if ($goalData !== null && (!$goalTriggered || $forceMultipleTransactions)) { + if ($goalData !== null && (!$goalTriggered || $forceMultipleTransactions) && !$suppressPersistence) { $this->sendTransaction($visitorId, $goal['id'], $goalData, $bucketingData, $segments); } @@ -1184,13 +1388,18 @@ private function sendTransaction(string $visitorId, string $goalId, array $goalD * @param array $visitorProperties Associative array of visitor properties * @param string $entityType Type of entity being filtered (e.g., 'audience') * @param string $field Identity field to use, defaults to 'id' + * @param string|null $visitorId qs-03: required only when an item's rule tree is a + * sole bucketed_into_experience_key rule, to resolve against SDK-stored + * visitor bucketing state instead of $visitorProperties. Null for callers + * that never carry such a rule (RuleManager path is untouched for them). * @return array Array of matched items or RuleError instances */ public function filterMatchedRecordsWithRule( array $items, array $visitorProperties, string $entityType, - string $field = IdentityField::ID + string $field = IdentityField::ID, + ?string $visitorId = null ): array { $this->_loggerManager?->trace( 'DataManager.filterMatchedRecordsWithRule()', @@ -1209,11 +1418,36 @@ public function filterMatchedRecordsWithRule( continue; } - $match = $this->_ruleManager->isRuleMatched( - $visitorProperties, - new RuleObject($item['rules']), - StringUtils::camelCase($entityType) . " #{$item[$field]}" - ); + // qs-03 (mutual-exclusion audience rule): a bucketed_into_experience_key + // rule resolves against SDK-stored visitor bucketing state instead of + // $visitorProperties. It is resolved read-only here (getEntity() + + // getData(), no bucketing/writes/tracking) into a raw boolean, then + // routed through the SAME untouched isRuleMatched() generic key/value + // dispatch every other rule uses — via a synthetic single-key data/rule + // pair whose 'equals' comparison reproduces `matching.negated ? !raw : + // raw` using RuleManager's own (unmodified) negation logic + // (Comparisons::equals()/returnNegationCheck()). RuleManager itself is + // never modified (AC7); any item whose rule tree is NOT a sole + // bucketed_into_experience_key rule (every generic key/value rule shape + // in production today) is passed through completely unchanged below. + $exclusionRule = ($visitorId !== null && is_array($item['rules'])) + ? $this->_findSoleBucketedIntoExperienceKeyRule($item['rules']) + : null; + + if ($exclusionRule !== null) { + [$ruleData, $ruleObject] = $this->_buildBucketedIntoExperienceKeyRuleMatch($exclusionRule, $visitorId); + $match = $this->_ruleManager->isRuleMatched( + $ruleData, + $ruleObject, + StringUtils::camelCase($entityType) . " #{$item[$field]}" + ); + } else { + $match = $this->_ruleManager->isRuleMatched( + $visitorProperties, + new RuleObject($item['rules']), + StringUtils::camelCase($entityType) . " #{$item[$field]}" + ); + } if ($match === true) { $matchedRecords[] = $item; @@ -1234,6 +1468,120 @@ public function filterMatchedRecordsWithRule( return $matchedRecords; } + /** + * qs-03: whether any of the given (already-fetched) audiences carries a + * bucketed_into_experience_key rule as its sole rule. Used to widen the + * DataManager.php empty-visitorProperties audience-evaluation gate SOLELY + * for experiences whose audience tree needs this rule type (AC4), while + * leaving the gate untouched for every generic-only audience (AC7). + * + * @param array> $audiences + */ + private function _audiencesContainBucketedIntoExperienceKeyRule(array $audiences): bool + { + foreach ($audiences as $audience) { + if (!empty($audience['rules']) && is_array($audience['rules']) && $this->_findSoleBucketedIntoExperienceKeyRule($audience['rules']) !== null) { + return true; + } + } + return false; + } + + /** + * qs-03: returns the rule element if $rulesTree contains EXACTLY ONE + * rule element in total and it is a bucketed_into_experience_key rule; + * otherwise null (including for mixed generic+exclusion trees, which are + * out of scope per qs-03-mutual-exclusion-rule.md's non-goals — no served + * config can yet emit one). Returning null routes the item through the + * untouched, generic isRuleMatched() path unchanged. + * + * @param array $rulesTree The OR/AND/OR_WHEN rule tree + * @return array{rule_type: string, matching: array{match_type: string, negated: bool}, value: mixed}|null + */ + private function _findSoleBucketedIntoExperienceKeyRule(array $rulesTree): ?array + { + $elements = $this->_collectRuleElements($rulesTree); + if (count($elements) === 1 && ($elements[0]['rule_type'] ?? null) === RuleType::BucketedIntoExperienceKey->value) { + return $elements[0]; + } + return null; + } + + /** + * qs-03: recursively collects every leaf rule element (identified by the + * presence of a `rule_type` key) anywhere within an OR/AND/OR_WHEN rule + * tree, regardless of nesting depth or shape. + * + * @param mixed $node + * @return array> + */ + private function _collectRuleElements(mixed $node): array + { + if (!is_array($node)) { + return []; + } + if (isset($node['rule_type'])) { + return [$node]; + } + $elements = []; + foreach ($node as $value) { + if (is_array($value)) { + $elements = array_merge($elements, $this->_collectRuleElements($value)); + } + } + return $elements; + } + + /** + * qs-03: resolves a bucketed_into_experience_key rule element read-only + * (target lookup via getEntity(), presence check via getData() — never + * triggers bucketing of the target, never writes, never tracks — AC5), + * then builds a synthetic single-key $data/RuleObject pair that reproduces + * the contract's `matching.negated ? !bucketedRaw : bucketedRaw` via the + * SAME real, unmodified isRuleMatched() -> Comparisons::equals() negation + * logic every generic 'equals' rule already uses. + * + * Unknown target key -> bucketedRaw = false + AC8 warning naming the key. + * + * @param array{rule_type: string, matching: array{match_type: string, negated: bool}, value: mixed} $rule + * @return array{0: array, 1: RuleObject} + */ + private function _buildBucketedIntoExperienceKeyRuleMatch(array $rule, string $visitorId): array + { + $targetKey = (string)($rule['value'] ?? ''); + $target = $this->getEntity($targetKey, 'experiences'); + + if ($target === null) { + $this->_loggerManager?->warn( + 'DataManager.filterMatchedRecordsWithRule()', + str_replace('#', $targetKey, Messages::BUCKETING_EXCLUSION_TARGET_NOT_FOUND) + ); + $bucketedRaw = false; + } else { + $visitorData = $this->getData($visitorId) ?? []; + $bucketingData = $visitorData['bucketing'] ?? []; + $bucketedRaw = array_key_exists((string)($target['id'] ?? ''), $bucketingData); + } + + $syntheticKey = '__convertSdk_bucketedIntoExperienceKey'; + $negated = (bool)($rule['matching']['negated'] ?? false); + $syntheticData = [$syntheticKey => $bucketedRaw ? 'true' : 'false']; + $syntheticRuleObject = new RuleObject([ + 'OR' => [ + ['AND' => [ + ['OR_WHEN' => [[ + 'rule_type' => RuleType::BucketedIntoExperienceKey->value, + 'key' => $syntheticKey, + 'matching' => ['match_type' => 'equals', 'negated' => $negated], + 'value' => 'true', + ]]], + ]], + ], + ]); + + return [$syntheticData, $syntheticRuleObject]; + } + /** * Get audiences that meet the custom segments. * diff --git a/packages/Data/src/Interfaces/DataManagerInterface.php b/packages/Data/src/Interfaces/DataManagerInterface.php index 8d0dcc3..7c712f0 100644 --- a/packages/Data/src/Interfaces/DataManagerInterface.php +++ b/packages/Data/src/Interfaces/DataManagerInterface.php @@ -125,9 +125,22 @@ public function getBucketingById(string $visitorId, string $experienceId, Bucket * @param GoalData[]|null $goalData Array of GoalData objects (optional) * @param VisitorSegments|null $segments (optional) * @param array|null $conversionSetting Associative array with ConversionSettingKey keys (optional) + * @param bool $suppressPersistence qs-02 capability (B) preview input — when true, + * suppresses the goal-triggered write and the conversion/transaction enqueue * @return RuleError|bool */ - public function convert(string $visitorId, string $goalId, ?array $goalRule = null, ?array $goalData = null, ?VisitorSegments $segments = null, ?array $conversionSetting = null): bool|RuleError; + public function convert(string $visitorId, string $goalId, ?array $goalRule = null, ?array $goalData = null, ?VisitorSegments $segments = null, ?array $conversionSetting = null, bool $suppressPersistence = false): bool|RuleError; + + /** + * Build a bucketed-variation array for a preview forced decision (qs-02 + * capability B preview input), bypassing every normal gate. Pure: never + * writes visitor state or enqueues a tracking event. + * + * @param array $experienceData + * @param string $variationId + * @return array|null + */ + public function buildPreviewDecision(array $experienceData, string $variationId): ?array; /** * Get a list of entities by type. diff --git a/packages/Data/tests/AnchoredBucketingLayoutTest.php b/packages/Data/tests/AnchoredBucketingLayoutTest.php new file mode 100644 index 0000000..483b541 --- /dev/null +++ b/packages/Data/tests/AnchoredBucketingLayoutTest.php @@ -0,0 +1,435 @@ +11 branch, + * AC2's per-sliver admission at version 12, AC3, AC4, AC5, AC8's version-12 sub-case, AC9's + * anchored not-bucketed sub-case) are EXPECTED to fail until BucketingManager / DataManager's + * fresh-bucketing branch implement the version gate and anchored algorithm. Packed-path + * assertions (AC1's version<=11/missing/non-numeric branches, AC6) are expected to pass now. + * + * Spec: _bmad-output/planning-artifacts/2026-03-13-convert-php-sdk/qs-01-anchored-bucketing-layout.md + */ +class AnchoredBucketingLayoutTest extends TestCase +{ + private const EXPERIENCE_ID = '900000001'; + + /** + * Builds a fresh DataManager wired with exactly one experience (id = EXPERIENCE_ID) + * carrying the given $variations and $version. Fresh per call so no visitor ever + * carries a stored decision across assertions unless the test deliberately calls + * putData() on the returned instance first (see the AC8 test). + * + * @param array> $variations + */ + private function makeDataManager(array $variations, int|float|string|null $version, string $experienceId = self::EXPERIENCE_ID): DataManager + { + return new DataManager( + new Config([ + 'environment' => 'production', + 'data' => new ConfigResponseData([ + 'account_id' => 'test-account', + 'project' => ['id' => 'test-project'], + 'experiences' => [[ + 'id' => $experienceId, + 'key' => $experienceId . '-key', + 'name' => 'Anchored Bucketing AC Test Experience', + 'version' => $version, + 'variations' => $variations, + ]], + ]), + ]), + new BucketingManager(), + $this->createMock(RuleManagerInterface::class), + $this->createMock(EventManagerInterface::class), + $this->createMock(ApiManagerInterface::class), + new LogManager() + ); + } + + /** + * @param array> $variations + */ + private function bucketFreshVisitor( + array $variations, + string $visitorId, + int|float|string|null $version, + string $experienceId = self::EXPERIENCE_ID + ): array|RuleError|BucketingError|null { + return $this->makeDataManager($variations, $version, $experienceId)->getBucketingById( + $visitorId, + $experienceId, + new BucketingAttributes([ + 'ignoreLocationProperties' => true, + 'enableTracking' => false, + ]) + ); + } + + private function assertVariation(string $expectedVariationId, array|RuleError|BucketingError|null $result, string $message = ''): void + { + $this->assertIsArray($result, $message); + $this->assertSame($expectedVariationId, $result['id'], $message); + } + + private function assertNotBucketed(array|RuleError|BucketingError|null $result, string $message = ''): void + { + $this->assertSame(BucketingError::VariationNotDecided, $result, $message); + } + + /** + * Three equal-share running arms (O/V1/V2), each carrying $trafficAllocation. Covers + * both the 15% (5/5/5) and 25% (8.333.../each) configs used throughout the AC2/AC3/AC6/ + * AC8 tests — only the per-arm share differs between call sites. + * + * @return array> + */ + private function thirds(float $trafficAllocation): array + { + return [ + ['id' => 'O', 'traffic_allocation' => $trafficAllocation, 'status' => 'running'], + ['id' => 'V1', 'traffic_allocation' => $trafficAllocation, 'status' => 'running'], + ['id' => 'V2', 'traffic_allocation' => $trafficAllocation, 'status' => 'running'], + ]; + } + + /** + * The O=10/V1=80/V2=10 config used across AC4/AC5/AC9. $statusOverrides maps a + * variation id to a status override (e.g. ['V1' => 'stopped']); any id not present + * defaults to 'running'. + * + * @param array $statusOverrides + * @return array> + */ + private function tenEightyTen(array $statusOverrides = []): array + { + $allocations = ['O' => 10, 'V1' => 80, 'V2' => 10]; + + $variations = []; + foreach ($allocations as $id => $trafficAllocation) { + $variations[] = [ + 'id' => $id, + 'traffic_allocation' => $trafficAllocation, + 'status' => $statusOverrides[$id] ?? 'running', + ]; + } + + return $variations; + } + + // --- AC1: gate branching ---------------------------------------------------------- + + /** + * Vectors #1 (v11 -> V1) and #19 (v12, IDENTICAL variations/visitor -> not bucketed). + * Only the `version` field differs; only the routed layout should explain the + * different outcome. + */ + public function testAc1Version12RoutesToAnchoredAndVersion11RoutesToPacked(): void + { + $variations = $this->thirds(5); + $visitorId = 'thirds-flip-V1-to-O-66'; // raw bucket value 601, per vector #1/#19 + + $this->assertVariation('V1', $this->bucketFreshVisitor($variations, $visitorId, 11), 'version 11 (packed) must still select V1 per vector #1'); + $this->assertNotBucketed($this->bucketFreshVisitor($variations, $visitorId, 12), 'version 12 (anchored) must NOT bucket this visitor per vector #19'); + } + + /** + * Vector #0 data (v11 -> O) reused at a missing and a non-numeric version: both must + * behave exactly as version 11 (packed), per AC1's "inert-on-ship" guarantee. + */ + public function testAc1MissingOrNonNumericVersionRoutesToPacked(): void + { + $variations = $this->thirds(5); + $visitorId = 'thirds-core-O-1'; // raw bucket value 293, per vector #0 + + $this->assertVariation('O', $this->bucketFreshVisitor($variations, $visitorId, null), 'missing version must route to packed (vector #0 outcome)'); + $this->assertVariation('O', $this->bucketFreshVisitor($variations, $visitorId, 'not-a-number'), 'non-numeric version must route to packed (vector #0 outcome)'); + } + + // --- AC2: raise is a superset ------------------------------------------------------- + + /** + * Vectors #13/#14, #15/#16, #17/#18: visitors already inside an arm at 15% (5/5/5) + * keep the SAME arm at 25% (8.333.../each) under anchored. + */ + public function testAc2RaiseKeepsAlreadyBucketedVisitorsInTheSameArm(): void + { + $fifteenPercent = $this->thirds(5); + $twentyFivePercent = $this->thirds(8.333333333333334); + + $cases = [ + 'thirds-core-O-1' => 'O', // vectors #13/#14, raw value 293 + 'thirds-anchored-V1-core-1' => 'V1', // vectors #15/#16, raw value 3617 + 'thirds-anchored-V2-core-24' => 'V2', // vectors #17/#18, raw value 6871 + ]; + + foreach ($cases as $visitorId => $expectedArm) { + $this->assertVariation($expectedArm, $this->bucketFreshVisitor($fifteenPercent, $visitorId, 12), "$visitorId must be in $expectedArm at 15%"); + $this->assertVariation($expectedArm, $this->bucketFreshVisitor($twentyFivePercent, $visitorId, 12), "$visitorId must STAY in $expectedArm at 25% (superset, AC2)"); + } + } + + /** + * Vectors #19/#20, #21/#22, #23/#24: visitors NOT bucketed at 15% are newly admitted + * into the raised arm's growth sliver at 25% — without disturbing any other arm. + */ + public function testAc2RaiseAdmitsNewVisitorsIntoTheGrowthSliverOnly(): void + { + $fifteenPercent = $this->thirds(5); + $twentyFivePercent = $this->thirds(8.333333333333334); + + $cases = [ + 'thirds-flip-V1-to-O-66' => 'O', // vectors #19/#20, raw value 601 + 'thirds-anchored-V1-sliver-15' => 'V1', // vectors #21/#22, raw value 3899 + 'thirds-anchored-V2-sliver-14' => 'V2', // vectors #23/#24, raw value 7353 + ]; + + foreach ($cases as $visitorId => $expectedArm) { + $this->assertNotBucketed($this->bucketFreshVisitor($fifteenPercent, $visitorId, 12), "$visitorId must NOT be bucketed at 15%"); + $this->assertVariation($expectedArm, $this->bucketFreshVisitor($twentyFivePercent, $visitorId, 12), "$visitorId must be admitted into $expectedArm's growth sliver at 25%"); + } + } + + // --- AC3: lower ejects evenly and never flips --------------------------------------- + + /** + * Vectors #25/#26: a visitor that is idle (not bucketed) at 25% stays idle when + * lowered to 15% — it is never incorrectly admitted or flipped into an arm. + */ + public function testAc3LowerNeverFlipsAnIdleVisitorIntoAnArm(): void + { + $twentyFivePercent = $this->thirds(8.333333333333334); + $fifteenPercent = $this->thirds(5); + $visitorId = 'thirds-flip-V2-to-V1-5'; // raw bucket value 1213 + + $this->assertNotBucketed($this->bucketFreshVisitor($twentyFivePercent, $visitorId, 12), 'must be idle at 25% per vector #26'); + $this->assertNotBucketed($this->bucketFreshVisitor($fifteenPercent, $visitorId, 12), 'must STILL be idle at 15% (never flips into an arm), per vector #25'); + } + + /** + * Vectors #19-#24 read in the lowering direction: a visitor admitted at 25% is + * ejected to not-bucketed at 15% — never reassigned to a different arm. + */ + public function testAc3LowerEjectsAdmittedVisitorsWithoutReassigningThem(): void + { + $twentyFivePercent = $this->thirds(8.333333333333334); + $fifteenPercent = $this->thirds(5); + + $visitorIds = ['thirds-flip-V1-to-O-66', 'thirds-anchored-V1-sliver-15', 'thirds-anchored-V2-sliver-14']; + + foreach ($visitorIds as $visitorId) { + $atHighCoverage = $this->bucketFreshVisitor($twentyFivePercent, $visitorId, 12); + $this->assertIsArray($atHighCoverage, "$visitorId must be bucketed at 25%"); + $this->assertNotBucketed($this->bucketFreshVisitor($fifteenPercent, $visitorId, 12), "$visitorId must be EJECTED (not reassigned) at 15%"); + } + } + + // --- AC4: stops don't move anchors -------------------------------------------------- + + /** + * Vectors #31-#36: stopping V1 (ta preserved) must not affect O's or V2's anchors, + * and must zero-width V1 itself (never selected while stopped). + */ + public function testAc4StoppingOneArmDoesNotMoveOtherArmsAnchorsAndZeroWidthsTheStoppedArm(): void + { + $allRunning = $this->tenEightyTen(); + $v1Stopped = $this->tenEightyTen(['V1' => 'stopped']); + + // vectors #31/#32: O unaffected by V1's stop + $this->assertVariation('O', $this->bucketFreshVisitor($allRunning, 'anchor-gate-visitor-106', 12)); + $this->assertVariation('O', $this->bucketFreshVisitor($v1Stopped, 'anchor-gate-visitor-106', 12), 'O must be byte-identical whether V1 runs or is stopped'); + + // vectors #33/#34: V2's anchor (9000) is byte-identical whether V1 runs or is stopped + $this->assertVariation('V2', $this->bucketFreshVisitor($allRunning, 'anchor-gate-visitor-162', 12)); + $this->assertVariation('V2', $this->bucketFreshVisitor($v1Stopped, 'anchor-gate-visitor-162', 12), "V2's anchor must not move when V1 stops"); + + // vectors #35/#36: V1 itself becomes zero-width (not bucketed) once stopped, anchor preserved + $this->assertVariation('V1', $this->bucketFreshVisitor($allRunning, 'anchor-gate-visitor-17', 12)); + $this->assertNotBucketed($this->bucketFreshVisitor($v1Stopped, 'anchor-gate-visitor-17', 12), 'stopped V1 keeps its weight/anchor but has zero width'); + } + + /** + * Vectors #37-#39: an explicit traffic_allocation=0 arm (Z) is zero-width — never + * treated as 100% default — and never perturbs its sibling arms' anchors. + */ + public function testAc4ExplicitZeroTrafficAllocationIsNeverTreatedAs100Percent(): void + { + $variations = [ + ['id' => 'O', 'traffic_allocation' => 2, 'status' => 'running'], + ['id' => 'V1', 'traffic_allocation' => 47, 'status' => 'running'], + ['id' => 'Z', 'traffic_allocation' => 0, 'status' => 'running'], + ['id' => 'V2', 'traffic_allocation' => 1, 'status' => 'running'], + ]; + + $this->assertVariation('O', $this->bucketFreshVisitor($variations, 'anchor-gate-visitor-106', 12)); + $this->assertVariation('V1', $this->bucketFreshVisitor($variations, 'anchor-gate-visitor-17', 12), "Z's explicit zero allocation must never be defaulted to 100 nor perturb V1's anchor"); + $this->assertVariation('V2', $this->bucketFreshVisitor($variations, 'anchor-gate-visitor-162', 12), 'Z must never be selected and must not shift V2\'s anchor'); + } + + // --- AC5: defaults & boundaries ------------------------------------------------------ + + /** + * Vectors #40, #42, #43, #44, #45: NaN/absent traffic_allocation defaults to a 100.0 + * weight (never zero, never excluded from the total). + */ + public function testAc5MissingTrafficAllocationDefaultsToOneHundredWeight(): void + { + // vector #40: single arm, ta omitted -> full traffic space + $this->assertVariation('DEFAULT', $this->bucketFreshVisitor( + [['id' => 'DEFAULT', 'status' => 'running']], + 'nan-default-visitor', + 12 + )); + + // vectors #42/#43: B=5, A omitted (defaults to 100) - A's defaulted weight must not + // swallow values that clearly belong inside B's own explicit band. + $twoArms = [ + ['id' => 'B', 'traffic_allocation' => 5, 'status' => 'running'], + ['id' => 'A', 'status' => 'running'], + ]; + $this->assertVariation('B', $this->bucketFreshVisitor($twoArms, 'anchor-gate-visitor-106', 12), "B's own band must win for values inside it"); + $this->assertVariation('A', $this->bucketFreshVisitor($twoArms, 'anchor-gate-visitor-162', 12), "A's defaulted 100-weight band must cover the rest"); + + // vectors #44/#45: single full-allocation arm is identical under v11 and v12 + $single = [['id' => 'ONLY', 'traffic_allocation' => 100, 'status' => 'running']]; + $this->assertVariation('ONLY', $this->bucketFreshVisitor($single, 'single-arm-visitor', 11)); + $this->assertVariation('ONLY', $this->bucketFreshVisitor($single, 'single-arm-visitor', 12)); + } + + /** + * Vectors #57/#58: total weight <= 0 (all arms zero-allocation) is never bucketed, + * regardless of visitor, under either layout. + */ + public function testAc5TotalWeightZeroIsNeverBucketed(): void + { + $variations = [ + ['id' => 'A', 'traffic_allocation' => 0, 'status' => 'running'], + ['id' => 'B', 'traffic_allocation' => 0, 'status' => 'stopped'], + ]; + + $this->assertNotBucketed($this->bucketFreshVisitor($variations, 'anchor-gate-visitor-106', 12), 'totalWeight <= 0 must never bucket (anchored)'); + $this->assertNotBucketed($this->bucketFreshVisitor($variations, 'anchor-gate-visitor-106', 11), 'totalWeight <= 0 must never bucket (packed)'); + } + + /** + * Vectors #52-#56: an anchor is INCLUSIVE (`value == anchor` is IN) while the far edge + * of a band is EXCLUSIVE (`value == anchor + width` is OUT, landing in the next arm). + */ + public function testAc5BoundaryValuesAreInclusiveAtAnchorAndExclusiveAtAnchorPlusWidth(): void + { + $variations = $this->tenEightyTen(); + + $this->assertVariation('O', $this->bucketFreshVisitor($variations, 'boundary-999-25207', 12), 'value 999 is just below V1\'s anchor (1000) -> stays in O'); + $this->assertVariation('V1', $this->bucketFreshVisitor($variations, 'boundary-1000-1145', 12), 'value 1000 EQUALS V1\'s anchor -> anchor is inclusive'); + $this->assertVariation('V1', $this->bucketFreshVisitor($variations, 'boundary-8999-359', 12), 'value 8999 is just below V2\'s anchor (9000) -> stays in V1'); + $this->assertVariation('V2', $this->bucketFreshVisitor($variations, 'boundary-9000-9598', 12), 'value 9000 EQUALS V2\'s anchor -> anchor is inclusive'); + $this->assertVariation('V2', $this->bucketFreshVisitor($variations, 'boundary-9999-5699', 12), 'value 9999 is the maximum representable traffic value, still inside V2'); + } + + // --- AC6: packed regression lock ----------------------------------------------------- + + /** + * Vectors #0, #3, #5, #7, #9, #11 (v11, unchanged packed table): the packed walk must + * remain bit-identical for version <= 11 — this is expected to PASS right now (no src + * change has been made). + */ + public function testAc6PackedPathIsUnchangedForVersion11(): void + { + $fifteenPercent = $this->thirds(5); + + $cases = [ + 'thirds-core-O-1' => 'O', // vector #0, raw value 293 + 'thirds-flip-V1-to-O-66' => 'V1', // vector #1, raw value 601 + 'thirds-flip-V2-to-V1-5' => 'V2', // vector #3, raw value 1213 + 'thirds-stable-V1-77' => 'V1', // vector #5, raw value 877 + ]; + + foreach ($cases as $visitorId => $expectedArm) { + $this->assertVariation($expectedArm, $this->bucketFreshVisitor($fifteenPercent, $visitorId, 11), "packed v11 regression: $visitorId -> $expectedArm"); + } + + // vector #11: exceeds the 15% total allocation -> not bucketed under packed + $this->assertNotBucketed($this->bucketFreshVisitor($fifteenPercent, 'thirds-idle-both-packed-3', 11)); + } + + // --- AC8: stored decision wins over both layouts -------------------------------------- + + /** + * A visitor with an existing stored decision must keep it regardless of whether the + * experience routes to packed (version 11) or anchored (version 12) — even when a + * fresh computation for that visitor would produce a DIFFERENT (or no) arm. + */ + public function testAc8StoredDecisionWinsOverFreshComputationForBothLayouts(): void + { + $variations = $this->thirds(5); + $visitorId = 'thirds-flip-V1-to-O-66'; // fresh compute: V1 at v11 (vector #1), not-bucketed at v12 (vector #19) + + foreach ([11, 12] as $version) { + $dataManager = $this->makeDataManager($variations, $version); + $dataManager->putData($visitorId, ['bucketing' => [self::EXPERIENCE_ID => 'V2']]); + + $result = $dataManager->getBucketingById( + $visitorId, + self::EXPERIENCE_ID, + new BucketingAttributes(['ignoreLocationProperties' => true, 'enableTracking' => false]) + ); + + $this->assertVariation('V2', $result, "stored decision must win over fresh computation at version $version"); + } + } + + // --- AC9: no event/API drift ---------------------------------------------------------- + + /** + * The bucketed-variation array shape (keys) and the not-bucketed sentinel type must be + * identical regardless of which layout (packed or anchored) produced the result. + */ + public function testAc9ReturnShapeAndNotBucketedSentinelAreUnchangedRegardlessOfLayout(): void + { + $expectedKeys = [ + 'experienceId', 'experienceName', 'experienceKey', 'bucketingAllocation', + 'id', 'name', 'key', 'traffic_allocation', 'status', 'changes', + ]; + + // 100%-total config: packed and anchored provably coincide (vectors #46/#47), so + // this isolates the SHAPE assertion from any layout-correctness concern. + $fullyAllocated = $this->tenEightyTen(); + + foreach ([11, 12] as $version) { + $result = $this->bucketFreshVisitor($fullyAllocated, 'anchor-gate-visitor-106', $version); + $this->assertIsArray($result, "version $version must return a bucketed array for this fully-allocated config"); + $this->assertSame($expectedKeys, array_keys($result), "return shape must be identical regardless of layout (version $version)"); + } + + // Not-bucketed sentinel must stay BucketingError::VariationNotDecided under anchored too. + $v1Stopped = $this->tenEightyTen(['V1' => 'stopped']); + $this->assertNotBucketed( + $this->bucketFreshVisitor($v1Stopped, 'anchor-gate-visitor-17', 12), + 'not-bucketed sentinel type must be unchanged under anchored (vector #36)' + ); + } +} diff --git a/packages/Data/tests/DataManagerCoverageTest.php b/packages/Data/tests/DataManagerCoverageTest.php index f0f9617..e6a2701 100644 --- a/packages/Data/tests/DataManagerCoverageTest.php +++ b/packages/Data/tests/DataManagerCoverageTest.php @@ -269,6 +269,158 @@ public function testSelectLocationsShouldFireActivatedOnForceEvent(): void $this->assertTrue($activatedFired); } + /** + * Parity lock (qs-16 correction, post-review of the prior remediation pass): + * confirmed directly against ../javascript-sdk/packages/js-sdk/src/context.ts + * (every preview call site sets `enableStorage: false, suppressEvents: true` + * together, e.g. lines ~250-252) and ../javascript-sdk/packages/data/src/ + * data-manager.ts selectLocations() (`if (!suppressEvents) { fire(LOCATION_ACTIVATED...) }` + * / `if (!suppressEvents) { fire(LOCATION_DEACTIVATED...) }`), which gates the + * event fires on a distinct `suppressEvents` flag, independent of `enableStorage`. + * JS DOES suppress LOCATION_ACTIVATED/LOCATION_DEACTIVATED while previewing. + * PHP's `suppressPersistence` is contractually preview-exclusive (see + * LocationAttributes::$suppressPersistence docblock — "never exposed as a + * public per-call override"), so PHP reuses that single flag to gate both + * event fires and the persistence write, rather than adding a second field. + * Location matching/bookkeeping must still run unconditionally. + */ + public function testSelectLocationsSuppressesActivatedEventWhenSuppressPersistenceTrue(): void + { + $activatedFired = false; + $this->eventManager->on(SystemEvents::LocationActivated, function () use (&$activatedFired) { + $activatedFired = true; + }); + + $items = [ + [ + 'id' => 'loc-1', + 'key' => 'homepage', + 'name' => 'Homepage', + 'rules' => [ + 'OR' => [ + ['AND' => [ + ['OR_WHEN' => [ + [ + 'rule_type' => 'generic_key_value', + 'matching' => ['match_type' => 'matches', 'negated' => false], + 'key' => 'url', + 'value' => 'https://convert.com/', + ], + ]], + ]], + ], + ], + ], + ]; + + $attributes = new LocationAttributes([ + 'locationProperties' => ['url' => 'https://convert.com/'], + 'suppressPersistence' => true, + ]); + + $result = $this->dataManager->selectLocations($this->visitorId, $items, $attributes); + + $this->assertCount(1, $result, 'matching must still run unconditionally under suppressPersistence'); + $this->assertFalse($activatedFired, 'LocationActivated must NOT fire under suppressPersistence — JS SDK parity via suppressEvents'); + $storedData = $this->dataManager->getData($this->visitorId) ?? []; + $this->assertEmpty($storedData['locations'] ?? [], 'the visitor-state write must be suppressed'); + } + + /** + * Companion regression: when suppressPersistence is false/absent (the default, + * non-preview path), LocationActivated must still fire exactly as before. + */ + public function testSelectLocationsFiresActivatedWhenSuppressPersistenceFalse(): void + { + $activatedFired = false; + $this->eventManager->on(SystemEvents::LocationActivated, function () use (&$activatedFired) { + $activatedFired = true; + }); + + $items = [ + [ + 'id' => 'loc-1', + 'key' => 'homepage', + 'name' => 'Homepage', + 'rules' => [ + 'OR' => [ + ['AND' => [ + ['OR_WHEN' => [ + [ + 'rule_type' => 'generic_key_value', + 'matching' => ['match_type' => 'matches', 'negated' => false], + 'key' => 'url', + 'value' => 'https://convert.com/', + ], + ]], + ]], + ], + ], + ], + ]; + + $attributes = new LocationAttributes([ + 'locationProperties' => ['url' => 'https://convert.com/'], + ]); + + $result = $this->dataManager->selectLocations($this->visitorId, $items, $attributes); + + $this->assertCount(1, $result); + $this->assertTrue($activatedFired, 'LocationActivated must fire when suppressPersistence is not set'); + $storedData = $this->dataManager->getData($this->visitorId) ?? []; + $this->assertContains('homepage', $storedData['locations'] ?? []); + } + + /** + * Deactivation counterpart: suppressPersistence=true must also gate + * LocationDeactivated, mirroring the Activated case above. + */ + public function testSelectLocationsSuppressesDeactivatedEventWhenSuppressPersistenceTrue(): void + { + $items = [ + [ + 'id' => 'loc-1', + 'key' => 'homepage', + 'name' => 'Homepage', + 'rules' => [ + 'OR' => [ + ['AND' => [ + ['OR_WHEN' => [ + [ + 'rule_type' => 'generic_key_value', + 'matching' => ['match_type' => 'matches', 'negated' => false], + 'key' => 'url', + 'value' => 'https://convert.com/', + ], + ]], + ]], + ], + ], + ], + ]; + + // Activate the location first (non-suppressed, so it persists — matching + // ../javascript-sdk's non-preview baseline behavior for this setup step). + $attributes = new LocationAttributes([ + 'locationProperties' => ['url' => 'https://convert.com/'], + ]); + $this->dataManager->selectLocations($this->visitorId, $items, $attributes); + + $deactivatedFired = false; + $this->eventManager->on(SystemEvents::LocationDeactivated, function () use (&$deactivatedFired) { + $deactivatedFired = true; + }); + + $attributes2 = new LocationAttributes([ + 'locationProperties' => ['url' => 'https://other.com/'], + 'suppressPersistence' => true, + ]); + + $result = $this->dataManager->selectLocations($this->visitorId, $items, $attributes2); + $this->assertCount(0, $result, 'matching must still run unconditionally under suppressPersistence'); + $this->assertFalse($deactivatedFired, 'LocationDeactivated must NOT fire under suppressPersistence — JS SDK parity via suppressEvents'); + } + // ---- filterMatchedCustomSegments tests ---- public function testFilterMatchedCustomSegmentsShouldReturnMatchingSegments(): void diff --git a/packages/Data/tests/MutualExclusionCombinationTest.php b/packages/Data/tests/MutualExclusionCombinationTest.php new file mode 100644 index 0000000..d164836 --- /dev/null +++ b/packages/Data/tests/MutualExclusionCombinationTest.php @@ -0,0 +1,123 @@ + "unknown + * rule_type" path, RuleManager.php's fail-closed default) whenever the + * audience block is entered, or (b) is never evaluated at all when + * visitorProperties is `[]` (DataManager.php:410 gate). Neither matches this + * table's intended values in every row, so relying on the aggregate + * `matched` alone would let some rows pass by coincidence. + */ +final class MutualExclusionCombinationTest extends TestCase +{ + private const GENERIC_KEY = 'plan'; + private const GENERIC_VALUE = 'enterprise'; + + /** + * @return array + * [matchingOption, genericConditionSatisfied, exclusionIntendedMatched, expectedAggregateMatched] + */ + public static function combinations(): array + { + return [ + 'all_bothPass_matches' => [GenericListMatchingOptions::ALL, true, true, true], + 'all_genericFails_noMatch' => [GenericListMatchingOptions::ALL, false, true, false], + 'any_exclusionPasses_matches' => [GenericListMatchingOptions::ANY, false, true, true], + 'any_bothFail_noMatch' => [GenericListMatchingOptions::ANY, false, false, false], + ]; + } + + #[DataProvider('combinations')] + public function testGenericAndExclusionRulesCombinePerMatchingOption( + string $matchingOption, + bool $genericConditionSatisfied, + bool $exclusionIntendedMatched, + bool $expectedAggregateMatched + ): void { + $visitorId = 'mx-combination-visitor'; + $configData = MutualExclusionAudienceBuilder::withCombinedAudiencesOnExperienceB( + MutualExclusionAudienceBuilder::loadBaseConfigData(), + $matchingOption, + self::GENERIC_KEY, + self::GENERIC_VALUE, + exclusionNegated: true // matches when visitor is NOT bucketed into exp-a + ); + + $built = MutualExclusionDataManagerFactory::build($configData); + $dataManager = $built['dataManager']; + $ruleManager = $built['ruleManager']; + + // exclusionIntendedMatched=false requires bucketedRaw=true (negated flips it to false). + if (!$exclusionIntendedMatched) { + $dataManager->putData($visitorId, [ + 'bucketing' => [MutualExclusionFixture::EXPERIENCE_A_ID => MutualExclusionFixture::VARIATION_A_ID], + ]); + } + + $visitorProperties = $genericConditionSatisfied ? [self::GENERIC_KEY => self::GENERIC_VALUE] : []; + + $result = $dataManager->matchRulesByField( + $visitorId, + MutualExclusionFixture::EXPERIENCE_B_KEY, + IdentityField::KEY, + new BucketingAttributes([ + 'visitorProperties' => $visitorProperties, + 'ignoreLocationProperties' => true, + ]) + ); + + self::assertSame( + $exclusionIntendedMatched, + $ruleManager->lastResultForLogEntryContaining(MutualExclusionAudienceBuilder::EXCLUSION_AUDIENCE_KEY), + 'The exclusion audience must itself resolve to its intended boolean, independent of the ALL/ANY aggregate.' + ); + + self::assertSame( + $expectedAggregateMatched, + $result !== null, + sprintf( + 'matching_options.audiences=%s, generic=%s, exclusionIntended=%s: expected matched=%s, got %s', + $matchingOption, + $genericConditionSatisfied ? 'pass' : 'fail', + $exclusionIntendedMatched ? 'true' : 'false', + $expectedAggregateMatched ? 'true' : 'false', + $result === null ? 'null' : 'non-null' + ) + ); + } +} diff --git a/packages/Data/tests/MutualExclusionFixture.php b/packages/Data/tests/MutualExclusionFixture.php new file mode 100644 index 0000000..55942fb --- /dev/null +++ b/packages/Data/tests/MutualExclusionFixture.php @@ -0,0 +1,138 @@ + bucketingMap array What getData($visitorId)['bucketing'] must + * resolve to (merged in-memory + store) at + * evaluation time. Keyed by (string) target + * experience id, per DataManager's stored + * bucketing-map shape. + * 1 => ruleValue string The `bucketed_into_experience_key` rule's + * `value` (a target experience KEY, not id). + * 2 => negated bool `matching.negated` on the rule. + * 3 => expectedMatched bool The contract's expected `matched` result. + * 4 => expectsWarning bool true only for rows 6/7 (AC8) — the + * unknown-target-key warning must fire, + * naming `ruleValue`. + * 5 => storeOnly bool true only for row 8 (AC3) — the fixture's + * bucketingMap MUST be placed exclusively in + * the persistent DataStore/cache, never in + * the in-memory bucketing map, to prove + * cross-request attribution. false for every + * other row means "in-memory is sufficient" + * — it does not forbid also exercising a + * persistent store for those rows. + * + * @return array, 1: string, 2: bool, 3: bool, 4: bool, 5: bool}> + */ + public static function rows(): array + { + return [ + 'row1_emptyMap_notNegated_expA' => [ + [], + self::EXPERIENCE_A_KEY, + false, + false, + false, + false, + ], + 'row2_emptyMap_negated_expA' => [ + [], + self::EXPERIENCE_A_KEY, + true, + true, + false, + false, + ], + 'row3_bucketedIntoA_notNegated_expA' => [ + [self::EXPERIENCE_A_ID => self::VARIATION_A_ID], + self::EXPERIENCE_A_KEY, + false, + true, + false, + false, + ], + 'row4_bucketedIntoA_negated_expA' => [ + [self::EXPERIENCE_A_ID => self::VARIATION_A_ID], + self::EXPERIENCE_A_KEY, + true, + false, + false, + false, + ], + 'row5_bucketedIntoB_negated_expA' => [ + [self::EXPERIENCE_B_ID => self::VARIATION_B_ID], + self::EXPERIENCE_A_KEY, + true, + true, + false, + false, + ], + 'row6_unknownTarget_notNegated' => [ + [], + self::UNKNOWN_EXPERIENCE_KEY, + false, + false, + true, + false, + ], + 'row7_unknownTarget_negated' => [ + [], + self::UNKNOWN_EXPERIENCE_KEY, + true, + true, + true, + false, + ], + 'row8_storeOnlyBucketing_negated_expA' => [ + [self::EXPERIENCE_A_ID => self::VARIATION_A_ID], + self::EXPERIENCE_A_KEY, + true, + false, + false, + true, + ], + ]; + } +} diff --git a/packages/Data/tests/MutualExclusionFixtureTest.php b/packages/Data/tests/MutualExclusionFixtureTest.php new file mode 100644 index 0000000..ce2dedc --- /dev/null +++ b/packages/Data/tests/MutualExclusionFixtureTest.php @@ -0,0 +1,95 @@ +assertCount(8, MutualExclusionFixture::rows()); + } + + public function testFixtureRowKeysAreUniqueAndDescriptive(): void + { + $keys = array_keys(MutualExclusionFixture::rows()); + $this->assertSame(array_unique($keys), $keys, 'Row keys must be unique dataset names'); + foreach ($keys as $key) { + $this->assertMatchesRegularExpression('/^row[1-8]_/', $key); + } + } + + public function testEachRowHasTheContractShape(): void + { + foreach (MutualExclusionFixture::rows() as $name => $row) { + $this->assertCount(6, $row, "Row '$name' must have exactly 6 columns"); + [$bucketingMap, $ruleValue, $negated, $expectedMatched, $expectsWarning, $storeOnly] = $row; + $this->assertIsArray($bucketingMap, "Row '$name' bucketingMap must be an array"); + $this->assertIsString($ruleValue, "Row '$name' ruleValue must be a string"); + $this->assertIsBool($negated, "Row '$name' negated must be a bool"); + $this->assertIsBool($expectedMatched, "Row '$name' expectedMatched must be a bool"); + $this->assertIsBool($expectsWarning, "Row '$name' expectsWarning must be a bool"); + $this->assertIsBool($storeOnly, "Row '$name' storeOnly must be a bool"); + } + } + + public function testOnlyRowsSixAndSevenExpectAWarning(): void + { + $warnRows = array_keys(array_filter( + MutualExclusionFixture::rows(), + fn (array $row): bool => $row[4] === true + )); + + $this->assertSame( + ['row6_unknownTarget_notNegated', 'row7_unknownTarget_negated'], + $warnRows, + 'AC8: only the unknown-target rows (6/7) expect a warning' + ); + } + + public function testOnlyRowEightIsStoreOnly(): void + { + $storeOnlyRows = array_keys(array_filter( + MutualExclusionFixture::rows(), + fn (array $row): bool => $row[5] === true + )); + + $this->assertSame( + ['row8_storeOnlyBucketing_negated_expA'], + $storeOnlyRows, + 'AC3: only row 8 requires persistent-store-only placement' + ); + } + + public function testRowsSixAndSevenTargetAKeyAbsentFromTheFixtureConfig(): void + { + $config = json_decode( + (string) file_get_contents(__DIR__ . '/mutual-exclusion-config.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $experienceKeys = array_column($config['data']['experiences'], 'key'); + + $this->assertNotContains(MutualExclusionFixture::UNKNOWN_EXPERIENCE_KEY, $experienceKeys); + $this->assertContains(MutualExclusionFixture::EXPERIENCE_A_KEY, $experienceKeys); + $this->assertContains(MutualExclusionFixture::EXPERIENCE_B_KEY, $experienceKeys); + } +} diff --git a/packages/Data/tests/MutualExclusionGenericRegressionTest.php b/packages/Data/tests/MutualExclusionGenericRegressionTest.php new file mode 100644 index 0000000..c1ee0b0 --- /dev/null +++ b/packages/Data/tests/MutualExclusionGenericRegressionTest.php @@ -0,0 +1,317 @@ +, 5: ?string, 6: bool}> + */ + public static function genericRuleVectors(): array + { + return [ + // generic_text_key_value — mirrors static-config.json's "browser" + // audience rule shape (string key/value, 'equals'-family comparator). + 'text_matches_visitorIdPassed' => [ + 'generic_text_key_value', 'browser', 'equals', 'chrome', + ['browser' => 'chrome'], 'mx-generic-visitor-1', true, + ], + 'text_notMatches_visitorIdPassed' => [ + 'generic_text_key_value', 'browser', 'equals', 'chrome', + ['browser' => 'firefox'], 'mx-generic-visitor-1', false, + ], + // generic_numeric_key_value — mirrors static-config.json's "feature" + // location rule shape (numeric key/value, 'less' comparator). + 'numeric_matches_visitorIdNull' => [ + 'generic_numeric_key_value', 'feature', 'less', 5, + ['feature' => 3], null, true, + ], + 'numeric_notMatches_visitorIdNull' => [ + 'generic_numeric_key_value', 'feature', 'less', 5, + ['feature' => 9], null, false, + ], + // generic_bool_key_value — mirrors static-config.json's "desktop" + // audience rule shape (real PHP bool key/value, 'equals' comparator). + 'bool_matches_visitorIdPassed' => [ + 'generic_bool_key_value', 'desktop', 'equals', true, + ['desktop' => true], 'mx-generic-visitor-2', true, + ], + 'bool_notMatches_visitorIdOmitted' => [ + 'generic_bool_key_value', 'desktop', 'equals', true, + ['desktop' => false], null, false, + ], + ]; + } + + #[DataProvider('genericRuleVectors')] + public function testGenericRuleTypeMatchesIdenticallyRegardlessOfVisitorIdParam( + string $ruleType, + string $key, + string $matchType, + mixed $ruleValue, + array $visitorProperties, + ?string $visitorId, + bool $expectedMatched + ): void { + $item = self::singleRuleItem($ruleType, $key, $matchType, $ruleValue, negated: false); + + $built = MutualExclusionDataManagerFactory::build(MutualExclusionAudienceBuilder::loadBaseConfigData()); + $dataManager = $built['dataManager']; + $ruleManager = $built['ruleManager']; + + $matched = $visitorId !== null + ? $dataManager->filterMatchedRecordsWithRule([$item], $visitorProperties, 'audience', IdentityField::KEY, $visitorId) + : $dataManager->filterMatchedRecordsWithRule([$item], $visitorProperties, 'audience', IdentityField::KEY); + + self::assertSame( + $expectedMatched, + $matched === [$item], + sprintf( + 'Expected %s rule (%s %s %s) against visitorProperties=%s (visitorId=%s) to resolve matched=%s', + $ruleType, + $key, + $matchType, + var_export($ruleValue, true), + json_encode($visitorProperties), + $visitorId ?? 'null', + $expectedMatched ? 'true' : 'false' + ) + ); + + self::assertCount( + 1, + $ruleManager->calls, + 'A single-item, single-rule-element generic tree must invoke isRuleMatched() exactly once.' + ); + self::assertSame( + $visitorProperties, + $ruleManager->calls[0]['data'], + 'A generic-only rule tree must route through isRuleMatched() with the ORIGINAL $visitorProperties ' + . 'unchanged — never the qs-03 synthetic single-key data pair — proving the else-branch (the ' + . 'original, untouched generic dispatch path) was taken regardless of whether $visitorId was passed.' + ); + } + + /** + * Item 2 — the critical AC7 counterpart to AC4: an experience whose ONLY + * audience carries a generic rule (zero bucketed_into_experience_key + * elements anywhere in its tree), evaluated with visitorProperties=[], + * must still resolve to null/not-matched. DataManager.php:419's + * `$hasBucketingExclusionAudience` must compute false here, so the widened + * gate (`$visitorProperties || $hasBucketingExclusionAudience`, + * DataManager.php:425) behaves EXACTLY as the pre-qs-03 + * `if ($visitorProperties)` gate did — audience evaluation must not even + * be entered. + */ + public function testGenericOnlyAudienceWithEmptyVisitorPropertiesStaysExcluded(): void + { + $configData = MutualExclusionAudienceBuilder::withGenericOnlyUnderTestExperience( + MutualExclusionAudienceBuilder::loadBaseConfigData(), + 'plan', + 'enterprise' + ); + + $built = MutualExclusionDataManagerFactory::build($configData); + $dataManager = $built['dataManager']; + $ruleManager = $built['ruleManager']; + + $result = $dataManager->matchRulesByField( + 'mx-generic-only-empty-props-visitor', + MutualExclusionAudienceBuilder::UNDER_TEST_EXPERIENCE_KEY, + IdentityField::KEY, + new BucketingAttributes([ + 'visitorProperties' => [], + 'ignoreLocationProperties' => true, + ]) + ); + + self::assertNull( + $result, + 'A generic-only audience with empty visitorProperties must still resolve to null — the qs-03 gate ' + . 'widening is scoped strictly to bucketed_into_experience_key audiences (AC7).' + ); + + self::assertSame( + 0, + $ruleManager->isRuleMatchedCallCount, + 'isRuleMatched() must never be invoked for a generic-only audience when visitorProperties is empty — ' + . 'the :410-area gate must skip audience evaluation entirely, exactly as before qs-03.' + ); + } + + /** + * Item 3: a rule tree containing MORE THAN ONE rule element (two generic + * rules combined under a single AND group) is never mistaken for a "sole + * exclusion rule" — _findSoleBucketedIntoExperienceKeyRule()'s "exactly + * one element total" guard (DataManager.php:1481-1488) falls through to + * the untouched generic AND-walk dispatch regardless of element count. + * Mirrors static-config.json's "homescreen" location rule shape (two + * generic_text_key_value elements ANDed together). + * + * Constructing a real mixed generic+exclusion tree is out of scope per + * qs-03's non-goals (no served config can yet emit one — see + * qs-03-mutual-exclusion-rule.md Non-goals, and PHP-3's decision-log + * "Assume a single bucketed_into_experience_key rule per exclusion + * audience tree" entry); two generic rules is the documented acceptable + * substitute, since it already exercises the ">1 element => not sole" + * guard using existing generic rule types. + * + * @return array, 1: bool}> + */ + public static function twoElementVectors(): array + { + return [ + 'bothMatch_andSatisfied' => [ + ['browser' => 'chrome', 'country' => 'US'], true, + ], + 'oneMismatches_andFails' => [ + ['browser' => 'chrome', 'country' => 'DE'], false, + ], + ]; + } + + #[DataProvider('twoElementVectors')] + public function testMultiElementGenericTreeNeverMistakenForSoleExclusionRule( + array $visitorProperties, + bool $expectedMatched + ): void { + $item = [ + 'id' => 'multi-element-item', + 'key' => 'multi-element-item', + 'rules' => [ + 'OR' => [ + ['AND' => [ + ['OR_WHEN' => [[ + 'rule_type' => 'generic_text_key_value', + 'matching' => ['match_type' => 'equals', 'negated' => false], + 'value' => 'chrome', + 'key' => 'browser', + ]]], + ['OR_WHEN' => [[ + 'rule_type' => 'generic_text_key_value', + 'matching' => ['match_type' => 'equals', 'negated' => false], + 'value' => 'US', + 'key' => 'country', + ]]], + ]], + ], + ], + ]; + + $built = MutualExclusionDataManagerFactory::build(MutualExclusionAudienceBuilder::loadBaseConfigData()); + $dataManager = $built['dataManager']; + $ruleManager = $built['ruleManager']; + + $matched = $dataManager->filterMatchedRecordsWithRule( + [$item], + $visitorProperties, + 'audience', + IdentityField::KEY, + 'mx-multi-element-visitor' + ); + + self::assertSame($expectedMatched, $matched === [$item]); + + self::assertCount( + 1, + $ruleManager->calls, + 'A single item still results in exactly ONE top-level isRuleMatched() call, regardless of how many ' + . 'rule elements its tree contains — the AND walk happens inside the real, unmodified RuleManager.' + ); + self::assertSame( + $visitorProperties, + $ruleManager->calls[0]['data'], + 'A >1-rule-element tree must route through isRuleMatched() with the ORIGINAL $visitorProperties — ' + . 'proving the sole-exclusion-rule guard correctly excludes multi-element trees from synthetic ' + . 'delegation, even though $visitorId was passed.' + ); + } + + /** + * Builds a single-rule-element OR/AND/OR_WHEN tree item, matching the + * shape MutualExclusionAudienceBuilder::genericRuleElement() uses but + * parameterized by rule_type/match_type/value-type so this single helper + * covers all 3 generic rule_type literals under test (avoids copy-pasting + * the tree shape per data-provider row — see + * .claude/rules/sonarqube-new-code-duplication.md). + * + * @param mixed $ruleValue + * @return array + */ + private static function singleRuleItem(string $ruleType, string $key, string $matchType, mixed $ruleValue, bool $negated): array + { + return [ + 'id' => 'generic-item-under-test', + 'key' => 'generic-item-under-test', + 'rules' => [ + 'OR' => [ + ['AND' => [ + ['OR_WHEN' => [[ + 'rule_type' => $ruleType, + 'matching' => ['match_type' => $matchType, 'negated' => $negated], + 'value' => $ruleValue, + 'key' => $key, + ]]], + ]], + ], + ], + ]; + } +} diff --git a/packages/Data/tests/MutualExclusionReadOnlyTest.php b/packages/Data/tests/MutualExclusionReadOnlyTest.php new file mode 100644 index 0000000..0570085 --- /dev/null +++ b/packages/Data/tests/MutualExclusionReadOnlyTest.php @@ -0,0 +1,92 @@ + $storeDouble]); + $dataManager = $built['dataManager']; + $bucketingManager = $built['bucketingManager']; + $apiManager = $built['apiManager']; + + // Sanity: visitor has no stored bucketing decision anywhere yet. Note: + // with a persistent store wired, getData() always merges to at least + // `[]` (never null — ObjectUtils::objectDeepMerge() of two empty + // arrays), so the sanity check targets the 'bucketing' sub-key. + self::assertSame([], $dataManager->getData($visitorId)['bucketing'] ?? [], 'Visitor must start unbucketed everywhere.'); + + $result = $dataManager->matchRulesByField( + $visitorId, + MutualExclusionAudienceBuilder::UNDER_TEST_EXPERIENCE_KEY, + IdentityField::KEY, + new BucketingAttributes([ + 'visitorProperties' => [], + 'ignoreLocationProperties' => true, + ]) + ); + + // AC1/row2 shape: negated + not-bucketed => matched. Asserted here too + // so a future "false negative" implementation (e.g. one that silently + // buckets the target to force a result) can't slip through unnoticed + // even though this file's focus is the read-only spies below. + self::assertNotNull($result, 'Sanity: exclusion rule should resolve matched=true (negated, not bucketed).'); + + // matchRulesByField() never buckets ANYTHING itself (bucketing only + // happens downstream in _getBucketingByField()/_retrieveBucketing(), + // which this test never calls) — so ANY recorded bucketing call at + // all, for exp-a or exp-under-test, is already a read-only violation. + self::assertSame( + [], + $bucketingManager->bucketedExperienceIds, + 'Evaluating the exclusion rule must never bucket its target experience (exp-a).' + ); + + self::assertSame( + [], + $storeDouble->setCalls, + 'Evaluating the exclusion rule must never write to the persistent store.' + ); + + self::assertSame( + [], + $apiManager->enqueuedVisitorIds, + 'Evaluating the exclusion rule must never enqueue a tracking event.' + ); + } +} diff --git a/packages/Data/tests/MutualExclusionRuleResolutionTest.php b/packages/Data/tests/MutualExclusionRuleResolutionTest.php new file mode 100644 index 0000000..69343d7 --- /dev/null +++ b/packages/Data/tests/MutualExclusionRuleResolutionTest.php @@ -0,0 +1,188 @@ + $storeDouble] : [] + ); + $dataManager = $built['dataManager']; + $ruleManager = $built['ruleManager']; + $logManager = $built['logManager']; + + if ($storeOnly) { + // Row 8: place the decision ONLY in the persistent store, never + // in memory — bypass putData() entirely. + $storeDouble->set($dataManager->getStoreKey($visitorId), ['bucketing' => $bucketingMap]); + } elseif ($bucketingMap !== []) { + $dataManager->putData($visitorId, ['bucketing' => $bucketingMap]); + } + + $result = $dataManager->matchRulesByField( + $visitorId, + MutualExclusionAudienceBuilder::UNDER_TEST_EXPERIENCE_KEY, + IdentityField::KEY, + new BucketingAttributes([ + 'visitorProperties' => [], + 'ignoreLocationProperties' => true, + ]) + ); + + self::assertSame( + $expectedMatched, + $result !== null, + sprintf( + 'Row expected matched=%s but matchRulesByField() returned %s', + $expectedMatched ? 'true' : 'false', + $result === null ? 'null' : 'non-null' + ) + ); + + self::assertGreaterThan( + 0, + $ruleManager->isRuleMatchedCallCount, + 'RuleManager::isRuleMatched() must be invoked even with empty visitorProperties ' + . 'once the audience carries a bucketed_into_experience_key rule ' + . '(DataManager.php:410 gate must widen for AC4).' + ); + + if ($expectsWarning) { + self::assertTrue( + $logManager->hasWarnContaining($ruleValue), + sprintf( + 'Expected a warn log naming unresolved target key "%s" ' + . '(Messages::BUCKETING_EXCLUSION_TARGET_NOT_FOUND, AC8).', + $ruleValue + ) + ); + } + } + + /** + * Regression test (Gemini review R1, post-qs-03 merge): mirrors fixture + * row 4 (bucketed into exp-a, negated exclusion rule targeting exp-a, + * expectedMatched=false) but constructs BucketingAttributes WITHOUT ever + * setting visitorProperties, so the internal field is genuinely `null` + * (every other row/test in this file uses `[]`). This is a real, + * previously-untested caller path: the qs-03 gate widening at + * DataManager.php:425 (`if ($visitorProperties || $hasBucketingExclusionAudience)`) + * lets execution reach filterMatchedRecordsWithRule() even when + * $visitorProperties is null, and that call site used to forward + * $visitorProperties as-is into filterMatchedRecordsWithRule()'s + * non-nullable `array $visitorProperties` parameter — a reachable + * TypeError. Asserts both that no TypeError is thrown AND that the + * negated-exclusion outcome is correct (mirrors row 4's expectedMatched). + */ + public function testNullVisitorPropertiesDoesNotThrowOnNegatedExclusion(): void + { + $visitorId = 'mx-null-visitor-properties'; + $configData = MutualExclusionAudienceBuilder::withUnderTestExperience( + MutualExclusionAudienceBuilder::loadBaseConfigData(), + MutualExclusionFixture::EXPERIENCE_A_KEY, + true + ); + + $built = MutualExclusionDataManagerFactory::build($configData); + $dataManager = $built['dataManager']; + $ruleManager = $built['ruleManager']; + + $dataManager->putData($visitorId, ['bucketing' => [ + MutualExclusionFixture::EXPERIENCE_A_ID => MutualExclusionFixture::VARIATION_A_ID, + ]]); + + $result = $dataManager->matchRulesByField( + $visitorId, + MutualExclusionAudienceBuilder::UNDER_TEST_EXPERIENCE_KEY, + IdentityField::KEY, + new BucketingAttributes([ + 'ignoreLocationProperties' => true, + // visitorProperties intentionally omitted -> genuinely null, + // not [] -- reproduces the previously-untested caller path. + ]) + ); + + self::assertNull( + $result, + 'Visitor already bucketed into exp-a must be excluded by the negated rule ' + . '(matches fixture row 4) even when visitorProperties was never set (null, not []).' + ); + + self::assertGreaterThan( + 0, + $ruleManager->isRuleMatchedCallCount, + 'RuleManager::isRuleMatched() must be invoked even with null visitorProperties ' + . 'once the audience carries a bucketed_into_experience_key rule.' + ); + } +} diff --git a/packages/Data/tests/Support/MutualExclusionTestSupport.php b/packages/Data/tests/Support/MutualExclusionTestSupport.php new file mode 100644 index 0000000..252f1db --- /dev/null +++ b/packages/Data/tests/Support/MutualExclusionTestSupport.php @@ -0,0 +1,653 @@ +, ruleSet: RuleObject, logEntry: ?string, result: bool|RuleError}> */ + public array $calls = []; + + public function __construct(private readonly RuleManagerInterface $real) + { + } + + public function getComparisonProcessorMethods(): array + { + return $this->real->getComparisonProcessorMethods(); + } + + public function isRuleMatched(array $data, RuleObject $ruleSet, ?string $logEntry = null): bool|RuleError + { + $this->isRuleMatchedCallCount++; + $result = $this->real->isRuleMatched($data, $ruleSet, $logEntry); + $this->calls[] = ['data' => $data, 'ruleSet' => $ruleSet, 'logEntry' => $logEntry, 'result' => $result]; + return $result; + } + + public function isValidRule(RuleElement $rule): bool + { + return $this->real->isValidRule($rule); + } + + /** + * Returns the `result` of the LAST recorded isRuleMatched() call whose + * `logEntry` contains $needle (e.g. an audience id), or null if none + * matched. Lets a test pin one specific audience's resolution + * independently of the overall ALL/ANY aggregate (see + * MutualExclusionCombinationTest). + */ + public function lastResultForLogEntryContaining(string $needle): bool|RuleError|null + { + for ($i = count($this->calls) - 1; $i >= 0; $i--) { + if (($this->calls[$i]['logEntry'] ?? null) !== null && str_contains($this->calls[$i]['logEntry'], $needle)) { + return $this->calls[$i]['result']; + } + } + return null; + } +} + +/** + * Records every getBucketForVisitor()/getBucketForVisitorAnchored() call's + * `experienceId` option while delegating to a real BucketingManagerInterface. + * Used by AC5 to prove the mutual-exclusion check never buckets its target. + */ +final class SpyBucketingManager implements BucketingManagerInterface +{ + /** @var array experienceId option per bucketing call */ + public array $bucketedExperienceIds = []; + + public function __construct(private readonly BucketingManagerInterface $real) + { + } + + public function selectBucket(array $buckets, float $value, float $redistribute = 0.0): ?string + { + return $this->real->selectBucket($buckets, $value, $redistribute); + } + + public function getValueVisitorBased(string $visitorId, ?array $options = null): int + { + return $this->real->getValueVisitorBased($visitorId, $options); + } + + public function getBucketForVisitor(array $buckets, string $visitorId, ?array $options = null): ?array + { + $this->bucketedExperienceIds[] = $options['experienceId'] ?? null; + return $this->real->getBucketForVisitor($buckets, $visitorId, $options); + } + + public function getBucketRanges(array $allocations): array + { + return $this->real->getBucketRanges($allocations); + } + + public function selectBucketAnchored(array $ranges, float $value): ?string + { + return $this->real->selectBucketAnchored($ranges, $value); + } + + public function getBucketForVisitorAnchored(array $allocations, string $visitorId, ?array $options = null): ?array + { + $this->bucketedExperienceIds[] = $options['experienceId'] ?? null; + return $this->real->getBucketForVisitorAnchored($allocations, $visitorId, $options); + } + + public function resetSpy(): void + { + $this->bucketedExperienceIds = []; + } +} + +/** + * Records every enqueue() call's visitorId while delegating to a real + * ApiManagerInterface. Used by AC5 to prove the mutual-exclusion check never + * fires a tracking event. + */ +final class SpyApiManager implements ApiManagerInterface +{ + /** @var array */ + public array $enqueuedVisitorIds = []; + + public function __construct(private readonly ApiManagerInterface $real) + { + } + + public function request(string $method, array $path, array $data = [], array $headers = []): array + { + return $this->real->request($method, $path, $data, $headers); + } + + public function enqueue(string $visitorId, VisitorTrackingEvents $eventRequest, ?VisitorSegments $segments = null): void + { + $this->enqueuedVisitorIds[] = $visitorId; + $this->real->enqueue($visitorId, $eventRequest, $segments); + } + + public function releaseQueue(?string $reason = null): void + { + $this->real->releaseQueue($reason); + } + + public function enableTracking(): void + { + $this->real->enableTracking(); + } + + public function disableTracking(): void + { + $this->real->disableTracking(); + } + + public function setData(ConfigResponseData $data): void + { + $this->real->setData($data); + } + + public function getConfig(): ConfigResponseData + { + return $this->real->getConfig(); + } + + public function getConfigForExperience(string $experienceId): ConfigResponseData + { + return $this->real->getConfigForExperience($experienceId); + } + + public function resetSpy(): void + { + $this->enqueuedVisitorIds = []; + } +} + +/** + * Records every warn() call (and every call at any level) while satisfying + * LogManagerInterface. Used by AC8 (unknown-target warning) and AC5 (no + * side-effecting log path is exercised as a proxy is unnecessary — kept + * intentionally minimal, no delegation needed since assertions only read + * recorded calls). + */ +final class SpyLogManager implements LogManagerInterface +{ + /** @var array> */ + public array $warnCalls = []; + + /** @var array}> */ + public array $allCalls = []; + + public function log(LogLevel $level, mixed ...$args): void + { + $this->allCalls[] = ['level' => $level->value, 'args' => $args]; + } + + public function trace(mixed ...$args): void + { + $this->allCalls[] = ['level' => 'trace', 'args' => $args]; + } + + public function debug(mixed ...$args): void + { + $this->allCalls[] = ['level' => 'debug', 'args' => $args]; + } + + public function info(mixed ...$args): void + { + $this->allCalls[] = ['level' => 'info', 'args' => $args]; + } + + public function warn(mixed ...$args): void + { + $this->warnCalls[] = $args; + $this->allCalls[] = ['level' => 'warn', 'args' => $args]; + } + + public function error(mixed ...$args): void + { + $this->allCalls[] = ['level' => 'error', 'args' => $args]; + } + + public function addClient(mixed $client = null, ?LogLevel $level = null, ?LogMethodMapInterface $methodMap = null): void + { + // No-op: this spy never fans out to a real client, only records. + } + + public function setClientLevel(LogLevel $level, mixed $client = null): void + { + // No-op: see addClient(). + } + + /** True iff any warn() call contains a string argument (recursively) containing $needle. */ + public function hasWarnContaining(string $needle): bool + { + foreach ($this->warnCalls as $args) { + if ($this->argsContain($args, $needle)) { + return true; + } + } + return false; + } + + private function argsContain(mixed $value, string $needle): bool + { + if (is_string($value)) { + return str_contains($value, $needle); + } + if (is_array($value)) { + foreach ($value as $item) { + if ($this->argsContain($item, $needle)) { + return true; + } + } + } + return false; + } +} + +/** + * PSR-3 logger capturing every message, for use as a `logger.customLoggers` + * entry via the PUBLIC ConvertSDK::create() config (`'logger' => + * ['logLevel' => LogLevel::Trace, 'customLoggers' => [$capture]]`). + * + * Used by integration tests that only have the public Context/ConvertSDK API + * available (no internal RuleManager DI seam like MutualExclusionDataManagerFactory + * provides at the DataManager-unit level) to prove that DataManager's + * audience-evaluation path (filterMatchedRecordsWithRule(), which LogManager + * traces unconditionally on every call — DataManager.php:1363) was genuinely + * entered, rather than skipped by the AC4 line-410 gate. LogManager + * concatenates every trace() arg into a single string and forwards it to a + * PSR-3 client's debug() method (trace maps to 'debug' in + * LogManager::$_monologMapping) — see LogManager.php:_log(). + */ +final class MutualExclusionLogCapture extends AbstractLogger +{ + /** @var array */ + public array $messages = []; + + public function log($level, $message, array $context = []): void + { + $this->messages[] = (string) $message; + } + + public function hasMessageContaining(string $needle): bool + { + foreach ($this->messages as $message) { + if (str_contains($message, $needle)) { + return true; + } + } + return false; + } +} + +/** + * Minimal PSR-16-shaped (get/set) persistent-store double. Distinct from + * DataManager's in-memory `_bucketedVisitors` — writing directly to this + * double (bypassing DataManager::putData()) is how AC3/row 8 places a + * decision ONLY in the persistent layer, never in memory. + */ +final class MutualExclusionDataStoreDouble +{ + /** @var array */ + private array $store = []; + + /** @var array keys passed to set() */ + public array $setCalls = []; + + public function get(string $key): mixed + { + return $this->store[$key] ?? null; + } + + public function set(string $key, mixed $value): void + { + $this->setCalls[] = $key; + $this->store[$key] = $value; + } + + public function resetSpy(): void + { + $this->setCalls = []; + } +} + +/** + * Builds config-data fragments (audiences + a synthetic experience) that + * carry a `bucketed_into_experience_key` rule, layered on top of the shared + * mutual-exclusion-config.json fixture. Never mutates the JSON file itself — + * every method returns a new in-memory `data` array. + */ +final class MutualExclusionAudienceBuilder +{ + /** Synthetic third experience — isolated from exp-a/exp-b (AC1/AC4/AC8/AC5). */ + public const UNDER_TEST_EXPERIENCE_ID = '100333'; + public const UNDER_TEST_EXPERIENCE_KEY = 'exp-under-test'; + public const UNDER_TEST_VARIATION_ID = '100903'; + + public const EXCLUSION_AUDIENCE_ID = '100777'; + public const EXCLUSION_AUDIENCE_KEY = 'mx-exclusion-audience'; + public const GENERIC_AUDIENCE_ID = '100778'; + public const GENERIC_AUDIENCE_KEY = 'mx-generic-audience'; + + /** @return array the decoded `data` object of mutual-exclusion-config.json */ + public static function loadBaseConfigData(): array + { + $decoded = json_decode( + (string) file_get_contents(__DIR__ . '/../mutual-exclusion-config.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + return $decoded['data']; + } + + /** @return array{rule_type: string, matching: array{match_type: string, negated: bool}, value: string} */ + public static function exclusionRuleElement(string $targetExperienceKey, bool $negated): array + { + return [ + 'rule_type' => RuleType::BucketedIntoExperienceKey->value, + 'matching' => ['match_type' => 'equals', 'negated' => $negated], + 'value' => $targetExperienceKey, + ]; + } + + /** @return array{rule_type: string, matching: array{match_type: string, negated: bool}, value: string, key: string} */ + public static function genericRuleElement(string $key, string $value, bool $negated = false): array + { + return [ + 'rule_type' => 'generic_key_value', + 'matching' => ['match_type' => 'matches', 'negated' => $negated], + 'value' => $value, + 'key' => $key, + ]; + } + + /** @param array $ruleElement */ + private static function singleRuleAudience(string $id, string $key, array $ruleElement): array + { + return [ + 'id' => $id, + 'name' => $key, + 'type' => ConfigAudienceTypes::TRANSIENT, + 'status' => 'active', + 'key' => $key, + 'preset' => false, + 'rules' => [ + 'OR' => [ + ['AND' => [ + ['OR_WHEN' => [$ruleElement]], + ]], + ], + ], + ]; + } + + public static function exclusionOnlyAudience(string $targetExperienceKey, bool $negated): array + { + return self::singleRuleAudience( + self::EXCLUSION_AUDIENCE_ID, + self::EXCLUSION_AUDIENCE_KEY, + self::exclusionRuleElement($targetExperienceKey, $negated) + ); + } + + public static function genericOnlyAudience(string $key, string $value, bool $negated = false): array + { + return self::singleRuleAudience( + self::GENERIC_AUDIENCE_ID, + self::GENERIC_AUDIENCE_KEY, + self::genericRuleElement($key, $value, $negated) + ); + } + + /** + * Shared shape for the synthetic "exp-under-test" experience, parameterized + * only by which audience id(s) it carries. Used by both + * withUnderTestExperience() (exclusion-rule audience) and + * withGenericOnlyUnderTestExperience() (AC7 regression lock — generic-only + * audience, no exclusion rule anywhere in the tree) so the two stay in + * lockstep and avoid duplicating this ~15-line fixture shape. + * + * @param array $audienceIds + * @return array + */ + private static function underTestExperienceShape(array $audienceIds): array + { + return [ + 'id' => self::UNDER_TEST_EXPERIENCE_ID, + 'name' => 'Mutual Exclusion Under Test', + 'key' => self::UNDER_TEST_EXPERIENCE_KEY, + 'type' => 'a/b_fullstack', + 'version' => 6, + 'status' => 'active', + 'environments' => ['live', 'staging'], + 'audiences' => $audienceIds, + 'settings' => ['matching_options' => ['audiences' => GenericListMatchingOptions::ALL]], + 'variations' => [[ + 'id' => self::UNDER_TEST_VARIATION_ID, + 'name' => 'Original', + 'status' => 'running', + 'is_baseline' => true, + 'changes' => [], + 'key' => self::UNDER_TEST_VARIATION_ID . '-original', + 'traffic_allocation' => 100.0, + ]], + ]; + } + + /** + * AC1/AC4/AC8/AC5: appends a synthetic "exp-under-test" experience whose + * SOLE audience is the exclusion rule — isolated from exp-a/exp-b so the + * fixture's bucketing-map rows never interact with exp-under-test's own + * bucketing state. + * + * @param array $configData + * @return array + */ + public static function withUnderTestExperience(array $configData, string $ruleValue, bool $negated): array + { + $configData['audiences'][] = self::exclusionOnlyAudience($ruleValue, $negated); + $configData['experiences'][] = self::underTestExperienceShape([self::EXCLUSION_AUDIENCE_ID]); + return $configData; + } + + /** + * AC7 (generic-rule regression lock): appends the SAME synthetic + * "exp-under-test" experience shape, but with a SOLE audience that carries + * only a generic key/value rule — no bucketed_into_experience_key rule + * anywhere in the tree. Used to lock that the qs-03 gate widening at + * DataManager.php:410-425 (`$visitorProperties || $hasBucketingExclusionAudience`) + * stays scoped strictly to exclusion audiences: a generic-only audience + * evaluated with empty visitorProperties must still resolve to null, exactly + * as the pre-qs-03 `if ($visitorProperties)` gate did. + * + * @param array $configData + * @return array + */ + public static function withGenericOnlyUnderTestExperience(array $configData, string $key, string $value): array + { + $configData['audiences'][] = self::genericOnlyAudience($key, $value); + $configData['experiences'][] = self::underTestExperienceShape([self::GENERIC_AUDIENCE_ID]); + return $configData; + } + + /** + * AC2/AC3/AC4: attaches the negated exclusion audience (targeting exp-a) + * directly onto the real exp-b, with `matching_options.audiences = all`. + * + * @param array $configData + * @return array + */ + public static function withExclusionOnExperienceB(array $configData, bool $negated = true): array + { + $configData['audiences'][] = self::exclusionOnlyAudience(MutualExclusionFixture::EXPERIENCE_A_KEY, $negated); + foreach ($configData['experiences'] as &$experience) { + if ($experience['key'] === MutualExclusionFixture::EXPERIENCE_B_KEY) { + $experience['audiences'] = [self::EXCLUSION_AUDIENCE_ID]; + $experience['settings'] = ['matching_options' => ['audiences' => GenericListMatchingOptions::ALL]]; + } + } + unset($experience); + return $configData; + } + + /** + * AC6: attaches BOTH a generic key/value audience and the exclusion + * audience onto the real exp-b, with a configurable + * `matching_options.audiences` (all/any). + * + * @param array $configData + * @return array + */ + public static function withCombinedAudiencesOnExperienceB( + array $configData, + string $matchingOption, + string $genericKey, + string $genericValue, + bool $exclusionNegated = true + ): array { + $configData['audiences'][] = self::genericOnlyAudience($genericKey, $genericValue); + $configData['audiences'][] = self::exclusionOnlyAudience(MutualExclusionFixture::EXPERIENCE_A_KEY, $exclusionNegated); + foreach ($configData['experiences'] as &$experience) { + if ($experience['key'] === MutualExclusionFixture::EXPERIENCE_B_KEY) { + $experience['audiences'] = [self::GENERIC_AUDIENCE_ID, self::EXCLUSION_AUDIENCE_ID]; + $experience['settings'] = ['matching_options' => ['audiences' => $matchingOption]]; + } + } + unset($experience); + return $configData; + } +} + +/** + * Wires a fully-functional DataManager (config + real BucketingManager + + * spy-wrapped RuleManager/ApiManager/BucketingManager + SpyLogManager) over + * a given `data` array, mirroring DataManagerTest's setUp() so PHP-3 tests + * don't duplicate that ~30-line wiring block per file (SonarCloud + * duplication gate — see .claude/rules/sonarqube-new-code-duplication.md). + */ +final class MutualExclusionDataManagerFactory +{ + /** + * @param array $data The `data` array (post-builder augmentation) + * @param array{ + * dataStore?: MutualExclusionDataStoreDouble, + * ruleManager?: RuleManagerInterface, + * bucketingManager?: BucketingManagerInterface, + * apiManager?: ApiManagerInterface, + * logManager?: LogManagerInterface, + * } $overrides + * @return array{ + * dataManager: DataManager, + * ruleManager: SpyRuleManager|RuleManagerInterface, + * bucketingManager: SpyBucketingManager|BucketingManagerInterface, + * apiManager: SpyApiManager|ApiManagerInterface, + * logManager: SpyLogManager|LogManagerInterface, + * } + */ + public static function build(array $data, array $overrides = []): array + { + $baseConfig = json_decode( + (string) file_get_contents(__DIR__ . '/../mutual-exclusion-config.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $defaultConfig = DefaultConfig::getDefault(); + $mergedConfig = ObjectUtils::objectDeepMerge($baseConfig, $defaultConfig, [ + 'api' => [ + 'endpoint' => [ + 'config' => 'http://localhost:8099', + 'track' => 'http://localhost:8099', + ], + ], + ]); + $mergedConfig['data'] = new ConfigResponseData($data); + unset($mergedConfig['sdkKey']); + $config = new Config($mergedConfig); + + $mockHttpClient = new MockHttpClient(); + $psr17Factory = new Psr17Factory(); + + $bucketingConfig = $config->getBucketing(); + $realBucketingManager = new BucketingManager( + maxTraffic: $bucketingConfig['max_traffic'] ?? 10000, + hashSeed: $bucketingConfig['hash_seed'] ?? 9999, + ); + $bucketingManager = $overrides['bucketingManager'] ?? new SpyBucketingManager($realBucketingManager); + + $realRuleManager = new RuleManager(); + $ruleManager = $overrides['ruleManager'] ?? new SpyRuleManager($realRuleManager); + + $eventManager = new EventManager(); + + $realApiManager = new ApiManager($config, $eventManager, null, $mockHttpClient, $psr17Factory, $psr17Factory); + $apiManager = $overrides['apiManager'] ?? new SpyApiManager($realApiManager); + + $logManager = $overrides['logManager'] ?? new SpyLogManager(); + + $dataManager = new DataManager($config, $bucketingManager, $ruleManager, $eventManager, $apiManager, $logManager); + + if (isset($overrides['dataStore'])) { + $dataManager->setDataStore($overrides['dataStore']); + } + + return [ + 'dataManager' => $dataManager, + 'ruleManager' => $ruleManager, + 'bucketingManager' => $bucketingManager, + 'apiManager' => $apiManager, + 'logManager' => $logManager, + ]; + } +} diff --git a/packages/Data/tests/mutual-exclusion-config.json b/packages/Data/tests/mutual-exclusion-config.json new file mode 100644 index 0000000..50c91e9 --- /dev/null +++ b/packages/Data/tests/mutual-exclusion-config.json @@ -0,0 +1,57 @@ +{ + "environment": "staging", + "data": { + "account_id": "900000", + "project": { + "id": "900001", + "name": "qs-03 Mutual Exclusion Test Project", + "type": "fullstack" + }, + "audiences": [], + "segments": [], + "features": [], + "goals": [], + "experiences": [ + { + "id": "100111", + "name": "Mutual Exclusion Experience A", + "key": "exp-a", + "type": "a/b_fullstack", + "version": 6, + "status": "active", + "environments": ["live", "staging"], + "variations": [ + { + "id": "100901", + "name": "Original", + "status": "running", + "is_baseline": true, + "changes": [], + "key": "100901-original", + "traffic_allocation": 100.0 + } + ] + }, + { + "id": "100222", + "name": "Mutual Exclusion Experience B", + "key": "exp-b", + "type": "a/b_fullstack", + "version": 6, + "status": "active", + "environments": ["live", "staging"], + "variations": [ + { + "id": "100902", + "name": "Original", + "status": "running", + "is_baseline": true, + "changes": [], + "key": "100902-original", + "traffic_allocation": 100.0 + } + ] + } + ] + } +} diff --git a/packages/Enums/src/Messages.php b/packages/Enums/src/Messages.php index 22a031d..3638d50 100644 --- a/packages/Enums/src/Messages.php +++ b/packages/Enums/src/Messages.php @@ -62,4 +62,11 @@ final class Messages public const NULL_RETURN_AUDIENCE_MISMATCH = 'Null return: visitor does not match audience rules'; public const NULL_RETURN_LOCATION_MISMATCH = 'Null return: visitor does not match location rules'; public const NULL_RETURN_EXPERIENCE_PAUSED = 'Null return: experience is paused/stopped'; + + // qs-02 capability (B) preview input + public const PREVIEW_EXPERIENCE_NOT_FOUND = 'Preview experience not found in config or via exp= fetch'; + public const PREVIEW_VARIATION_NOT_FOUND = 'Preview variation not found on the resolved experience'; + + // qs-03 (mutual-exclusion audience rule, bucketed_into_experience_key) — AC8 + public const BUCKETING_EXCLUSION_TARGET_NOT_FOUND = 'Mutual exclusion target experience key "#" not found in config'; } diff --git a/packages/Enums/src/RuleType.php b/packages/Enums/src/RuleType.php new file mode 100644 index 0000000..54f9b4d --- /dev/null +++ b/packages/Enums/src/RuleType.php @@ -0,0 +1,10 @@ +assertStringContainsString('#', Messages::BUCKETING_EXCLUSION_TARGET_NOT_FOUND); + } + + public function testRuleTypeBucketedIntoExperienceKeyValue(): void + { + $this->assertSame('bucketed_into_experience_key', RuleType::BucketedIntoExperienceKey->value); + } +} diff --git a/packages/Php-sdk/src/Context.php b/packages/Php-sdk/src/Context.php index 8a9b4fe..f7db2a7 100644 --- a/packages/Php-sdk/src/Context.php +++ b/packages/Php-sdk/src/Context.php @@ -30,9 +30,11 @@ use ConvertSdk\Interfaces\FeatureManagerInterface; use ConvertSdk\Interfaces\LogManagerInterface; use ConvertSdk\Interfaces\SegmentsManagerInterface; +use ConvertSdk\Preview\PreviewResolver; use ConvertSdk\Utils\ObjectUtils; use OpenAPI\Client\BucketingAttributes; use OpenAPI\Client\Config; +use Psr\SimpleCache\CacheInterface; /** * Provides visitor context for running experiences, features, and tracking conversions. @@ -45,6 +47,30 @@ final class Context implements ContextInterface /** @var ?array */ private ?array $visitorProperties = null; + /** + * qs-02 capability (B) preview input — the resolved experience data for the + * current preview target (set via {@see setPreview()}), or null when no + * preview is active or the target could not be resolved (bad input → + * inert, per contract §2). A non-null value here is the SOLE source of + * truth for "is this context in preview mode" — it gates BOTH the forced + * decision in {@see runExperience()} AND the zero-trace persistence + * suppression forwarded to every DataManager call this context makes. + * + * @var array|null + */ + private ?array $previewExperience = null; + + /** + * qs-02 capability (B) preview input — the pre-built forced decision for + * {@see $previewExperience}, computed once in {@see setPreview()} via + * {@see DataManagerInterface::buildPreviewDecision()} so a bad variationId + * is caught eagerly (both experience and variation validity are decided + * once, at setPreview() time — never re-derived per runExperience() call). + * + * @var array|null + */ + private ?array $previewDecision = null; + /** * @param Config $config SDK configuration * @param string $visitorId Unique visitor identifier @@ -56,6 +82,8 @@ final class Context implements ContextInterface * @param ApiManagerInterface $apiManager API manager instance * @param LogManagerInterface|null $loggerManager Optional logger manager instance * @param array|null $visitorAttributes Initial visitor attributes for targeting + * @param CacheInterface|null $cache Optional PSR-16 cache, used for qs-02 preview-target + * memoization (`preview_{experienceId}`, 60s TTL) — never the normal config cache * * @throws InvalidArgumentException If visitorId is empty */ @@ -70,6 +98,7 @@ public function __construct( private readonly ApiManagerInterface $apiManager, private readonly ?LogManagerInterface $loggerManager = null, ?array $visitorAttributes = null, + private readonly ?CacheInterface $cache = null, ) { if ($visitorId === '') { throw new InvalidArgumentException('Visitor ID must not be empty'); @@ -86,6 +115,53 @@ public function __construct( } } + /** + * qs-02 capability (B) preview input — force this context to decide a + * specific variation for a specific experience, bypassing audiences, + * segments, locations, the environment check, experience status, variation + * status/traffic filters, stored decisions, and the bucketing hash. + * + * Resolution and variation validation both happen eagerly, here — not + * lazily inside runExperience() — so "the context behaves fully normally" + * on bad input (contract §2) is a single, context-wide decision rather + * than a per-call fallback. When resolution succeeds, this context + * becomes zero-trace for its ENTIRE lifetime (contract §2 "Zero-trace"): + * every runExperience()/runExperiences()/trackConversion() call this + * context makes afterwards suppresses visitor-state persistence and + * tracking enqueues, not just calls targeting $experienceId. + * + * Single preview target per context — calling this again overwrites the + * previous target (last-write-wins). Never leaks to other contexts: the + * resolved state lives entirely on this Context instance, never on the + * shared DataManager/ApiManager singletons. + * + * @param string $experienceId The experience id (numeric string) + * @param string $variationId The variation id to force (numeric string) + * @return void + */ + public function setPreview(string $experienceId, string $variationId): void + { + $resolver = new PreviewResolver($this->dataManager, $this->apiManager, $this->cache, $this->loggerManager); + $experienceData = $resolver->resolveExperience($experienceId); + + if ($experienceData === null) { + $this->previewExperience = null; + $this->previewDecision = null; + return; + } + + $decision = $this->dataManager->buildPreviewDecision($experienceData, $variationId); + if ($decision === null) { + // Inert on bad input (contract §2) — DataManager already warned. + $this->previewExperience = null; + $this->previewDecision = null; + return; + } + + $this->previewExperience = $experienceData; + $this->previewDecision = $decision; + } + /** * Get variation from specific experience. * @@ -103,10 +179,25 @@ public function runExperience(string $experienceKey, ?BucketingAttributes $attri return null; } + // qs-02 capability (B) preview input — force the resolved decision when + // this experience key is the active preview target on this context. + // Remediation (post-qs-02): the previewed decision is forced, not a + // real bucketing outcome — never notify consumer listeners for it. + // Mirrors JS SDK's Context.runExperience(), which returns + // getPreviewDecision() directly with no BUCKETING fire at all. + if ($this->previewExperience !== null && ($this->previewExperience['key'] ?? null) === $experienceKey) { + return $this->mapToBucketedVariationDto($this->previewDecision); + } + $visitorProperties = $this->getVisitorProperties($attributes?->getVisitorProperties()); $forwardedData = $attributes ? get_object_vars($attributes) : []; $forwardedData['visitorProperties'] = $visitorProperties; $forwardedData['environment'] = $forwardedData['environment'] ?? $this->environment; + // qs-02: zero-trace across the WHOLE context once a preview is active — + // other experiences still decide normally, but never persist/track. + if ($this->previewExperience !== null) { + $forwardedData['suppressPersistence'] = true; + } $result = $this->experienceManager->selectVariation( $this->visitorId, $experienceKey, @@ -120,16 +211,22 @@ public function runExperience(string $experienceKey, ?BucketingAttributes $attri return null; } - $this->eventManager->fire( - SystemEvents::Bucketing, - [ - 'visitorId' => $this->visitorId, - 'experienceKey' => $experienceKey, - 'variationKey' => $result['key'] ?? null, - ], - null, - true - ); + // Remediation (post-qs-02): suppress the in-process notification for + // the WHOLE context while a preview is active — mirrors JS SDK's + // `if (!this._preview)` gate, applied to every experience evaluated + // on this context, not just the previewed one. + if ($this->previewExperience === null) { + $this->eventManager->fire( + SystemEvents::Bucketing, + [ + 'visitorId' => $this->visitorId, + 'experienceKey' => $experienceKey, + 'variationKey' => $result['key'] ?? null, + ], + null, + true + ); + } return $this->mapToBucketedVariationDto($result); } @@ -154,27 +251,72 @@ public function runExperiences(?BucketingAttributes $attributes = null): array $forwardedData = $attributes ? get_object_vars($attributes) : []; $forwardedData['visitorProperties'] = $visitorProperties; $forwardedData['environment'] = $forwardedData['environment'] ?? $this->environment; + // qs-02: zero-trace across the WHOLE context once a preview is active. + if ($this->previewExperience !== null) { + $forwardedData['suppressPersistence'] = true; + } $bucketedVariations = $this->experienceManager->selectVariations( $this->visitorId, new BucketingAttributes($forwardedData) ); + // qs-02 capability (B) contract §3 precedence — "preview forcing beats + // stored decisions and normal bucketing for the target experience" is + // method-agnostic, so the bulk method must honor it too, not just + // runExperience(). Scoped to the in-config preview target: when the + // target experience is present in config and would normally decide + // (running, environment-matching — i.e. it already appears in + // $bucketedVariations), replace that entry with the forced decision, + // mirroring runExperience()'s short-circuit at ~line 184. An experience + // absent from this bulk result (out-of-config-only via ?exp= fetch, or + // blocked by a status/environment/traffic gate the preview would + // otherwise bypass) is intentionally NOT injected here — replicating + // runExperience()'s full gate bypass for the bulk method would require + // restructuring how this method sources per-experience decisions, which + // is out of scope for this fix (see qs-02 decision-audit remediation, + // Defect 3 note in the PHP SDK decision log). + if ($this->previewExperience !== null) { + // Source the key from the decision actually built by + // DataManager::buildPreviewDecision() (experienceKey === + // ConfigExperience::getKey()) rather than the raw config + // experience — this is the authoritative key used to build the + // forced decision. Guard against null/empty: without it, a + // bucketed variation with a missing/null `experienceKey` would + // spuriously match null === null and be overwritten with the + // preview decision (mirrors the defensive idiom in + // ApiManager::redactDebugTokenForLog()'s null/empty-token guard). + $previewKey = $this->previewDecision['experienceKey'] ?? null; + if ($previewKey !== null && $previewKey !== '') { + foreach ($bucketedVariations as $index => $variation) { + if (is_array($variation) && ($variation['experienceKey'] ?? null) === $previewKey) { + $bucketedVariations[$index] = $this->previewDecision; + break; + } + } + } + } + $dtos = []; foreach ($bucketedVariations as $variation) { if (!is_array($variation)) { continue; } - $this->eventManager->fire( - SystemEvents::Bucketing, - [ - 'visitorId' => $this->visitorId, - 'experienceKey' => $variation['experienceKey'] ?? null, - 'variationKey' => $variation['key'] ?? null, - ], - null, - true - ); + // Remediation (post-qs-02): same context-wide suppression as + // runExperience() above — applies to every entry in the bulk + // result, including the previewed experience's forced entry. + if ($this->previewExperience === null) { + $this->eventManager->fire( + SystemEvents::Bucketing, + [ + 'visitorId' => $this->visitorId, + 'experienceKey' => $variation['experienceKey'] ?? null, + 'variationKey' => $variation['key'] ?? null, + ], + null, + true + ); + } $dtos[] = $this->mapToBucketedVariationDto($variation); } @@ -200,18 +342,25 @@ public function runFeature(string $key, ?BucketingAttributes $attributes = null) $visitorProperties = $this->getVisitorProperties($attributes?->getVisitorProperties()); + $forwardedData = [ + 'visitorProperties' => $visitorProperties, + 'locationProperties' => $attributes?->getLocationProperties(), + 'updateVisitorProperties' => $attributes?->getUpdateVisitorProperties(), + 'typeCasting' => $attributes !== null && method_exists($attributes, 'getTypeCasting') + ? $attributes->getTypeCasting() + : true, + 'environment' => $attributes?->getEnvironment() ?? $this->environment, + ]; + // qs-02: zero-trace across the WHOLE context once a preview is active — + // runFeature() buckets every experience in config, not just a named one. + if ($this->previewExperience !== null) { + $forwardedData['suppressPersistence'] = true; + } + $result = $this->featureManager->runFeature( $this->visitorId, $key, - new BucketingAttributes([ - 'visitorProperties' => $visitorProperties, - 'locationProperties' => $attributes?->getLocationProperties(), - 'updateVisitorProperties' => $attributes?->getUpdateVisitorProperties(), - 'typeCasting' => $attributes !== null && method_exists($attributes, 'getTypeCasting') - ? $attributes->getTypeCasting() - : true, - 'environment' => $attributes?->getEnvironment() ?? $this->environment, - ]), + new BucketingAttributes($forwardedData), $attributes?->getExperienceKeys() ); @@ -225,19 +374,22 @@ public function runFeature(string $key, ?BucketingAttributes $attributes = null) $dto = $this->mapToBucketedFeatureDto($result); - // Fire event only for enabled features + // Fire event only for enabled features. Remediation (post-qs-02): + // also suppressed context-wide while a preview is active. if ($dto->status === FeatureStatus::Enabled) { - $this->eventManager->fire( - SystemEvents::Bucketing, - [ - 'visitorId' => $this->visitorId, - 'experienceKey' => $result['experienceKey'] ?? null, - 'featureKey' => $key, - 'status' => $result['status'] ?? null, - ], - null, - true - ); + if ($this->previewExperience === null) { + $this->eventManager->fire( + SystemEvents::Bucketing, + [ + 'visitorId' => $this->visitorId, + 'experienceKey' => $result['experienceKey'] ?? null, + 'featureKey' => $key, + 'status' => $result['status'] ?? null, + ], + null, + true + ); + } } return $dto; @@ -250,17 +402,21 @@ public function runFeature(string $key, ?BucketingAttributes $attributes = null) } $dto = $this->mapToBucketedFeatureDto($feature); if ($dto->status === FeatureStatus::Enabled) { - $this->eventManager->fire( - SystemEvents::Bucketing, - [ - 'visitorId' => $this->visitorId, - 'experienceKey' => $feature['experienceKey'] ?? null, - 'featureKey' => $key, - 'status' => $feature['status'] ?? null, - ], - null, - true - ); + // Remediation (post-qs-02): suppressed context-wide while a + // preview is active — the DTO is still returned regardless. + if ($this->previewExperience === null) { + $this->eventManager->fire( + SystemEvents::Bucketing, + [ + 'visitorId' => $this->visitorId, + 'experienceKey' => $feature['experienceKey'] ?? null, + 'featureKey' => $key, + 'status' => $feature['status'] ?? null, + ], + null, + true + ); + } return $dto; } } @@ -292,7 +448,7 @@ public function runFeatures(?BucketingAttributes $attributes = null): array $visitorProperties = $this->getVisitorProperties($attributes?->getVisitorProperties()); - $bucketedFeatures = $this->featureManager->runFeatures($this->visitorId, new BucketingAttributes([ + $forwardedData = [ 'visitorProperties' => $visitorProperties, 'locationProperties' => $attributes?->getLocationProperties(), 'updateVisitorProperties' => $attributes?->getUpdateVisitorProperties(), @@ -300,7 +456,14 @@ public function runFeatures(?BucketingAttributes $attributes = null): array ? $attributes->getTypeCasting() : true, 'environment' => $attributes?->getEnvironment() ?? $this->environment, - ])); + ]; + // qs-02: zero-trace across the WHOLE context once a preview is active — + // runFeatures() buckets every experience in config. + if ($this->previewExperience !== null) { + $forwardedData['suppressPersistence'] = true; + } + + $bucketedFeatures = $this->featureManager->runFeatures($this->visitorId, new BucketingAttributes($forwardedData)); // Filter out RuleError results $matchedErrors = array_filter($bucketedFeatures, function ($match) { @@ -318,8 +481,9 @@ public function runFeatures(?BucketingAttributes $attributes = null): array $dto = $this->mapToBucketedFeatureDto($feature); - // Fire event only for enabled features - if ($dto->status === FeatureStatus::Enabled) { + // Fire event only for enabled features. Remediation (post-qs-02): + // also suppressed context-wide while a preview is active. + if ($dto->status === FeatureStatus::Enabled && $this->previewExperience === null) { $this->eventManager->fire( SystemEvents::Bucketing, [ @@ -374,7 +538,9 @@ public function trackConversion(string $goalKey, ?ConversionAttributes $attribut $attributes?->ruleData, $conversionData, $segments, - $attributes?->conversionSetting + $attributes?->conversionSetting, + // qs-02: zero-trace across the WHOLE context once a preview is active. + $this->previewExperience !== null ); if ($triggered instanceof RuleError) { @@ -383,7 +549,13 @@ public function trackConversion(string $goalKey, ?ConversionAttributes $attribut if ($triggered === false) { return false; } - if ($triggered) { + // Remediation (post-qs-02, found via sweep — not in the original + // Bucketing/Location scope): DataManager::convert() returns `true` + // regardless of $suppressPersistence (it only gates the goal-write + // and the sendConversion()/sendTransaction() enqueues), so this fire + // needs its own context-wide preview gate — the same defect class as + // the Bucketing fires above. + if ($triggered && $this->previewExperience === null) { $this->eventManager->fire( SystemEvents::Conversion, [ diff --git a/packages/Php-sdk/src/Core.php b/packages/Php-sdk/src/Core.php index 4849371..16cfac1 100644 --- a/packages/Php-sdk/src/Core.php +++ b/packages/Php-sdk/src/Core.php @@ -171,7 +171,8 @@ public function createContext(string $visitorId, ?array $visitorAttributes = nul $this->segmentsManager, $this->apiManager, $this->loggerManager, - $visitorAttributes + $visitorAttributes, + $this->cache ); } @@ -242,27 +243,34 @@ private function fetchConfig(): void ? $this->config->getApi()['endpoint']['config'] : ''; - // Check cache first - $cachedData = $this->cache->get($cacheKey); + // qs-02 AC2 — with debugToken set, the PSR-16 config cache entry is + // neither read nor written: every request fetches live from origin. + $debugToken = $this->config->getDebugToken(); + $skipCache = $debugToken !== null && $debugToken !== ''; - if ($cachedData instanceof ConfigResponseData) { - $this->loggerManager?->trace('Core.fetchConfig()', 'Using cached config'); + $cachedData = null; + if (!$skipCache) { + $cachedData = $this->cache->get($cacheKey); - try { - $this->configValidator->validate($cachedData); - } catch (ConfigValidationException $e) { - $this->loggerManager?->error('Core.fetchConfig()', ['error' => 'Cached config invalid, fetching fresh: ' . $e->getMessage()]); - $this->cache->delete($cacheKey); + if ($cachedData instanceof ConfigResponseData) { + $this->loggerManager?->trace('Core.fetchConfig()', 'Using cached config'); + + try { + $this->configValidator->validate($cachedData); + } catch (ConfigValidationException $e) { + $this->loggerManager?->error('Core.fetchConfig()', ['error' => 'Cached config invalid, fetching fresh: ' . $e->getMessage()]); + $this->cache->delete($cacheKey); + $cachedData = null; + } + } else { $cachedData = null; } - } else { - $cachedData = null; } if ($cachedData !== null) { $data = $cachedData; } else { - // Cache miss — fetch via HTTP + // Cache miss (or cache skipped for debugToken) — fetch via HTTP try { $data = $this->apiManager->getConfig(); } catch (\RuntimeException $error) { @@ -278,9 +286,11 @@ private function fetchConfig(): void // Validate fresh config $this->configValidator->validate($data); - // Store in cache - $this->cache->set($cacheKey, $data, $this->dataRefreshInterval); - $this->loggerManager?->trace('Core.fetchConfig()', 'Config cached with TTL ' . $this->dataRefreshInterval . 's'); + if (!$skipCache) { + // Store in cache + $this->cache->set($cacheKey, $data, $this->dataRefreshInterval); + $this->loggerManager?->trace('Core.fetchConfig()', 'Config cached with TTL ' . $this->dataRefreshInterval . 's'); + } } $this->dataManager->setConfigData($data); diff --git a/packages/Php-sdk/src/Interfaces/ContextInterface.php b/packages/Php-sdk/src/Interfaces/ContextInterface.php index fc48990..dd10f20 100644 --- a/packages/Php-sdk/src/Interfaces/ContextInterface.php +++ b/packages/Php-sdk/src/Interfaces/ContextInterface.php @@ -164,4 +164,20 @@ public function getAttributes(): array; * @return string The visitor ID */ public function getVisitorId(): string; + + /** + * qs-02 capability (B) preview input — force this context to decide a + * specific variation for a specific experience, bypassing every normal + * gate (audiences, segments, locations, environment, statuses, traffic, + * stored decisions, bucketing hash). Once resolved, this context becomes + * zero-trace for its entire lifetime: no tracking events and no + * visitor-state persistence writes, for any experience run through it. + * Inert on bad input (unknown experience/variation id) — the context then + * behaves fully normally. Per-context only; never leaks to other contexts. + * + * @param string $experienceId The experience id (numeric string) + * @param string $variationId The variation id to force (numeric string) + * @return void + */ + public function setPreview(string $experienceId, string $variationId): void; } diff --git a/packages/Php-sdk/src/Preview/PreviewParam.php b/packages/Php-sdk/src/Preview/PreviewParam.php new file mode 100644 index 0000000..ffdfa06 --- /dev/null +++ b/packages/Php-sdk/src/Preview/PreviewParam.php @@ -0,0 +1,45 @@ + $matches[1], + 'variationId' => $matches[2], + ]; + } +} diff --git a/packages/Php-sdk/src/Preview/PreviewResolver.php b/packages/Php-sdk/src/Preview/PreviewResolver.php new file mode 100644 index 0000000..a5f3038 --- /dev/null +++ b/packages/Php-sdk/src/Preview/PreviewResolver.php @@ -0,0 +1,98 @@ +|null The experience data, or null when it + * cannot be resolved from the current config nor via the `?exp=` fetch. + */ + public function resolveExperience(string $experienceId): ?array + { + $existing = $this->dataManager->getEntityById($experienceId, 'experiences'); + if ($existing !== null) { + return $existing; + } + + $cacheKey = self::CACHE_KEY_PREFIX . $experienceId; + if ($this->cache !== null) { + $cached = $this->cache->get($cacheKey); + if (is_array($cached)) { + return $cached; + } + } + + try { + $response = $this->apiManager->getConfigForExperience($experienceId); + } catch (\Throwable $e) { + $this->loggerManager?->warn( + 'PreviewResolver.resolveExperience()', + Messages::PREVIEW_EXPERIENCE_NOT_FOUND, + ['experienceId' => $experienceId, 'error' => $e->getMessage()] + ); + return null; + } + + foreach ($response->getExperiences() ?? [] as $candidate) { + if (is_array($candidate) && (string)($candidate['id'] ?? '') === $experienceId) { + $this->cache?->set($cacheKey, $candidate, self::CACHE_TTL_SECONDS); + return $candidate; + } + } + + $this->loggerManager?->warn( + 'PreviewResolver.resolveExperience()', + Messages::PREVIEW_EXPERIENCE_NOT_FOUND, + ['experienceId' => $experienceId] + ); + + return null; + } +} diff --git a/packages/Php-sdk/tests/Config/CoreDebugTokenCacheTest.php b/packages/Php-sdk/tests/Config/CoreDebugTokenCacheTest.php new file mode 100644 index 0000000..c7059b5 --- /dev/null +++ b/packages/Php-sdk/tests/Config/CoreDebugTokenCacheTest.php @@ -0,0 +1,227 @@ + '10022898', + 'project' => ['id' => '10025986', 'name' => 'Test Project'], + ]); + } + + private function makeConfig(string $sdkKey, string $debugToken = self::DEBUG_TOKEN): Config + { + return new Config([ + 'sdkKey' => $sdkKey, + 'debugToken' => $debugToken, + 'environment' => 'staging', + 'api' => [ + 'endpoint' => [ + 'config' => 'http://cdn.example.com', + 'track' => 'http://track.example.com', + ], + ], + ]); + } + + /** + * @return array{ + * dataManager: DataManagerInterface&\PHPUnit\Framework\MockObject\MockObject, + * eventManager: EventManagerInterface&\PHPUnit\Framework\MockObject\MockObject, + * experienceManager: ExperienceManagerInterface&\PHPUnit\Framework\MockObject\MockObject, + * featureManager: FeatureManagerInterface&\PHPUnit\Framework\MockObject\MockObject, + * segmentsManager: SegmentsManagerInterface&\PHPUnit\Framework\MockObject\MockObject, + * loggerManager: LogManagerInterface&\PHPUnit\Framework\MockObject\MockObject, + * } + */ + private function makeDependencies(): array + { + return [ + 'dataManager' => $this->createMock(DataManagerInterface::class), + 'eventManager' => $this->createMock(EventManagerInterface::class), + 'experienceManager' => $this->createMock(ExperienceManagerInterface::class), + 'featureManager' => $this->createMock(FeatureManagerInterface::class), + 'segmentsManager' => $this->createMock(SegmentsManagerInterface::class), + 'loggerManager' => $this->createMock(LogManagerInterface::class), + ]; + } + + /** + * @param array{ + * dataManager: DataManagerInterface, + * eventManager: EventManagerInterface, + * experienceManager: ExperienceManagerInterface, + * featureManager: FeatureManagerInterface, + * segmentsManager: SegmentsManagerInterface, + * loggerManager: LogManagerInterface, + * } $deps + */ + private function buildCore(Config $config, ApiManagerInterface $apiManager, CacheInterface $cache, array $deps): Core + { + return new Core( + $config, + $deps['dataManager'], + $deps['eventManager'], + $deps['experienceManager'], + $deps['featureManager'], + $deps['segmentsManager'], + $apiManager, + $cache, + Core::DEFAULT_DATA_REFRESH_INTERVAL, + $deps['loggerManager'], + ); + } + + /** + * A non-throwing counting spy around the PSR-16 cache contract. + * + * A PHPUnit mock configured with `expects($this->never())` would work + * too, but `Core::initialize()` wraps `fetchConfig()` in a broad + * `catch (\Exception $e)` — the mock's ExpectationFailedException would + * be swallowed there and surface only as an indirect, confusing failure + * downstream (e.g. "getConfig() expected once, called 0 times"). A + * counting spy that never throws sidesteps that interaction and gives a + * direct, unambiguous assertion on call counts after construction. + */ + private function makeCountingCache(): CacheInterface + { + return new class () implements CacheInterface { + public int $getCalls = 0; + public int $setCalls = 0; + public int $deleteCalls = 0; + + public function get(string $key, mixed $default = null): mixed + { + $this->getCalls++; + return $default; + } + + public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool + { + $this->setCalls++; + return true; + } + + public function delete(string $key): bool + { + $this->deleteCalls++; + return true; + } + + public function clear(): bool + { + return true; + } + + public function has(string $key): bool + { + return false; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + return []; + } + + public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool + { + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + return true; + } + }; + } + + /** + * @param CacheInterface&object{getCalls: int, setCalls: int, deleteCalls: int} $cache + */ + private function assertCacheNeverConsulted(CacheInterface $cache): void + { + $this->assertSame(0, $cache->getCalls, 'PSR-16 cache->get() must not be consulted when debugToken is set'); + $this->assertSame(0, $cache->setCalls, 'PSR-16 cache->set() must not be written to when debugToken is set'); + $this->assertSame(0, $cache->deleteCalls, 'PSR-16 cache->delete() must not be invoked when debugToken is set'); + } + + /** + * AC2 — a single fetch with debugToken set must not touch the PSR-16 + * config cache entry at all (no get/set/delete), and must hit origin. + */ + #[Test] + public function debugTokenSkipsCacheReadAndWriteOnFetch(): void + { + $configData = $this->validConfigData(); + + $apiManager = $this->createMock(ApiManagerInterface::class); + $apiManager->expects($this->once())->method('getConfig')->willReturn($configData); + + $cache = $this->makeCountingCache(); + + $deps = $this->makeDependencies(); + $deps['dataManager']->expects($this->once())->method('setConfigData')->with($configData); + + $config = $this->makeConfig('debug_key_1'); + + $core = $this->buildCore($config, $apiManager, $cache, $deps); + $this->assertInstanceOf(Core::class, $core); + $this->assertCacheNeverConsulted($cache); + } + + /** + * AC2 — two sequential fetches (modelled as two separate `Core` + * instances, since PHP-FPM tears down state between requests) must both + * hit origin; the shared PSR-16 cache backend must never be consulted. + */ + #[Test] + public function debugTokenCausesEveryRequestToHitOriginAcrossSeparateCoreInstances(): void + { + $configData = $this->validConfigData(); + + $apiManager = $this->createMock(ApiManagerInterface::class); + $apiManager->expects($this->exactly(2))->method('getConfig')->willReturn($configData); + + $cache = $this->makeCountingCache(); + + $config = $this->makeConfig('debug_key_2'); + + $firstCore = $this->buildCore($config, $apiManager, $cache, $this->makeDependencies()); + $secondCore = $this->buildCore($config, $apiManager, $cache, $this->makeDependencies()); + + $this->assertInstanceOf(Core::class, $firstCore); + $this->assertInstanceOf(Core::class, $secondCore); + $this->assertCacheNeverConsulted($cache); + } +} diff --git a/packages/Php-sdk/tests/MutualExclusionIntegrationTest.php b/packages/Php-sdk/tests/MutualExclusionIntegrationTest.php new file mode 100644 index 0000000..663ccc7 --- /dev/null +++ b/packages/Php-sdk/tests/MutualExclusionIntegrationTest.php @@ -0,0 +1,110 @@ + ..., 'environment' => ..., 'network' => + * ['tracking' => false]])` + `createContext()->runExperience()` pattern. + * + * Config: the real exp-a/exp-b from mutual-exclusion-config.json, with the + * negated `bucketed_into_experience_key` rule (targeting exp-a) attached as + * exp-b's SOLE audience (`matching_options.audiences = all`) — see + * MutualExclusionAudienceBuilder::withExclusionOnExperienceB(). + * + * AC4: no visitorAttributes are ever passed to createContext(), and no + * visitorProperties are ever set on the BucketingAttributes passed to + * runExperience() — Context::getVisitorProperties() resolves this to `[]`. + * `ignoreLocationProperties: true` is set purely to bypass the (unrelated) + * location-matching gate at DataManager.php:353-397, which independently + * requires either locationProperties or ignoreLocationProperties for ANY + * experience lacking a location restriction — orthogonal to qs-03, and not + * a "new application input" in the AC4 sense (it carries no visitor data). + * + * Genuineness note: for the "excluded" scenario, DataManager.php:410's + * current `if ($visitorProperties)` gate ALSO happens to return null with + * `[]` properties (audience block skipped entirely) — the SAME null a + * correct exclusion would produce. To avoid a coincidental pass, this test + * additionally injects a `logger.customLoggers` PSR-3 capture (public config + * — see ConvertSDK::create()'s `logger.logLevel`/`logger.customLoggers`) and + * asserts DataManager::filterMatchedRecordsWithRule() was actually invoked + * (it traces unconditionally on every call, DataManager.php:1363..1371) — + * proving the audience block was genuinely entered, not gate-skipped. + */ +final class MutualExclusionIntegrationTest extends TestCase +{ + private function noLocationGateAttributes(): BucketingAttributes + { + return new BucketingAttributes(['ignoreLocationProperties' => true]); + } + + /** @return array{sdk: Core, logCapture: MutualExclusionLogCapture} */ + private function createSdkWithLogCapture(): array + { + $configData = MutualExclusionAudienceBuilder::withExclusionOnExperienceB( + MutualExclusionAudienceBuilder::loadBaseConfigData() + ); + $logCapture = new MutualExclusionLogCapture(); + $sdk = ConvertSDK::create([ + 'data' => $configData, + 'environment' => 'staging', + 'network' => ['tracking' => false], + 'logger' => [ + 'logLevel' => LogLevel::Trace, + 'customLoggers' => [$logCapture], + ], + ]); + + return ['sdk' => $sdk, 'logCapture' => $logCapture]; + } + + public function testVisitorBucketedIntoExpAIsExcludedFromExpB(): void + { + ['sdk' => $sdk, 'logCapture' => $logCapture] = $this->createSdkWithLogCapture(); + $context = $sdk->createContext('mx-ac2-visitor-excluded'); + + $variationA = $context->runExperience(MutualExclusionFixture::EXPERIENCE_A_KEY, $this->noLocationGateAttributes()); + self::assertInstanceOf(BucketedVariation::class, $variationA, 'Visitor must bucket into exp-a first.'); + + $variationB = $context->runExperience(MutualExclusionFixture::EXPERIENCE_B_KEY, $this->noLocationGateAttributes()); + + self::assertTrue( + $logCapture->hasMessageContaining('filterMatchedRecordsWithRule'), + 'DataManager::filterMatchedRecordsWithRule() must be invoked even with empty visitorProperties ' + . 'once exp-b\'s audience carries a bucketed_into_experience_key rule ' + . '(DataManager.php:410 gate must widen for AC4) — otherwise a null result below is coincidental, not a real exclusion.' + ); + self::assertNull($variationB, 'Visitor already bucketed into exp-a must be excluded from exp-b.'); + } + + public function testVisitorWhoNeverRanExpABucketsIntoExpBNormally(): void + { + ['sdk' => $sdk] = $this->createSdkWithLogCapture(); + $context = $sdk->createContext('mx-ac2-visitor-unbucketed'); + + $variationB = $context->runExperience(MutualExclusionFixture::EXPERIENCE_B_KEY, $this->noLocationGateAttributes()); + self::assertInstanceOf( + BucketedVariation::class, + $variationB, + 'A visitor who never ran exp-a must bucket into exp-b normally (negated exclusion dissolves).' + ); + } +} diff --git a/packages/Php-sdk/tests/MutualExclusionPersistenceTest.php b/packages/Php-sdk/tests/MutualExclusionPersistenceTest.php new file mode 100644 index 0000000..9184cf1 --- /dev/null +++ b/packages/Php-sdk/tests/MutualExclusionPersistenceTest.php @@ -0,0 +1,101 @@ +setDataStore($dataStore);`). The second instance's DataManager + * has an empty in-memory store for this visitor — its getData() can ONLY see + * exp-a's decision by reading it back out of the shared store, exactly + * fixture row 8 ("bucketed only in the persistent store"). + * + * Genuineness note: like MutualExclusionIntegrationTest's "excluded" + * scenario, DataManager.php:410's current `if ($visitorProperties)` gate + * ALSO returns null for `[]` properties regardless of the store — so a bare + * `assertNull($variationB)` would coincidentally pass today without any real + * cross-instance persistence check. A `logger.customLoggers` PSR-3 capture + * (public config, see ConvertSDK::create()) proves + * DataManager::filterMatchedRecordsWithRule() was genuinely invoked on + * $sdk2's instance (it traces unconditionally on every call, + * DataManager.php:1363..1371). + */ +final class MutualExclusionPersistenceTest extends TestCase +{ + public function testExpADecisionInOneInstanceExcludesVisitorFromExpBInANewInstance(): void + { + $configData = MutualExclusionAudienceBuilder::withExclusionOnExperienceB( + MutualExclusionAudienceBuilder::loadBaseConfigData() + ); + $sharedStore = new MutualExclusionDataStoreDouble(); + $visitorId = 'mx-ac3-visitor'; + $attributes = new BucketingAttributes(['ignoreLocationProperties' => true]); + + $sdk1 = ConvertSDK::create([ + 'data' => $configData, + 'environment' => 'staging', + 'network' => ['tracking' => false], + 'dataStore' => $sharedStore, + ]); + $context1 = $sdk1->createContext($visitorId); + $variationA = $context1->runExperience(MutualExclusionFixture::EXPERIENCE_A_KEY, $attributes); + self::assertInstanceOf(BucketedVariation::class, $variationA, 'First instance must bucket the visitor into exp-a.'); + + // Sanity: the shared store double actually received exp-a's write — + // otherwise the second instance's exclusion below would be + // impossible to attribute to persistence at all. + self::assertNotEmpty($sharedStore->setCalls, 'exp-a\'s decision must be written to the shared persistent store.'); + + // NEW instance, same shared store, same visitor id — fresh in-memory + // DataManager state for this visitor (row 8: bucketing map present + // ONLY in the persistent store from this instance's point of view). + $logCapture = new MutualExclusionLogCapture(); + $sdk2 = ConvertSDK::create([ + 'data' => $configData, + 'environment' => 'staging', + 'network' => ['tracking' => false], + 'dataStore' => $sharedStore, + 'logger' => [ + 'logLevel' => LogLevel::Trace, + 'customLoggers' => [$logCapture], + ], + ]); + $context2 = $sdk2->createContext($visitorId); + + $variationB = $context2->runExperience(MutualExclusionFixture::EXPERIENCE_B_KEY, $attributes); + + self::assertTrue( + $logCapture->hasMessageContaining('filterMatchedRecordsWithRule'), + 'DataManager::filterMatchedRecordsWithRule() must be invoked on the NEW instance even with empty ' + . 'visitorProperties (DataManager.php:410 gate must widen for AC4) — otherwise a null result below ' + . 'is coincidental, not real cross-instance persistence.' + ); + self::assertNull( + $variationB, + 'A visitor bucketed into exp-a by a PRIOR instance must be excluded from exp-b on a NEW instance sharing the same persistent store.' + ); + } +} diff --git a/packages/Php-sdk/tests/Preview/ContextPreviewTest.php b/packages/Php-sdk/tests/Preview/ContextPreviewTest.php new file mode 100644 index 0000000..ecc8a84 --- /dev/null +++ b/packages/Php-sdk/tests/Preview/ContextPreviewTest.php @@ -0,0 +1,1002 @@ + */ + public array $calls = []; + + /** @var array */ + private array $store = []; + + public function get(string $key, mixed $default = null): mixed + { + $this->calls[] = ['op' => 'get', 'key' => $key]; + return $this->store[$key] ?? $default; + } + + public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool + { + $this->calls[] = ['op' => 'set', 'key' => $key]; + $this->store[$key] = $value; + return true; + } + + public function delete(string $key): bool + { + $this->calls[] = ['op' => 'delete', 'key' => $key]; + unset($this->store[$key]); + return true; + } + + public function clear(): bool + { + $this->store = []; + return true; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->store); + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $result = []; + foreach ($keys as $key) { + $result[$key] = $this->get($key, $default); + } + return $result; + } + + public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set($key, $value, $ttl); + } + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + return true; + } +} + +/** + * Counting spy for the duck-typed visitor dataStore (get/set, per + * DataManager::setDataStore()) — used to prove qs-02 AC6's "zero visitor-state + * persistence writes" and AC7's "concurrent non-preview context persists + * normally" without depending on cache internals. + */ +class RecordingDataStore +{ + public int $setCalls = 0; + + /** @var array */ + public array $setKeys = []; + + /** @var array */ + private array $data = []; + + public function get(?string $key = null): mixed + { + return $key === null ? $this->data : ($this->data[$key] ?? null); + } + + public function set(string $key, mixed $value): void + { + $this->setCalls++; + $this->setKeys[] = $key; + $this->data[$key] = $value; + } +} + +/** + * qs-02 capability (B) preview input — AC4, AC5, AC6, AC7, AC8 (Context/Core + * integration level). + * + * Every rig below wires REAL BucketingManager/RuleManager/DataManager/ + * ExperienceManager/ApiManager instances (mirrors ContextTest.php's + * construction pattern) so the bypass assertions (AC5) exercise the actual + * gating code — matchRulesByField's environment check, isVariationActive's + * status/traffic filter, and the stored-decision-first branch in + * DataManager::_retrieveBucketing() — rather than a mock that trivially + * returns whatever the test wants. FeatureManager/SegmentsManager are mocked + * since no test here exercises features or segments. + * + * Only ApiManager's PSR-18 HTTP client (Http\Mock\Client) and the PSR-16 + * cache / visitor dataStore (RecordingCache / RecordingDataStore, above) are + * test doubles — both are spies, not stubs, so every assertion is on real + * call counts/keys/URLs the SDK actually produced. + * + * "Shutdown" (AC6) is modeled by directly invoking + * `ApiManager::releaseQueue('shutdown')` — the exact call + * `ConvertSDK::create()`'s `register_shutdown_function` handler makes — since + * PHPUnit cannot observe a real PHP-FPM process teardown. + * + * RED-phase note (qs-02 PHP-2, TDD RED): `Context::setPreview()` does not + * exist yet. Every test below is expected to fail with + * `Error: Call to undefined method ConvertSdk\Context::setPreview()` until + * the PHP-2 GREEN implementation lands. + * + * @see ../../../../../ai-driven-product-dev/_bmad-output/planning-artifacts/2026-03-13-convert-php-sdk/qs-02-experiment-preview.md + */ +class ContextPreviewTest extends TestCase +{ + private const ENVIRONMENT = 'production'; + private const HOST = 'http://localhost'; + private const PORT = 8093; + private const OTHER_EXPERIENCE_ID = '500'; + private const OTHER_EXPERIENCE_KEY = 'other-exp'; + private const GOAL_KEY = 'preview-goal'; + private const NO_LOCATION_GATE = ['ignoreLocationProperties' => true]; + private const FEATURE_ID = '20001'; + private const FEATURE_KEY = 'preview-feature'; + private const FEATURE_EXPERIENCE_ID = '9105'; + private const FEATURE_EXPERIENCE_KEY = 'feature-carrying-exp'; + /** Non-empty location properties so the location-agnostic fixtures (no + * `locations`/`site_area`) fall into matchRulesByField()'s "not restricted" + * branch and actually reach the bucketing/persistence code — required so a + * zero-trace regression test genuinely exercises the write path instead of + * short-circuiting on the location gate before it ever would. + */ + private const LOCATION_PROPERTIES = ['locationProperties' => ['url' => 'https://convert.com/']]; + private const LOCATION_ID = '30001'; + private const LOCATION_KEY = 'preview-location'; + private const LOCATION_EXPERIENCE_ID = '9109'; + private const LOCATION_EXPERIENCE_KEY = 'location-bearing-exp'; + + private MockHttpClient $mockHttpClient; + private Psr17Factory $psr17Factory; + + protected function setUp(): void + { + $this->mockHttpClient = new MockHttpClient(); + $this->psr17Factory = new Psr17Factory(); + } + + // -- Rig construction ---------------------------------------------------------------- + + /** + * Builds a fresh Core wired with real managers sharing ONE ApiManager/DataManager + * pair (mirrors ConvertSDK::create()'s single-singleton wiring, per the "shared + * singleton managers" crux documented for this task). `$extraExperiences` are + * visible in the config from the moment the rig is built — i.e. "already in the + * current config" per qs-02 contract §2, needing no ?exp= fetch. + * + * @param array> $extraExperiences + * @param array> $features + * @param ExperienceManagerInterface|null $experienceManagerOverride Substitutes a + * test double for the real ExperienceManager — used only by tests that need to + * synthesize a bucketedVariations shape the real manager can never produce (e.g. + * an entry missing `experienceKey`), while keeping DataManager/ApiManager real so + * {@see \ConvertSdk\Context::setPreview()}'s resolution/persistence still exercises + * genuine code. + * @param array> $locations Top-level config `locations` + * entities (id/key/name/rules) — only referenced by fixtures whose `locations` + * field names their `id` (see {@see locationBearingExperience()}). + * @return array{core: Core, dataManager: DataManager, apiManager: ApiManager, cache: RecordingCache, dataStore: RecordingDataStore, eventManager: EventManager} + */ + private function buildRig( + array $extraExperiences = [], + array $features = [], + ?ExperienceManagerInterface $experienceManagerOverride = null, + array $locations = [] + ): array { + $data = new ConfigResponseData([ + 'account_id' => 'acct-1', + 'project' => ['id' => 'proj-1'], + 'experiences' => array_merge([$this->otherExperience()], $extraExperiences), + 'features' => $features, + 'locations' => $locations, + 'goals' => [ + ['id' => '7001', 'key' => self::GOAL_KEY, 'name' => 'Preview Goal', 'rules' => null], + ], + ]); + + $config = new Config([ + 'environment' => self::ENVIRONMENT, + 'data' => $data, + 'api' => [ + 'endpoint' => [ + 'config' => self::HOST . ':' . self::PORT, + 'track' => self::HOST . ':' . self::PORT, + ], + ], + 'network' => ['tracking' => false], + ]); + + $eventManager = new EventManager(); + $apiManager = new ApiManager( + $config, + $eventManager, + null, + $this->mockHttpClient, + $this->psr17Factory, + $this->psr17Factory + ); + $dataManager = new DataManager( + $config, + new BucketingManager(), + new RuleManager(), + $eventManager, + $apiManager, + new LogManager() + ); + $dataStore = new RecordingDataStore(); + $dataManager->setDataStore($dataStore); + + $experienceManager = $experienceManagerOverride ?? new ExperienceManager(dataManager: $dataManager); + $featureManager = new FeatureManager(dataManager: $dataManager); + $cache = new RecordingCache(); + + $core = new Core( + $config, + $dataManager, + $eventManager, + $experienceManager, + $featureManager, + $this->createMock(SegmentsManagerInterface::class), + $apiManager, + $cache, + Core::DEFAULT_DATA_REFRESH_INTERVAL, + null, + ); + + return [ + 'core' => $core, + 'dataManager' => $dataManager, + 'apiManager' => $apiManager, + 'cache' => $cache, + 'dataStore' => $dataStore, + 'eventManager' => $eventManager, + ]; + } + + /** + * Registers spies on SystemEvents::Bucketing, SystemEvents::Conversion, + * SystemEvents::LocationActivated, and SystemEvents::LocationDeactivated, + * capturing every fired payload. Used by the preview event-suppression + * tests (assert empty) and the non-preview regression/control test + * (assert non-empty) below. + * + * qs-16 correction: LocationActivated/LocationDeactivated are spied here too + * (not just Bucketing/Conversion) because JS SDK parity requires them + * suppressed under preview as well — see + * ../javascript-sdk/packages/data/src/data-manager.ts selectLocations()'s + * `suppressEvents` gate and every preview call site in + * ../javascript-sdk/packages/js-sdk/src/context.ts setting + * `suppressEvents: true` alongside `enableStorage: false`. + * + * @return \stdClass{bucketing: array, conversion: array, locationActivated: array, locationDeactivated: array} + */ + private function attachEventSpies(EventManager $eventManager): \stdClass + { + $captured = new \stdClass(); + $captured->bucketing = []; + $captured->conversion = []; + $captured->locationActivated = []; + $captured->locationDeactivated = []; + $eventManager->on(SystemEvents::Bucketing, function ($args) use ($captured) { + $captured->bucketing[] = $args; + }); + $eventManager->on(SystemEvents::Conversion, function ($args) use ($captured) { + $captured->conversion[] = $args; + }); + $eventManager->on(SystemEvents::LocationActivated, function ($args) use ($captured) { + $captured->locationActivated[] = $args; + }); + $eventManager->on(SystemEvents::LocationDeactivated, function ($args) use ($captured) { + $captured->locationDeactivated[] = $args; + }); + return $captured; + } + + /** + * A location entity present in the config's top-level `locations` list — + * paired with {@see locationBearingExperience()}'s `locations: [id]` field. + * Matches when `locationProperties.url === 'https://convert.com/'` (the + * value {@see LOCATION_PROPERTIES} supplies), so running the paired + * experience with those properties genuinely reaches + * `DataManager::selectLocations()` and fires `LocationActivated` on first + * match — the only way to prove the qs-16 event-suppression gate end to + * end (a fixture without `locations`/`site_area` short-circuits into + * matchRulesByField()'s "not restricted" branch and never calls + * selectLocations() at all). + * + * @return array + */ + private function locationFixture(): array + { + return [ + 'id' => self::LOCATION_ID, + 'key' => self::LOCATION_KEY, + 'name' => 'Preview Location', + 'rules' => [ + 'OR' => [ + ['AND' => [ + ['OR_WHEN' => [ + [ + 'rule_type' => 'generic_key_value', + 'matching' => ['match_type' => 'matches', 'negated' => false], + 'key' => 'url', + 'value' => 'https://convert.com/', + ], + ]], + ]], + ], + ], + ]; + } + + /** + * A location-restricted, audience-agnostic experience — a DIFFERENT experience + * from the preview target, so running it on an active preview context exercises + * "other experiences still decide (and location-match) normally" (AC6) while + * proving the location events themselves are zero-trace (qs-16 correction). + * + * @return array + */ + private function locationBearingExperience(): array + { + return $this->experienceFixture(self::LOCATION_EXPERIENCE_ID, self::LOCATION_EXPERIENCE_KEY, [ + 'locations' => [self::LOCATION_ID], + ], [ + $this->variation(self::LOCATION_EXPERIENCE_ID . '-A', 'a'), + $this->variation(self::LOCATION_EXPERIENCE_ID . '-B', 'b'), + ]); + } + + /** + * A location/audience-agnostic, always-decidable experience present in the base + * config of every rig — used to prove "other experiences still evaluate and + * decide normally on a preview context" (AC6) and "a concurrent non-preview + * context buckets normally" (AC7). + * + * @return array + */ + private function otherExperience(): array + { + return [ + 'id' => self::OTHER_EXPERIENCE_ID, + 'key' => self::OTHER_EXPERIENCE_KEY, + 'name' => 'Other Experience', + 'status' => 'running', + 'variations' => [ + $this->variation(self::OTHER_EXPERIENCE_ID . '-A', 'a'), + $this->variation(self::OTHER_EXPERIENCE_ID . '-B', 'b'), + ], + ]; + } + + /** + * A feature declaration paired with {@see featureCarryingExperience()} — used to prove + * the Context::runFeature()/runFeatures() zero-trace regression (qs-02 decision-audit + * Defect 1/2): these methods bucket EVERY experience in the config (not just a + * targeted one), so a leak here would persist/track for an experience the caller + * never even named. + * + * @return array + */ + private function featureFixture(): array + { + return [ + 'id' => self::FEATURE_ID, + 'key' => self::FEATURE_KEY, + 'name' => 'Preview Feature', + 'variables' => [], + ]; + } + + /** + * A location/audience-agnostic, always-decidable experience carrying a fullStackFeature + * change linked to {@see featureFixture()} — present in the base config (like + * {@see otherExperience()}) so Context::runFeature()/runFeatures() bucket it as part of + * their "iterate every experience in config" sweep. + * + * @return array + */ + private function featureCarryingExperience(): array + { + return $this->experienceFixture(self::FEATURE_EXPERIENCE_ID, self::FEATURE_EXPERIENCE_KEY, [], [ + $this->variation(self::FEATURE_EXPERIENCE_ID . '-A', 'a', [ + 'changes' => [['id' => 'chg-feat-a', 'type' => 'fullStackFeature', 'data' => ['feature_id' => self::FEATURE_ID]]], + ]), + $this->variation(self::FEATURE_EXPERIENCE_ID . '-B', 'b', [ + 'changes' => [['id' => 'chg-feat-b', 'type' => 'fullStackFeature', 'data' => ['feature_id' => self::FEATURE_ID]]], + ]), + ]); + } + + /** + * @param array $experienceOverrides + * @param array> $variations + * @return array + */ + private function experienceFixture(string $id, string $key, array $experienceOverrides, array $variations): array + { + return array_merge([ + 'id' => $id, + 'key' => $key, + 'name' => 'Preview Target ' . $key, + 'status' => 'running', + 'variations' => $variations, + ], $experienceOverrides); + } + + /** + * @param array $overrides + * @return array + */ + private function variation(string $id, string $key, array $overrides = []): array + { + return array_merge([ + 'id' => $id, + 'key' => $key, + 'status' => 'running', + 'traffic_allocation' => 50, + 'changes' => [['id' => 'chg-' . $id, 'type' => 'fullStackFeature', 'data' => []]], + ], $overrides); + } + + /** + * Response body shape verified against the backend OpenAPI contract + * (`backend/apiDoc/serving/src/responses/index.yaml` — `ProjectConfigResponse` + * resolves directly to the `ConfigResponseData` schema, with no enclosing + * envelope) and against the JS SDK's own real-HTTP-server integration test + * for `getConfigByExperience()` + * (`javascript-sdk/packages/api/tests/api-manager-config-by-experience.tests.ts`), + * whose mock server returns the config fields at the top level of the + * response body. + * + * @param array $experience + */ + private function queueExpFetchResponse(array $experience): void + { + $this->mockHttpClient->addResponse(new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([ + 'account_id' => 'acct-1', + 'project' => ['id' => 'proj-1'], + 'experiences' => [$experience], + ]))); + } + + /** + * @return array + */ + private function trackRequests(): array + { + return array_values(array_filter( + $this->mockHttpClient->getRequests(), + fn ($request) => str_contains((string) $request->getUri(), '/track/') + )); + } + + /** + * @return array + */ + private function expRequestsFor(string $experienceId): array + { + return array_values(array_filter( + $this->mockHttpClient->getRequests(), + fn ($request) => str_contains((string) $request->getUri(), 'exp=' . $experienceId) + )); + } + + // -- AC4 + AC5: full bypass sweep ----------------------------------------------------- + + /** + * Six cases, each disabling exactly one normal gate that would otherwise block or + * redirect the decision; the shared assertion is "preview forces the given + * variation regardless, and — where a real non-preview control call is + * meaningful — the non-preview path is unaffected by the preview override." + * + * 'draft status' is also AC4's literal scenario ("a draft experience delivered + * only via the ?exp= fetch"). + * + * @return array, + * 3: array>, 4: bool, 5: ?string, 6: string, 7: ?string + * }> + */ + public static function bypassCasesProvider(): array + { + return [ + 'draft status — delivered only via ?exp= fetch (AC4)' => [ + '9001', 'draft-exp', ['status' => 'draft'], + [['id' => '9001-A', 'key' => 'a'], ['id' => '9001-B', 'key' => 'b']], + false, null, '9001-B', null, + ], + 'paused status — delivered only via ?exp= fetch' => [ + '9002', 'paused-exp', ['status' => 'paused'], + [['id' => '9002-A', 'key' => 'a'], ['id' => '9002-B', 'key' => 'b']], + false, null, '9002-B', null, + ], + 'mismatched environment — present in config under a different environment' => [ + '9003', 'env-mismatch-exp', ['environment' => 'staging'], + [['id' => '9003-A', 'key' => 'a'], ['id' => '9003-B', 'key' => 'b']], + true, null, '9003-B', null, + ], + 'non-running variation — sole variation is not RUNNING' => [ + '9004', 'non-running-exp', [], + [['id' => '9004-A', 'key' => 'a', 'overrides' => ['status' => 'paused']]], + true, null, '9004-A', null, + ], + 'zero-traffic variation — sole variation has traffic_allocation 0' => [ + '9005', 'zero-traffic-exp', [], + [['id' => '9005-A', 'key' => 'a', 'overrides' => ['traffic_allocation' => 0]]], + true, null, '9005-A', null, + ], + 'different stored decision — visitor already bucketed elsewhere' => [ + '9006', 'stored-decision-exp', [], + [['id' => '9006-A', 'key' => 'a'], ['id' => '9006-B', 'key' => 'b']], + true, '9006-A', '9006-B', '9006-A', + ], + ]; + } + + /** + * @param array $experienceOverrides + * @param array> $variationSpecs + */ + #[DataProvider('bypassCasesProvider')] + public function testPreviewBypassesAllNormalGates( + string $experienceId, + string $experienceKey, + array $experienceOverrides, + array $variationSpecs, + bool $presentInBaseConfig, + ?string $seedStoredVariationId, + string $forcedVariationId, + ?string $expectedNormalVariationId + ): void { + $variations = array_map( + fn (array $spec) => $this->variation($spec['id'], $spec['key'], $spec['overrides'] ?? []), + $variationSpecs + ); + $experience = $this->experienceFixture($experienceId, $experienceKey, $experienceOverrides, $variations); + + $rig = $this->buildRig($presentInBaseConfig ? [$experience] : []); + if (!$presentInBaseConfig) { + $this->queueExpFetchResponse($experience); + } + + $previewVisitorId = 'preview-visitor-' . $experienceId; + if ($seedStoredVariationId !== null) { + $rig['dataManager']->putData($previewVisitorId, ['bucketing' => [$experienceId => $seedStoredVariationId]]); + } + + $previewContext = $rig['core']->createContext($previewVisitorId); + $previewContext->setPreview($experienceId, $forcedVariationId); + $decision = $previewContext->runExperience($experienceKey); + + $this->assertInstanceOf(BucketedVariation::class, $decision, 'preview must force a decision regardless of the disabled gate'); + $this->assertSame($forcedVariationId, $decision->variationId, 'preview must return the exact requested variation'); + $this->assertSame($experienceId, $decision->experienceId); + + if (!$presentInBaseConfig) { + $this->assertCount(1, $this->expRequestsFor($experienceId), 'AC4: exactly one ?exp= fetch expected when the experience is absent from the current config'); + } + + $normalVisitorId = $seedStoredVariationId !== null ? $previewVisitorId : 'normal-visitor-' . $experienceId; + $normalContext = $rig['core']->createContext($normalVisitorId); + $normalDecision = $normalContext->runExperience($experienceKey, new BucketingAttributes(self::NO_LOCATION_GATE)); + + if ($expectedNormalVariationId === null) { + $this->assertNull($normalDecision, 'non-preview evaluation must be blocked by the real gate this case exercises'); + } else { + $this->assertInstanceOf(BucketedVariation::class, $normalDecision); + $this->assertSame($expectedNormalVariationId, $normalDecision->variationId, 'non-preview context must be unaffected by the preview override (preview must not have persisted anything)'); + } + } + + // -- §3 precedence: the bulk method must also honor preview forcing ------------------ + + /** + * qs-02 decision-audit remediation, Defect 3: contract §3 ("preview forcing beats + * stored decisions and normal bucketing for the target experience on that context") + * is method-agnostic — runExperiences() must honor it for an in-config, running, + * environment-matching preview target, not just runExperience(). Seeds a stored + * decision (mirroring the 'different stored decision' bypassCasesProvider() case) + * so the un-forced bulk result is deterministic, proving the override — not hash + * luck — is what makes the assertion pass. + */ + #[Test] + public function previewForcingOverridesTheTargetExperienceInBulkRunExperiences(): void + { + $targetId = '9106'; + $targetKey = 'bulk-precedence-exp'; + $target = $this->experienceFixture($targetId, $targetKey, [], [ + $this->variation('9106-A', 'a'), + $this->variation('9106-B', 'b'), + ]); + + $rig = $this->buildRig([$target]); + $visitorId = 'preview-visitor-bulk-precedence'; + $rig['dataManager']->putData($visitorId, ['bucketing' => [$targetId => '9106-A']]); + + $context = $rig['core']->createContext($visitorId); + $context->setPreview($targetId, '9106-B'); + + $decisions = $context->runExperiences(new BucketingAttributes(self::NO_LOCATION_GATE)); + + $byKey = []; + foreach ($decisions as $decision) { + $byKey[$decision->experienceKey] = $decision; + } + + $this->assertArrayHasKey($targetKey, $byKey, 'the in-config running preview target must still appear in the bulk result'); + $this->assertSame('9106-B', $byKey[$targetKey]->variationId, 'preview forcing must beat the stored decision for the target experience in the bulk method too (contract §3, method-agnostic)'); + $this->assertArrayHasKey(self::OTHER_EXPERIENCE_KEY, $byKey, 'other experiences must still decide normally on a preview context'); + } + + /** + * Gemini PR #51 review finding (correctness): before the fix, the bulk + * injection loop sourced `$previewKey` from `$this->previewExperience['key'] + * ?? null` with no guard — when the preview target's raw config data is + * missing `key` (malformed/legacy data; {@see ConfigExperience::getKey()} + * defaults to `null` when unset), `$previewKey` resolves to `null`. Any + * OTHER bucketed variation entry whose own `experienceKey` is also + * missing/null would then spuriously satisfy `null === null` and get + * overwritten with the preview decision — even though it has nothing to + * do with the preview target. + * + * The real {@see \ConvertSdk\ExperienceManager::selectVariations()} can + * never itself produce an entry with a missing `experienceKey` (it always + * sets it from the `string $experienceKey` it was called with), so this + * synthesizes that shape via a mocked ExperienceManager to exercise the + * defensive guard directly. Fixed code sources the key from + * `$this->previewDecision['experienceKey']` (the authoritative key + * {@see \ConvertSdk\DataManager::buildPreviewDecision()} derives from + * `ConfigExperience::getKey()`) and skips injection entirely when it is + * null/empty — this test's target experience has the same "no key" + * defect, so both sourcing approaches agree here; the guard itself is + * what's under test. + */ + #[Test] + public function nullPreviewKeyDoesNotOverwriteABucketedVariationWithMissingExperienceKey(): void + { + $targetId = '9107'; + // Deliberately no 'key' field — ConfigExperience::getKey() returns + // null for it, so previewDecision['experienceKey'] is null too. + $targetWithNoKey = [ + 'id' => $targetId, + 'status' => 'running', + 'variations' => [ + $this->variation($targetId . '-A', 'a'), + ], + ]; + + $unrelatedVariationId = 'unrelated-exp-A'; + $spy = $this->createMock(ExperienceManagerInterface::class); + $spy->method('selectVariations')->willReturn([ + // No 'experienceKey' entry at all — the shape a real + // ExperienceManager can never produce, but the guard must + // still defend against it. + ['id' => $unrelatedVariationId, 'key' => 'a', 'changes' => []], + ]); + + $rig = $this->buildRig([$targetWithNoKey], [], $spy); + + $context = $rig['core']->createContext('preview-visitor-null-key'); + $context->setPreview($targetId, $targetId . '-A'); + + $decisions = $context->runExperiences(); + + $this->assertCount(1, $decisions); + $this->assertSame( + $unrelatedVariationId, + $decisions[0]->variationId, + 'a bucketed variation with a missing experienceKey must not be overwritten by the preview decision when the preview key itself is null/empty' + ); + $this->assertSame('', $decisions[0]->experienceKey); + } + + // -- AC6: zero trace across the full lifecycle, including shutdown ------------------- + + #[Test] + public function previewContextLeavesZeroTraceAcrossFullLifecycleIncludingShutdown(): void + { + $targetId = '9101'; + $targetKey = 'shutdown-exp'; + $experience = $this->experienceFixture($targetId, $targetKey, ['status' => 'draft'], [ + $this->variation('9101-A', 'a'), + $this->variation('9101-B', 'b'), + ]); + + $rig = $this->buildRig(); + $this->queueExpFetchResponse($experience); + + $context = $rig['core']->createContext('preview-visitor-ac6'); + $context->setPreview($targetId, '9101-B'); + + $forced = $context->runExperience($targetKey); + $this->assertInstanceOf(BucketedVariation::class, $forced); + $this->assertSame('9101-B', $forced->variationId); + + // A DIFFERENT experience on the SAME preview context must still decide + // normally (coherent rendering) — but the whole context is zero-trace, not + // just the forced target. + $other = $context->runExperience(self::OTHER_EXPERIENCE_KEY, new BucketingAttributes(self::NO_LOCATION_GATE)); + $this->assertInstanceOf(BucketedVariation::class, $other, 'other experiences must still decide normally on a preview context'); + + $context->trackConversion(self::GOAL_KEY); + + // Model the PHP-FPM shutdown handler ConvertSDK::create() registers. + $rig['apiManager']->releaseQueue('shutdown'); + + $this->assertSame([], $this->trackRequests(), 'zero requests to the track endpoint across the full preview-context lifecycle, including shutdown flush'); + $this->assertSame(0, $rig['dataStore']->setCalls, 'zero visitor-state dataStore writes across the full preview-context lifecycle'); + } + + /** + * Remediation (post-qs-02): "zero-trace" also covers the in-process + * SystemEvents::Bucketing / SystemEvents::Conversion pub/sub — a + * preview-active context must never notify consumer listeners, not just + * skip network tracking / persistence. Exercises every surface that fires + * SystemEvents::Bucketing (runExperience() on the target AND a different + * experience, runExperiences(), runFeature(), runFeatures()) plus + * trackConversion()'s SystemEvents::Conversion — all on the SAME preview + * context — and asserts zero fires across the board. + * + * JS SDK parity confirmed directly against + * ../javascript-sdk/packages/js-sdk/src/context.ts: every run*() method + * gates its BUCKETING fire on `if (!this._preview)` and trackConversion() + * short-circuits entirely under preview (never reaching its CONVERSION + * fire). + * + * qs-16 correction: this test ALSO asserts zero + * SystemEvents::LocationActivated / LocationDeactivated fires under + * preview. A prior remediation pass wrongly concluded JS fires Location + * events regardless of preview, based on + * ../javascript-sdk/packages/data/src/data-manager.ts selectLocations()'s + * `enableStorage` docblock — that flag is persistence-only, but JS keeps a + * SEPARATE `suppressEvents` flag (declared alongside `enableStorage` on + * `packages/types/src/{BucketingAttributes,LocationAttributes}.ts`) that + * specifically gates the two event fires (`if (!suppressEvents) { fire(...) }` + * at data-manager.ts's LOCATION_ACTIVATED/LOCATION_DEACTIVATED sites), and + * every preview call site in context.ts sets `suppressEvents: true` + * alongside `enableStorage: false`. PHP reuses its single + * `suppressPersistence` flag (contractually preview-exclusive, per + * LocationAttributes::$suppressPersistence's docblock) to gate both the + * persistence write AND the two Location event fires in + * DataManager::selectLocations() — see the qs-16 fix there. + */ + #[Test] + public function previewContextFiresZeroBucketingOrConversionEventsAcrossEveryRunMethod(): void + { + $targetId = '9108'; + $targetKey = 'event-suppression-target-exp'; + $target = $this->experienceFixture($targetId, $targetKey, ['status' => 'draft'], [ + $this->variation('9108-A', 'a'), + $this->variation('9108-B', 'b'), + ]); + + $rig = $this->buildRig( + [$this->featureCarryingExperience(), $this->locationBearingExperience()], + [$this->featureFixture()], + null, + [$this->locationFixture()] + ); + $this->queueExpFetchResponse($target); + $captured = $this->attachEventSpies($rig['eventManager']); + + $context = $rig['core']->createContext('preview-visitor-event-suppression'); + $context->setPreview($targetId, '9108-B'); + + $forced = $context->runExperience($targetKey); + $this->assertInstanceOf(BucketedVariation::class, $forced, 'preview target must still force its decision'); + + $other = $context->runExperience(self::OTHER_EXPERIENCE_KEY, new BucketingAttributes(self::NO_LOCATION_GATE)); + $this->assertInstanceOf(BucketedVariation::class, $other, 'a different experience must still decide normally on the same preview context'); + + // qs-16: a DIFFERENT, location-restricted experience must still location-match + // (AC6 "other experiences decide normally") while its LocationActivated fire + // is suppressed (zero-trace, corrected AC5). + $locationMatched = $context->runExperience(self::LOCATION_EXPERIENCE_KEY, new BucketingAttributes(self::LOCATION_PROPERTIES)); + $this->assertInstanceOf(BucketedVariation::class, $locationMatched, 'the location-restricted experience must still location-match and decide normally on a preview context'); + + $all = $context->runExperiences(new BucketingAttributes(self::NO_LOCATION_GATE)); + $this->assertNotEmpty($all, 'runExperiences() must actually bucket, not be blocked by a gate — otherwise this test would pass trivially'); + + $feature = $context->runFeature(self::FEATURE_KEY, new BucketingAttributes(self::LOCATION_PROPERTIES)); + $this->assertInstanceOf(BucketedFeature::class, $feature, 'runFeature() must actually bucket, not be blocked by a gate'); + + $features = $context->runFeatures(new BucketingAttributes(self::LOCATION_PROPERTIES)); + $this->assertNotEmpty($features, 'runFeatures() must actually bucket, not be blocked by a gate'); + + $context->trackConversion(self::GOAL_KEY); + + $this->assertSame([], $captured->bucketing, 'zero SystemEvents::Bucketing fires across runExperience() (target + other), runExperiences(), runFeature(), and runFeatures() on a preview context'); + $this->assertSame([], $captured->conversion, 'zero SystemEvents::Conversion fires from trackConversion() on a preview context'); + $this->assertSame([], $captured->locationActivated, 'zero SystemEvents::LocationActivated fires on a preview context (qs-16 correction)'); + $this->assertSame([], $captured->locationDeactivated, 'zero SystemEvents::LocationDeactivated fires on a preview context (qs-16 correction)'); + } + + /** + * Control/regression case for the fix above: a NORMAL (non-preview) + * context must keep firing SystemEvents::Bucketing, SystemEvents::Conversion, + * and (qs-16 correction) SystemEvents::LocationActivated exactly as before — + * proving the new `$this->previewExperience === null` / `suppressPersistence` + * gates do not suppress anything outside an active preview. + */ + #[Test] + public function nonPreviewContextStillFiresBucketingAndConversionEventsNormally(): void + { + $rig = $this->buildRig( + [$this->featureCarryingExperience(), $this->locationBearingExperience()], + [$this->featureFixture()], + null, + [$this->locationFixture()] + ); + $captured = $this->attachEventSpies($rig['eventManager']); + + $context = $rig['core']->createContext('normal-visitor-event-regression'); + + $decision = $context->runExperience(self::OTHER_EXPERIENCE_KEY, new BucketingAttributes(self::NO_LOCATION_GATE)); + $this->assertInstanceOf(BucketedVariation::class, $decision); + + $locationMatched = $context->runExperience(self::LOCATION_EXPERIENCE_KEY, new BucketingAttributes(self::LOCATION_PROPERTIES)); + $this->assertInstanceOf(BucketedVariation::class, $locationMatched); + + $feature = $context->runFeature(self::FEATURE_KEY, new BucketingAttributes(self::LOCATION_PROPERTIES)); + $this->assertInstanceOf(BucketedFeature::class, $feature); + + $context->trackConversion(self::GOAL_KEY); + + $this->assertNotEmpty($captured->bucketing, 'a normal non-preview context must still fire SystemEvents::Bucketing'); + $this->assertNotEmpty($captured->conversion, 'a normal non-preview context must still fire SystemEvents::Conversion'); + $this->assertNotEmpty($captured->locationActivated, 'a normal non-preview context must still fire SystemEvents::LocationActivated (qs-16 correction)'); + } + + /** + * qs-02 decision-audit remediation, Defect 1/2: Context::runFeature()/runFeatures() must + * be just as zero-trace as runExperience()/runExperiences() on a preview-set context. + * These feature methods bucket EVERY experience in the config (FeatureManager::runFeatures() + * has no experience filter by default), so calling either one on a preview context leaks + * persistence/tracking for every not-yet-bucketed experience unless suppressPersistence is + * forwarded — exactly the gap the original AC6 test missed by mocking FeatureManager instead + * of exercising the real bucketing path. + */ + #[Test] + public function previewContextLeavesZeroTraceAcrossFeatureMethodsIncludingShutdown(): void + { + $targetId = '9104'; + $targetKey = 'feature-preview-target-exp'; + $target = $this->experienceFixture($targetId, $targetKey, ['status' => 'draft'], [ + $this->variation('9104-A', 'a'), + $this->variation('9104-B', 'b'), + ]); + + $rig = $this->buildRig([$this->featureCarryingExperience()], [$this->featureFixture()]); + $this->queueExpFetchResponse($target); + + $context = $rig['core']->createContext('preview-visitor-feature'); + $context->setPreview($targetId, '9104-B'); + + $forced = $context->runExperience($targetKey); + $this->assertInstanceOf(BucketedVariation::class, $forced, 'preview target must still force its decision'); + + $feature = $context->runFeature(self::FEATURE_KEY, new BucketingAttributes(self::LOCATION_PROPERTIES)); + $this->assertInstanceOf(BucketedFeature::class, $feature, 'the feature-carrying experience must actually bucket, not be blocked by a gate — otherwise this test would pass trivially'); + + $features = $context->runFeatures(new BucketingAttributes(self::LOCATION_PROPERTIES)); + $this->assertNotEmpty($features, 'runFeatures() must actually bucket experiences, not be blocked by a gate — otherwise this test would pass trivially'); + + // Model the PHP-FPM shutdown handler ConvertSDK::create() registers. + $rig['apiManager']->releaseQueue('shutdown'); + + $this->assertSame([], $this->trackRequests(), 'zero requests to the track endpoint after runFeature()/runFeatures() on a preview context, including shutdown flush'); + $this->assertSame(0, $rig['dataStore']->setCalls, 'zero visitor-state dataStore writes after runFeature()/runFeatures() on a preview context'); + } + + // -- AC7: isolation from a concurrent non-preview context ---------------------------- + + #[Test] + public function concurrentNonPreviewContextTracksAndPersistsNormallyOnSharedManagers(): void + { + $targetId = '9102'; + $targetKey = 'concurrent-exp'; + $experience = $this->experienceFixture($targetId, $targetKey, ['status' => 'draft'], [ + $this->variation('9102-A', 'a'), + $this->variation('9102-B', 'b'), + ]); + + $rig = $this->buildRig(); + $this->queueExpFetchResponse($experience); + + $previewContext = $rig['core']->createContext('preview-visitor-ac7'); + $previewContext->setPreview($targetId, '9102-B'); + $previewContext->runExperience($targetKey); + $previewContext->trackConversion(self::GOAL_KEY); + + // Concurrent NON-preview context on the SAME Core/managers (same ApiManager + // queue, same DataManager, same dataStore/cache) must be unaffected. + $normalContext = $rig['core']->createContext('normal-visitor-ac7'); + $normalDecision = $normalContext->runExperience(self::OTHER_EXPERIENCE_KEY, new BucketingAttributes(self::NO_LOCATION_GATE)); + $normalContext->trackConversion(self::GOAL_KEY); + + $rig['apiManager']->releaseQueue('shutdown'); + + $this->assertInstanceOf(BucketedVariation::class, $normalDecision); + $this->assertGreaterThan(0, $rig['dataStore']->setCalls, 'the concurrent non-preview context must persist visitor state normally'); + $this->assertNotEmpty($this->trackRequests(), 'the concurrent non-preview context must still send tracking requests'); + } + + // -- AC8: memoization ------------------------------------------------------------------ + + #[Test] + public function twoPreviewResolutionsWithinSixtySecondsCauseExactlyOneOriginFetchViaPreviewScopedKey(): void + { + $targetId = '9103'; + $targetKey = 'memo-exp'; + $experience = $this->experienceFixture($targetId, $targetKey, ['status' => 'draft'], [ + $this->variation('9103-A', 'a'), + $this->variation('9103-B', 'b'), + ]); + + $rig = $this->buildRig(); + // Exactly ONE response queued — a second fetch would fall through to the + // mock client's default empty-200 response, which the assertions below + // would catch via the raw request count (not via queue exhaustion). + $this->queueExpFetchResponse($experience); + + $firstContext = $rig['core']->createContext('preview-visitor-ac8-a'); + $firstContext->setPreview($targetId, '9103-B'); + $firstDecision = $firstContext->runExperience($targetKey); + + $secondContext = $rig['core']->createContext('preview-visitor-ac8-b'); + $secondContext->setPreview($targetId, '9103-B'); + $secondDecision = $secondContext->runExperience($targetKey); + + $this->assertInstanceOf(BucketedVariation::class, $firstDecision); + $this->assertInstanceOf(BucketedVariation::class, $secondDecision); + $this->assertSame('9103-B', $firstDecision->variationId); + $this->assertSame('9103-B', $secondDecision->variationId); + + $this->assertCount(1, $this->expRequestsFor($targetId), 'two preview resolutions for the same experience within 60s must cause exactly one origin fetch'); + + $recordedKeys = array_column($rig['cache']->calls, 'key'); + $this->assertContains('preview_' . $targetId, $recordedKeys, 'memoization must use the preview-scoped key preview_{experienceId}'); + foreach ($recordedKeys as $key) { + $this->assertStringNotContainsString('convert_sdk.config.', $key, 'preview memoization must never use the normal config cache key'); + } + } +} diff --git a/packages/Php-sdk/tests/Preview/PreviewParamTest.php b/packages/Php-sdk/tests/Preview/PreviewParamTest.php new file mode 100644 index 0000000..770942e --- /dev/null +++ b/packages/Php-sdk/tests/Preview/PreviewParamTest.php @@ -0,0 +1,69 @@ + string, 'variationId' => string]` rather than a + * positional tuple — the spec leaves the exact return shape open, and an + * associative array reads directly into + * `$context->setPreview($parsed['experienceId'], $parsed['variationId'])` + * without a positional-index footgun. + * + * RED-phase note (qs-02 PHP-2, TDD RED): `ConvertSdk\Preview\PreviewParam` does + * not exist yet. Every test below is expected to fail with + * `Error: Class "ConvertSdk\Preview\PreviewParam" not found` until the PHP-2 + * GREEN implementation lands. + * + * @see ../../../../../ai-driven-product-dev/_bmad-output/planning-artifacts/2026-03-13-convert-php-sdk/qs-02-experiment-preview.md + */ +class PreviewParamTest extends TestCase +{ + #[Test] + public function parseReturnsExperienceAndVariationIdPairForAWellFormedValue(): void + { + $result = PreviewParam::parse('123.456'); + + $this->assertSame(['experienceId' => '123', 'variationId' => '456'], $result); + } + + /** + * @return array + */ + public static function malformedValueProvider(): array + { + return [ + 'no dot separator' => ['123456'], + 'too many dots' => ['123.456.789'], + 'non-numeric experienceId' => ['abc.456'], + 'non-numeric variationId' => ['123.abc'], + 'empty string' => [''], + 'empty experienceId segment' => ['.456'], + 'empty variationId segment' => ['123.'], + 'negative experienceId' => ['-123.456'], + 'whitespace around ids' => [' 123.456 '], + 'trailing garbage after variationId' => ['123.456abc'], + ]; + } + + #[DataProvider('malformedValueProvider')] + public function testParseReturnsNullForMalformedValue(string $value): void + { + $this->assertNull(PreviewParam::parse($value)); + } +} diff --git a/packages/Types/lib/BucketingAttributes.php b/packages/Types/lib/BucketingAttributes.php index 35f47f4..4bf6506 100644 --- a/packages/Types/lib/BucketingAttributes.php +++ b/packages/Types/lib/BucketingAttributes.php @@ -58,6 +58,19 @@ class BucketingAttributes */ public $ignoreLocationProperties; + /** + * qs-02 capability (B) preview input — per-context suppression signal. + * When true, DataManager suppresses ALL visitor-state persistence writes + * (putData()) and ALL tracking-event enqueues for this call, regardless of + * `enableTracking` (which only gates the bucketing-event enqueue, not + * persistence). Set by Context on every forwarded call once + * `setPreview()` has resolved successfully — never exposed as a public + * per-call override. + * + * @var bool|null + */ + public $suppressPersistence; + /** * Constructor to initialize the object with data. * @@ -74,6 +87,7 @@ public function __construct(array $data = []) $this->forceVariationId = $data['forceVariationId'] ?? null; $this->enableTracking = $data['enableTracking'] ?? null; $this->ignoreLocationProperties = $data['ignoreLocationProperties'] ?? null; + $this->suppressPersistence = $data['suppressPersistence'] ?? null; } /** @@ -273,4 +287,26 @@ public function setIgnoreLocationProperties(?bool $ignoreLocationProperties): se $this->ignoreLocationProperties = $ignoreLocationProperties; return $this; } + + /** + * Get whether visitor-state persistence and tracking enqueues are suppressed. + * + * @return bool|null + */ + public function getSuppressPersistence(): ?bool + { + return $this->suppressPersistence; + } + + /** + * Set whether visitor-state persistence and tracking enqueues are suppressed. + * + * @param bool|null $suppressPersistence + * @return self + */ + public function setSuppressPersistence(?bool $suppressPersistence): self + { + $this->suppressPersistence = $suppressPersistence; + return $this; + } } \ No newline at end of file diff --git a/packages/Types/lib/Config.php b/packages/Types/lib/Config.php index 3f15287..eccaadc 100644 --- a/packages/Types/lib/Config.php +++ b/packages/Types/lib/Config.php @@ -49,6 +49,15 @@ class Config /** @var ?string Optional SDK key secret */ private ?string $sdkKeySecret = null; + /** + * Optional QA/preview debug token (qs-02 capability A). When set, every + * config-fetch URL carries `debug_token=` and `_conv_low_cache=1` + * (forced), and the SDK-side config cache is bypassed entirely. + * + * @var ?string + */ + private ?string $debugToken = null; + /** @var ?ConfigResponseData Configuration data from API */ private ?ConfigResponseData $data = null; @@ -105,6 +114,9 @@ public function __construct(array $options) $this->logger = isset($options['logger']) && is_array($options['logger']) ? $options['logger'] : null; $this->network = isset($options['network']) && is_array($options['network']) ? $options['network'] : null; $this->mapper = isset($options['mapper']) && is_callable($options['mapper']) ? $options['mapper'] : null; + $this->debugToken = isset($options['debugToken']) && is_string($options['debugToken']) && $options['debugToken'] !== '' + ? $options['debugToken'] + : null; } // Getters @@ -170,6 +182,11 @@ public function getSdkKeySecret(): ?string return $this->sdkKeySecret; } + public function getDebugToken(): ?string + { + return $this->debugToken; + } + public function getData(): ?ConfigResponseData { return $this->data; diff --git a/packages/Types/lib/LocationAttributes.php b/packages/Types/lib/LocationAttributes.php index bd614e8..79d6cb0 100644 --- a/packages/Types/lib/LocationAttributes.php +++ b/packages/Types/lib/LocationAttributes.php @@ -30,6 +30,16 @@ class LocationAttributes */ protected $forceEvent; + /** + * qs-02 capability (B) preview input — mirrors + * {@see \OpenAPI\Client\BucketingAttributes::$suppressPersistence}. When + * true, {@see \ConvertSdk\DataManager::selectLocations()} suppresses its + * visitor-state persistence write. + * + * @var bool|null + */ + protected $suppressPersistence; + /** * Constructor to initialize the object with data. * @@ -47,6 +57,7 @@ public function __construct(array $data = []) $this->identityField = $identityField; $this->forceEvent = $data['forceEvent'] ?? null; + $this->suppressPersistence = $data['suppressPersistence'] ?? null; } /** @@ -118,4 +129,26 @@ public function setForceEvent(?bool $forceEvent): self $this->forceEvent = $forceEvent; return $this; } + + /** + * Get whether visitor-state persistence is suppressed. + * + * @return bool|null + */ + public function getSuppressPersistence(): ?bool + { + return $this->suppressPersistence; + } + + /** + * Set whether visitor-state persistence is suppressed. + * + * @param bool|null $suppressPersistence + * @return self + */ + public function setSuppressPersistence(?bool $suppressPersistence): self + { + $this->suppressPersistence = $suppressPersistence; + return $this; + } } \ No newline at end of file diff --git a/tests/CrossSdk/AnchoredBucketingGoldenVectorTest.php b/tests/CrossSdk/AnchoredBucketingGoldenVectorTest.php new file mode 100644 index 0000000..4375058 --- /dev/null +++ b/tests/CrossSdk/AnchoredBucketingGoldenVectorTest.php @@ -0,0 +1,136 @@ + 11) cases are EXPECTED to fail until + * the anchored layout is implemented in BucketingManager / DataManager's fresh-bucketing + * branch. Packed (version <= 11) cases are expected to pass unchanged (AC6 regression lock). + * + * Spec: _bmad-output/planning-artifacts/2026-03-13-convert-php-sdk/qs-01-anchored-bucketing-layout.md + */ +class AnchoredBucketingGoldenVectorTest extends TestCase +{ + private const FIXTURE_PATH = __DIR__ . '/cross-sdk-bucketing-vectors.json'; + + /** + * @return iterable}> + */ + public static function vectorProvider(): iterable + { + $vectors = json_decode(file_get_contents(self::FIXTURE_PATH), true); + + foreach ($vectors as $index => $vector) { + $label = sprintf( + '#%d [v%d] %s', + $index, + $vector['version'], + mb_substr($vector['description'], 0, 90) + ); + yield $label => [$vector]; + } + } + + /** + * @param array{description: string, experienceId: string, visitorId: string, version: int|float, variations: array>, expected: string|null} $vector + */ + #[DataProvider('vectorProvider')] + public function testGoldenVectorMatchesExpectedVariation(array $vector): void + { + $result = $this->bucketVisitor( + $vector['variations'], + $vector['visitorId'], + $vector['version'], + $vector['experienceId'] + ); + + if ($vector['expected'] === null) { + $this->assertSame( + BucketingError::VariationNotDecided, + $result, + $vector['description'] + ); + return; + } + + $this->assertIsArray($result, $vector['description']); + $this->assertSame($vector['expected'], $result['id'], $vector['description']); + } + + /** + * Drives ONE vector through the real fresh-bucketing path: a brand-new DataManager per + * vector (so no visitor ever carries a stored decision across rows — every row is an + * independent "first encounter", matching how the fixture rows are authored), the real + * BucketingManager (real MurmurHash3 + real bucket math, unmocked), and + * DataManager::getBucketingById() — the actual production entry point that resolves a + * visitor into a variation for an experience, including whatever version-gated + * packed/anchored branch it contains. + * + * @param array> $variations + */ + private function bucketVisitor( + array $variations, + string $visitorId, + int|float $version, + string $experienceId + ): array|RuleError|BucketingError|null { + $dataManager = new DataManager( + new Config([ + 'environment' => 'production', + 'data' => new ConfigResponseData([ + 'account_id' => 'cross-sdk-test-account', + 'project' => ['id' => 'cross-sdk-test-project'], + 'experiences' => [[ + 'id' => $experienceId, + 'key' => $experienceId . '-key', + 'name' => 'Cross-SDK Anchored Bucketing Vector', + 'version' => $version, + 'variations' => $variations, + ]], + ]), + ]), + new BucketingManager(), + $this->createMock(RuleManagerInterface::class), + $this->createMock(EventManagerInterface::class), + $this->createMock(ApiManagerInterface::class), + new LogManager() + ); + + return $dataManager->getBucketingById( + $visitorId, + $experienceId, + new BucketingAttributes([ + 'ignoreLocationProperties' => true, + 'enableTracking' => false, + ]) + ); + } +} diff --git a/tests/CrossSdk/cross-sdk-bucketing-vectors.json b/tests/CrossSdk/cross-sdk-bucketing-vectors.json new file mode 100644 index 0000000..d101550 --- /dev/null +++ b/tests/CrossSdk/cross-sdk-bucketing-vectors.json @@ -0,0 +1,701 @@ +[ + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 601) now lands in O's RELOCATED band [0,833.33) -> reassigned to O. Documents the Distilled.ie incident: raising total allocation FLIPPED this visitor from V1 to O under the packed cumulative walk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) lands in V2's band [1000,1500) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression][incident-flip] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1213) now lands in V1's RELOCATED band [833.33,1666.67) -> reassigned to V1. Second flip from the same incident (V2 -> V1)", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 877 (visitor thirds-stable-V1-77) lands in V1's band [500,1000) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 877) still lands in V1's band [833.33,1666.67) -> V1 unaffected. Contrast vector: not every visitor flips on a packed raise, only those whose value falls inside a relocated sub-range", + "experienceId": "900000001", + "visitorId": "thirds-stable-V1-77", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1547 (visitor thirds-null-to-V1-25pct-48) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][lower-ejection-contrast] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1547) is newly admitted into V1's band [833.33,1666.67) at 25%. Read in reverse (25% -> 15%), this is AC3's packed lower-skew contrast vector: lowering coverage EJECTS this visitor to null, it is never reassigned to a different arm", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V1-25pct-48", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[packed-regression][sub-100%-exhaustion] v11 thirds 15% (5/5/5): value 1733 (visitor thirds-null-to-V2-25pct-majority-6) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression][incident-skew] v11 thirds 25% (8.333.../each): SAME visitor as above (value 1733) is newly admitted into V2's band [1666.67,2500) at 25%. Documents the incident's uneven skew: the newly opened packed band overwhelmingly favors the LAST arm (V2), not an even 3-way split", + "experienceId": "900000001", + "visitorId": "thirds-null-to-V2-25pct-majority-6", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[packed-regression] v11 thirds 15% (5/5/5): value 3134 (visitor thirds-idle-both-packed-3) exceeds the 15% total allocation -> not bucketed", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[packed-regression] v11 thirds 25% (8.333.../each): SAME visitor as above (value 3134) ALSO exceeds the 25% total allocation -> not bucketed at either coverage", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-packed-3", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 293 (visitor thirds-core-O-1) lands in O's band [0,500) -> O", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 293) stays in O's SUPERSET band [0,833.33) -> O. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-core-O-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 3617 (visitor thirds-anchored-V1-core-1) lands in V1's band [3333.33,3833.33) -> V1", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3617) stays in V1's SUPERSET band [3333.33,4166.67) -> V1. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-core-1", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 15% (5/5/5): value 6871 (visitor thirds-anchored-V2-core-24) lands in V2's band [6666.67,7166.67) -> V2", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-basic-thirds][raise-superset-core] v12 thirds 25% (8.333.../each): SAME visitor as above (value 6871) stays in V2's SUPERSET band [6666.67,7500) -> V2. No flip (AC2)", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-core-24", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 601 (visitor thirds-flip-V1-to-O-66) is NOT bucketed (falls between O's band [0,500) and V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 601) is newly admitted into O's growth sliver [500,833.33) at 25%. Contrast with the packed vector for this same value (V1 -> O flip): anchored never reassigns an already-bucketed visitor, it only ever admits from null", + "experienceId": "900000001", + "visitorId": "thirds-flip-V1-to-O-66", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 3899 (visitor thirds-anchored-V1-sliver-15) is NOT bucketed (exceeds V1's band [3333.33,3833.33))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 3899) is newly admitted into V1's growth sliver (3833.33,4166.67) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V1-sliver-15", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[per-sliver-admission][lower-ejection] v12 thirds 15% (5/5/5): value 7353 (visitor thirds-anchored-V2-sliver-14) is NOT bucketed (exceeds V2's band [6666.67,7166.67))", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[per-sliver-admission] v12 thirds 25% (8.333.../each): SAME visitor as above (value 7353) is newly admitted into V2's growth sliver (7166.67,7500) at 25%", + "experienceId": "900000001", + "visitorId": "thirds-anchored-V2-sliver-14", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 15% (5/5/5): value 1213 (visitor thirds-flip-V2-to-V1-5) is NOT bucketed under anchored. Contrast with the packed vectors for this same value (V2 -> V1 flip): anchored has no arm assignment at all here at either coverage, so there is no reassignment risk", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle][incident-fix-contrast] v12 thirds 25% (8.333.../each): SAME visitor as above (value 1213) is STILL NOT bucketed under anchored at the higher coverage either", + "experienceId": "900000001", + "visitorId": "thirds-flip-V2-to-V1-5", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 5848 (visitor thirds-idle-both-anchored-mid-2) is idle (falls between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 5848) is STILL idle at the higher coverage (still between V1's and V2's bands)", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-anchored-mid-2", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 15% (5/5/5): value 8455 (visitor thirds-idle-both-high-0) exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 5, "status": "running"}, + {"id": "V1", "traffic_allocation": 5, "status": "running"}, + {"id": "V2", "traffic_allocation": 5, "status": "running"} + ], + "expected": null + }, + { + "description": "[anchored-idle] v12 thirds 25% (8.333.../each): SAME visitor as above (value 8455) STILL exceeds V2's band -> idle", + "experienceId": "900000001", + "visitorId": "thirds-idle-both-high-0", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V1", "traffic_allocation": 8.333333333333334, "status": "running"}, + {"id": "V2", "traffic_allocation": 8.333333333333334, "status": "running"} + ], + "expected": null + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 102) -> O, unaffected by V1's stop (AC4 anchor stability)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 9807) -> V2's anchor (9000) is byte-identical whether V1 runs or is stopped (AC4)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80/V2=10 all RUNNING: value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[stopped-arm-stability] v12, O=10/V1=80 STOPPED/V2=10: SAME visitor as above (value 4957) -> stopped V1 keeps its weight (anchor stable at 1000) but has zero width, so it is never selected -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "stopped"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": null + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 4957 (visitor anchor-gate-visitor-17) -> V1. Z's explicit zero allocation is never defaulted to 100 and never perturbs V1's anchor; Z is never selected", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[ta-zero-width] v12, O=2/V1=47/Z=0(explicit)/V2=1: value 9807 (visitor anchor-gate-visitor-162) -> V2. Z's zero-width entry does not shift V2's anchor since it contributes zero weight", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 2, "status": "running"}, + {"id": "V1", "traffic_allocation": 47, "status": "running"}, + {"id": "Z", "traffic_allocation": 0, "status": "running"}, + {"id": "V2", "traffic_allocation": 1, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[nan-default] v12, single arm DEFAULT with traffic_allocation OMITTED (isNaN(ta) -> 100.0 default, full traffic space): any visitor is bucketed into DEFAULT", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 12, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v11, SAME single arm DEFAULT with traffic_allocation OMITTED: packed path also defaults to 100.0 -> DEFAULT (v11 === v12 for the NaN-default single-arm case)", + "experienceId": "900000001", + "visitorId": "nan-default-visitor", + "version": 11, + "variations": [ + {"id": "DEFAULT", "status": "running"} + ], + "expected": "DEFAULT" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 102 (visitor anchor-gate-visitor-106) falls in B's own band [0,500) -> B (isNaN default on A does not swallow values clearly inside B's own range; config order wins ties)", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "B" + }, + { + "description": "[nan-default] v12, two arms B(traffic_allocation=5) and A(traffic_allocation OMITTED -> defaults to 100): value 9807 (visitor anchor-gate-visitor-162) falls well inside A's defaulted 100-weight band -> A", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "B", "traffic_allocation": 5, "status": "running"}, + {"id": "A", "status": "running"} + ], + "expected": "A" + }, + { + "description": "[single-arm-v11-eq-v12] v11, single arm ONLY at traffic_allocation=100: any visitor -> ONLY", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 11, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[single-arm-v11-eq-v12] v12, SAME single arm ONLY at traffic_allocation=100: anchored path -> ONLY (v11 === v12 for a single full-allocation arm)", + "experienceId": "900000001", + "visitorId": "single-arm-visitor", + "version": 12, + "variations": [ + {"id": "ONLY", "traffic_allocation": 100, "status": "running"} + ], + "expected": "ONLY" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 102 (visitor anchor-gate-visitor-106) -> O", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 102) -> O. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 4957 (visitor anchor-gate-visitor-17) -> V1", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 4957) -> V1. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-17", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[100pct-total-v11-eq-v12] v11, O=10/V1=80/V2=10 (total 100%, all running): value 9807 (visitor anchor-gate-visitor-162) -> V2", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 11, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[100pct-total-v11-eq-v12] v12, SAME O=10/V1=80/V2=10 config: SAME visitor (value 9807) -> V2. Packed and anchored coincide exactly at 100% total allocation", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-162", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 999 (visitor boundary-999-25207) is just below V1's anchor (1000) -> O (upper edge of O's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-999-25207", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "O" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 1000 (visitor boundary-1000-1145) EQUALS V1's anchor exactly -> V1 (anchor is inclusive: anchor <= value)", + "experienceId": "900000001", + "visitorId": "boundary-1000-1145", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 8999 (visitor boundary-8999-359) is just below V2's anchor (9000) -> V1 (upper edge of V1's half-open range)", + "experienceId": "900000001", + "visitorId": "boundary-8999-359", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V1" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9000 (visitor boundary-9000-9598) EQUALS V2's anchor exactly -> V2 (anchor is inclusive)", + "experienceId": "900000001", + "visitorId": "boundary-9000-9598", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[boundary-hit] v12, O=10/V1=80/V2=10: value 9999 (visitor boundary-9999-5699) is the maximum representable traffic value, still inside V2's range -> V2", + "experienceId": "900000001", + "visitorId": "boundary-9999-5699", + "version": 12, + "variations": [ + {"id": "O", "traffic_allocation": 10, "status": "running"}, + {"id": "V1", "traffic_allocation": 80, "status": "running"}, + {"id": "V2", "traffic_allocation": 10, "status": "running"} + ], + "expected": "V2" + }, + { + "description": "[total-weight-zero] v12, two arms both traffic_allocation=0 (one running, one stopped): totalWeight is 0 -> not bucketed regardless of visitor", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 12, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + }, + { + "description": "[total-weight-zero] v11, SAME two zero-allocation arms: packed path filters both out entirely (empty bucket set) -> not bucketed", + "experienceId": "900000001", + "visitorId": "anchor-gate-visitor-106", + "version": 11, + "variations": [ + {"id": "A", "traffic_allocation": 0, "status": "running"}, + {"id": "B", "traffic_allocation": 0, "status": "stopped"} + ], + "expected": null + } +]