From e8562e0d7598f39c5ef9dc7e2c508d562c07cc19 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Tue, 30 Sep 2025 14:09:34 +0100 Subject: [PATCH 01/12] 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 66af89224ce69edbad8758668a19b02e99c70259 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Thu, 16 Oct 2025 21:10:55 +0100 Subject: [PATCH 02/12] 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 4d7a50e10f887694ee9804a5670dce5766e454a5 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Thu, 16 Oct 2025 21:11:52 +0100 Subject: [PATCH 03/12] 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 67e07d36f91ebb2fa958a90de7cf13a9e4fa3f71 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Thu, 16 Oct 2025 21:13:11 +0100 Subject: [PATCH 04/12] 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 ab4f6f329e0915622495ae43e381fa9146ce8463 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Thu, 16 Oct 2025 21:18:44 +0100 Subject: [PATCH 05/12] 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 0339bb1dbe3331fcf7b01d756c81acceac1e455e Mon Sep 17 00:00:00 2001 From: tmakinde Date: Fri, 7 Nov 2025 08:10:45 +0100 Subject: [PATCH 06/12] 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 03b67d19beb9c4cbe95f7dec0b1bdfa880a9a8ea Mon Sep 17 00:00:00 2001 From: tmakinde Date: Mon, 17 Nov 2025 09:29:48 +0100 Subject: [PATCH 07/12] 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 76a7ff99775a59d6abb4ca89b6a7271d2a15402b Mon Sep 17 00:00:00 2001 From: tmakinde Date: Mon, 17 Nov 2025 09:31:08 +0100 Subject: [PATCH 08/12] 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()); + } +} From ded21c0561e89a096cc904e0d8af36fd8823752a Mon Sep 17 00:00:00 2001 From: tmakinde Date: Fri, 2 Jan 2026 14:47:49 +0100 Subject: [PATCH 09/12] chore: Refactor default value check for readability Signed-off-by: tmakinde --- .../multiprovider/Multiprovider.php | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/src/implementation/multiprovider/Multiprovider.php b/src/implementation/multiprovider/Multiprovider.php index 66ce0a7..ae9f5b0 100644 --- a/src/implementation/multiprovider/Multiprovider.php +++ b/src/implementation/multiprovider/Multiprovider.php @@ -240,24 +240,45 @@ private function evaluateProvider(Provider $provider, string $providerName, Stra $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), - }; + switch ($flagType) { + case 'boolean': + if (!is_bool($defaultValue)) { + throw new InvalidArgumentException('Default value for boolean flag must be bool'); + } + $details = $provider->resolveBooleanValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + case 'string': + if (!is_string($defaultValue)) { + throw new InvalidArgumentException('Default value for string flag must be string'); + } + $details = $provider->resolveStringValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + case 'integer': + if (!is_int($defaultValue)) { + throw new InvalidArgumentException('Default value for integer flag must be int'); + } + $details = $provider->resolveIntegerValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + case 'float': + if (!is_float($defaultValue)) { + throw new InvalidArgumentException('Default value for float flag must be float'); + } + $details = $provider->resolveFloatValue($context->getFlagKey(), $defaultValue, $evalContext); + + break; + case 'object': + if (!is_array($defaultValue)) { + throw new InvalidArgumentException('Default value for object flag must be array'); + } + $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) { From 75e1019530746696be72d5592b28e5910a6ab9fb Mon Sep 17 00:00:00 2001 From: tmakinde Date: Mon, 5 Jan 2026 07:07:42 +0100 Subject: [PATCH 10/12] chore: Use enum instead of string for provider runmode Signed-off-by: tmakinde --- .../strategy/BaseEvaluationStrategy.php | 3 ++- .../strategy/ComparisonStrategy.php | 3 ++- .../strategy/FirstMatchStrategy.php | 3 ++- .../strategy/FirstSuccessfulStrategy.php | 3 ++- src/interfaces/provider/RunMode.php | 21 +++++++++++++++++++ 5 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 src/interfaces/provider/RunMode.php diff --git a/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php b/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php index 50f17f4..00cbd16 100644 --- a/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php +++ b/src/implementation/multiprovider/strategy/BaseEvaluationStrategy.php @@ -6,6 +6,7 @@ use OpenFeature\implementation\multiprovider\FinalResult; use OpenFeature\implementation\multiprovider\ProviderResolutionResult; +use OpenFeature\interfaces\provider\RunMode; /** * Base class for multi-provider evaluation strategies per OpenFeature specification. @@ -14,7 +15,7 @@ */ abstract class BaseEvaluationStrategy { - public string $runMode = 'sequential'; + public string $runMode = RunMode::SEQUENTIAL; /** * Determine if the given provider should be evaluated. diff --git a/src/implementation/multiprovider/strategy/ComparisonStrategy.php b/src/implementation/multiprovider/strategy/ComparisonStrategy.php index 94efdd3..5eef2ac 100644 --- a/src/implementation/multiprovider/strategy/ComparisonStrategy.php +++ b/src/implementation/multiprovider/strategy/ComparisonStrategy.php @@ -6,6 +6,7 @@ use OpenFeature\implementation\multiprovider\FinalResult; use OpenFeature\implementation\multiprovider\ProviderResolutionResult; +use OpenFeature\interfaces\provider\RunMode; use Throwable; use function count; @@ -23,7 +24,7 @@ */ class ComparisonStrategy extends BaseEvaluationStrategy { - public string $runMode = 'parallel'; + public string $runMode = RunMode::PARALLEL; /** * @param string|null $fallbackProviderName Name of provider to use when results don't match diff --git a/src/implementation/multiprovider/strategy/FirstMatchStrategy.php b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php index 3d241b2..576c730 100644 --- a/src/implementation/multiprovider/strategy/FirstMatchStrategy.php +++ b/src/implementation/multiprovider/strategy/FirstMatchStrategy.php @@ -7,6 +7,7 @@ use OpenFeature\implementation\multiprovider\FinalResult; use OpenFeature\implementation\multiprovider\ProviderResolutionResult; use OpenFeature\interfaces\provider\ErrorCode; +use OpenFeature\interfaces\provider\RunMode; use OpenFeature\interfaces\provider\ThrowableWithResolutionError; use Throwable; @@ -22,7 +23,7 @@ */ class FirstMatchStrategy extends BaseEvaluationStrategy { - public string $runMode = 'sequential'; + public string $runMode = RunMode::SEQUENTIAL; /** * All providers should be evaluated by default. diff --git a/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php b/src/implementation/multiprovider/strategy/FirstSuccessfulStrategy.php index b3967be..e4b2366 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 OpenFeature\interfaces\provider\RunMode; use Throwable; /** @@ -21,7 +22,7 @@ */ class FirstSuccessfulStrategy extends BaseEvaluationStrategy { - public string $runMode = 'sequential'; + public string $runMode = RunMode::SEQUENTIAL; /** * All providers should be evaluated by default. 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'; +} From b8d5d3bbe842eab807300a026a95f7eaff68c2b6 Mon Sep 17 00:00:00 2001 From: tmakinde Date: Mon, 5 Jan 2026 07:39:05 +0100 Subject: [PATCH 11/12] chore: Move invariant check for flag type vs default value up into the context Signed-off-by: tmakinde --- .../multiprovider/Multiprovider.php | 21 +++------- .../strategy/StrategyEvaluationContext.php | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/implementation/multiprovider/Multiprovider.php b/src/implementation/multiprovider/Multiprovider.php index ae9f5b0..546ded5 100644 --- a/src/implementation/multiprovider/Multiprovider.php +++ b/src/implementation/multiprovider/Multiprovider.php @@ -24,6 +24,7 @@ use function array_filter; use function array_keys; use function array_map; +use function assert; use function count; use function implode; use function is_array; @@ -242,37 +243,27 @@ private function evaluateProvider(Provider $provider, string $providerName, Stra switch ($flagType) { case 'boolean': - if (!is_bool($defaultValue)) { - throw new InvalidArgumentException('Default value for boolean flag must be bool'); - } + assert(is_bool($defaultValue)); $details = $provider->resolveBooleanValue($context->getFlagKey(), $defaultValue, $evalContext); break; case 'string': - if (!is_string($defaultValue)) { - throw new InvalidArgumentException('Default value for string flag must be string'); - } + assert(is_string($defaultValue)); $details = $provider->resolveStringValue($context->getFlagKey(), $defaultValue, $evalContext); break; case 'integer': - if (!is_int($defaultValue)) { - throw new InvalidArgumentException('Default value for integer flag must be int'); - } + assert(is_int($defaultValue)); $details = $provider->resolveIntegerValue($context->getFlagKey(), $defaultValue, $evalContext); break; case 'float': - if (!is_float($defaultValue)) { - throw new InvalidArgumentException('Default value for float flag must be float'); - } + assert(is_float($defaultValue)); $details = $provider->resolveFloatValue($context->getFlagKey(), $defaultValue, $evalContext); break; case 'object': - if (!is_array($defaultValue)) { - throw new InvalidArgumentException('Default value for object flag must be array'); - } + assert(is_array($defaultValue)); $details = $provider->resolveObjectValue($context->getFlagKey(), $defaultValue, $evalContext); break; diff --git a/src/implementation/multiprovider/strategy/StrategyEvaluationContext.php b/src/implementation/multiprovider/strategy/StrategyEvaluationContext.php index b4b8278..2e6feda 100644 --- a/src/implementation/multiprovider/strategy/StrategyEvaluationContext.php +++ b/src/implementation/multiprovider/strategy/StrategyEvaluationContext.php @@ -4,8 +4,15 @@ namespace OpenFeature\implementation\multiprovider\strategy; +use InvalidArgumentException; use OpenFeature\interfaces\flags\EvaluationContext; +use function is_array; +use function is_bool; +use function is_float; +use function is_int; +use function is_string; + /** * Context information for the overall evaluation across all providers. */ @@ -17,6 +24,41 @@ public function __construct( private mixed $defaultValue, private EvaluationContext $evaluationContext, ) { + // Invariant check: flagType must match defaultValue type + switch ($flagType) { + case 'boolean': + if (!is_bool($defaultValue)) { + throw new InvalidArgumentException('Default value for boolean flag must be bool'); + } + + break; + case 'string': + if (!is_string($defaultValue)) { + throw new InvalidArgumentException('Default value for string flag must be string'); + } + + break; + case 'integer': + if (!is_int($defaultValue)) { + throw new InvalidArgumentException('Default value for integer flag must be int'); + } + + break; + case 'float': + if (!is_float($defaultValue)) { + throw new InvalidArgumentException('Default value for float flag must be float'); + } + + break; + case 'object': + if (!is_array($defaultValue)) { + throw new InvalidArgumentException('Default value for object flag must be array'); + } + + break; + default: + throw new InvalidArgumentException('Unknown flag type: ' . $flagType); + } } public function getFlagKey(): string From bfd4ac68f40d4d6d3fcee4449c6631a67863a68e Mon Sep 17 00:00:00 2001 From: tmakinde Date: Mon, 5 Jan 2026 08:09:13 +0100 Subject: [PATCH 12/12] chore: Add more test coverage for strategy context and evaluation Signed-off-by: tmakinde --- tests/unit/StrategyEvaluationContextTest.php | 86 +++++++++++++++++++ tests/unit/StrategyPerProviderContextTest.php | 59 +++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/unit/StrategyEvaluationContextTest.php create mode 100644 tests/unit/StrategyPerProviderContextTest.php 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()); + } +}