From 99a9a7861e49860d397b6d321a1a9966fbe6fe74 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Tue, 30 Sep 2025 14:09:34 +0100 Subject: [PATCH 1/8] feat: Created methods to validate and register providers input Signed-off-by: tmakinde --- .../multiprovider/Multiprovider.php | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/implementation/multiprovider/Multiprovider.php diff --git a/src/implementation/multiprovider/Multiprovider.php b/src/implementation/multiprovider/Multiprovider.php new file mode 100644 index 0000000..6ca1c0a --- /dev/null +++ b/src/implementation/multiprovider/Multiprovider.php @@ -0,0 +1,127 @@ + + */ + private static array $supportedProviderData = [ + 'name', 'provider', + ]; + + public const NAME = 'Multiprovider'; + + /** + * @var array Providers indexed by their names. + */ + protected array $providersByName = []; + + /** + * Multiprovider constructor. + * + * @param array $providerData Array of provider data entries. + * @param StrategyInterface|null $strategy Optional strategy instance. + */ + public function __construct(array $providerData = [], protected ?StrategyInterface $strategy = null) + { + $this->validateProviderData($providerData); + $this->registerProviders($providerData); + } + + /** + * 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 $index => $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 at index ' . $index . ': ' . implode(', ', $unSupportedKeys), + ); + } + if (isset($entry['name']) && trim($entry['name']) === '') { + throw new InvalidArgumentException( + 'Each provider data entry must have a non-empty string "name" key at index ' . $index, + ); + } + } + + $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; + } +} From a74d08b23eaebdcc3acad1edd9b64003b72da9d7 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Thu, 16 Oct 2025 21:10:55 +0100 Subject: [PATCH 2/8] feat: Add multiprovider strategy files Signed-off-by: tmakinde --- .../strategy/BaseEvaluationStrategy.php | 79 ++++++++ .../strategy/ComparisonStrategy.php | 169 ++++++++++++++++++ .../strategy/FirstMatchStrategy.php | 144 +++++++++++++++ .../strategy/FirstSuccessfulStrategy.php | 93 ++++++++++ .../strategy/StrategyEvaluationContext.php | 41 +++++ .../strategy/StrategyPerProviderContext.php | 37 ++++ 6 files changed, 563 insertions(+) create mode 100644 src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php create mode 100644 src/implementation/multiprovider/strategy/ComparisonStrategy.php create mode 100644 src/implementation/multiprovider/strategy/FirstMatchStrategy.php create mode 100644 src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php create mode 100644 src/implementation/multiprovider/strategy/StrategyEvaluationContext.php create mode 100644 src/implementation/multiprovider/strategy/StrategyPerProviderContext.php diff --git a/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php b/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php new file mode 100644 index 0000000..50f17f4 --- /dev/null +++ b/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php @@ -0,0 +1,79 @@ + $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..efe5140 --- /dev/null +++ b/src/implementation/multiprovider/strategy/ComparisonStrategy.php @@ -0,0 +1,169 @@ + $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->isSuccessful()) { + $successfulResults[] = $resolution; + } elseif ($resolution->hasError()) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $resolution->getError(), + ]; + } + } + + // If no successful results, return errors + if (count($successfulResults) === 0) { + return new FinalResult(null, null, $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 + $firstValue = $successfulResults[0]->getDetails()->getValue(); + $allMatch = true; + + foreach ($successfulResults as $result) { + if ($result->getDetails()->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 + if ($this->onMismatch !== null && is_callable($this->onMismatch)) { + try { + ($this->onMismatch)($successfulResults); + } catch (Throwable $e) { + // Ignore errors from callback + } + } + + // Return fallback provider result if configured + if ($this->fallbackProviderName !== null) { + foreach ($successfulResults as $result) { + if ($result->getProviderName() === $this->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..6988b4b --- /dev/null +++ b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php @@ -0,0 +1,144 @@ +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 && $resolutionError->getResolutionErrorCode() === 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 array $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 && $resolutionError->getResolutionErrorCode() === ErrorCode::FLAG_NOT_FOUND()) { + $isFlagNotFound = true; + } + } + + if (!$isFlagNotFound) { + // 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()) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $resolution->getError(), + ]; + } + } + + 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..3fbb944 --- /dev/null +++ b/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php @@ -0,0 +1,93 @@ +isSuccessful(); + } + + /** + * Returns the first successful result. + * If no provider succeeds, returns all errors aggregated. + * + * @param StrategyEvaluationContext $context Context for the overall evaluation + * @param array $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()) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $resolution->getError(), + ]; + } + } + + 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..b4b8278 --- /dev/null +++ b/src/implementation/multiprovider/strategy/StrategyEvaluationContext.php @@ -0,0 +1,41 @@ +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; + } +} From 47230ea224575182e2e3213c541ea90f7db9ab7a Mon Sep 17 00:00:00 2001 From: tmakinde Date: Thu, 16 Oct 2025 21:11:52 +0100 Subject: [PATCH 3/8] feat: add multiprovider final result implementation Signed-off-by: tmakinde --- .../multiprovider/FinalResult.php | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/implementation/multiprovider/FinalResult.php 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; + } +} From c7da656888a98f76ceeab7fbe9e9ef4afca707dd Mon Sep 17 00:00:00 2001 From: tmakinde Date: Thu, 16 Oct 2025 21:13:11 +0100 Subject: [PATCH 4/8] feat: add a single provider resolution implementation Signed-off-by: tmakinde --- .../ProviderResolutionResult.php | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/implementation/multiprovider/ProviderResolutionResult.php 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; + } +} From 1d47a57bf1f91db4bcec1135e76dbdbaf13d03dc Mon Sep 17 00:00:00 2001 From: tmakinde Date: Thu, 16 Oct 2025 21:18:44 +0100 Subject: [PATCH 5/8] feat: resolve providers through strategy and return proper error as stated in documentation Signed-off-by: tmakinde --- .../multiprovider/Multiprovider.php | 248 +++++++++++++++++- .../strategy/ComparisonStrategy.php | 46 ++-- .../strategy/FirstMatchStrategy.php | 20 +- .../strategy/FirstSuccessfulStrategy.php | 14 +- 4 files changed, 294 insertions(+), 34 deletions(-) diff --git a/src/implementation/multiprovider/Multiprovider.php b/src/implementation/multiprovider/Multiprovider.php index 6ca1c0a..f827313 100644 --- a/src/implementation/multiprovider/Multiprovider.php +++ b/src/implementation/multiprovider/Multiprovider.php @@ -5,9 +5,19 @@ namespace OpenFeature\implementation\multiprovider; use InvalidArgumentException; +use OpenFeature\implementation\multiprovider\strategy\BaseEvaluationStrategy; +use OpenFeature\implementation\multiprovider\strategy\FirstMatchStrategy; +use OpenFeature\implementation\multiprovider\strategy\StrategyEvaluationContext; +use OpenFeature\implementation\multiprovider\strategy\StrategyPerProviderContext; +use OpenFeature\implementation\provider\AbstractProvider; +use OpenFeature\implementation\provider\Reason; +use OpenFeature\implementation\provider\ResolutionDetailsBuilder; +use OpenFeature\implementation\provider\ResolutionError; +use OpenFeature\interfaces\flags\EvaluationContext; +use OpenFeature\interfaces\provider\ErrorCode; use OpenFeature\interfaces\provider\Provider; -use OpenFeature\interfaces\strategy\Strategy as StrategyInterface; -use Psr\Log\LoggerAwareTrait; +use OpenFeature\interfaces\provider\ResolutionDetails; +use Throwable; use function array_count_values; use function array_diff; @@ -16,12 +26,17 @@ use function array_map; use function count; use function implode; +use function is_array; +use function is_bool; +use function is_float; +use function is_int; +use function is_string; use function strtolower; use function trim; -class Multiprovider +class Multiprovider extends AbstractProvider { - use LoggerAwareTrait; + protected static string $NAME = 'Multiprovider'; /** * List of supported keys in each provider data entry. @@ -39,16 +54,237 @@ class Multiprovider */ 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 StrategyInterface|null $strategy Optional strategy instance. + * @param BaseEvaluationStrategy|null $strategy Optional strategy instance. */ - public function __construct(array $providerData = [], protected ?StrategyInterface $strategy = null) + 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(); + + $details = match ($flagType) { + 'boolean' => is_bool($defaultValue) + ? $provider->resolveBooleanValue($context->getFlagKey(), $defaultValue, $evalContext) + : throw new InvalidArgumentException('Default value for boolean flag must be bool'), + 'string' => is_string($defaultValue) + ? $provider->resolveStringValue($context->getFlagKey(), $defaultValue, $evalContext) + : throw new InvalidArgumentException('Default value for string flag must be string'), + 'integer' => is_int($defaultValue) + ? $provider->resolveIntegerValue($context->getFlagKey(), $defaultValue, $evalContext) + : throw new InvalidArgumentException('Default value for integer flag must be int'), + 'float' => is_float($defaultValue) + ? $provider->resolveFloatValue($context->getFlagKey(), $defaultValue, $evalContext) + : throw new InvalidArgumentException('Default value for float flag must be float'), + 'object' => is_array($defaultValue) + ? $provider->resolveObjectValue($context->getFlagKey(), $defaultValue, $evalContext) + : throw new InvalidArgumentException('Default value for object flag must be array'), + 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(); } /** diff --git a/src/implementation/multiprovider/strategy/ComparisonStrategy.php b/src/implementation/multiprovider/strategy/ComparisonStrategy.php index efe5140..94efdd3 100644 --- a/src/implementation/multiprovider/strategy/ComparisonStrategy.php +++ b/src/implementation/multiprovider/strategy/ComparisonStrategy.php @@ -9,7 +9,6 @@ use Throwable; use function count; -use function is_callable; /** * ComparisonStrategy requires all providers to agree on a value. @@ -36,6 +35,16 @@ public function __construct( ) { } + public function getFallbackProviderName(): ?string + { + return $this->fallbackProviderName; + } + + public function getOnMismatch(): ?callable + { + return $this->onMismatch; + } + /** * All providers should be evaluated by default. * This allows for comparison of results across providers. @@ -73,7 +82,7 @@ public function shouldEvaluateNextProvider( * If no successful results, returns aggregated errors. * * @param StrategyEvaluationContext $context Context for the overall evaluation - * @param array $resolutions Array of resolution results from all providers + * @param ProviderResolutionResult[] $resolutions Array of resolution results from all providers * * @return FinalResult The final result of the evaluation */ @@ -86,19 +95,22 @@ public function determineFinalResult( $errors = []; foreach ($resolutions as $resolution) { - if ($resolution->isSuccessful()) { + if ($resolution->hasError()) { + $err = $resolution->getError(); + if ($err instanceof Throwable) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $err, + ]; + } + } else { $successfulResults[] = $resolution; - } elseif ($resolution->hasError()) { - $errors[] = [ - 'providerName' => $resolution->getProviderName(), - 'error' => $resolution->getError(), - ]; } } // If no successful results, return errors if (count($successfulResults) === 0) { - return new FinalResult(null, null, $errors ?: null); + return new FinalResult(null, null, $errors !== [] ? $errors : null); } // If only one successful result, return it @@ -113,11 +125,13 @@ public function determineFinalResult( } // Compare all successful values - $firstValue = $successfulResults[0]->getDetails()->getValue(); + $firstDetails = $successfulResults[0]->getDetails(); + $firstValue = $firstDetails ? $firstDetails->getValue() : null; $allMatch = true; foreach ($successfulResults as $result) { - if ($result->getDetails()->getValue() !== $firstValue) { + $details = $result->getDetails(); + if (!$details || $details->getValue() !== $firstValue) { $allMatch = false; break; @@ -136,18 +150,20 @@ public function determineFinalResult( } // Values don't match - call onMismatch callback if provided - if ($this->onMismatch !== null && is_callable($this->onMismatch)) { + $onMismatch = $this->getOnMismatch(); + if ($onMismatch !== null) { try { - ($this->onMismatch)($successfulResults); + $onMismatch($successfulResults); } catch (Throwable $e) { // Ignore errors from callback } } // Return fallback provider result if configured - if ($this->fallbackProviderName !== null) { + $fallbackProviderName = $this->getFallbackProviderName(); + if ($fallbackProviderName !== null) { foreach ($successfulResults as $result) { - if ($result->getProviderName() === $this->fallbackProviderName) { + if ($result->getProviderName() === $fallbackProviderName) { return new FinalResult( $result->getDetails(), $result->getProviderName(), diff --git a/src/implementation/multiprovider/strategy/FirstMatchStrategy.php b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php index 6988b4b..4a58c61 100644 --- a/src/implementation/multiprovider/strategy/FirstMatchStrategy.php +++ b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php @@ -8,6 +8,7 @@ use OpenFeature\implementation\multiprovider\ProviderResolutionResult; use OpenFeature\interfaces\provider\ErrorCode; use OpenFeature\interfaces\provider\ThrowableWithResolutionError; +use Throwable; /** * FirstMatchStrategy returns the first result from a provider that is not FLAG_NOT_FOUND. @@ -60,7 +61,7 @@ public function shouldEvaluateNextProvider( // Check if error is ThrowableWithResolutionError with FLAG_NOT_FOUND if ($error instanceof ThrowableWithResolutionError) { $resolutionError = $error->getResolutionError(); - if ($resolutionError && $resolutionError->getResolutionErrorCode() === ErrorCode::FLAG_NOT_FOUND()) { + if ($resolutionError->getResolutionErrorCode() === ErrorCode::FLAG_NOT_FOUND()) { // Continue to next provider for FLAG_NOT_FOUND return true; } @@ -79,7 +80,7 @@ public function shouldEvaluateNextProvider( * If all providers returned FLAG_NOT_FOUND or no results, return error. * * @param StrategyEvaluationContext $context Context for the overall evaluation - * @param array $resolutions Array of resolution results from all providers + * @param ProviderResolutionResult[] $resolutions Array of resolution results from all providers * * @return FinalResult The final result of the evaluation */ @@ -107,12 +108,12 @@ public function determineFinalResult( $isFlagNotFound = false; if ($error instanceof ThrowableWithResolutionError) { $resolutionError = $error->getResolutionError(); - if ($resolutionError && $resolutionError->getResolutionErrorCode() === ErrorCode::FLAG_NOT_FOUND()) { + if ($resolutionError->getResolutionErrorCode() === ErrorCode::FLAG_NOT_FOUND()) { $isFlagNotFound = true; } } - if (!$isFlagNotFound) { + if (!$isFlagNotFound && $error instanceof Throwable) { // Return this error return new FinalResult( null, @@ -132,10 +133,13 @@ public function determineFinalResult( $errors = []; foreach ($resolutions as $resolution) { if ($resolution->hasError()) { - $errors[] = [ - 'providerName' => $resolution->getProviderName(), - 'error' => $resolution->getError(), - ]; + $err = $resolution->getError(); + if ($err instanceof Throwable) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $err, + ]; + } } } diff --git a/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php b/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php index 3fbb944..f002040 100644 --- a/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php +++ b/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php @@ -6,6 +6,7 @@ use OpenFeature\implementation\multiprovider\FinalResult; use OpenFeature\implementation\multiprovider\ProviderResolutionResult; +use Throwable; /** * FirstSuccessfulStrategy returns the first successful result from a provider. @@ -58,7 +59,7 @@ public function shouldEvaluateNextProvider( * If no provider succeeds, returns all errors aggregated. * * @param StrategyEvaluationContext $context Context for the overall evaluation - * @param array $resolutions Array of resolution results from all providers + * @param ProviderResolutionResult[] $resolutions Array of resolution results from all providers * * @return FinalResult The final result of the evaluation */ @@ -81,10 +82,13 @@ public function determineFinalResult( $errors = []; foreach ($resolutions as $resolution) { if ($resolution->hasError()) { - $errors[] = [ - 'providerName' => $resolution->getProviderName(), - 'error' => $resolution->getError(), - ]; + $err = $resolution->getError(); + if ($err instanceof Throwable) { + $errors[] = [ + 'providerName' => $resolution->getProviderName(), + 'error' => $err, + ]; + } } } From a5d35fccf0a00a49bcecd31119d0d39425f40ca2 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Fri, 7 Nov 2025 08:10:45 +0100 Subject: [PATCH 6/8] fix: Refactor strategy implementation Signed-off-by: tmakinde --- .../multiprovider/strategy/FirstMatchStrategy.php | 4 ++-- .../multiprovider/strategy/FirstSuccessfulStrategy.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/implementation/multiprovider/strategy/FirstMatchStrategy.php b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php index 4a58c61..3d241b2 100644 --- a/src/implementation/multiprovider/strategy/FirstMatchStrategy.php +++ b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php @@ -61,7 +61,7 @@ public function shouldEvaluateNextProvider( // Check if error is ThrowableWithResolutionError with FLAG_NOT_FOUND if ($error instanceof ThrowableWithResolutionError) { $resolutionError = $error->getResolutionError(); - if ($resolutionError->getResolutionErrorCode() === ErrorCode::FLAG_NOT_FOUND()) { + if ($resolutionError->getResolutionErrorCode()->equals(ErrorCode::FLAG_NOT_FOUND())) { // Continue to next provider for FLAG_NOT_FOUND return true; } @@ -108,7 +108,7 @@ public function determineFinalResult( $isFlagNotFound = false; if ($error instanceof ThrowableWithResolutionError) { $resolutionError = $error->getResolutionError(); - if ($resolutionError->getResolutionErrorCode() === ErrorCode::FLAG_NOT_FOUND()) { + if ($resolutionError->getResolutionErrorCode()->equals(ErrorCode::FLAG_NOT_FOUND())) { $isFlagNotFound = true; } } diff --git a/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php b/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php index f002040..b3967be 100644 --- a/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php +++ b/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php @@ -49,9 +49,9 @@ public function shouldEvaluateNextProvider( StrategyPerProviderContext $context, ProviderResolutionResult $result, ): bool { - // If we found a successful result, stop here - // Otherwise, continue to next provider (even if there was an error) - return $result->isSuccessful(); + // If we found a successful result, stop here (return false) + // Otherwise, continue to next provider (return true) + return !$result->isSuccessful(); } /** From 3a68951547cdcc7cb8c44f6df47202aab20b0ca4 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Mon, 17 Nov 2025 09:29:48 +0100 Subject: [PATCH 7/8] chore: refactor provider validation method Signed-off-by: tmakinde --- src/implementation/multiprovider/Multiprovider.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/implementation/multiprovider/Multiprovider.php b/src/implementation/multiprovider/Multiprovider.php index f827313..66ce0a7 100644 --- a/src/implementation/multiprovider/Multiprovider.php +++ b/src/implementation/multiprovider/Multiprovider.php @@ -296,18 +296,14 @@ private function createErrorResolution(string $flagKey, mixed $defaultValue, ?ar */ private function validateProviderData(array $providerData): void { - foreach ($providerData as $index => $entry) { + 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 at index ' . $index . ': ' . implode(', ', $unSupportedKeys), - ); + 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 at index ' . $index, - ); + throw new InvalidArgumentException('Each provider data entry must have a non-empty string "name" key'); } } From 418f73aff56bbe61c5aa73713d59de661ee08d3f Mon Sep 17 00:00:00 2001 From: tmakinde Date: Mon, 17 Nov 2025 09:31:08 +0100 Subject: [PATCH 8/8] test: Add test for provider and it strategy Signed-off-by: tmakinde --- tests/unit/ComparisonStrategyTest.php | 187 ++++++++++++++++ tests/unit/FinalResultTest.php | 61 +++++ tests/unit/MultiProviderStrategyTest.php | 224 +++++++++++++++++++ tests/unit/MultiproviderTest.php | 235 ++++++++++++++++++++ tests/unit/ProviderResolutionResultTest.php | 71 ++++++ 5 files changed, 778 insertions(+) create mode 100644 tests/unit/ComparisonStrategyTest.php create mode 100644 tests/unit/FinalResultTest.php create mode 100644 tests/unit/MultiProviderStrategyTest.php create mode 100644 tests/unit/MultiproviderTest.php create mode 100644 tests/unit/ProviderResolutionResultTest.php 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()); + } +}