diff --git a/packages/EasyTest/composer.json b/packages/EasyTest/composer.json index ac5def85f..fa0063a5d 100644 --- a/packages/EasyTest/composer.json +++ b/packages/EasyTest/composer.json @@ -6,8 +6,11 @@ "require": { "php": "^7.2", "symfony/console": "^4.4 || ^5.1.5", + "nesbot/carbon": "^2.22", "nette/utils": "^3.1", + "symfony/intl": "^4.4 || ^5.1.5", "symfony/http-kernel": "^4.4 || ^5.1.5", + "symfony/validator": "^4.4 || ^5.1.5", "symplify/autowire-array-parameter": "^8.3.41" }, "require-dev": { diff --git a/packages/EasyTest/config/services.yaml b/packages/EasyTest/config/services.yaml index b538dad92..2d4450bbf 100644 --- a/packages/EasyTest/config/services.yaml +++ b/packages/EasyTest/config/services.yaml @@ -8,3 +8,4 @@ services: resource: '../src' exclude: - '../src/HttpKernel/*' + - '../src/InvalidDataMaker/*' diff --git a/packages/EasyTest/src/InvalidDataMaker/AbstractInvalidDataMaker.php b/packages/EasyTest/src/InvalidDataMaker/AbstractInvalidDataMaker.php new file mode 100644 index 000000000..454b4bda8 --- /dev/null +++ b/packages/EasyTest/src/InvalidDataMaker/AbstractInvalidDataMaker.php @@ -0,0 +1,246 @@ +property = $property; + } + + final public static function addTranslations(string $translations): void + { + self::$translations[] = $translations; + } + + final public static function make(string $property): self + { + return new static($property); + } + + /** + * @param mixed $value + * + * @return mixed[] + */ + final protected function create(string $caseName, $value, ?string $message = null): array + { + if ($this->asString === true) { + $value = (string)$value; + } + + if ($this->asArrayElement === true) { + $value = [$value]; + } + + $invalidData = [ + $this->property => $value, + ]; + + $data = [ + $caseName => [ + 'data' => $invalidData, + 'message' => (string)($this->message ?? $message), + 'propertyPath' => $this->resolvePropertyPath($invalidData), + ], + ]; + + if ($this->wrapWith !== null) { + $data = $this->applyWrapWith($data); + } + + return $data; + } + + /** + * @param mixed[]|null $params + */ + final protected function translateMessage(string $messageKey, ?array $params = null, ?int $plural = null): string + { + $params[self::PLURAL_PARAM] = $plural; + + return self::$translator->trans($messageKey, $params); + } + + private static function createTranslationLoader(string $extension): LoaderInterface + { + if (\in_array($extension, ['yaml', 'yml'], true)) { + return new YamlFileLoader(); + } + + if ($extension === 'xlf') { + return new XliffFileLoader(); + } + + throw new LogicException('Only YAML and XLF translation formats are supported.'); + } + + private static function initTranslator(): void + { + if (self::$translator !== null) { + return; + } + + $locale = 'en'; + $translator = new Translator($locale); + + foreach (self::$translations as $translation) { + $extension = \strtolower(\pathinfo($translation, \PATHINFO_EXTENSION)); + $translator->addLoader($extension, self::createTranslationLoader($extension)); + $translator->addResource($extension, $translation, $locale); + } + + self::$translator = $translator; + } + + /** + * @param mixed[] $data + * + * @return mixed[] + */ + private function applyWrapWith(array $data): array + { + /** @var string $caseName */ + $caseName = \current(\array_keys($data)); + $caseData = $data[$caseName]['data']; + + $wrappedPropertyPath = "{$this->wrapWith}.{$this->property}"; + /** @var string $newCaseName */ + $newCaseName = \str_replace($this->property, $wrappedPropertyPath, $caseName); + + return [ + $newCaseName => [ + 'data' => [ + $this->wrapWith => $caseData, + ], + 'message' => $data[$caseName]['message'], + 'propertyPath' => $wrappedPropertyPath, + ], + ]; + } + + /** + * @param mixed[] $invalidData + * + * @noinspection MultipleReturnStatementsInspection + */ + private function resolvePropertyPath(array $invalidData): string + { + if ($this->propertyPath !== null) { + return $this->propertyPath; + } + + $propertyName = (string)\array_key_first($invalidData); + + if (\is_array($invalidData[$propertyName]) && \count($invalidData[$propertyName]) > 0) { + // The case of stubs collection ('prop' => [ [], [], [], [] ]) + if (($invalidData[$propertyName][0] ?? null) === []) { + return $propertyName; + } + + $currentProperty = \current(\array_keys($invalidData[$propertyName])); + + if ($currentProperty === 0) { + return $propertyName . '[0]'; + } + + return $propertyName . '.' . $this->resolvePropertyPath($invalidData[$propertyName]); + } + + return $propertyName; + } + + final public function asArrayElement(): self + { + $this->asArrayElement = true; + + return $this; + } + + final public function asString(): self + { + $this->asString = true; + + return $this; + } + + final public function message(string $message): self + { + $this->message = $message; + + return $this; + } + + final public function propertyPath(string $propertyPath): self + { + $this->propertyPath = $propertyPath; + + return $this; + } + + final public function wrapWith(string $wrapWith): self + { + $this->wrapWith = $wrapWith; + + return $this; + } +} diff --git a/packages/EasyTest/src/InvalidDataMaker/InvalidDataMaker.php b/packages/EasyTest/src/InvalidDataMaker/InvalidDataMaker.php new file mode 100644 index 000000000..61ed26371 --- /dev/null +++ b/packages/EasyTest/src/InvalidDataMaker/InvalidDataMaker.php @@ -0,0 +1,439 @@ + + */ + public function yieldArrayCollectionWithFewerItems(int $minElements): iterable + { + $value = new ArrayCollection(\array_fill(0, $minElements - 1, null)); + $message = $this->translateMessage( + (new Count([ + 'min' => $minElements, + ]))->minMessage, + [ + '{{ limit }}' => $minElements, + ], + $minElements + ); + + yield from $this->create("{$this->property} has too few elements in the collection", $value, $message); + } + + /** + * @return iterable + */ + public function yieldArrayCollectionWithMoreItems(int $maxElements): iterable + { + $value = new ArrayCollection(\array_fill(0, $maxElements - 1, null)); + $message = $this->translateMessage( + (new Count([ + 'max' => $maxElements, + ]))->maxMessage, + [ + '{{ limit }}' => $maxElements, + ], + $maxElements + ); + + yield from $this->create("{$this->property} has too many elements in the collection", $value, $message); + } + + /** + * @return iterable + */ + public function yieldArrayWithFewerItems(int $minElements): iterable + { + $value = \array_fill(0, $minElements - 1, null); + $message = $this->translateMessage( + (new Count([ + 'min' => $minElements, + ]))->minMessage, + [ + '{{ limit }}' => $minElements, + ], + $minElements + ); + + yield from $this->create("{$this->property} has too few elements in the array", $value, $message); + } + + /** + * @return iterable + */ + public function yieldArrayWithMoreItems(int $maxElements): iterable + { + $value = \array_fill(0, $maxElements + 1, null); + $message = $this->translateMessage( + (new Count([ + 'max' => $maxElements, + ]))->maxMessage, + [ + '{{ limit }}' => $maxElements, + ], + $maxElements + ); + + yield from $this->create("{$this->property} has too many elements in the array", $value, $message); + } + + /** + * @return iterable + */ + public function yieldBlankString(): iterable + { + $value = ''; + $message = $this->translateMessage((new NotBlank())->message); + + yield from $this->create("{$this->property} is blank", $value, $message); + } + + /** + * @return iterable + */ + public function yieldDateTimeLessThanOrEqualToNow(): iterable + { + $dateTime = Carbon::now(); + $message = $this->translateMessage( + (new GreaterThan([ + 'value' => 'now', + ]))->message, + [ + '{{ compared_value }}' => 'now', + ] + ); + + $value = $dateTime->clone() + ->subSecond() + ->toAtomString(); + yield from $this->create("{$this->property} has less datetime", $value, $message); + + $value = $dateTime->toAtomString(); + yield from $this->create("{$this->property} has equal datetime", $value, $message); + } + + /** + * @return iterable + */ + public function yieldEmptyArray(): iterable + { + yield from $this->yieldArrayWithFewerItems(1); + } + + /** + * @return iterable + */ + public function yieldEmptyArrayCollection(): iterable + { + yield from $this->yieldArrayCollectionWithFewerItems(1); + } + + /** + * @return iterable + */ + public function yieldIntegerGreaterThanGiven(int $lessThanOrEqualValue): iterable + { + $value = $lessThanOrEqualValue + 1; + $message = $this->translateMessage( + (new LessThanOrEqual([ + 'value' => $value, + ]))->message, + [ + '{{ compared_value }}' => $lessThanOrEqualValue, + ] + ); + + yield from $this->create("{$this->property} has greater value", $value, $message); + } + + /** + * @return iterable + */ + public function yieldIntegerGreaterThanOrEqualToGiven(int $lessThanValue): iterable + { + $value = $lessThanValue + 1; + $message = $this->translateMessage( + (new LessThan([ + 'value' => $value, + ]))->message, + [ + '{{ compared_value }}' => $lessThanValue, + ] + ); + yield from $this->create("{$this->property} has greater value", $value, $message); + + $value = $lessThanValue; + $message = $this->translateMessage( + (new LessThan([ + 'value' => $value, + ]))->message, + [ + '{{ compared_value }}' => $lessThanValue, + ] + ); + yield from $this->create("{$this->property} has equal value", $value, $message); + } + + /** + * @return iterable + */ + public function yieldInvalidChoice(): iterable + { + $value = 'invalid-choice'; + $message = $this->translateMessage((new Choice())->message); + + yield from $this->create("{$this->property} is not a valid choice", $value, $message); + } + + /** + * @return iterable + */ + public function yieldInvalidCreditCardNumber(): iterable + { + $value = '1111222233334444'; + $message = $this->translateMessage((new CardScheme([ + 'schemes' => null, + ]))->message); + + yield from $this->create("{$this->property} is not a valid credit card number", $value, $message); + } + + /** + * @return iterable + */ + public function yieldInvalidCurrencyCode(): iterable + { + $value = 'invalid-currency-code'; + $message = $this->translateMessage((new Currency())->message); + + yield from $this->create("{$this->property} is invalid currency", $value, $message); + } + + /** + * @return iterable + */ + public function yieldInvalidEmail(): iterable + { + $value = 'invalid-email'; + $message = $this->translateMessage((new Email())->message); + + yield from $this->create("{$this->property} is invalid email", $value, $message); + } + + /** + * @return iterable + */ + public function yieldInvalidExactLengthString(int $exactLength): iterable + { + $message = $this->translateMessage( + (new Length([ + 'max' => $exactLength, + 'min' => $exactLength, + ]))->exactMessage, + [ + '{{ limit }}' => $exactLength, + ], + $exactLength + ); + + $value = \str_pad('1', $exactLength + 1, '1'); + yield from $this->create("{$this->property} has length more than expected", $value, $message); + + $value = \str_pad('', $exactLength - 1, '1'); + yield from $this->create("{$this->property} has length less than expected", $value, $message); + } + + /** + * This method is intended to be used with a custom constraint, so a custom message should be passed, e.g.: + * InvalidDataMaker::make('amount') + * ->message('This value is not a valid decimal number or has more than 3 digits in a precision.') + * ->yieldInvalidFloat(3); + * + * @return iterable + */ + public function yieldInvalidFloat(int $precision, ?int $integerPart = null): iterable + { + $value = ($integerPart ?? 0) + \round(1 / 3, $precision + 1); + yield from $this->create("{$this->property} has invalid precision", $value); + + $value = 'abc'; + yield from $this->create("{$this->property} is a string", $value); + + $value = 10; + yield from $this->create("{$this->property} is an integer", $value); + } + + /** + * @return iterable + */ + public function yieldInvalidTimezone(): iterable + { + $value = 'invalid-timezone'; + $message = $this->translateMessage((new Timezone())->message); + + yield from $this->create("{$this->property} is invalid timezone", $value, $message); + } + + /** + * @return iterable + */ + public function yieldInvalidUrl(): iterable + { + $value = 'invalid-url'; + $message = $this->translateMessage((new Url())->message); + + yield from $this->create("{$this->property} is invalid url", $value, $message); + } + + /** + * @return iterable + */ + public function yieldInvalidUuid(): iterable + { + $value = 'some-invalid-uuid'; + $message = $this->translateMessage((new Uuid())->message); + + yield from $this->create("{$this->property} is invalid uuid", $value, $message); + } + + /** + * @return iterable + */ + public function yieldNegativeNumber(): iterable + { + $value = -1; + $message = $this->translateMessage((new PositiveOrZero())->message); + + yield from $this->create("{$this->property} has negative value", $value, $message); + } + + /** + * @return iterable + */ + public function yieldNegativeOrZeroNumber(): iterable + { + $message = $this->translateMessage((new Positive())->message); + + $value = -1; + yield from $this->create("{$this->property} has negative value", $value, $message); + + $value = 0; + yield from $this->create("{$this->property} has zero value", $value, $message); + } + + /** + * @return iterable + */ + public function yieldNonDigitSymbols(): iterable + { + $value = '111-aaa'; + $message = $this->translateMessage( + (new Type([ + 'type' => 'digit', + ]))->message, + [ + '{{ type }}' => 'digit', + ] + ); + + yield from $this->create("{$this->property} has non-digit symbols", $value, $message); + } + + /** + * @return iterable + */ + public function yieldNonLuhnCreditCardNumber(): iterable + { + $value = '4388576018402626'; + $message = $this->translateMessage((new Luhn())->message); + + yield from $this->create("{$this->property} do not pass the Luhn algorithm", $value, $message); + } + + /** + * @return iterable + */ + public function yieldOutOfRangeNumber(int $min, int $max): iterable + { + $message = $this->translateMessage( + (new Range(\compact('min', 'max')))->notInRangeMessage, + [ + '{{ min }}' => $min, + '{{ max }}' => $max, + ] + ); + + $value = $max + 1; + yield from $this->create("{$this->property} is out of range (above)", $value, $message); + + $value = $min - 1; + yield from $this->create("{$this->property} is out of range (below)", $value, $message); + } + + /** + * @return iterable + */ + public function yieldTooLongString(int $maxLength): iterable + { + $value = \str_pad('g', $maxLength + 1, 'g'); + $message = $this->translateMessage( + (new Length([ + 'max' => $maxLength, + ]))->maxMessage, + [ + '{{ limit }}' => $maxLength, + ], + $maxLength + ); + + yield from $this->create("{$this->property} is too long", $value, $message); + } + + /** + * @return iterable + */ + public function yieldTooShortString(int $minLength): iterable + { + $value = $minLength > 1 ? \str_pad('g', $minLength - 1, 'g') : ''; + $message = $this->translateMessage( + (new Length([ + 'min' => $minLength, + ]))->minMessage, + [ + '{{ limit }}' => $minLength, + ], + $minLength + ); + + yield from $this->create("{$this->property} is too short", $value, $message); + } +} diff --git a/phpstan.neon b/phpstan.neon index 64216c942..fe11645de 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -210,6 +210,9 @@ parameters: - message: '#Cannot cast array\|string\|null to string.#' path: packages/EasyTest/src/Console/Commands + - message: '#Unsafe usage of new static\(\)#' + path: packages/EasyTest/src/InvalidDataMaker/AbstractInvalidDataMaker.php + # ---- EasyWebhook ---- - message: '#Unsafe usage of new static\(\)#' # Until we find a better design path: packages/EasyWebhook/src/AbstractWebhook.php