diff --git a/src/implementation/multiprovider/FinalResult.php b/src/implementation/multiprovider/FinalResult.php new file mode 100644 index 0000000..65d315b --- /dev/null +++ b/src/implementation/multiprovider/FinalResult.php @@ -0,0 +1,57 @@ +|null $errors Array of errors from providers if unsuccessful + */ + public function __construct( + private ?ResolutionDetails $details = null, + private ?string $providerName = null, + private ?array $errors = null, + ) { + } + + public function getDetails(): ?ResolutionDetails + { + return $this->details; + } + + public function getProviderName(): ?string + { + return $this->providerName; + } + + /** + * @return array|null + */ + public function getErrors(): ?array + { + return $this->errors; + } + + public function isSuccessful(): bool + { + return $this->details !== null && $this->errors === null; + } + + public function hasErrors(): bool + { + return $this->errors !== null && count($this->errors) > 0; + } +} diff --git a/src/implementation/multiprovider/Multiprovider.php b/src/implementation/multiprovider/Multiprovider.php new file mode 100644 index 0000000..546ded5 --- /dev/null +++ b/src/implementation/multiprovider/Multiprovider.php @@ -0,0 +1,371 @@ + + */ + private static array $supportedProviderData = [ + 'name', 'provider', + ]; + + public const NAME = 'Multiprovider'; + + /** + * @var array Providers indexed by their names. + */ + protected array $providersByName = []; + + /** + * The evaluation strategy to use for flag resolution. + */ + protected BaseEvaluationStrategy $strategy; + + /** + * Multiprovider constructor. + * + * @param array $providerData Array of provider data entries. + * @param BaseEvaluationStrategy|null $strategy Optional strategy instance. + */ + public function __construct(array $providerData = [], ?BaseEvaluationStrategy $strategy = null) + { + $this->validateProviderData($providerData); + $this->registerProviders($providerData); + + $this->strategy = $strategy ?? new FirstMatchStrategy(); + } + + /** + * Resolves the flag value for the provided flag key as a boolean + * + * @param string $flagKey The flag key to resolve + * @param bool $defaultValue The default value to return if no provider resolves the flag + * @param EvaluationContext|null $context The evaluation context + * + * @return ResolutionDetails The resolution details + */ + public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluateFlag('boolean', $flagKey, $defaultValue, $context); + } + + /** + * Resolves the flag value for the provided flag key as a string + * * @param string $flagKey The flag key to resolve + * + * @param string $defaultValue The default value to return if no provider resolves the flag + * @param EvaluationContext|null $context The evaluation context + * + * @return ResolutionDetails The resolution details + */ + public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluateFlag('string', $flagKey, $defaultValue, $context); + } + + /** + * Resolves the flag value for the provided flag key as an integer + * * @param string $flagKey The flag key to resolve + * + * @param int $defaultValue The default value to return if no provider resolves the flag + * @param EvaluationContext|null $context The evaluation context + * + * @return ResolutionDetails The resolution details + */ + public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluateFlag('integer', $flagKey, $defaultValue, $context); + } + + /** + * Resolves the flag value for the provided flag key as a float + * * @param string $flagKey The flag key to resolve + * + * @param float $defaultValue The default value to return if no provider resolves the flag + * @param EvaluationContext|null $context The evaluation context + * + * @return ResolutionDetails The resolution details + */ + public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluateFlag('float', $flagKey, $defaultValue, $context); + } + + /** + * Resolves the flag value for the provided flag key as an object + * + * @param string $flagKey The flag key to resolve + * @param EvaluationContext|null $context The evaluation context + * @param mixed[] $defaultValue + * + * @return ResolutionDetails The resolution details + */ + public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetails + { + return $this->evaluateFlag('object', $flagKey, $defaultValue, $context); + } + + /** + * Core evaluation logic that works with the strategy to resolve flags across multiple providers. + */ + private function evaluateFlag(string $flagType, string $flagKey, mixed $defaultValue, ?EvaluationContext $context): ResolutionDetails + { + $context = $context ?? new \OpenFeature\implementation\flags\EvaluationContext(); + + // Create base evaluation context + $baseContext = new StrategyEvaluationContext($flagKey, $flagType, $defaultValue, $context); + + // Collect results from providers based on strategy + if ($this->strategy->runMode === 'parallel') { + $resolutions = $this->evaluateParallel($baseContext); + } else { + $resolutions = $this->evaluateSequential($baseContext); + } + + // Let strategy determine final result + $finalResult = $this->strategy->determineFinalResult($baseContext, $resolutions); + + if ($finalResult->isSuccessful()) { + $details = $finalResult->getDetails(); + if ($details instanceof ResolutionDetails) { + return $details; + } + } + + // Handle error case + return $this->createErrorResolution($flagKey, $defaultValue, $finalResult->getErrors()); + } + + /** + * Evaluate providers sequentially based on strategy decisions. + * + * @return array Array of resolution results from evaluated providers. + */ + private function evaluateSequential(StrategyEvaluationContext $baseContext): array + { + $resolutions = []; + + foreach ($this->providersByName as $providerName => $provider) { + $perProviderContext = new StrategyPerProviderContext($baseContext, $providerName, $provider); + + // Check if we should evaluate this provider + if (!$this->strategy->shouldEvaluateThisProvider($perProviderContext)) { + continue; + } + + // Evaluate provider + $result = $this->evaluateProvider($provider, $providerName, $baseContext); + $resolutions[] = $result; + + // Check if we should continue to next provider + if (!$this->strategy->shouldEvaluateNextProvider($perProviderContext, $result)) { + break; + } + } + + return $resolutions; + } + + /** + * Evaluate all providers in parallel (all that pass shouldEvaluateThisProvider). + * + * @return array Array of resolution results from evaluated providers. + */ + private function evaluateParallel(StrategyEvaluationContext $baseContext): array + { + $resolutions = []; + + foreach ($this->providersByName as $providerName => $provider) { + $perProviderContext = new StrategyPerProviderContext($baseContext, $providerName, $provider); + + // Check if we should evaluate this provider + if (!$this->strategy->shouldEvaluateThisProvider($perProviderContext)) { + continue; + } + + // Evaluate provider + $result = $this->evaluateProvider($provider, $providerName, $baseContext); + $resolutions[] = $result; + } + + return $resolutions; + } + + /** + * Evaluate a single provider and return result with error handling. + */ + private function evaluateProvider(Provider $provider, string $providerName, StrategyEvaluationContext $context): ProviderResolutionResult + { + try { + $flagType = $context->getFlagType(); + /** @var bool|string|int|float|array|null $defaultValue */ + $defaultValue = $context->getDefaultValue(); + $evalContext = $context->getEvaluationContext(); + + switch ($flagType) { + case 'boolean': + assert(is_bool($defaultValue)); + $details = $provider->resolveBooleanValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + case 'string': + assert(is_string($defaultValue)); + $details = $provider->resolveStringValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + case 'integer': + assert(is_int($defaultValue)); + $details = $provider->resolveIntegerValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + case 'float': + assert(is_float($defaultValue)); + $details = $provider->resolveFloatValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + case 'object': + assert(is_array($defaultValue)); + $details = $provider->resolveObjectValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + default: + throw new InvalidArgumentException('Unknown flag type: ' . $flagType); + } + + return new ProviderResolutionResult($providerName, $provider, $details, null); + } catch (Throwable $error) { + return new ProviderResolutionResult($providerName, $provider, null, $error); + } + } + + /** + * Create an error resolution with aggregated errors from multiple providers. + * + * @param string $flagKey The flag key being evaluated. + * @param mixed $defaultValue The default value to return. + * @param array|null $errors Array of errors encountered during evaluation. + */ + private function createErrorResolution(string $flagKey, mixed $defaultValue, ?array $errors): ResolutionDetails + { + $errorMessage = 'Multi-provider evaluation failed'; + $errorCode = ErrorCode::GENERAL(); + + if ($errors !== null && count($errors) > 0) { + $errorMessage .= ' with ' . count($errors) . ' provider error(s)'; + } + + return (new ResolutionDetailsBuilder()) + ->withReason(Reason::ERROR) + ->withError(new ResolutionError($errorCode, $errorMessage)) + ->build(); + } + + /** + * Validate the provider data array. + * + * @param array $providerData Array of provider data entries. + * + * @throws InvalidArgumentException If unsupported keys, invalid names, or duplicate names are found. + */ + private function validateProviderData(array $providerData): void + { + foreach ($providerData as $entry) { + // check that entry contains only supported keys + $unSupportedKeys = array_diff(array_keys($entry), self::$supportedProviderData); + if (count($unSupportedKeys) !== 0) { + throw new InvalidArgumentException('Unsupported keys in provider data entry'); + } + if (isset($entry['name']) && trim($entry['name']) === '') { + throw new InvalidArgumentException('Each provider data entry must have a non-empty string "name" key'); + } + } + + $names = array_map(fn ($entry) => $entry['name'] ?? null, $providerData); + $nameCounts = array_count_values(array_filter($names)); // filter out nulls, count occurrences of each name + $duplicateNames = array_keys(array_filter($nameCounts, fn ($count) => $count > 1)); // filter by count > 1 to get duplicates + + if ($duplicateNames !== []) { + throw new InvalidArgumentException('Duplicate provider names found: ' . implode(', ', $duplicateNames)); + } + } + + /** + * Register providers by their names. + * + * @param array $providerData Array of provider data entries. + * + * @throws InvalidArgumentException If duplicate provider names are detected during assignment. + */ + private function registerProviders(array $providerData): void + { + $counts = []; // track how many times a base name is used + + foreach ($providerData as $entry) { + if (isset($entry['name']) && $entry['name'] !== '') { + $this->providersByName[$entry['name']] = $entry['provider']; + } else { + $name = $this->uniqueProviderName($entry['provider']->getMetadata()->getName(), $counts); + if (isset($this->providersByName[$name])) { + throw new InvalidArgumentException('Duplicate provider name detected during assignment: ' . $name); + } + $this->providersByName[$name] = $entry['provider']; + } + } + } + + /** + * Generate a unique provider name by appending a count suffix if necessary. + * E.g., if "ProviderA" is used twice, the second instance becomes "ProviderA_2". + * + * @param string $name The base name of the provider. + * @param array $count Reference to an associative array tracking name counts. + * + * @return string A unique provider name. + */ + private function uniqueProviderName(string $name, array &$count): string + { + $key = strtolower($name); + $count[$key] = ($count[$key] ?? 0) + 1; + + return $count[$key] > 1 ? $name . '_' . $count[$key] : $name; + } +} diff --git a/src/implementation/multiprovider/ProviderResolutionResult.php b/src/implementation/multiprovider/ProviderResolutionResult.php new file mode 100644 index 0000000..dba72c3 --- /dev/null +++ b/src/implementation/multiprovider/ProviderResolutionResult.php @@ -0,0 +1,54 @@ +providerName; + } + + public function getProvider(): Provider + { + return $this->provider; + } + + public function getDetails(): ?ResolutionDetails + { + return $this->details; + } + + public function getError(): ?Throwable + { + return $this->error; + } + + public function hasError(): bool + { + return $this->error !== null; + } + + public function isSuccessful(): bool + { + return $this->details !== null && $this->error === null; + } +} diff --git a/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php b/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php new file mode 100644 index 0000000..00cbd16 --- /dev/null +++ b/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php @@ -0,0 +1,80 @@ + $trackingEventDetails Details of the tracking event + * + * @return bool True to track with this provider, false to skip + */ + public function shouldTrackWithThisProvider( + StrategyPerProviderContext $context, + string $trackingEventName, + array $trackingEventDetails, + ): bool { + return true; + } +} diff --git a/src/implementation/multiprovider/strategy/ComparisonStrategy.php b/src/implementation/multiprovider/strategy/ComparisonStrategy.php new file mode 100644 index 0000000..5eef2ac --- /dev/null +++ b/src/implementation/multiprovider/strategy/ComparisonStrategy.php @@ -0,0 +1,186 @@ +fallbackProviderName; + } + + public function getOnMismatch(): ?callable + { + return $this->onMismatch; + } + + /** + * All providers should be evaluated by default. + * This allows for comparison of results across providers. + * + * @param StrategyPerProviderContext $context Context for the specific provider being evaluated + * + * @return bool True to evaluate this provider, false to skip + */ + public function shouldEvaluateThisProvider( + StrategyPerProviderContext $context, + ): bool { + return true; + } + + /** + * In parallel mode, this is not called. + * If somehow running sequentially, always continue to evaluate all providers. + * + * @param StrategyPerProviderContext $context Context for the specific provider just evaluated + * @param ProviderResolutionResult $result Result from the provider that was just evaluated + * + * @return bool True to continue to next provider, false to stop evaluation + */ + public function shouldEvaluateNextProvider( + StrategyPerProviderContext $context, + ProviderResolutionResult $result, + ): bool { + return true; + } + + /** + * Compares all successful results. + * If they match, returns the common value. + * If they don't match, returns fallback provider result or first result. + * If no successful results, returns aggregated errors. + * + * @param StrategyEvaluationContext $context Context for the overall evaluation + * @param ProviderResolutionResult[] $resolutions Array of resolution results from all providers + * + * @return FinalResult The final result of the evaluation + */ + public function determineFinalResult( + StrategyEvaluationContext $context, + array $resolutions, + ): FinalResult { + // Separate successful results from errors + $successfulResults = []; + $errors = []; + + foreach ($resolutions as $resolution) { + if ($resolution->hasError()) { + $err = $resolution->getError(); + if ($err instanceof Throwable) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $err, + ]; + } + } else { + $successfulResults[] = $resolution; + } + } + + // If no successful results, return errors + if (count($successfulResults) === 0) { + return new FinalResult(null, null, $errors !== [] ? $errors : null); + } + + // If only one successful result, return it + if (count($successfulResults) === 1) { + $result = $successfulResults[0]; + + return new FinalResult( + $result->getDetails(), + $result->getProviderName(), + null, + ); + } + + // Compare all successful values + $firstDetails = $successfulResults[0]->getDetails(); + $firstValue = $firstDetails ? $firstDetails->getValue() : null; + $allMatch = true; + + foreach ($successfulResults as $result) { + $details = $result->getDetails(); + if (!$details || $details->getValue() !== $firstValue) { + $allMatch = false; + + break; + } + } + + // If all values match, return the first one + if ($allMatch) { + $result = $successfulResults[0]; + + return new FinalResult( + $result->getDetails(), + $result->getProviderName(), + null, + ); + } + + // Values don't match - call onMismatch callback if provided + $onMismatch = $this->getOnMismatch(); + if ($onMismatch !== null) { + try { + $onMismatch($successfulResults); + } catch (Throwable $e) { + // Ignore errors from callback + } + } + + // Return fallback provider result if configured + $fallbackProviderName = $this->getFallbackProviderName(); + if ($fallbackProviderName !== null) { + foreach ($successfulResults as $result) { + if ($result->getProviderName() === $fallbackProviderName) { + return new FinalResult( + $result->getDetails(), + $result->getProviderName(), + null, + ); + } + } + } + + // No fallback configured or fallback not found, return first result + $result = $successfulResults[0]; + + return new FinalResult( + $result->getDetails(), + $result->getProviderName(), + null, + ); + } +} diff --git a/src/implementation/multiprovider/strategy/FirstMatchStrategy.php b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php new file mode 100644 index 0000000..576c730 --- /dev/null +++ b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php @@ -0,0 +1,149 @@ +isSuccessful()) { + return false; + } + + // If there's an error, check if it's FLAG_NOT_FOUND + $error = $result->getError(); + if ($error !== null) { + // Check if error is ThrowableWithResolutionError with FLAG_NOT_FOUND + if ($error instanceof ThrowableWithResolutionError) { + $resolutionError = $error->getResolutionError(); + if ($resolutionError->getResolutionErrorCode()->equals(ErrorCode::FLAG_NOT_FOUND())) { + // Continue to next provider for FLAG_NOT_FOUND + return true; + } + } + + // For any other error, stop here + return false; + } + + // Continue if no result + return true; + } + + /** + * Returns the first successful result or the first non-FLAG_NOT_FOUND error. + * If all providers returned FLAG_NOT_FOUND or no results, return error. + * + * @param StrategyEvaluationContext $context Context for the overall evaluation + * @param ProviderResolutionResult[] $resolutions Array of resolution results from all providers + * + * @return FinalResult The final result of the evaluation + */ + public function determineFinalResult( + StrategyEvaluationContext $context, + array $resolutions, + ): FinalResult { + // Find first successful resolution + foreach ($resolutions as $resolution) { + if ($resolution->isSuccessful()) { + return new FinalResult( + $resolution->getDetails(), + $resolution->getProviderName(), + null, + ); + } + } + + // Find first error that is not FLAG_NOT_FOUND + foreach ($resolutions as $resolution) { + if ($resolution->hasError()) { + $error = $resolution->getError(); + + // Check if it's NOT FLAG_NOT_FOUND + $isFlagNotFound = false; + if ($error instanceof ThrowableWithResolutionError) { + $resolutionError = $error->getResolutionError(); + if ($resolutionError->getResolutionErrorCode()->equals(ErrorCode::FLAG_NOT_FOUND())) { + $isFlagNotFound = true; + } + } + + if (!$isFlagNotFound && $error instanceof Throwable) { + // Return this error + return new FinalResult( + null, + null, + [ + [ + 'providerName' => $resolution->getProviderName(), + 'error' => $error, + ], + ], + ); + } + } + } + + // All providers returned FLAG_NOT_FOUND or no results + $errors = []; + foreach ($resolutions as $resolution) { + if ($resolution->hasError()) { + $err = $resolution->getError(); + if ($err instanceof Throwable) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $err, + ]; + } + } + } + + return new FinalResult(null, null, $errors ?: null); + } +} diff --git a/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php b/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php new file mode 100644 index 0000000..e4b2366 --- /dev/null +++ b/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php @@ -0,0 +1,98 @@ +isSuccessful(); + } + + /** + * Returns the first successful result. + * If no provider succeeds, returns all errors aggregated. + * + * @param StrategyEvaluationContext $context Context for the overall evaluation + * @param ProviderResolutionResult[] $resolutions Array of resolution results from all providers + * + * @return FinalResult The final result of the evaluation + */ + public function determineFinalResult( + StrategyEvaluationContext $context, + array $resolutions, + ): FinalResult { + // Find first successful resolution + foreach ($resolutions as $resolution) { + if ($resolution->isSuccessful()) { + return new FinalResult( + $resolution->getDetails(), + $resolution->getProviderName(), + null, + ); + } + } + + // No successful results, aggregate all errors + $errors = []; + foreach ($resolutions as $resolution) { + if ($resolution->hasError()) { + $err = $resolution->getError(); + if ($err instanceof Throwable) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $err, + ]; + } + } + } + + return new FinalResult(null, null, $errors ?: null); + } +} diff --git a/src/implementation/multiprovider/strategy/StrategyEvaluationContext.php b/src/implementation/multiprovider/strategy/StrategyEvaluationContext.php new file mode 100644 index 0000000..2e6feda --- /dev/null +++ b/src/implementation/multiprovider/strategy/StrategyEvaluationContext.php @@ -0,0 +1,83 @@ +flagKey; + } + + public function getFlagType(): string + { + return $this->flagType; + } + + public function getDefaultValue(): mixed + { + return $this->defaultValue; + } + + public function getEvaluationContext(): EvaluationContext + { + return $this->evaluationContext; + } +} diff --git a/src/implementation/multiprovider/strategy/StrategyPerProviderContext.php b/src/implementation/multiprovider/strategy/StrategyPerProviderContext.php new file mode 100644 index 0000000..7731356 --- /dev/null +++ b/src/implementation/multiprovider/strategy/StrategyPerProviderContext.php @@ -0,0 +1,37 @@ +getFlagKey(), + $baseContext->getFlagType(), + $baseContext->getDefaultValue(), + $baseContext->getEvaluationContext(), + ); + } + + public function getProviderName(): string + { + return $this->providerName; + } + + public function getProvider(): Provider + { + return $this->provider; + } +} diff --git a/src/interfaces/provider/RunMode.php b/src/interfaces/provider/RunMode.php new file mode 100644 index 0000000..abe9120 --- /dev/null +++ b/src/interfaces/provider/RunMode.php @@ -0,0 +1,21 @@ + + * @psalm-immutable + */ +final class RunMode extends Enum +{ + public const SEQUENTIAL = 'sequential'; + public const PARALLEL = 'parallel'; +} diff --git a/tests/unit/ComparisonStrategyTest.php b/tests/unit/ComparisonStrategyTest.php new file mode 100644 index 0000000..5ee0fa2 --- /dev/null +++ b/tests/unit/ComparisonStrategyTest.php @@ -0,0 +1,187 @@ +providerA = Mockery::mock(Provider::class); + $this->providerB = Mockery::mock(Provider::class); + $this->providerC = Mockery::mock(Provider::class); + + $this->providerA->shouldReceive('getMetadata->getName')->andReturn('ProviderA'); + $this->providerB->shouldReceive('getMetadata->getName')->andReturn('ProviderB'); + $this->providerC->shouldReceive('getMetadata->getName')->andReturn('ProviderC'); + } + + private function details(bool $value): ResolutionDetails + { + return (new ResolutionDetailsBuilder())->withValue($value)->build(); + } + + public function testAllProvidersAgreeReturnsFirstValue(): void + { + $strategy = new ComparisonStrategy(); + $this->providerA->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + $this->providerB->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + $this->providerC->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + + $mp = new Multiprovider( + [ + ['name' => 'a', 'provider' => $this->providerA], + ['name' => 'b', 'provider' => $this->providerB], + ['name' => 'c', 'provider' => $this->providerC], + ], + $strategy, + ); + + $res = $mp->resolveBooleanValue('flag', false, new EvaluationContext()); + $this->assertTrue($res->getValue()); + } + + public function testMismatchUsesFallbackProvider(): void + { + $strategy = new ComparisonStrategy('b'); + $this->providerA->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + $this->providerB->shouldReceive('resolveBooleanValue')->andReturn($this->details(false)); + $this->providerC->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + + $mp = new Multiprovider( + [ + ['name' => 'a', 'provider' => $this->providerA], + ['name' => 'b', 'provider' => $this->providerB], + ['name' => 'c', 'provider' => $this->providerC], + ], + $strategy, + ); + + $res = $mp->resolveBooleanValue('flag', false, new EvaluationContext()); + $this->assertFalse($res->getValue()); + } + + public function testMismatchWithoutFallbackReturnsFirstSuccessful(): void + { + $strategy = new ComparisonStrategy(); // no fallback + $this->providerA->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + $this->providerB->shouldReceive('resolveBooleanValue')->andReturn($this->details(false)); + + $mp = new Multiprovider( + [ + ['name' => 'a', 'provider' => $this->providerA], + ['name' => 'b', 'provider' => $this->providerB], + ], + $strategy, + ); + + $res = $mp->resolveBooleanValue('flag', false, new EvaluationContext()); + $this->assertTrue($res->getValue()); + } + + public function testOnMismatchCallbackInvoked(): void + { + $invoked = false; + $capturedCount = 0; + $callback = function (array $resolutions) use (&$invoked, &$capturedCount): void { + $invoked = true; + $capturedCount = count($resolutions); + }; + + $strategy = new ComparisonStrategy(null, $callback); + $this->providerA->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + $this->providerB->shouldReceive('resolveBooleanValue')->andReturn($this->details(false)); + $this->providerC->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + + $mp = new Multiprovider( + [ + ['name' => 'a', 'provider' => $this->providerA], + ['name' => 'b', 'provider' => $this->providerB], + ['name' => 'c', 'provider' => $this->providerC], + ], + $strategy, + ); + + $mp->resolveBooleanValue('flag', false, new EvaluationContext()); + $this->assertTrue($invoked); + $this->assertEquals(3, $capturedCount); + } + + public function testSingleSuccessfulResult(): void + { + $strategy = new ComparisonStrategy(); + $this->providerA->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + $this->providerB->shouldReceive('resolveBooleanValue')->andThrow(new Exception('err')); + $this->providerC->shouldReceive('resolveBooleanValue')->andThrow(new Exception('err2')); + + $mp = new Multiprovider( + [ + ['name' => 'a', 'provider' => $this->providerA], + ['name' => 'b', 'provider' => $this->providerB], + ['name' => 'c', 'provider' => $this->providerC], + ], + $strategy, + ); + + $res = $mp->resolveBooleanValue('flag', false, new EvaluationContext()); + $this->assertTrue($res->getValue()); + } + + public function testNoSuccessfulResultsReturnsError(): void + { + $strategy = new ComparisonStrategy(); + $this->providerA->shouldReceive('resolveBooleanValue')->andThrow(new Exception('a')); + $this->providerB->shouldReceive('resolveBooleanValue')->andThrow(new Exception('b')); + + $mp = new Multiprovider( + [ + ['name' => 'a', 'provider' => $this->providerA], + ['name' => 'b', 'provider' => $this->providerB], + ], + $strategy, + ); + + $res = $mp->resolveBooleanValue('flag', false, new EvaluationContext()); + $this->assertNotNull($res->getError()); + } + + public function testMismatchFallbackNotFoundReturnsFirst(): void + { + $strategy = new ComparisonStrategy('non-existent'); + $this->providerA->shouldReceive('resolveBooleanValue')->andReturn($this->details(false)); + $this->providerB->shouldReceive('resolveBooleanValue')->andReturn($this->details(true)); + + $mp = new Multiprovider( + [ + ['name' => 'a', 'provider' => $this->providerA], + ['name' => 'b', 'provider' => $this->providerB], + ], + $strategy, + ); + + $res = $mp->resolveBooleanValue('flag', true, new EvaluationContext()); + $this->assertFalse($res->getValue()); + } +} diff --git a/tests/unit/FinalResultTest.php b/tests/unit/FinalResultTest.php new file mode 100644 index 0000000..fe40e73 --- /dev/null +++ b/tests/unit/FinalResultTest.php @@ -0,0 +1,61 @@ +|null $value + */ + private function details(bool | string | int | float | DateTime | array | null $value): ResolutionDetails + { + return (new ResolutionDetailsBuilder())->withValue($value)->build(); + } + + public function testSuccessfulResult(): void + { + $details = $this->details(true); + $final = new FinalResult($details, 'ProviderA', null); + + $this->assertTrue($final->isSuccessful()); + $this->assertFalse($final->hasErrors()); + $this->assertSame($details, $final->getDetails()); + $this->assertEquals('ProviderA', $final->getProviderName()); + $this->assertNull($final->getErrors()); + } + + public function testResultWithErrors(): void + { + $errors = [ + ['providerName' => 'ProviderA', 'error' => new Exception('fail A')], + ['providerName' => 'ProviderB', 'error' => new Exception('fail B')], + ]; + $final = new FinalResult(null, null, $errors); + + $this->assertFalse($final->isSuccessful()); + $this->assertTrue($final->hasErrors()); + $this->assertNull($final->getDetails()); + $this->assertNull($final->getProviderName()); + $errors = $final->getErrors(); + $this->assertNotNull($errors); + $this->assertIsArray($errors); + $this->assertCount(2, $errors); + } + + public function testEmptyErrorsArrayTreatedAsNoErrors(): void + { + $final = new FinalResult(null, null, []); + $this->assertFalse($final->isSuccessful()); + $this->assertFalse($final->hasErrors()); + $this->assertSame([], $final->getErrors()); + } +} diff --git a/tests/unit/MultiProviderStrategyTest.php b/tests/unit/MultiProviderStrategyTest.php new file mode 100644 index 0000000..6e83d77 --- /dev/null +++ b/tests/unit/MultiProviderStrategyTest.php @@ -0,0 +1,224 @@ +mockProvider1 = Mockery::mock(Provider::class); + $this->mockProvider2 = Mockery::mock(Provider::class); + $this->mockProvider3 = Mockery::mock(Provider::class); + + // Setup basic metadata for providers + $this->mockProvider1->shouldReceive('getMetadata->getName')->andReturn('Provider1'); + $this->mockProvider2->shouldReceive('getMetadata->getName')->andReturn('Provider2'); + $this->mockProvider3->shouldReceive('getMetadata->getName')->andReturn('Provider3'); + + // Create base evaluation context for tests + $this->baseContext = new StrategyEvaluationContext( + 'test-flag', + 'boolean', + false, + new EvaluationContext(), + ); + } + + public function testFirstMatchStrategyRunMode(): void + { + $strategy = new FirstMatchStrategy(); + $this->assertEquals('sequential', $strategy->runMode); + } + + public function testFirstSuccessfulStrategyRunMode(): void + { + $strategy = new FirstSuccessfulStrategy(); + $this->assertEquals('sequential', $strategy->runMode); + } + + public function testFirstMatchStrategyShouldEvaluateThisProvider(): void + { + $strategy = new FirstMatchStrategy(); + $context = new StrategyPerProviderContext($this->baseContext, 'test1', $this->mockProvider1); + + $this->assertTrue($strategy->shouldEvaluateThisProvider($context)); + } + + public function testFirstSuccessfulStrategyShouldEvaluateThisProvider(): void + { + $strategy = new FirstSuccessfulStrategy(); + $context = new StrategyPerProviderContext($this->baseContext, 'test1', $this->mockProvider1); + + $this->assertTrue($strategy->shouldEvaluateThisProvider($context)); + } + + public function testFirstMatchStrategyWithSuccessfulResult(): void + { + $strategy = new FirstMatchStrategy(); + $context = new StrategyPerProviderContext($this->baseContext, 'test1', $this->mockProvider1); + + $details = $this->createResolutionDetails(true); + $result = new ProviderResolutionResult('test1', $this->mockProvider1, $details, null); + + $this->assertFalse($strategy->shouldEvaluateNextProvider($context, $result)); + } + + public function testFirstMatchStrategyWithFlagNotFoundError(): void + { + $strategy = new FirstMatchStrategy(); + $context = new StrategyPerProviderContext($this->baseContext, 'test1', $this->mockProvider1); + + $error = new class extends Exception implements ThrowableWithResolutionError { + public function getResolutionError(): \OpenFeature\interfaces\provider\ResolutionError + { + return new ResolutionError(ErrorCode::FLAG_NOT_FOUND(), 'Flag not found'); + } + }; + + $result = new ProviderResolutionResult('test1', $this->mockProvider1, null, $error); + + $this->assertTrue($strategy->shouldEvaluateNextProvider($context, $result)); + } + + public function testFirstMatchStrategyWithGeneralError(): void + { + $strategy = new FirstMatchStrategy(); + $context = new StrategyPerProviderContext($this->baseContext, 'test1', $this->mockProvider1); + + $error = new class extends Exception implements ThrowableWithResolutionError { + public function getResolutionError(): \OpenFeature\interfaces\provider\ResolutionError + { + return new ResolutionError(ErrorCode::GENERAL(), 'General error'); + } + }; + + $result = new ProviderResolutionResult('test1', $this->mockProvider1, null, $error); + + $this->assertFalse($strategy->shouldEvaluateNextProvider($context, $result)); + } + + public function testFirstSuccessfulStrategyWithSuccessfulResult(): void + { + $strategy = new FirstSuccessfulStrategy(); + $context = new StrategyPerProviderContext($this->baseContext, 'test1', $this->mockProvider1); + + $details = $this->createResolutionDetails(true); + $result = new ProviderResolutionResult('test1', $this->mockProvider1, $details, null); + + $this->assertFalse($strategy->shouldEvaluateNextProvider($context, $result)); + } + + public function testFirstSuccessfulStrategyWithError(): void + { + $strategy = new FirstSuccessfulStrategy(); + $context = new StrategyPerProviderContext($this->baseContext, 'test1', $this->mockProvider1); + + $error = new Exception('Test error'); + $result = new ProviderResolutionResult('test1', $this->mockProvider1, null, $error); + + $this->assertTrue($strategy->shouldEvaluateNextProvider($context, $result)); + } + + public function testFirstMatchStrategyDetermineFinalResultSuccess(): void + { + $strategy = new FirstMatchStrategy(); + + $details1 = $this->createResolutionDetails(true); + $result1 = new ProviderResolutionResult('test1', $this->mockProvider1, $details1, null); + + $finalResult = $strategy->determineFinalResult($this->baseContext, [$result1]); + + $this->assertTrue($finalResult->isSuccessful()); + $this->assertEquals('test1', $finalResult->getProviderName()); + $this->assertSame($details1, $finalResult->getDetails()); + } + + public function testFirstMatchStrategyDetermineFinalResultAllFlagNotFound(): void + { + $strategy = new FirstMatchStrategy(); + + $error = new class extends Exception implements ThrowableWithResolutionError { + public function getResolutionError(): \OpenFeature\interfaces\provider\ResolutionError + { + return new ResolutionError(ErrorCode::FLAG_NOT_FOUND(), 'Flag not found'); + } + }; + + $result1 = new ProviderResolutionResult('test1', $this->mockProvider1, null, $error); + $result2 = new ProviderResolutionResult('test2', $this->mockProvider2, null, $error); + + $finalResult = $strategy->determineFinalResult($this->baseContext, [$result1, $result2]); + + $this->assertFalse($finalResult->isSuccessful()); + $this->assertNotNull($finalResult->getErrors()); + } + + public function testFirstSuccessfulStrategyDetermineFinalResultSuccess(): void + { + $strategy = new FirstSuccessfulStrategy(); + + $error = new Exception('Test error'); + $result1 = new ProviderResolutionResult('test1', $this->mockProvider1, null, $error); + + $details2 = $this->createResolutionDetails(true); + $result2 = new ProviderResolutionResult('test2', $this->mockProvider2, $details2, null); + + $finalResult = $strategy->determineFinalResult($this->baseContext, [$result1, $result2]); + + $this->assertTrue($finalResult->isSuccessful()); + $this->assertEquals('test2', $finalResult->getProviderName()); + $this->assertSame($details2, $finalResult->getDetails()); + } + + public function testFirstSuccessfulStrategyDetermineFinalResultAllErrors(): void + { + $strategy = new FirstSuccessfulStrategy(); + + $error1 = new Exception('Error 1'); + $error2 = new Exception('Error 2'); + + $result1 = new ProviderResolutionResult('test1', $this->mockProvider1, null, $error1); + $result2 = new ProviderResolutionResult('test2', $this->mockProvider2, null, $error2); + + $finalResult = $strategy->determineFinalResult($this->baseContext, [$result1, $result2]); + /** @var ThrowableWithResolutionError[] $error */ + $error = $finalResult->getErrors(); + $this->assertFalse($finalResult->isSuccessful()); + $this->assertCount(2, $error); + } + + /** + * @param bool|string|int|float|DateTime|array|null $value + */ + private function createResolutionDetails(bool | string | int | float | DateTime | array | null $value): ResolutionDetails + { + return (new ResolutionDetailsBuilder()) + ->withValue($value) + ->build(); + } +} diff --git a/tests/unit/MultiproviderTest.php b/tests/unit/MultiproviderTest.php new file mode 100644 index 0000000..68148c9 --- /dev/null +++ b/tests/unit/MultiproviderTest.php @@ -0,0 +1,235 @@ +mockProvider1 = Mockery::mock(Provider::class); + $this->mockProvider2 = Mockery::mock(Provider::class); + $this->mockProvider3 = Mockery::mock(Provider::class); + + // Setup basic metadata for providers + $this->mockProvider1->shouldReceive('getMetadata->getName')->andReturn('Provider1'); + $this->mockProvider2->shouldReceive('getMetadata->getName')->andReturn('Provider2'); + $this->mockProvider3->shouldReceive('getMetadata->getName')->andReturn('Provider3'); + } + + public function testConstructorWithValidProviderData(): void + { + $providerData = [ + ['name' => 'test1', 'provider' => $this->mockProvider1], + ['name' => 'test2', 'provider' => $this->mockProvider2], + ]; + + $multiprovider = new Multiprovider($providerData); + $this->assertInstanceOf(Multiprovider::class, $multiprovider); + } + + public function testConstructorWithDuplicateNames(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Duplicate provider names found: test1'); + + $providerData = [ + ['name' => 'test1', 'provider' => $this->mockProvider1], + ['name' => 'test1', 'provider' => $this->mockProvider2], + ]; + + new Multiprovider($providerData); + } + + public function testConstructorWithEmptyName(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Each provider data entry must have a non-empty string "name" key'); + + $providerData = [ + ['name' => '', 'provider' => $this->mockProvider1], + ]; + + new Multiprovider($providerData); + } + + public function testConstructorWithUnsupportedKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported keys in provider data entry'); + + $providerData = [ + ['name' => 'test1', 'provider' => $this->mockProvider1, 'unsupported' => 'value'], + ]; + + new Multiprovider($providerData); + } + + public function testAutoGeneratedProviderNames(): void + { + $providerData = [ + ['provider' => $this->mockProvider1], + ['provider' => $this->mockProvider1], // Same provider, should get _2 suffix + ]; + + $multiprovider = new Multiprovider($providerData); + $this->assertInstanceOf(Multiprovider::class, $multiprovider); + } + + public function testResolveBooleanValue(): void + { + $this->mockProvider1->shouldReceive('resolveBooleanValue') + ->once() + ->with('test-flag', false, Mockery::type(EvaluationContext::class)) + ->andReturn($this->createResolutionDetails(true)); + + $providerData = [['name' => 'test1', 'provider' => $this->mockProvider1]]; + $multiprovider = new Multiprovider($providerData); + + $result = $multiprovider->resolveBooleanValue('test-flag', false); + $this->assertTrue($result->getValue()); + } + + public function testResolveStringValue(): void + { + $this->mockProvider1->shouldReceive('resolveStringValue') + ->once() + ->with('test-flag', 'default', Mockery::type(EvaluationContext::class)) + ->andReturn($this->createResolutionDetails('resolved')); + + $providerData = [['name' => 'test1', 'provider' => $this->mockProvider1]]; + $multiprovider = new Multiprovider($providerData); + + $result = $multiprovider->resolveStringValue('test-flag', 'default'); + $this->assertEquals('resolved', $result->getValue()); + } + + public function testResolveIntegerValue(): void + { + $this->mockProvider1->shouldReceive('resolveIntegerValue') + ->once() + ->with('test-flag', 0, Mockery::type(EvaluationContext::class)) + ->andReturn($this->createResolutionDetails(42)); + + $providerData = [['name' => 'test1', 'provider' => $this->mockProvider1]]; + $multiprovider = new Multiprovider($providerData); + + $result = $multiprovider->resolveIntegerValue('test-flag', 0); + $this->assertEquals(42, $result->getValue()); + } + + public function testResolveFloatValue(): void + { + $this->mockProvider1->shouldReceive('resolveFloatValue') + ->once() + ->with('test-flag', 0.0, Mockery::type(EvaluationContext::class)) + ->andReturn($this->createResolutionDetails(3.14)); + + $providerData = [['name' => 'test1', 'provider' => $this->mockProvider1]]; + $multiprovider = new Multiprovider($providerData); + + $result = $multiprovider->resolveFloatValue('test-flag', 0.0); + $this->assertEquals(3.14, $result->getValue()); + } + + public function testResolveObjectValue(): void + { + $defaultValue = ['key' => 'default']; + $resolvedValue = ['key' => 'resolved']; + + $this->mockProvider1->shouldReceive('resolveObjectValue') + ->once() + ->with('test-flag', $defaultValue, Mockery::type(EvaluationContext::class)) + ->andReturn($this->createResolutionDetails($resolvedValue)); + + $providerData = [['name' => 'test1', 'provider' => $this->mockProvider1]]; + $multiprovider = new Multiprovider($providerData); + + $result = $multiprovider->resolveObjectValue('test-flag', $defaultValue); + $this->assertEquals($resolvedValue, $result->getValue()); + } + + public function testInvalidDefaultValueType(): void + { + $this->expectException(TypeError::class); + $this->expectExceptionMessage('must be of type bool, string given'); + + $providerData = [['name' => 'test1', 'provider' => $this->mockProvider1]]; + $multiprovider = new Multiprovider($providerData); + + // Passing string instead of boolean + // @phpstan-ignore-next-line intentional wrong type to trigger TypeError + $multiprovider->resolveBooleanValue('test-flag', 'invalid'); + } + + public function testWithNullEvaluationContext(): void + { + $this->mockProvider1->shouldReceive('resolveBooleanValue') + ->once() + ->with('test-flag', false, Mockery::type(EvaluationContext::class)) + ->andReturn($this->createResolutionDetails(true)); + + $providerData = [['name' => 'test1', 'provider' => $this->mockProvider1]]; + $multiprovider = new Multiprovider($providerData); + + $result = $multiprovider->resolveBooleanValue('test-flag', false, null); + $this->assertTrue($result->getValue()); + } + + public function testProviderThrowingUnexpectedException(): void + { + $this->mockProvider1->shouldReceive('resolveBooleanValue') + ->once() + ->andThrow(new Exception('Unexpected error')); + + $providerData = [['name' => 'test1', 'provider' => $this->mockProvider1]]; + $multiprovider = new Multiprovider($providerData); + + $result = $multiprovider->resolveBooleanValue('test-flag', false); + + $this->assertNotNull($result->getError()); + $this->assertEquals(ErrorCode::GENERAL(), $result->getError()->getResolutionErrorCode()); + } + + public function testEmptyProviderList(): void + { + $multiprovider = new Multiprovider([]); + $result = $multiprovider->resolveBooleanValue('test-flag', false); + + $this->assertNotNull($result->getError()); + $this->assertEquals(ErrorCode::GENERAL(), $result->getError()->getResolutionErrorCode()); + } + + /** + * @param bool|string|int|float|DateTime|array|null $value + */ + private function createResolutionDetails(bool | string | int | float | DateTime | array | null $value): ResolutionDetails + { + return (new ResolutionDetailsBuilder()) + ->withValue($value) + ->build(); + } +} diff --git a/tests/unit/ProviderResolutionResultTest.php b/tests/unit/ProviderResolutionResultTest.php new file mode 100644 index 0000000..577fc7d --- /dev/null +++ b/tests/unit/ProviderResolutionResultTest.php @@ -0,0 +1,71 @@ +provider = Mockery::mock(Provider::class); + $this->provider->shouldReceive('getMetadata->getName')->andReturn('TestProvider'); + } + + /** + * @param bool|string|int|float|DateTime|array|null $value + */ + private function details(bool | string | int | float | DateTime | array | null $value): ResolutionDetails + { + return (new ResolutionDetailsBuilder())->withValue($value)->build(); + } + + public function testSuccessfulResult(): void + { + $details = $this->details(true); + $result = new ProviderResolutionResult('TestProvider', $this->provider, $details, null); + + $this->assertSame('TestProvider', $result->getProviderName()); + $this->assertSame($this->provider, $result->getProvider()); + $this->assertSame($details, $result->getDetails()); + $this->assertNull($result->getError()); + $this->assertFalse($result->hasError()); + $this->assertTrue($result->isSuccessful()); + } + + public function testErrorResult(): void + { + $error = new Exception('failure'); + $result = new ProviderResolutionResult('TestProvider', $this->provider, null, $error); + + $this->assertSame('TestProvider', $result->getProviderName()); + $this->assertNull($result->getDetails()); + $this->assertSame($error, $result->getError()); + $this->assertTrue($result->hasError()); + $this->assertFalse($result->isSuccessful()); + } + + public function testEmptyResultNeitherSuccessNorError(): void + { + $result = new ProviderResolutionResult('TestProvider', $this->provider, null, null); + + $this->assertNull($result->getDetails()); + $this->assertNull($result->getError()); + $this->assertFalse($result->hasError()); + $this->assertFalse($result->isSuccessful()); + } +} diff --git a/tests/unit/StrategyEvaluationContextTest.php b/tests/unit/StrategyEvaluationContextTest.php new file mode 100644 index 0000000..c59f435 --- /dev/null +++ b/tests/unit/StrategyEvaluationContextTest.php @@ -0,0 +1,86 @@ +assertEquals('flag-key', $context->getFlagKey()); + $this->assertEquals('boolean', $context->getFlagType()); + $this->assertTrue($context->getDefaultValue()); + $this->assertInstanceOf(EvaluationContext::class, $context->getEvaluationContext()); + } + + public function testValidStringFlagType(): void + { + $context = new StrategyEvaluationContext('flag-key', 'string', 'default', new EvaluationContext()); + $this->assertEquals('string', $context->getFlagType()); + $this->assertEquals('default', $context->getDefaultValue()); + } + + public function testValidIntegerFlagType(): void + { + $context = new StrategyEvaluationContext('flag-key', 'integer', 42, new EvaluationContext()); + $this->assertEquals('integer', $context->getFlagType()); + $this->assertEquals(42, $context->getDefaultValue()); + } + + public function testValidFloatFlagType(): void + { + $context = new StrategyEvaluationContext('flag-key', 'float', 3.14, new EvaluationContext()); + $this->assertEquals('float', $context->getFlagType()); + $this->assertEquals(3.14, $context->getDefaultValue()); + } + + public function testValidObjectFlagType(): void + { + $context = new StrategyEvaluationContext('flag-key', 'object', ['key' => 'value'], new EvaluationContext()); + $this->assertEquals('object', $context->getFlagType()); + $this->assertEquals(['key' => 'value'], $context->getDefaultValue()); + } + + public function testInvalidBooleanDefaultValueThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new StrategyEvaluationContext('flag-key', 'boolean', 'not-a-bool', new EvaluationContext()); + } + + public function testInvalidStringDefaultValueThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new StrategyEvaluationContext('flag-key', 'string', 123, new EvaluationContext()); + } + + public function testInvalidIntegerDefaultValueThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new StrategyEvaluationContext('flag-key', 'integer', 'not-an-int', new EvaluationContext()); + } + + public function testInvalidFloatDefaultValueThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new StrategyEvaluationContext('flag-key', 'float', 'not-a-float', new EvaluationContext()); + } + + public function testInvalidObjectDefaultValueThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new StrategyEvaluationContext('flag-key', 'object', 'not-an-array', new EvaluationContext()); + } + + public function testUnknownFlagTypeThrows(): void + { + $this->expectException(InvalidArgumentException::class); + new StrategyEvaluationContext('flag-key', 'unknown-type', 'value', new EvaluationContext()); + } +} diff --git a/tests/unit/StrategyPerProviderContextTest.php b/tests/unit/StrategyPerProviderContextTest.php new file mode 100644 index 0000000..3240e3f --- /dev/null +++ b/tests/unit/StrategyPerProviderContextTest.php @@ -0,0 +1,59 @@ +mockProvider = Mockery::mock(Provider::class); + $this->mockProvider->shouldReceive('getMetadata->getName')->andReturn('TestProvider'); + } + + public function testProviderContextGetters(): void + { + $baseContext = new StrategyEvaluationContext( + 'flag-key', + 'string', + 'default-value', + new EvaluationContext(), + ); + $providerName = 'TestProviderName'; + $context = new StrategyPerProviderContext($baseContext, $providerName, $this->mockProvider); + + $this->assertEquals('flag-key', $context->getFlagKey()); + $this->assertEquals('string', $context->getFlagType()); + $this->assertEquals('default-value', $context->getDefaultValue()); + $this->assertInstanceOf(EvaluationContext::class, $context->getEvaluationContext()); + $this->assertEquals($providerName, $context->getProviderName()); + $this->assertSame($this->mockProvider, $context->getProvider()); + } + + public function testProviderContextWithDifferentProviderName(): void + { + $baseContext = new StrategyEvaluationContext( + 'flag-key', + 'boolean', + true, + new EvaluationContext(), + ); + $providerName = 'AnotherProvider'; + $context = new StrategyPerProviderContext($baseContext, $providerName, $this->mockProvider); + + $this->assertEquals($providerName, $context->getProviderName()); + } +}