diff --git a/README.md b/README.md index 4aa51e0..047b152 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,10 @@ Some services are provided to be injected in contexts, which are the following: *Note:* You don't really need to bother with the services names, as they are compatible with behat's auto-wiring feature. -In order to enable the Json assertions, you need to either extend -`Behapi\Json\AbstractContext` or use `Behapi\Context\Json`. If you want to use -something else for the source (as the `Behapi\Json\Context` context is -dependant on [php-http](https://github.com/php-http/)), extend the -`Behapi\Json\AbstractContext` class. +In order to enable the Json assertions, you need to use `Behapi\Context\Json`. +If you want to use something else for the source (as the `Behapi\Json\Context` +context is dependant on [php-http](https://github.com/php-http/)), extend the +`Behapi\Json\Context` class. If you need to play with the request being built, or the response created when the request is sent, you need to inject the `@Behapi\HttpHistory\History`. It is diff --git a/phpstan.neon b/phpstan.neon index 3c492cf..6e60db9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,8 +6,16 @@ parameters: ignoreErrors: # have to ignore it, because Symfony config works like that and I'm waaay # too lazy to properly structure the node builders just to add some asserts - # and stuff like that for every line (or almost). - - '{Cannot call method arrayNode() on Symfony\Component\Config\Definition\Builder\NodeParentInterface|null.}' + # and stuff like that for (almost) every line. + - '{^Cannot call method arrayNode\\() on Symfony\Component\Config\Definition\Builder\NodeParentInterface|null.$}' + + # Waiting for https://github.com/phpstan/phpstan/issues/1615 to be solved + # and then this ignore pattern can be gone. + # + # As there is some sort of "magic" with nette / neon, we kinda have to make + # a magic pattern so we can't focus this ignore on the Assert lib + # specifically. But as it should temporary, who cares, amiritte ? + - "{^Trying to invoke array\\('Webmozart[\\\\].+?', 'notSame'|'same'\\) but it might not be a callable.$}" includes: - vendor/phpstan/phpstan-strict-rules/rules.neon diff --git a/src/Json/AbstractContext.php b/src/Json/AbstractContext.php deleted file mode 100644 index e5beaf9..0000000 --- a/src/Json/AbstractContext.php +++ /dev/null @@ -1,255 +0,0 @@ -accessor = PropertyAccess::createPropertyAccessor(); - } - - abstract protected function getJson(); - - protected function getValue(string $path) - { - return $this->accessor->getValue($this->getJson(), $path); - } - - /** @Then :path should be accessible in the latest json response */ - final public function path_should_be_readable(string $path): void - { - Assert::true($this->accessor->isReadable($this->getJson(), $path), "The path $path should be a valid path"); - } - - /** @Then :path should not exist in the latest json response */ - final public function path_should_not_be_readable(string $path): void - { - Assert::false($this->accessor->isReadable($this->getJson(), $path), "The path $path should not be a valid path"); - } - - /** @Then in the json, :path should be equal to :expected */ - final public function the_json_path_should_be_equal_to(string $path, $expected): void - { - Assert::eq($this->getValue($path), $expected); - } - - /** @Then in the json, :path should not be equal to :expected */ - final public function the_json_path_should_not_be_equal_to(string $path, $expected): void - { - Assert::notEq($this->getValue($path), $expected); - } - - /** @Then in the json, :path should be: */ - final public function the_json_path_should_be_py_string(string $path, PyStringNode $expected): void - { - Assert::same($this->getValue($path), $expected->getRaw()); - } - - /** @Then /^in the json, "(?P(?:[^"]|\\")*)" should be (?Ptrue|false)$/ */ - final public function the_json_path_should_be(string $path, string $expected): void - { - Assert::same($this->getValue($path), 'true' === $expected); - } - - /** @Then /^in the json, "(?P(?:[^"]|\\")*)" should not be (?Ptrue|false)$/ */ - final public function the_json_path_should_not_be(string $path, string $expected): void - { - Assert::notSame($this->getValue($path), 'true' === $expected); - } - - /** @Then in the json, :path should be null */ - final public function the_json_path_should_be_null(string $path): void - { - Assert::null($this->getValue($path)); - } - - /** @Then in the json, :path should not be null */ - final public function the_json_path_should_not_be_null(string $path): void - { - Assert::notNull($this->getValue($path)); - } - - /** @Then in the json, :path should be empty */ - final public function the_json_path_should_be_empty(string $path): void - { - Assert::isEmpty($this->getValue($path)); - } - - /** @Then in the json, :path should not be empty */ - final public function the_json_path_should_not_be_empty(string $path): void - { - Assert::notEmpty($this->getValue($path)); - } - - /** @Then in the json, :path should contain :expected */ - final public function the_json_path_contains(string $path, $expected): void - { - Assert::contains($this->getValue($path), $expected); - } - - /** @Then /^in the json, :path should not contain :expected */ - final public function the_json_path_not_contains(string $path, $expected): void - { - Assert::notContains($this->getValue($path), $expected); - } - - /** @Then in the json, :path collection should contain an element with :value equal to :expected */ - final public function the_json_path_collection_contains(string $path, string $value, $expected): void - { - $collection = $this->accessor->getValue($this->getJson(), $path); - - foreach ($collection as $element) { - if ($expected === $this->accessor->getValue($element, $value)) { - return; - } - } - - throw new InvalidArgumentException("$path collection does not contain an element with $value equal to $expected"); - } - - /** @Then in the json, :path should be a valid date(time) */ - final public function the_json_path_should_be_a_valid_date(string $path): void - { - try { - new DateTime($this->getValue($path)); - } catch (Throwable $t) { - throw new InvalidArgumentException("$path does not contain a valid date"); - } - } - - /** @Then in the json, :path should be greater than :expected */ - final public function the_json_path_should_be_greater_than(string $path, int $expected): void - { - Assert::greaterThan($this->getValue($path), $expected); - } - - /** @Then in the json, :path should be greater than or equal to :expected */ - final public function the_json_path_should_be_greater_or_equal_than(string $path, int $expected): void - { - Assert::greaterThanEq($this->getValue($path), $expected); - } - - /** @Then in the json, :path should be less than :expected */ - final public function the_json_path_should_be_less_than(string $path, int $expected): void - { - Assert::lessThan($this->getValue($path), $expected); - } - - /** @Then in the json, :path should be less than or equal to :expected */ - final public function the_json_path_should_be_less_or_equal_than(string $path, int $expected): void - { - Assert::lessThanEq($this->getValue($path), $expected); - } - - /** @Then in the json, :path should be an array */ - final public function should_be_an_array(string $path): void - { - Assert::isArray($this->getValue($path)); - } - - /** @Then in the json, :path should have at least :count element(s) */ - final public function the_json_path_should_have_at_least_elements(string $path, int $count): void - { - $value = $this->getValue($path); - - Assert::isArray($value); - Assert::greaterThanEq(count($value), $count); - } - - /** @Then in the json, :path should have :count element(s) */ - final public function the_json_path_should_have_elements(string $path, int $count): void - { - Assert::count($this->getValue($path), $count); - } - - /** @Then in the json, :path should have at most :count element(s) */ - final public function the_json_path_should_have_at_most_elements(string $path, int $count): void - { - $value = $this->getValue($path); - - Assert::isCountable($value); - Assert::maxCount($value, $count); - } - - /** @Then in the json, :path should match :pattern */ - final public function the_json_path_should_match(string $path, string $pattern): void - { - Assert::regex($this->getValue($path), $pattern); - } - - /** - * @Then in the json, :path should not match :pattern - * - * ----- - * - * Note :: The body of this assertion should be replaced by a - * `Assert::notRegex` as soon as the Assert's PR - * https://github.com/webmozart/assert/pull/58 is merged and released. - */ - final public function the_json_path_should_not_match(string $path, string $pattern): void - { - if (!preg_match($pattern, $this->getValue($path), $matches, PREG_OFFSET_CAPTURE)) { - // it's all good, it is supposed not to match. :} - return; - } - - throw new InvalidArgumentException("The value matches {$pattern} at offset {$matches[0][1]}"); - } - - /** @Then in the json, the root should be an array */ - final public function root_should_be_an_array(): void - { - Assert::isArray($this->getJson()); - } - - /** @Then in the json, the root should have :count element(s) */ - final public function the_root_should_have_elements(int $count): void - { - $value = $this->getJson(); - - Assert::isArray($value); - Assert::count($value, $count); - } - - /** @Then in the json, the root should have at most :count element(s) */ - final public function the_root_should_have_at_most_elements(int $count): void - { - $value = $this->getJson(); - - Assert::isArray($value); - Assert::lessThanEq($value, $count); - } - - /** @Then in the json, :path should be a valid json encoded string */ - final public function the_json_path_should_be_a_valid_json_encoded_string(string $path): void - { - $value = json_decode($this->getValue($path)); - - Assert::notNull($value); - Assert::same(json_last_error(), JSON_ERROR_NONE); - } -} diff --git a/src/Json/ComparisonTrait.php b/src/Json/ComparisonTrait.php new file mode 100644 index 0000000..e8fb300 --- /dev/null +++ b/src/Json/ComparisonTrait.php @@ -0,0 +1,33 @@ +getValue($path), $expected); + } + + /** @Then in the json, :path should be greater than or equal to :expected */ + final public function the_json_path_should_be_greater_or_equal_than(string $path, int $expected): void + { + Assert::greaterThanEq($this->getValue($path), $expected); + } + + /** @Then in the json, :path should be less than :expected */ + final public function the_json_path_should_be_less_than(string $path, int $expected): void + { + Assert::lessThan($this->getValue($path), $expected); + } + + /** @Then in the json, :path should be less than or equal to :expected */ + final public function the_json_path_should_be_less_or_equal_than(string $path, int $expected): void + { + Assert::lessThanEq($this->getValue($path), $expected); + } +} diff --git a/src/Json/Context.php b/src/Json/Context.php index dd0f957..251f9c9 100644 --- a/src/Json/Context.php +++ b/src/Json/Context.php @@ -2,39 +2,180 @@ namespace Behapi\Json; use stdClass; +use DateTimeImmutable; + +use Throwable; use InvalidArgumentException; +use Behat\Gherkin\Node\PyStringNode; +use Behat\Behat\Context\Context as BehatContext; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessor; + use Webmozart\Assert\Assert; use Behapi\HttpHistory\History as HttpHistory; use function sprintf; +use function preg_match; use function json_decode; use function json_last_error; use function json_last_error_msg; use const JSON_ERROR_NONE; +use const PREG_OFFSET_CAPTURE; -class Context extends AbstractContext +class Context implements BehatContext { + use CountTrait; + use RegexTrait; + use ComparisonTrait; + use EachInCollectionTrait; + /** @var HttpHistory */ private $history; /** @var string[] */ private $contentTypes; + /** @var PropertyAccessor */ + private $accessor; + public function __construct(HttpHistory $history, array $contentTypes = ['application/json']) { - parent::__construct(); + $this->accessor = PropertyAccess::createPropertyAccessor(); $this->history = $history; $this->contentTypes = $contentTypes; } - protected function getJson() + /** @return mixed */ + protected function getValue(?string $path) + { + $json = json_decode((string) $this->history->getLastResponse()->getBody()); + + return $path === null ? $json : $this->accessor->getValue($json, $path); + } + + /** @Then :path should be accessible in the latest json response */ + final public function path_should_be_readable(string $path): void + { + Assert::true($this->accessor->isReadable($this->getValue(null), $path), "The path $path should be a valid path"); + } + + /** @Then :path should not exist in the latest json response */ + final public function path_should_not_be_readable(string $path): void + { + Assert::false($this->accessor->isReadable($this->getValue(null), $path), "The path $path should not be a valid path"); + } + + /** + * @Then in the json, :path should be equal to :expected + * @Then in the json, :path should :not be equal to :expected + */ + final public function the_json_path_should_be_equal_to(string $path, ?string $not = null, $expected): void + { + $assert = [Assert::class, $not !== null ? 'notEq' : 'eq']; + assert(is_callable($assert)); + + $assert($this->getValue($path), $expected); + } + + /** @Then in the json, :path should be: */ + final public function the_json_path_should_be_py_string(string $path, PyStringNode $expected): void + { + Assert::same($this->getValue($path), $expected->getRaw()); + } + + /** + * @Then /^in the json, "(?P(?:[^"]|\\")*)" should be (?Ptrue|false)$/ + * @Then /^in the json, "(?P(?:[^"]|\\")*)" should :not be (?Ptrue|false)$/ + */ + final public function the_json_path_should_be(string $path, ?string $not = null, string $expected): void + { + $assert = [Assert::class, $not !== null ? 'notSame' : 'same']; + assert(is_callable($assert)); + + $assert($this->getValue($path), 'true' === $expected); + } + + /** + * @Then in the json, :path should be null + * @Then in the json, :path should :not be null + */ + final public function the_json_path_should_be_null(string $path, ?string $not = null): void + { + $assert = [Assert::class, $not !== null ? 'notNull' : 'null']; + assert(is_callable($assert)); + + $assert($this->getValue($path)); + } + + /** + * @Then in the json, :path should be empty + * @Then in the json, :path should :not be empty + */ + final public function the_json_path_should_be_empty(string $path, ?string $not = null): void + { + $assert = [Assert::class, $not !== null ? 'notEmpty' : 'isEmpty']; + assert(is_callable($assert)); + + $assert($this->getValue($path)); + } + + /** + * @Then in the json, :path should contain :expected + * @Then in the json, :path should :not contain :expected + */ + final public function the_json_path_contains(string $path, ?string $not = null, $expected): void + { + $assert = [Assert::class, $not !== null ? 'notContains' : 'contains']; + assert(is_callable($assert)); + + $assert($this->getValue($path), $expected); + } + + /** @Then in the json, :path collection should contain an element with :value equal to :expected */ + final public function the_json_path_collection_contains(string $path, string $value, $expected): void + { + $collection = $this->getValue($path); + Assert::isIterable($collection); + + foreach ($collection as $element) { + if ($expected === $this->accessor->getValue($element, $value)) { + return; + } + } + + throw new InvalidArgumentException("$path collection does not contain an element with $value equal to $expected"); + } + + /** @Then in the json, :path should be a valid date(time) */ + final public function the_json_path_should_be_a_valid_date(string $path): void + { + try { + new DateTimeImmutable($this->getValue($path)); + } catch (Throwable $t) { + throw new InvalidArgumentException("$path does not contain a valid date"); + } + } + + /** + * @Then in the json, the root should be an array + * @Then in the json, :path should be an array + */ + final public function should_be_an_array(?string $path = null): void + { + Assert::isArray($this->getValue($path)); + } + + /** @Then in the json, :path should be a valid json encoded string */ + final public function the_json_path_should_be_a_valid_json_encoded_string(string $path): void { - return json_decode((string) $this->history->getLastResponse()->getBody()); + json_decode($this->getValue($path)); + Assert::same(json_last_error(), JSON_ERROR_NONE); } /** @@ -48,7 +189,7 @@ protected function getJson() */ public function response_should_be_a_valid_json_response() { - $this->getJson(); + $this->getValue(null); [$contentType,] = explode(';', $this->history->getLastResponse()->getHeaderLine('Content-Type'), 2); diff --git a/src/Json/CountTrait.php b/src/Json/CountTrait.php new file mode 100644 index 0000000..2c70cf7 --- /dev/null +++ b/src/Json/CountTrait.php @@ -0,0 +1,45 @@ +getValue($path); + + Assert::isCountable($value); + Assert::minCount($value, $count); + } + + /** + * @Then in the json, the root collection should have :count element(s) + * @Then in the json, :path collection should have :count element(s) + */ + final public function the_json_path_should_have_elements(?string $path = null, int $count): void + { + $value = $this->getValue($path); + + Assert::isCountable($value); + Assert::count($value, $count); + } + + /** + * @Then in the json, the root collection should have at most :count element(s) + * @Then in the json, :path collection should have at most :count element(s) + */ + final public function the_json_path_should_have_at_most_elements(?string $path = null, int $count): void + { + $value = $this->getValue($path); + + Assert::isCountable($value); + Assert::maxCount($value, $count); + } +} diff --git a/src/Json/EachInCollectionTrait.php b/src/Json/EachInCollectionTrait.php new file mode 100644 index 0000000..af6e6d7 --- /dev/null +++ b/src/Json/EachInCollectionTrait.php @@ -0,0 +1,89 @@ +(?:[^"]|\\")*)\") collection should have a(?:n?) "(?P(?:[^"]|\\")*)" property$/ + * @Then /^in the json, each elements in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should (?Pnot) have a(?:n?) "(?P(?:[^"]|\\")*)" property$/ + **/ + final public function the_json_path_elements_in_collection_should_have_a_property(string $path, ?string $not = null, string $property): void + { + $assert = [Assert::class, $not !== null ? 'allPropertyNotExists' : 'allPropertyExists']; + assert(is_callable($assert)); + + $assert($this->getValue(empty($path) ? null : $path), $property); + } + + /** + * @Then /^in the json, each "(?P(?:[^"]|\\")*)" property in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should be equal to "(?P(?:[^"]|\\")*)"$/ + * @Then /^in the json, each "(?P(?:[^"]|\\")*)" property in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should (?Pnot) be equal to "(?P(?:[^"]|\\")*)"$/ + */ + final public function the_json_each_elements_in_collection_should_be_equal_to(string $path, string $property, ?string $not = null, string $expected): void + { + $values = $this->getValue(empty($path) ? null : $path); + Assert::isIterable($values); + + $assert = [Assert::class, $not !== null ? 'notSame' : 'same']; + assert(is_callable($assert)); + + foreach ($values as $element) { + $assert($this->accessor->getValue($element, $property), $expected); + } + } + + /** + * @Then /^in the json, each "(?P(?:[^"]|\\")*)" property in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should be (?Ptrue|false)$/ + * @Then /^in the json, each "(?P(?:[^"]|\\")*)" property in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should (?Pnot) be (?Ptrue|false)$/ + */ + final public function the_json_each_elements_in_collection_should_be_bool(string $path, string $property, ?string $not = null, string $expected): void + { + $values = $this->getValue(empty($path) ? null : $path); + Assert::isIterable($values); + + $assert = [Assert::class, $not !== null ? 'notSame' : 'same']; + assert(is_callable($assert)); + + foreach ($values as $element) { + $assert($this->accessor->getValue($element, $property), $expected === 'true'); + } + } + + /** + * @Then /^in the json, each "(?P(?:[^"]|\\")*)" property in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should be equal to (?P[0-9]+)$/ + * @Then /^in the json, each "(?P(?:[^"]|\\")*)" property in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should (?Pnot) be equal to (?P[0-9]+)$/ + */ + final public function the_json_each_elements_in_collection_should_be_equal_to_int(string $path, string $property, ?string $not = null, int $expected): void + { + $values = $this->getValue(empty($path) ? null : $path); + Assert::isIterable($values); + + $assert = [Assert::class, $not !== null ? 'notSame' : 'same']; + assert(is_callable($assert)); + + foreach ($values as $element) { + $assert($this->accessor->getValue($element, $property), $expected); + } + } + + /** + * @Then /^in the json, each "(?P(?:[^"]|\\")*)" property in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should contain "(?P(?:[^"]|\\")*)"$/ + * @Then /^in the json, each "(?P(?:[^"]|\\")*)" property in the (?:root|\"(?P(?:[^"]|\\")*)\") collection should (?Pnot) contain "(?P(?:[^"]|\\")*)"$/ + **/ + final public function the_json_each_elements_in_collection_should_contain(string $path, string $property, ?string $not = null, string $expected): void + { + $values = $this->getValue(empty($path) ? null : $path); + Assert::isIterable($values); + + $assert = [Assert::class, $not !== null ? 'notSame' : 'same']; + assert(is_callable($assert)); + + foreach ($values as $element) { + $assert($this->accessor->getValue($element, $property), $expected); + } + } +} diff --git a/src/Json/RegexTrait.php b/src/Json/RegexTrait.php new file mode 100644 index 0000000..b48f846 --- /dev/null +++ b/src/Json/RegexTrait.php @@ -0,0 +1,36 @@ +getValue($path), $pattern); + } + + /** + * @Then in the json, :path should not match :pattern + * + * ----- + * + * Note :: The body of this assertion should be replaced by a + * `Assert::notRegex` as soon as the Assert's PR + * https://github.com/webmozart/assert/pull/58 is merged and released. + */ + final public function the_json_path_should_not_match(string $path, string $pattern): void + { + if (!preg_match($pattern, $this->getValue($path), $matches, PREG_OFFSET_CAPTURE)) { + // it's all good, it is supposed not to match. :} + return; + } + + throw new InvalidArgumentException("The value matches {$pattern} at offset {$matches[0][1]}"); + } +}