├── .github ├── CONTRIBUTING.md └── workflows │ └── ci.yml ├── .gitignore ├── .php-cs-fixer.php ├── .readthedocs.yaml ├── ChangeLog.md ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── docs ├── Makefile ├── conf.py ├── guide │ ├── extending-the-extension.rst │ ├── send-request.rst │ ├── setup-request.rst │ └── verify-server-response.rst ├── index.rst ├── installation │ ├── configuration.rst │ ├── installation.rst │ ├── requirements.rst │ └── upgrade.rst └── requirements.txt ├── features ├── README.md ├── add-custom-matcher-functions.feature ├── auth.feature ├── bootstrap │ ├── FeatureContext.php │ └── index.php ├── built-in-custom-matcher-functions-failures.feature ├── built-in-custom-matcher-functions.feature ├── client-errors.feature ├── configure-client.feature ├── context.feature ├── examples-from-docs.feature ├── file-uploads.feature ├── form-data.feature ├── issue-13.feature ├── issue-34.feature ├── jwt-matcher.feature ├── manipulate-query-string.feature ├── request-body.feature ├── setup-request-failures.feature ├── setup-request.feature ├── verify-response-failures.feature ├── verify-response.feature └── whens.feature ├── phpstan.neon ├── phpunit.dist.xml ├── src ├── ArrayContainsComparator.php ├── ArrayContainsComparator │ └── Matcher │ │ ├── ArrayLength.php │ │ ├── ArrayMaxLength.php │ │ ├── ArrayMinLength.php │ │ ├── GreaterThan.php │ │ ├── JWT.php │ │ ├── LessThan.php │ │ ├── RegExp.php │ │ └── VariableType.php ├── Context │ ├── ApiClientAwareContext.php │ ├── ApiContext.php │ ├── ArrayContainsComparatorAwareContext.php │ └── Initializer │ │ ├── ApiClientAwareInitializer.php │ │ └── ArrayContainsComparatorAwareInitializer.php ├── Exception │ ├── ArrayContainsComparatorException.php │ └── AssertionFailedException.php └── ServiceContainer │ └── BehatApiExtension.php └── tests ├── ArrayContainsComparator └── Matcher │ ├── ArrayLengthTest.php │ ├── ArrayMaxLengthTest.php │ ├── ArrayMinLengthTest.php │ ├── GreaterThanTest.php │ ├── JWTTest.php │ ├── LessThanTest.php │ ├── RegExpTest.php │ └── VariableTypeTest.php ├── ArrayContainsComparatorTest.php ├── Context ├── ApiContextTest.php └── Initializer │ ├── ApiClientAwareInitializerTest.php │ └── ArrayContainsComparatorAwareInitializerTest.php ├── Exception └── ArrayContainsComparatorExceptionTest.php └── ServiceContainer └── BehatApiExtensionTest.php /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Behat API Extension 2 | 3 | If you want to contribute to the Behat API Extension please follow the following guidelines. 4 | 5 | ## Running tests and static analysis 6 | 7 | Behat API Extension has both [Behat](http://docs.behat.org/) and [PHPUnit](https://phpunit.de/) tests, and when adding new features or fixing bugs you are required to add relevant test cases. 8 | 9 | The Behat tests requires a web server hosting the `features/bootstrap/index.php` script. A quick and easy alternative is to use PHPs built in web server: 10 | 11 | php -S localhost:8080 -t ./features/bootstrap > build/httpd.log 2>&1 12 | 13 | After this has been started you can execute the test suites by running: 14 | 15 | ./vendor/bin/behat --strict 16 | ./vendor/bin/phpunit 17 | 18 | [PHPStan](https://phpstan.org/) is used for static code analysis: 19 | 20 | vendor/bin/phpstan 21 | 22 | ## Documentation 23 | 24 | The extension uses [Sphinx](http://www.sphinx-doc.org/en/stable/) for documentation, and all end-user documentation resides in the `docs` directory. To generate the current documentation after checking out your fork simply run the `docs` composer script: 25 | 26 | composer run docs 27 | 28 | from the project root directory. If the command fails you are most likely missing packages not installable by Composer. Install missing packages and re-run the command to generate docs. 29 | 30 | ## Reporting bugs 31 | 32 | Use the [issue tracker on GitHub](https://github.com/imbo/behat-api-extension/issues) for this. Please add necessary steps that can reproduce the bugs. 33 | 34 | ## Submitting a pull request 35 | 36 | If you want to implement a new feature, fork this project and create a feature branch called `feature/my-awesome-feature`, and send a pull request. The feature needs to be fully documented and tested before it will be merged. 37 | 38 | If the pull request is a bug fix, remember to file an issue in the issue tracker first, then create a branch called `issue/`. One or more test cases to verify the bug is required. When creating specific test cases for issues, please add a `@see` tag to the docblock, or as a comment in the feature file. For instance: 39 | 40 | ```php 41 | /** 42 | * @see https://github.com/imbo/behat-api-extension/issues/ 43 | */ 44 | public function testSomething() 45 | { 46 | // ... 47 | } 48 | ``` 49 | 50 | Please also specify which commit that resolves the bug by adding `Resolves #` to the commit message. 51 | 52 | ## Coding standards 53 | 54 | This library follows the [imbo/imbo-coding-standard](https://github.com/imbo/imbo-coding-standard) coding standard, and runs [php-cs-fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) as a step in the CI workflow. 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | php: ["8.3", "8.4"] 9 | name: Validate and test on PHP ${{ matrix.php }} 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: ${{ matrix.php }} 18 | 19 | - name: PHP version 20 | run: php -v 21 | 22 | - name: PHP info 23 | run: php -i 24 | 25 | - name: PHP modules 26 | run: php -m 27 | 28 | - name: Validate composer files 29 | run: composer validate --strict 30 | 31 | - name: Get Composer cache directory 32 | id: composer-cache-dir 33 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 34 | 35 | - name: Cache Composer packages 36 | id: composer-cache 37 | uses: actions/cache@v4 38 | with: 39 | path: ${{ steps.composer-cache-dir.outputs.dir }} 40 | key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} 41 | restore-keys: | 42 | ${{ runner.os }}-php-${{ matrix.php }}-composer- 43 | 44 | - name: Install dependencies 45 | run: composer install 46 | 47 | - name: Start dev server 48 | run: php -S localhost:8080 -t ./features/bootstrap > httpd.log 2>&1 & 49 | 50 | - name: Run unit tests 51 | run: vendor/bin/phpunit 52 | 53 | - name: Run integration tests 54 | run: vendor/bin/behat --strict 55 | 56 | - name: Run static code analysis 57 | run: vendor/bin/phpstan 58 | 59 | - name: Check coding standard 60 | run: vendor/bin/php-cs-fixer fix --dry-run --diff 61 | env: 62 | PHP_CS_FIXER_IGNORE_ENV: 1 63 | 64 | - uses: actions/upload-artifact@v4 65 | if: always() 66 | with: 67 | name: httpd-php-${{ matrix.php }} 68 | path: httpd.log 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | build 3 | phpunit.xml 4 | docs/_build 5 | .idea 6 | .phpunit.result.cache 7 | .phpunit.cache 8 | .vscode 9 | .php-cs-fixer.cache 10 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | files() 6 | ->name('*.php') 7 | ->in(__DIR__) 8 | ->exclude('vendor'); 9 | 10 | return (new Imbo\CodingStandard\Config()) 11 | ->setFinder($finder); 12 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-24.04 4 | tools: 5 | python: "3.12" 6 | 7 | sphinx: 8 | configuration: docs/conf.py 9 | 10 | python: 11 | install: 12 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | Changelog for Behat API Extension 2 | ================================= 3 | 4 | v6.0.0 5 | ------ 6 | __2025-01-08__ 7 | 8 | * [#139](https://github.com/imbo/behat-api-extension/pull/139): Require PHP >= 8.3 9 | - [#138](https://github.com/imbo/behat-api-extension/pull/138): Support for PHP 8.4 ([@LenaDooms](https://github.com/LenaDooms)) 10 | 11 | v5.0.0 12 | ------ 13 | __2023-03-27__ 14 | 15 | * [#127](https://github.com/imbo/behat-api-extension/issue/127): Allow configuration of the internal Guzzle client without using the deprecated getConfig method 16 | 17 | v4.0.0 18 | ------ 19 | __2023-03-18__ 20 | 21 | * [#126](https://github.com/imbo/behat-api-extension/pull/126): Add type hints to most of the code base 22 | * [#125](https://github.com/imbo/behat-api-extension/pull/125): Require PHP >= 8.1 23 | 24 | v3.0.1 25 | ------ 26 | __2022-06-27__ 27 | 28 | * [#109](https://github.com/imbo/behat-api-extension/pull/109): Step for asserting an empty response body 29 | * [#106](https://github.com/imbo/behat-api-extension/issues/106): Adopt coding standard 30 | 31 | v3.0.0 32 | ------ 33 | __2021-05-25__ 34 | 35 | * [#101](https://github.com/imbo/behat-api-extension/issues/101): Require PHP >= 7.4 36 | * [#92](https://github.com/imbo/behat-api-extension/pull/92): Add password grant OAuth step ([@ABGEO](https://github.com/ABGEO)) 37 | * [#85](https://github.com/imbo/behat-api-extension/pull/85): Add support for manipulating query parameters using steps 38 | 39 | v2.3.1 40 | ------ 41 | __2020-01-29__ 42 | 43 | * Minor docs fix and bumped copyright year 44 | 45 | v2.3.0 46 | ------ 47 | __2020-01-29__ 48 | 49 | * [#84](https://github.com/imbo/behat-api-extension/pull/84): Added support for `any` and multiple variable types with the `@variableType` matcher 50 | 51 | v2.2.1 52 | ------ 53 | __2019-09-15__ 54 | 55 | * [#74](https://github.com/imbo/behat-api-extension/pull/74): Docs fix ([@adambro](https://github.com/adambro)) 56 | 57 | v2.2.0 58 | ------ 59 | __2019-04-04__ 60 | 61 | * [#78](https://github.com/imbo/behat-api-extension/pull/78): Added new step for sending multipart form data ([@miteshmap](https://github.com/miteshmap)) 62 | 63 | v2.1.0 64 | ------ 65 | __2018-01-20__ 66 | 67 | * [#67](https://github.com/imbo/behat-api-extension/pull/67): Pass in entire array in the `apiClient` part of the configuration to the Guzzle Client instead of specifying specific configuration options ([@vitalyiegorov](https://github.com/vitalyiegorov)) 68 | * [#64](https://github.com/imbo/behat-api-extension/pull/64): Move connectability validation of the `base_uri` option so that `behat --help` (amongst others) can be executed without validating the configuration ([@oxkhar](https://github.com/oxkhar)) 69 | * [#54](https://github.com/imbo/behat-api-extension/pull/54): Added support for JWT matching using the `@jwt()` custom matcher function, which uses the [firebase/php-jwt](https://packagist.org/packages/firebase/php-jwt) package ([@Zwartpet](https://github.com/Zwartpet)) 70 | 71 | Bug fixes: 72 | 73 | * [#57](https://github.com/imbo/behat-api-extension/pull/57): Use HTTP GET when no method is specified 74 | 75 | Other changes: 76 | 77 | * [#56](https://github.com/imbo/behat-api-extension/pull/56): Grammar fix ([@FabianPiconeDev](https://github.com/FabianPiconeDev)) 78 | 79 | v2.0.1 80 | ------ 81 | __2017-04-09__ 82 | 83 | * [#48](https://github.com/imbo/behat-api-extension/pull/48): Allow HTTP PATCH (and other HTTP methods) with form parameters 84 | 85 | v2.0.0 86 | ------ 87 | __2017-04-01__ 88 | 89 | * Removed and updated some steps and public methods (refer to [the docs](https://behat-api-extension.readthedocs.io) regarding upgrading) 90 | * Added more steps (refer to [the guide](https://behat-api-extension.readthedocs.io) to see all available steps) 91 | 92 | Other changes: 93 | 94 | * [#43](https://github.com/imbo/behat-api-extension/issues/43): Matcher functions for greater than and less than 95 | * [#36](https://github.com/imbo/behat-api-extension/issues/36): Improved documentation: https://behat-api-extension.readthedocs.io 96 | * [#29](https://github.com/imbo/behat-api-extension/issues/29): New step: Assert response status line 97 | * [#19](https://github.com/imbo/behat-api-extension/issues/19): New steps: Set request body to a string or a file before sending the request 98 | * [#18](https://github.com/imbo/behat-api-extension/issues/18): New step: Assert response reason phrase 99 | 100 | v1.0.4 101 | ------ 102 | __2016-10-26__ 103 | 104 | * [#15](https://github.com/imbo/behat-api-extension/issues/15): Add support for checking numerical arrays on root 105 | 106 | v1.0.3 107 | ------ 108 | __2016-10-13__ 109 | 110 | Bug fixes: 111 | 112 | * [#13](https://github.com/imbo/behat-api-extension/issues/13): Checking multi-dimensional arrays 113 | 114 | v1.0.2 115 | ------ 116 | __2016-09-15__ 117 | 118 | * [#8](https://github.com/imbo/behat-api-extension/issues/8): Step(s) for working with form data 119 | 120 | Bug fixes: 121 | 122 | * [#7](https://github.com/imbo/behat-api-extension/issues/7): Don't allow request body when sending multipart/form-data requests 123 | * [#5](https://github.com/imbo/behat-api-extension/issues/5): Attaching files does not work 124 | 125 | v1.0.1 126 | ------ 127 | __2016-09-10__ 128 | 129 | * [#3](https://github.com/imbo/behat-api-extension/issues/3): Don't restrict comparisons to scalar values 130 | 131 | Bug fixes: 132 | 133 | * [#1](https://github.com/imbo/behat-api-extension/issues/1): Can't compare null values 134 | 135 | v1.0.0 136 | ------ 137 | __2016-09-10__ 138 | 139 | * Initial release 140 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) Christer Edvartsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to 7 | deal in the Software without restriction, including without limitation the 8 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | sell copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | * The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Behat API Extension 2 | 3 | [![CI](https://github.com/imbo/behat-api-extension/actions/workflows/ci.yml/badge.svg)](https://github.com/imbo/behat-api-extension/actions/workflows/ci.yml) 4 | 5 | This Behat extension provides an easy way to test JSON-based API's in [Behat 3](http://behat.org). Inspired by [behat/web-api-extension](https://github.com/Behat/WebApiExtension/) and originally written to test the [Imbo API](http://imbo.io). 6 | 7 | ## Installation / Configuration / Documentation 8 | 9 | End-user docs can be found [here](https://behat-api-extension.readthedocs.io/). 10 | 11 | ## Copyright / License 12 | 13 | Licensed under the [MIT license](LICENSE). 14 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imbo/behat-api-extension", 3 | "type": "library", 4 | "description": "API extension for Behat", 5 | "keywords": [ 6 | "behat", 7 | "testing", 8 | "api", 9 | "REST", 10 | "http" 11 | ], 12 | "homepage": "https://github.com/imbo/behat-api-extension", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Christer Edvartsen", 17 | "email": "cogo@starzinger.net", 18 | "homepage": "https://github.com/christeredvartsen" 19 | }, 20 | { 21 | "name": "Contributors", 22 | "homepage": "https://github.com/imbo/behat-api-extension/graphs/contributors" 23 | } 24 | ], 25 | "support": { 26 | "source": "https://github.com/imbo/behat-api-extension", 27 | "docs": "http://behat-api-extension.readthedocs.io/", 28 | "issues": "https://github.com/imbo/behat-api-extension/issues" 29 | }, 30 | "require": { 31 | "php": ">=8.3", 32 | "ext-json": "*", 33 | "beberlei/assert": "^3.3", 34 | "behat/behat": "^3.8", 35 | "firebase/php-jwt": "^6.4", 36 | "guzzlehttp/guzzle": "^7.3" 37 | }, 38 | "require-dev": { 39 | "friendsofphp/php-cs-fixer": "^3.70", 40 | "imbo/imbo-coding-standard": "^2.0", 41 | "phpstan/extension-installer": "^1.4", 42 | "phpstan/phpstan": "^2.1", 43 | "phpstan/phpstan-deprecation-rules": "^2.0", 44 | "phpstan/phpstan-phpunit": "^2.0", 45 | "phpunit/phpunit": "^12.0", 46 | "slim/psr7": "^1.3", 47 | "slim/slim": "^4.7", 48 | "symfony/process": "^7.2", 49 | "tuupola/slim-basic-auth": "^3.3" 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "Imbo\\BehatApiExtension\\": "src/" 54 | } 55 | }, 56 | "autoload-dev": { 57 | "psr-4": { 58 | "Imbo\\BehatApiExtension\\": "tests/" 59 | } 60 | }, 61 | "scripts": { 62 | "ci": [ 63 | "@phpunit", 64 | "@behat", 65 | "@sa", 66 | "@cs" 67 | ], 68 | "test": [ 69 | "@phpunit", 70 | "@behat" 71 | ], 72 | "phpunit": "vendor/bin/phpunit", 73 | "phpunit:coverage": "vendor/bin/phpunit --coverage-html build/coverage", 74 | "behat": "vendor/bin/behat --strict", 75 | "sa": "vendor/bin/phpstan", 76 | "cs": "vendor/bin/php-cs-fixer fix --dry-run --diff", 77 | "dev": "php -S localhost:8080 -t ./features/bootstrap > build/httpd.log 2>&1", 78 | "docs": "cd docs; make html" 79 | }, 80 | "config": { 81 | "sort-packages": true, 82 | "allow-plugins": { 83 | "phpstan/extension-installer": true 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " clean to empty build dir" 27 | @echo " spelling to run spell check" 28 | @echo " html to make standalone HTML files" 29 | 30 | clean: 31 | rm -rf $(BUILDDIR)/* 32 | @echo 33 | @echo "Clean build dir finished." 34 | 35 | spelling: 36 | $(SPHINXBUILD) -b spelling $(ALLSPHINXOPTS) $(BUILDDIR)/spelling 37 | @echo 38 | @echo "Spell check finished." 39 | 40 | html: 41 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 42 | @echo 43 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 44 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Behat API Extension documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Dec 15 15:49:57 2016. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | from datetime import date 18 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinxcontrib.phpdomain', 'sphinx_rtd_theme'] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix of source filenames. 39 | source_suffix = '.rst' 40 | 41 | # The encoding of source files. 42 | #source_encoding = 'utf-8-sig' 43 | 44 | # The master toctree document. 45 | master_doc = 'index' 46 | 47 | # General information about the project. 48 | project = u'Behat API Extension' 49 | copyright = u'2016-' + str(date.today().year) + ', Christer Edvartsen' 50 | 51 | # The version info for the project you're documenting, acts as replacement for 52 | # |version| and |release|, also used in various other places throughout the 53 | # built documents. 54 | # 55 | # The short X.Y version. 56 | version = '6.0' 57 | # The full version, including alpha/beta/rc tags. 58 | release = '6.0.0' 59 | 60 | # The language for content autogenerated by Sphinx. Refer to documentation 61 | # for a list of supported languages. 62 | #language = None 63 | 64 | # There are two options for replacing |today|: either, you set today to some 65 | # non-false value, then it is used: 66 | #today = '' 67 | # Else, today_fmt is used as the format for a strftime call. 68 | #today_fmt = '%B %d, %Y' 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | exclude_patterns = ['_build'] 73 | 74 | # The reST default role (used for this markup: `text`) to use for all 75 | # documents. 76 | #default_role = None 77 | 78 | # If true, '()' will be appended to :func: etc. cross-reference text. 79 | #add_function_parentheses = True 80 | 81 | # If true, the current module name will be prepended to all description 82 | # unit titles (such as .. function::). 83 | #add_module_names = True 84 | 85 | # If true, sectionauthor and moduleauthor directives will be shown in the 86 | # output. They are ignored by default. 87 | #show_authors = False 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = 'sphinx' 91 | 92 | # A list of ignored prefixes for module index sorting. 93 | #modindex_common_prefix = [] 94 | 95 | # If true, keep warnings as "system message" paragraphs in the built documents. 96 | #keep_warnings = False 97 | 98 | 99 | # -- Options for HTML output ---------------------------------------------- 100 | 101 | # The theme to use for HTML and HTML Help pages. See the documentation for 102 | # a list of builtin themes. 103 | html_theme = 'sphinx_rtd_theme' 104 | 105 | # Theme options are theme-specific and customize the look and feel of a theme 106 | # further. For a list of options available for each theme, see the 107 | # documentation. 108 | #html_theme_options = {} 109 | 110 | # Add any paths that contain custom themes here, relative to this directory. 111 | #html_theme_path = [] 112 | 113 | # The name for this set of Sphinx documents. If None, it defaults to 114 | # " v documentation". 115 | #html_title = None 116 | 117 | # A shorter title for the navigation bar. Default is the same as html_title. 118 | #html_short_title = None 119 | 120 | # The name of an image file (relative to this directory) to place at the top 121 | # of the sidebar. 122 | #html_logo = None 123 | 124 | # The name of an image file (within the static path) to use as favicon of the 125 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 126 | # pixels large. 127 | #html_favicon = None 128 | 129 | # Add any paths that contain custom static files (such as style sheets) here, 130 | # relative to this directory. They are copied after the builtin static files, 131 | # so a file named "default.css" will overwrite the builtin "default.css". 132 | html_static_path = ['_static'] 133 | 134 | # Add any extra paths that contain custom files (such as robots.txt or 135 | # .htaccess) here, relative to this directory. These files are copied 136 | # directly to the root of the documentation. 137 | #html_extra_path = [] 138 | 139 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 140 | # using the given strftime format. 141 | #html_last_updated_fmt = '%b %d, %Y' 142 | 143 | # If true, SmartyPants will be used to convert quotes and dashes to 144 | # typographically correct entities. 145 | #html_use_smartypants = True 146 | 147 | # Custom sidebar templates, maps document names to template names. 148 | #html_sidebars = {} 149 | 150 | # Additional templates that should be rendered to pages, maps page names to 151 | # template names. 152 | #html_additional_pages = {} 153 | 154 | # If false, no module index is generated. 155 | #html_domain_indices = True 156 | 157 | # If false, no index is generated. 158 | #html_use_index = True 159 | 160 | # If true, the index is split into individual pages for each letter. 161 | #html_split_index = False 162 | 163 | # If true, links to the reST sources are added to the pages. 164 | #html_show_sourcelink = True 165 | 166 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 167 | #html_show_sphinx = True 168 | 169 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 170 | #html_show_copyright = True 171 | 172 | # If true, an OpenSearch description file will be output, and all pages will 173 | # contain a tag referring to it. The value of this option must be the 174 | # base URL from which the finished HTML is served. 175 | #html_use_opensearch = '' 176 | 177 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 178 | #html_file_suffix = None 179 | 180 | # Output file base name for HTML help builder. 181 | htmlhelp_basename = 'BehatAPIExtensiondoc' 182 | 183 | 184 | # -- Options for LaTeX output --------------------------------------------- 185 | 186 | latex_elements = { 187 | # The paper size ('letterpaper' or 'a4paper'). 188 | #'papersize': 'letterpaper', 189 | 190 | # The font size ('10pt', '11pt' or '12pt'). 191 | #'pointsize': '10pt', 192 | 193 | # Additional stuff for the LaTeX preamble. 194 | #'preamble': '', 195 | } 196 | 197 | # Grouping the document tree into LaTeX files. List of tuples 198 | # (source start file, target name, title, 199 | # author, documentclass [howto, manual, or own class]). 200 | latex_documents = [ 201 | ('index', 'BehatAPIExtension.tex', u'Behat API Extension Documentation', 202 | u'Christer Edvartsen', 'manual'), 203 | ] 204 | 205 | # The name of an image file (relative to this directory) to place at the top of 206 | # the title page. 207 | #latex_logo = None 208 | 209 | # For "manual" documents, if this is true, then toplevel headings are parts, 210 | # not chapters. 211 | #latex_use_parts = False 212 | 213 | # If true, show page references after internal links. 214 | #latex_show_pagerefs = False 215 | 216 | # If true, show URL addresses after external links. 217 | #latex_show_urls = False 218 | 219 | # Documents to append as an appendix to all manuals. 220 | #latex_appendices = [] 221 | 222 | # If false, no module index is generated. 223 | #latex_domain_indices = True 224 | 225 | 226 | # -- Options for manual page output --------------------------------------- 227 | 228 | # One entry per manual page. List of tuples 229 | # (source start file, name, description, authors, manual section). 230 | man_pages = [ 231 | ('index', 'behatapiextension', u'Behat API Extension Documentation', 232 | [u'Christer Edvartsen'], 1) 233 | ] 234 | 235 | # If true, show URL addresses after external links. 236 | #man_show_urls = False 237 | 238 | 239 | # -- Options for Texinfo output ------------------------------------------- 240 | 241 | # Grouping the document tree into Texinfo files. List of tuples 242 | # (source start file, target name, title, author, 243 | # dir menu entry, description, category) 244 | texinfo_documents = [ 245 | ('index', 'BehatAPIExtension', u'Behat API Extension Documentation', 246 | u'Christer Edvartsen', 'BehatAPIExtension', 'One line description of project.', 247 | 'Miscellaneous'), 248 | ] 249 | 250 | # Documents to append as an appendix to all manuals. 251 | #texinfo_appendices = [] 252 | 253 | # If false, no module index is generated. 254 | #texinfo_domain_indices = True 255 | 256 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 257 | #texinfo_show_urls = 'footnote' 258 | 259 | # If true, do not generate a @detailmenu in the "Top" node's menu. 260 | #texinfo_no_detailmenu = False 261 | 262 | # Allow php syntax highlighting without prepending client``, ``$this->request``, ``$this->requestOptions``, ``$this->response`` and ``$this->arrayContainsComparator`` properties respectively. Keep in mind that ``$this->response`` is not populated until the client has made a request, i.e. after any of the aforementioned ``@When`` steps have finished. 5 | 6 | Add ``@Given``'s, ``@When``'s and/or ``@Then``'s 7 | ------------------------------------------------ 8 | 9 | If you want to add a ``@Given``, ``@When`` and/or ``@Then`` step, simply add a method in your ``FeatureContext`` class along with the step using annotations in the ``phpdoc`` block: 10 | 11 | .. code-block:: php 12 | 13 | response, and throw a AssertionFailedException 25 | // exception if the assertion fails. 26 | } 27 | } 28 | 29 | With the above example you can now use ``Then I want to check something`` can be used in your feature files along with the steps defined by the extension. 30 | 31 | .. _configure-the-api-client: 32 | 33 | Configure the API client 34 | ------------------------ 35 | 36 | If you wish to configure the internal API client (``GuzzleHttp\Client``) this can be done in the initialization-phase: 37 | 38 | .. code-block:: php 39 | 40 | push(Middleware::mapRequest( 51 | fn ($req) => $req->withAddedHeader('Some-Custom-Header', 'some value') 52 | )); 53 | $config['handler'] = $stack; 54 | return parent::initializeClient($config); 55 | } 56 | } 57 | 58 | Register custom matcher functions 59 | --------------------------------- 60 | 61 | The extension comes with some built in matcher functions used to verify JSON-content (see :ref:`then-the-response-body-contains-json`), like for instance ``@arrayLength`` and ``@regExp``. These functions are basically callbacks to PHP methods / functions, so you can easily define your own and use them in your tests: 62 | 63 | .. code-block:: php 64 | 65 | addFunction('gt', function ($num, $gt) { 77 | $num = (int) $num; 78 | $gt = (int) $gt; 79 | 80 | if ($num <= $gt) { 81 | throw new InvalidArgumentException(sprintf( 82 | 'Expected number to be greater than %d, got: %d.', 83 | $gt, 84 | $num 85 | )); 86 | } 87 | }); 88 | 89 | return parent::setArrayContainsComparator($comparator); 90 | } 91 | } 92 | 93 | The above snippet adds a custom matcher function called ``@gt`` that can be used to check if a number is greater than another number. Given the following response body: 94 | 95 | .. code-block:: json 96 | 97 | { 98 | "number": 42 99 | } 100 | 101 | the number in the ``number`` key could be verified with: 102 | 103 | .. code-block:: gherkin 104 | 105 | Then the response body contains JSON: 106 | """ 107 | { 108 | "number": "@gt(40)" 109 | } 110 | """ 111 | -------------------------------------------------------------------------------- /docs/guide/send-request.rst: -------------------------------------------------------------------------------- 1 | Send the request 2 | ================ 3 | 4 | After setting up the request it can be sent to the server in a few different ways. Keep in mind that all configuration regarding the request must be done prior to any of the following steps, as they will actually send the request. 5 | 6 | .. contents:: Available steps 7 | :local: 8 | 9 | When I request ``:path`` 10 | ------------------------ 11 | 12 | Request ``:path`` using HTTP GET. Shorthand for :ref:`When I request :path using HTTP GET `. 13 | 14 | .. _when-i-request-path-using-http-method: 15 | 16 | When I request ``:path`` using HTTP ``:method`` 17 | ----------------------------------------------- 18 | 19 | ``:path`` is relative to the ``base_uri`` configuration option, and ``:method`` is any HTTP method, for instance ``POST`` or ``DELETE``. If ``:path`` starts with a slash, it will be relative to the root of ``base_uri``. 20 | 21 | **Examples:** 22 | 23 | *Assume that the ``base_uri`` configuration option has been set to ``http://example.com/dir`` in the following examples.* 24 | 25 | ===================================================== ===================== =========== ======================================= 26 | Step ``:path`` ``:method`` Resulting URI 27 | ===================================================== ===================== =========== ======================================= 28 | When I request "``/?foo=bar&bar=foo``" ``/?foo=bar&bar=foo`` ``GET`` ``http://example.com/?foo=bar&bar=foo`` 29 | When I request "``/some/path``" using HTTP ``DELETE`` ``/some/path`` ``DELETE`` ``http://example.com/some/path`` 30 | When I request "``foobar``" using HTTP ``POST`` ``foobar`` ``POST`` ``http://example.com/dir/foobar`` 31 | ===================================================== ===================== =========== ======================================= 32 | -------------------------------------------------------------------------------- /docs/guide/setup-request.rst: -------------------------------------------------------------------------------- 1 | Set up the request 2 | ================== 3 | 4 | The following steps can be used prior to sending a request. 5 | 6 | .. contents:: Available steps 7 | :local: 8 | 9 | .. _given-i-attach-path-to-the-request-as-partname: 10 | 11 | Given I attach ``:path`` to the request as ``:partName`` 12 | -------------------------------------------------------- 13 | 14 | Attach a file to the request (causing a ``multipart/form-data`` request, populating the ``$_FILES`` array on the server). Can be repeated to attach several files. If a specified file does not exist an ``InvalidArgumentException`` exception will be thrown. ``:path`` is relative to the working directory unless it's absolute. 15 | 16 | **Examples:** 17 | 18 | ========================================================================== ========================= ================================================== 19 | Step ``:path`` Entry in ``$_FILES`` on the server (``:partName``) 20 | ========================================================================== ========================= ================================================== 21 | Given I attach "``/path/to/file.jpg``" to the request as "``file1``" ``/path/to/file.jpg`` $_FILES['``file1``'] 22 | Given I attach "``c:\some\file.jpg``" to the request as "``file2``" ``c:\some\file.jpg`` $_FILES['``file2``'] 23 | Given I attach "``features/some.feature``" to the request as "``feature``" ``features/some.feature`` $_FILES['``feature``'] 24 | ========================================================================== ========================= ================================================== 25 | 26 | This step can not be used when sending requests with a request body. Doing so results in an ``InvalidArgumentException`` exception. 27 | 28 | Given the following multipart form parameters are set: ```` 29 | ---------------------------------------------------------------------- 30 | 31 | This step can be used to set form parameters (as if the request is a ``
`` being submitted). A table node must be used to specify which fields / values to send: 32 | 33 | .. code-block:: gherkin 34 | 35 | Given the following multipart form parameters are set: 36 | | name | value | 37 | | foo | bar | 38 | | bar | foo | 39 | | bar | bar | 40 | 41 | The first row in the table must contain two values: ``name`` and ``value``. The rows that follows are the fields / values you want to send. This step sets the HTTP method to ``POST`` by default and the ``Content-Type`` request header to ``multipart/form-data``. 42 | 43 | This step can not be used when sending requests with a request body. Doing so results in an ``InvalidArgumentException`` exception. 44 | 45 | To use a different HTTP method, simply specify the wanted method in the :ref:`when-i-request-path-using-http-method` step. 46 | 47 | Given I am authenticating as ``:username`` with password ``:password`` 48 | ---------------------------------------------------------------------- 49 | 50 | Use this step to set up basic authentication to the next request. 51 | 52 | **Examples:** 53 | 54 | ============================================================== ============= ============= 55 | Step ``:username`` ``:password`` 56 | ============================================================== ============= ============= 57 | Given I am authenticating as "``foo``" with password "``bar``" ``foo`` ``bar`` 58 | ============================================================== ============= ============= 59 | 60 | Given I get an OAuth token using password grant from ``:path`` with ``:username`` and ``:password`` in scope ``:scope`` using client ID ``:clientId`` (and client secret ``:clientSecret``) 61 | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 62 | 63 | Send a request using password grant to the given ``:path`` for an access token that will be added as a ``Authorization`` header for the next request. The endpoint is required to respond with a JSON object that contains the ``access_token`` key, for instance: 64 | 65 | .. code-block:: json 66 | 67 | { 68 | "access_token": "some-token" 69 | } 70 | 71 | Given the above response body, the next request will have the following header set: ``Authorization: Bearer some-token``. 72 | 73 | **Examples:** 74 | 75 | .. code-block:: gherkin 76 | 77 | Given I get an OAuth token using password grant from "/token" with "user" and "password" in scope "scope" using client ID "id" and client secret "secret" 78 | When I request "/path/that/requires/token/in/header" 79 | 80 | The second step in the above example will include the required ``Authorization`` header given the response from ``/token`` as seen in the first step. 81 | 82 | .. _given-the-header-request-header-is-value: 83 | 84 | Given the ``:header`` request header is ``:value`` 85 | -------------------------------------------------- 86 | 87 | Set the ``:header`` request header to ``:value``. Can be repeated to set multiple headers. When repeated with the same ``:header`` the last value will be used. 88 | 89 | Trying to force specific headers to have certain values combined with other steps that ends up modifying request headers (for instance attaching files) can lead to undefined behavior. 90 | 91 | **Examples:** 92 | 93 | =============================================================== ============== ==================== 94 | Step ``:header`` ``:value`` 95 | =============================================================== ============== ==================== 96 | Given the "``User-Agent``" request header is "``test/1.0``" ``User-Agent`` ``test/1.0`` 97 | Given the "``Accept``" request header is "``application/json``" ``Accept`` ``application/json`` 98 | =============================================================== ============== ==================== 99 | 100 | Given the ``:header`` request header contains ``:value`` 101 | -------------------------------------------------------- 102 | 103 | Add ``:value`` to the ``:header`` request header. Can be repeated to set multiple headers. When repeated with the same ``:header`` the header will be converted to an array. 104 | 105 | **Examples:** 106 | 107 | ======================================================= =========== ========== 108 | Step ``:header`` ``:value`` 109 | ======================================================= =========== ========== 110 | Given the "``X-Foo``" request header contains "``Bar``" ``X-Foo`` ``Bar`` 111 | ======================================================= =========== ========== 112 | 113 | Given the following form parameters are set: ```` 114 | ------------------------------------------------------------ 115 | 116 | This step can be used to set form parameters (as if the request is a ```` being submitted). A table node must be used to specify which fields / values to send: 117 | 118 | .. code-block:: gherkin 119 | 120 | Given the following form parameters are set: 121 | | name | value | 122 | | foo | bar | 123 | | bar | foo | 124 | | bar | bar | 125 | 126 | The first row in the table must contain two values: ``name`` and ``value``. The rows that follows are the fields / values you want to send. This step sets the HTTP method to ``POST`` by default and the ``Content-Type`` request header to ``application/x-www-form-urlencoded``, unless the step is combined with :ref:`given-i-attach-path-to-the-request-as-partname`, in which case the ``Content-Type`` request header will be set to ``multipart/form-data`` and all the specified fields will be sent as parts in the multipart request. 127 | 128 | This step can not be used when sending requests with a request body. Doing so results in an ``InvalidArgumentException`` exception. 129 | 130 | To use a different HTTP method, simply specify the wanted method in the :ref:`when-i-request-path-using-http-method` step. 131 | 132 | Given the request body is: ```` 133 | --------------------------------------------- 134 | 135 | Set the request body to a string represented by the contents of the ````. 136 | 137 | **Examples:** 138 | 139 | .. code-block:: gherkin 140 | 141 | Given the request body is: 142 | """ 143 | { 144 | "some": "data" 145 | } 146 | """ 147 | 148 | Given the request body contains ``:path`` 149 | ----------------------------------------- 150 | 151 | This step can be used to set the contents of the file at ``:path`` in the request body. If the file does not exist or is not readable the step will fail. 152 | 153 | **Examples:** 154 | 155 | =================================================== ================= 156 | Step ``:path`` 157 | =================================================== ================= 158 | Given the request body contains "``/path/to/file``" ``/path/to/file`` 159 | =================================================== ================= 160 | 161 | The step will figure out the mime type of the file (using `mime_content_type `_) and set the ``Content-Type`` request header as well. If you wish to override the mime type you can use the :ref:`given-the-header-request-header-is-value` step **after** setting the request body. 162 | 163 | .. _given-the-response-body-contains-a-jwt: 164 | 165 | Given the response body contains a JWT identified by ``:name``, signed with ``:secret``: ```` 166 | ----------------------------------------------------------------------------------------------------------- 167 | 168 | This step can be used to prepare the `JWT `_ custom matcher function with data that it is going to match on. If the response contains JWTs these can be registered with this step, then matched with the :ref:`then-the-response-body-contains-json` step after the response has been received. The ```` represents the payload of the JWT: 169 | 170 | **Examples:** 171 | 172 | .. code-block:: gherkin 173 | 174 | Given the response body contains a JWT identified by "my JWT", signed with "some secret": 175 | """ 176 | { 177 | "some": "data", 178 | "value": "@regExp(/(some|expression)/i)" 179 | } 180 | """ 181 | 182 | The above step would register a JWT which can be matched with ``@jwt(my JWT)`` using the :ref:`@jwt() ` custom matcher function. The way the payload is matched is similar to matching a JSON response body, as explained in the :ref:`then-the-response-body-contains-json` section, which means :ref:`custom matcher functions ` can be used, as seen in the example above. 183 | 184 | Given the query parameter ``:name`` is ``:value`` 185 | ------------------------------------------------- 186 | 187 | This step can be used to set a single query parameter to a specific value for the upcoming request. 188 | 189 | **Examples:** 190 | 191 | .. code-block:: gherkin 192 | 193 | Given the query parameter "foo" is "bar" 194 | And the query parameter "bar" is "foo" 195 | When I request "/path" 196 | 197 | The above steps would end up with a request to ``/path?foo=bar&bar=foo``. 198 | 199 | .. note:: When this step is used all query parameters specified in the path portion of ``When I request "/path"`` are ignored. 200 | 201 | Given the query parameter ``:name`` is: ```` 202 | ------------------------------------------------------- 203 | 204 | This step can be used to set multiple values to a single query parameter for the upcoming request. 205 | 206 | **Examples:** 207 | 208 | .. code-block:: gherkin 209 | 210 | Given the query parameter "foo" is: 211 | | value | 212 | | foo | 213 | | bar | 214 | When I request "/path" 215 | 216 | The above steps would end up with a request to ``/path?foo[0]=foo&foo[1]=bar``. 217 | 218 | .. note:: When this step is used all query parameters specified in the path portion of ``When I request "/path"`` are ignored. 219 | 220 | Given the following query parameters are set: ```` 221 | ------------------------------------------------------------- 222 | 223 | This step can be used to set multiple query parameters at once for the upcoming request. 224 | 225 | **Examples:** 226 | 227 | .. code-block:: gherkin 228 | 229 | Given the following query parameters are set: 230 | | name | value | 231 | | foo | bar | 232 | | bar | foo | 233 | When I request "/path" 234 | 235 | The above steps would end up with a request to ``/path?foo=bar&bar=foo``. 236 | 237 | .. note:: When this step is used all query parameters specified in the path portion of ``When I request "/path"`` are ignored. -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Behat API Extension 2 | ################### 3 | 4 | An open source (`MIT licensed `_) Behat extension that provides an easy way to test JSON-based APIs in Behat 3. 5 | 6 | Installation guide 7 | ****************** 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | installation/requirements 13 | installation/installation 14 | installation/configuration 15 | installation/upgrade 16 | 17 | End user guide 18 | ************** 19 | 20 | .. toctree:: 21 | :maxdepth: 3 22 | 23 | guide/setup-request 24 | guide/send-request 25 | guide/verify-server-response 26 | guide/extending-the-extension 27 | -------------------------------------------------------------------------------- /docs/installation/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | After you have installed the extension you need to activate it in your Behat configuration file (for instance ``behat.yml``): 5 | 6 | .. code-block:: yaml 7 | 8 | default: 9 | suites: 10 | default: 11 | # ... 12 | 13 | extensions: 14 | Imbo\BehatApiExtension: ~ 15 | 16 | The following configuration options are required for the extension to work as expected: 17 | 18 | ====================== ====== ===================== ======================================= 19 | Key Type Default value Description 20 | ====================== ====== ===================== ======================================= 21 | ``apiClient.base_uri`` string http://localhost:8080 Base URI of the application under test. 22 | ====================== ====== ===================== ======================================= 23 | 24 | It should be noted that everything in the ``apiClient`` configuration array is passed directly to the Guzzle Client instance used internally by the extension. 25 | 26 | Example of a configuration file with several configuration entries: 27 | 28 | .. code-block:: yaml 29 | 30 | default: 31 | suites: 32 | default: 33 | # ... 34 | 35 | extensions: 36 | Imbo\BehatApiExtension: 37 | apiClient: 38 | base_uri: http://localhost:8080 39 | timeout: 5.0 40 | verify: false 41 | 42 | Refer to the `Guzzle documentation `_ for available configuration options for the Guzzle client. 43 | -------------------------------------------------------------------------------- /docs/installation/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install the extension using `Composer `_: 5 | 6 | .. code-block:: console 7 | 8 | composer require --dev imbo/behat-api-extension 9 | -------------------------------------------------------------------------------- /docs/installation/requirements.rst: -------------------------------------------------------------------------------- 1 | Requirements 2 | ============ 3 | 4 | Refer to ``composer.json`` for more details. 5 | -------------------------------------------------------------------------------- /docs/installation/upgrade.rst: -------------------------------------------------------------------------------- 1 | Upgrading 2 | ========= 3 | 4 | This section will cover breaking changes between major versions and other related information to ease upgrading to the latest version. 5 | 6 | Migration from v5.x to v6.x 7 | --------------------------- 8 | 9 | .. contents:: Changes 10 | :local: 11 | :depth: 1 12 | 13 | PHP version requirement 14 | ^^^^^^^^^^^^^^^^^^^^^^^ 15 | 16 | ``v6.x`` requires ``PHP >= 8.3``. 17 | 18 | Migration from v4.x to v5.x 19 | --------------------------- 20 | 21 | .. contents:: Changes 22 | :local: 23 | :depth: 1 24 | 25 | Internal HTTP client configuration 26 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 27 | 28 | Previous versions of the extension suggested using the ``GuzzleHttp\Client::getConfig()`` method to customize the internal HTTP client. This method has been deprecated, and the initialization of the internal HTTP client in the extension had to be changed as a consequence. Refer to the :ref:`configure-the-api-client` section for more information. 29 | 30 | Migration from v3.x to v4.x 31 | --------------------------- 32 | 33 | .. contents:: Changes 34 | :local: 35 | :depth: 1 36 | 37 | PHP version requirement 38 | ^^^^^^^^^^^^^^^^^^^^^^^ 39 | 40 | ``v4.x`` requires ``PHP >= 8.1``. 41 | 42 | Type hints 43 | ^^^^^^^^^^ 44 | 45 | Type hints have been added to a plethora of the code base, so child classes will most likely break as a consequence. You will have to add missing type hints if you have extended any classes that have type hints added to them. 46 | 47 | Migration from v2.x to v3.x 48 | --------------------------- 49 | 50 | .. contents:: Changes 51 | :local: 52 | :depth: 1 53 | 54 | The usage of Behat API Extension itself has not changed between these versions, but ``>=3.0`` requires ``PHP >= 7.4``. 55 | 56 | Migrating from v1.x to v2.x 57 | --------------------------- 58 | 59 | .. contents:: Changes 60 | :local: 61 | :depth: 1 62 | 63 | Configuration change 64 | ^^^^^^^^^^^^^^^^^^^^ 65 | 66 | In ``v1`` the extension only had a single configuration option, which was ``base_uri``. This is still an option in ``v2``, but it has been added to an ``apiClient`` key. 67 | 68 | **v1 behat.yml** 69 | 70 | .. code-block:: yaml 71 | 72 | default: 73 | suites: 74 | default: 75 | # ... 76 | 77 | extensions: 78 | Imbo\BehatApiExtension: 79 | base_uri: http://localhost:8080 80 | 81 | **v2 behat.yml** 82 | 83 | .. code-block:: yaml 84 | 85 | default: 86 | suites: 87 | default: 88 | # ... 89 | 90 | extensions: 91 | Imbo\BehatApiExtension: 92 | apiClient: 93 | base_uri: http://localhost:8080 94 | 95 | Renamed public methods 96 | ^^^^^^^^^^^^^^^^^^^^^^ 97 | 98 | The following public methods in the ``Imbo\BehatApiExtension\Context\ApiContext`` class have been renamed: 99 | 100 | ==================================================== ========================================= 101 | ``v1`` method name ``v2`` method name 102 | ==================================================== ========================================= 103 | ``givenIAttachAFileToTheRequest`` ``addMultipartFileToRequest`` 104 | ``givenIAuthenticateAs`` ``setBasicAuth`` 105 | ``givenTheRequestHeaderIs`` ``addRequestHeader`` 106 | ``giventhefollowingformparametersareset`` ``setRequestFormParams`` 107 | ``givenTheRequestBodyIs`` ``setRequestBody`` 108 | ``givenTheRequestBodyContains`` ``setRequestBodyToFileResource`` 109 | ``whenIRequestPath`` ``requestPath`` 110 | ``thenTheResponseCodeIs`` ``assertResponseCodeIs`` 111 | ``thenTheResponseCodeIsNot`` ``assertResponseCodeIsNot`` 112 | ``thenTheResponseReasonPhraseIs`` ``assertResponseReasonPhraseIs`` 113 | ``thenTheResponseStatusLineIs`` ``assertResponseStatusLineIs`` 114 | ``thenTheResponseIs`` ``assertResponseIs`` 115 | ``thenTheResponseIsNot`` ``assertResponseIsNot`` 116 | ``thenTheResponseHeaderExists`` ``assertResponseHeaderExists`` 117 | ``thenTheResponseHeaderDoesNotExist`` ``assertResponseHeaderDoesNotExists`` 118 | ``thenTheResponseHeaderIs`` ``assertResponseHeaderIs`` 119 | ``thenTheResponseHeaderMatches`` ``assertResponseHeaderMatches`` 120 | ``thenTheResponseBodyIsAnEmptyObject`` ``assertResponseBodyIsAnEmptyJsonObject`` 121 | ``thenTheResponseBodyIsAnEmptyArray`` ``assertResponseBodyIsAnEmptyJsonArray`` 122 | ``thenTheResponseBodyIsAnArrayOfLength`` ``assertResponseBodyJsonArrayLength`` 123 | ``thenTheResponseBodyIsAnArrayWithALengthOfAtLeast`` ``assertResponseBodyJsonArrayMinLength`` 124 | ``thenTheResponseBodyIsAnArrayWithALengthOfAtMost`` ``assertResponseBodyJsonArrayMaxLength`` 125 | ``thenTheResponseBodyIs`` ``assertResponseBodyIs`` 126 | ``thenTheResponseBodyMatches`` ``assertResponseBodyMatches`` 127 | ``thenTheResponseBodyContains`` ``assertResponseBodyContainsJson`` 128 | ==================================================== ========================================= 129 | 130 | Some methods have also been removed (as the result of removed steps): 131 | 132 | * ``whenIRequestPathWithBody`` 133 | * ``whenIRequestPathWithJsonBody`` 134 | * ``whenISendFile`` 135 | 136 | Updated steps 137 | ^^^^^^^^^^^^^ 138 | 139 | ``v1`` contained several ``When`` steps that could configure the request as well as sending it, in the same step. These steps has been removed in ``v2.0.0``, and the extension now requires you to configure all aspects of the request using the ``Given`` steps prior to issuing one of the few ``When`` steps. 140 | 141 | .. contents:: Removed / updated steps 142 | :local: 143 | 144 | Given the request body is ``:string`` 145 | """"""""""""""""""""""""""""""""""""" 146 | 147 | This step now uses a ```` instead of a regular string: 148 | 149 | **v1** 150 | 151 | .. code-block:: gherkin 152 | 153 | Given the request body is "some data" 154 | 155 | **v2** 156 | 157 | .. code-block:: gherkin 158 | 159 | Given the request body is: 160 | """ 161 | some data 162 | """ 163 | 164 | When I request ``:path`` using HTTP ``:method`` with body: ```` 165 | """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" 166 | 167 | The body needs to be set using a ``Given`` step and not in the ``When`` step: 168 | 169 | **v1** 170 | 171 | .. code-block:: gherkin 172 | 173 | When I request "/some/path" using HTTP POST with body: 174 | """ 175 | {"some":"data"} 176 | """ 177 | 178 | **v2** 179 | 180 | .. code-block:: gherkin 181 | 182 | Given the request body is: 183 | """ 184 | {"some":"data"} 185 | """ 186 | When I request "/some/path" using HTTP POST 187 | 188 | When I request ``:path`` using HTTP ``:method`` with JSON body: ```` 189 | """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" 190 | 191 | The ``Content-Type`` header and body needs to be set using ``Given`` steps: 192 | 193 | **v1** 194 | 195 | .. code-block:: gherkin 196 | 197 | When I request "/some/path" using HTTP POST with JSON body: 198 | """ 199 | {"some":"data"} 200 | """ 201 | 202 | **v2** 203 | 204 | .. code-block:: gherkin 205 | 206 | Given the request body is: 207 | """ 208 | {"some":"data"} 209 | """ 210 | And the "Content-Type" request header is "application/json" 211 | When I request "/some/path" using HTTP POST 212 | 213 | When I send ``:filePath`` (as ``:mimeType``) to ``:path`` using HTTP ``:method`` 214 | """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" 215 | 216 | These steps must be replaced with the following: 217 | 218 | **v1** 219 | 220 | .. code-block:: gherkin 221 | 222 | When I send "/some/file.jpg" to "/some/endpoint" using HTTP POST 223 | 224 | .. code-block:: gherkin 225 | 226 | When I send "/some/file" as "application/json" to "/some/endpoint" using HTTP POST 227 | 228 | **v2** 229 | 230 | .. code-block:: gherkin 231 | 232 | Given the request body contains "/some/file.jpg" 233 | When I request "/some/endpoint" using HTTP POST 234 | 235 | .. code-block:: gherkin 236 | 237 | Given the request body contains "/some/file" 238 | And the "Content-Type" request header is "application/json" 239 | When I request "/some/endpoint" using HTTP POST 240 | 241 | The first form in the old and new versions will guess the mime type of the file and set the ``Content-Type`` request header accordingly. 242 | 243 | Then the response body is an empty object 244 | """"""""""""""""""""""""""""""""""""""""" 245 | 246 | Slight change that adds "JSON" in the step text for clarification: 247 | 248 | **v1** 249 | 250 | .. code-block:: gherkin 251 | 252 | Then the response body is an empty object 253 | 254 | **v2** 255 | 256 | .. code-block:: gherkin 257 | 258 | Then the response body is an empty JSON object 259 | 260 | Then the response body is an empty array 261 | """""""""""""""""""""""""""""""""""""""" 262 | 263 | Slight change that adds "JSON" in the step text for clarification: 264 | 265 | **v1** 266 | 267 | .. code-block:: gherkin 268 | 269 | Then the response body is an empty array 270 | 271 | **v2** 272 | 273 | .. code-block:: gherkin 274 | 275 | Then the response body is an empty JSON array 276 | 277 | Then the response body is an array of length ``:length`` 278 | """""""""""""""""""""""""""""""""""""""""""""""""""""""" 279 | 280 | Slight change that adds "JSON" in the step text for clarification: 281 | 282 | **v1** 283 | 284 | .. code-block:: gherkin 285 | 286 | Then the response body is an array of length 5 287 | 288 | **v2** 289 | 290 | .. code-block:: gherkin 291 | 292 | Then the response body is a JSON array of length 5 293 | 294 | Then the response body is an array with a length of at least ``:length`` 295 | """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" 296 | 297 | Slight change that adds "JSON" in the step text for clarification: 298 | 299 | **v1** 300 | 301 | .. code-block:: gherkin 302 | 303 | Then the response body is an array with a length of at least 5 304 | 305 | **v2** 306 | 307 | .. code-block:: gherkin 308 | 309 | Then the response body is a JSON array with a length of at least 5 310 | 311 | Then the response body is an array with a length of at most ``:length`` 312 | """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" 313 | 314 | Slight change that adds "JSON" in the step text for clarification: 315 | 316 | **v1** 317 | 318 | .. code-block:: gherkin 319 | 320 | Then the response body is an array with a length of at most 5 321 | 322 | **v2** 323 | 324 | .. code-block:: gherkin 325 | 326 | Then the response body is a JSON array with a length of at most 5 327 | 328 | Then the response body contains: ```` 329 | """"""""""""""""""""""""""""""""""""""""""""""""""" 330 | 331 | Slight change that adds "JSON" in the step text for clarification: 332 | 333 | **v1** 334 | 335 | .. code-block:: gherkin 336 | 337 | Then the response body contains: 338 | """ 339 | {"some": "value"} 340 | """ 341 | 342 | **v2** 343 | 344 | .. code-block:: gherkin 345 | 346 | Then the response body contains JSON: 347 | """ 348 | {"some": "value"} 349 | """ 350 | 351 | Functions names for the JSON matcher 352 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 353 | 354 | When recursively checking a JSON response body, some custom functions exist that is represented as the value in a key / value pair. Below is a table of all available functions in ``v1`` along with the updated names used in ``v2``: 355 | 356 | ====================== ======================== 357 | ``v1`` function ``v2`` function 358 | ====================== ======================== 359 | ``@length(num)`` ``@arrayLength(num)`` 360 | ``@atLeast(num)`` ``@arrayMinLength(num)`` 361 | ``@atMost(num)`` ``@arrayMaxLength(num)`` 362 | ``/pattern/`` ``@regExp(/pattern/)`` 363 | ====================== ======================== 364 | 365 | ``v2`` have also added more such functions, refer to the :ref:`custom-matcher-functions-and-targeting` section for a complete list. 366 | 367 | Exceptions 368 | ^^^^^^^^^^ 369 | 370 | The extension will from ``v2`` on throw native PHP exceptions or namespaced exceptions (like for instance ``Imbo\BehatApiExtension\Exception\AssertionException``). In ``v1`` exceptions could come directly from ``beberlei/assert``, which is the assertion library used in the extension. The fact that the extension uses this library is an implementation detail, and it should be possible to switch out this library without making any changes to the public API of the extension. 371 | 372 | If versions after ``v2`` throws other exceptions it should be classified as a bug and fixed accordingly. 373 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinxcontrib-phpdomain 2 | sphinx_rtd_theme -------------------------------------------------------------------------------- /features/README.md: -------------------------------------------------------------------------------- 1 | # Behat tests 2 | 3 | For these tests to pass an HTTP server must be set up to listen on `http://localhost:8080`, with a document root set to the `bootstrap` directory. A composer script exists in `composer.json` that uses PHPs built in web server for this purpose. 4 | 5 | In the project root directory, run the following command: 6 | 7 | composer dev --timeout=0 8 | 9 | and to execute the tests run (also from the project root): 10 | 11 | composer test:behat 12 | 13 | or 14 | 15 | ./vendor/bin/behat --strict 16 | -------------------------------------------------------------------------------- /features/add-custom-matcher-functions.feature: -------------------------------------------------------------------------------- 1 | Feature: Custom function addition 2 | In order to enable custom matcher functions 3 | As a developer 4 | I need to be able to add the matcher in the feature context 5 | 6 | Background: 7 | Given a file named "features/bootstrap/FeatureContext.php" with: 8 | """ 9 | addFunction('myMatcher', new MyMatcher()); 25 | $comparator->addFunction('valueIs', function ($actual, $expected) { 26 | if ($actual !== $expected) { 27 | throw new InvalidArgumentException(sprintf( 28 | 'Expected "%s", got "%s".', 29 | $expected, 30 | $actual 31 | )); 32 | } 33 | }); 34 | 35 | return parent::setArrayContainsComparator($comparator); 36 | } 37 | 38 | /** 39 | * @Then :actual is :expected 40 | */ 41 | public function assertValueIsBar($actual, $expected) { 42 | $needle = ['value' => sprintf('@valueIs(%s)', $expected)]; 43 | $haystack = ['value' => $actual]; 44 | 45 | Assertion::true( 46 | $this->arrayContainsComparator->compare($needle, $haystack) 47 | ); 48 | } 49 | } 50 | """ 51 | 52 | Scenario: Custom function passes 53 | Given a file named "behat.yml" with: 54 | """ 55 | default: 56 | formatters: 57 | progress: ~ 58 | extensions: 59 | Imbo\BehatApiExtension: ~ 60 | """ 61 | And a file named "features/test-custom-function.feature" with: 62 | """ 63 | Feature: Custom matcher function 64 | In order to use a custom matcher function 65 | As a feature runner 66 | I need to be able to expose the function 67 | 68 | Scenario: Call step that invokes custom matcher function 69 | Then "foo" is "foo" 70 | """ 71 | When I run "behat features/test-custom-function.feature" 72 | Then it should pass with: 73 | """ 74 | . 75 | 76 | 1 scenario (1 passed) 77 | 1 step (1 passed) 78 | """ 79 | 80 | Scenario: Custom function fails 81 | Given a file named "behat.yml" with: 82 | """ 83 | default: 84 | formatters: 85 | progress: ~ 86 | extensions: 87 | Imbo\BehatApiExtension: ~ 88 | """ 89 | And a file named "features/test-custom-function-failure.feature" with: 90 | """ 91 | Feature: Custom matcher function 92 | In order to use a custom matcher function 93 | As a feature runner 94 | I need to be able to expose the function 95 | 96 | Scenario: Call step that invokes custom matcher function 97 | Then "actual" is "expected" 98 | """ 99 | When I run "behat features/test-custom-function-failure.feature" 100 | Then it should fail with: 101 | """ 102 | Function "valueIs" failed with error message: "Expected "expected", got "actual".". 103 | """ 104 | 105 | Scenario: Custom myMatcher class passes 106 | Given a file named "behat.yml" with: 107 | """ 108 | default: 109 | formatters: 110 | progress: ~ 111 | extensions: 112 | Imbo\BehatApiExtension: ~ 113 | """ 114 | And a file named "features/test-custom-matcher-class.feature" with: 115 | """ 116 | Feature: Custom matcher function 117 | In order to use a custom matcher function 118 | As a feature runner 119 | I need to be able to expose the function 120 | 121 | Scenario: Call step that invokes custom matcher function 122 | When I request "/" 123 | Then the response body contains JSON: 124 | ''' 125 | { 126 | "string": "@myMatcher()" 127 | } 128 | ''' 129 | """ 130 | When I run "behat features/test-custom-matcher-class.feature" 131 | Then it should pass with: 132 | """ 133 | .. 134 | 135 | 1 scenario (1 passed) 136 | 2 steps (2 passed) 137 | """ 138 | 139 | Scenario: Custom myMatcher class passes when used in list 140 | Given a file named "behat.yml" with: 141 | """ 142 | default: 143 | formatters: 144 | progress: ~ 145 | extensions: 146 | Imbo\BehatApiExtension: ~ 147 | """ 148 | And a file named "features/test-custom-matcher-class-in-list.feature" with: 149 | """ 150 | Feature: Custom matcher function 151 | In order to use a custom matcher function 152 | As a feature runner 153 | I need to be able to expose the function 154 | 155 | Scenario: Call step that invokes custom matcher function 156 | When I request "/list" 157 | Then the response body contains JSON: 158 | ''' 159 | { 160 | "[0]": { 161 | "string": "@myMatcher()" 162 | } 163 | } 164 | ''' 165 | """ 166 | When I run "behat features/test-custom-matcher-class-in-list.feature" 167 | Then it should pass with: 168 | """ 169 | .. 170 | 171 | 1 scenario (1 passed) 172 | 2 steps (2 passed) 173 | """ 174 | 175 | Scenario: Custom myMatcher class fails 176 | Given a file named "behat.yml" with: 177 | """ 178 | default: 179 | formatters: 180 | progress: ~ 181 | extensions: 182 | Imbo\BehatApiExtension: ~ 183 | """ 184 | And a file named "features/test-custom-matcher-class-fails.feature" with: 185 | """ 186 | Feature: Custom matcher function 187 | In order to use a custom matcher function 188 | As a feature runner 189 | I need to be able to expose the function 190 | 191 | Scenario: Call step that invokes custom matcher function 192 | When I request "/" 193 | Then the response body contains JSON: 194 | ''' 195 | { 196 | "integer": "@myMatcher()" 197 | } 198 | ''' 199 | """ 200 | When I run "behat features/test-custom-matcher-class-fails.feature" 201 | Then it should fail with: 202 | """ 203 | Want string yo 204 | """ 205 | 206 | Scenario: Custom myMatcher class fails when used with list 207 | Given a file named "behat.yml" with: 208 | """ 209 | default: 210 | formatters: 211 | progress: ~ 212 | extensions: 213 | Imbo\BehatApiExtension: ~ 214 | """ 215 | And a file named "features/test-custom-matcher-class-fails-in-list.feature" with: 216 | """ 217 | Feature: Custom matcher function 218 | In order to use a custom matcher function 219 | As a feature runner 220 | I need to be able to expose the function 221 | 222 | Scenario: Call step that invokes custom matcher function 223 | When I request "/list" 224 | Then the response body contains JSON: 225 | ''' 226 | { 227 | "[0]": { 228 | "integer": "@myMatcher()" 229 | } 230 | } 231 | ''' 232 | """ 233 | When I run "behat features/test-custom-matcher-class-fails-in-list.feature" 234 | Then it should fail with: 235 | """ 236 | Want string yo 237 | """ 238 | 239 | -------------------------------------------------------------------------------- /features/auth.feature: -------------------------------------------------------------------------------- 1 | Feature: Test auth steps 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | suites: 17 | default: 18 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 19 | """ 20 | 21 | Scenario: Successfully authenticate 22 | Given a file named "features/auth-success.feature" with: 23 | """ 24 | Feature: Set up the request 25 | Scenario: Specify auth 26 | Given I am authenticating as "foo" with password "bar" 27 | When I request "/basicAuth" 28 | Then the response body contains JSON: 29 | ''' 30 | { 31 | "user": "foo" 32 | } 33 | ''' 34 | """ 35 | When I run "behat features/auth-success.feature" 36 | Then it should pass with: 37 | """ 38 | ... 39 | 40 | 1 scenario (1 passed) 41 | 3 steps (3 passed) 42 | """ 43 | 44 | Scenario: Unsuccessful authentication 45 | Given a file named "features/auth-no-success.feature" with: 46 | """ 47 | Feature: Set up the request 48 | Scenario: Specify auth 49 | Given I am authenticating as "foo" with password "foobar" 50 | When I request "/basicAuth" 51 | Then the response code is 401 52 | """ 53 | When I run "behat features/auth-no-success.feature" 54 | Then it should pass with: 55 | """ 56 | ... 57 | 58 | 1 scenario (1 passed) 59 | 3 steps (3 passed) 60 | """ 61 | 62 | Scenario: Successfully OAuth 63 | Given a file named "features/oauth-success.feature" with: 64 | """ 65 | Feature: Set up the request 66 | Scenario: Specify auth 67 | Given I get an OAuth token using password grant from "/oauth/token" with "foo" and "bar" in scope "baz" using client ID "id" and client secret "secret" 68 | When I request "/securedWithOAuth" 69 | Then the response code is 200 70 | And the response body contains JSON: 71 | ''' 72 | { 73 | "users": { 74 | "foo": "bar" 75 | } 76 | } 77 | ''' 78 | """ 79 | When I run "behat features/oauth-success.feature" 80 | Then it should pass with: 81 | """ 82 | ... 83 | 84 | 1 scenario (1 passed) 85 | 4 steps (4 passed) 86 | """ 87 | 88 | Scenario: Unsuccessfully OAuth 89 | Given a file named "features/oauth-no-success.feature" with: 90 | """ 91 | Feature: Set up the request 92 | Scenario: Specify auth 93 | Given I get an OAuth token using password grant from "/oauth/token" with "invalid" and "invalid" in scope "baz" using client ID "id" 94 | When I request "/securedWithOAuth" 95 | """ 96 | When I run "behat features/oauth-no-success.feature" 97 | Then it should fail with: 98 | """ 99 | Expected request for access token to pass, got status code 401 with the following response: {"error":"invalid_request"} (RuntimeException) 100 | 101 | 1 scenario (1 failed) 102 | 2 steps (1 failed, 1 skipped) 103 | """ 104 | 105 | Scenario: Invalid OAuth token response 106 | Given a file named "features/oauth-missing-token.feature" with: 107 | """ 108 | Feature: Set up the request 109 | Scenario: Specify auth 110 | Given I get an OAuth token using password grant from "/echoHttpMethod" with "foo" and "bar" in scope "baz" using client ID "id" 111 | When I request "/securedWithOAuth" 112 | """ 113 | When I run "behat features/oauth-missing-token.feature" 114 | Then it should fail with: 115 | """ 116 | Missing access_token from response body: {"method":"POST"} (RuntimeException) 117 | 118 | 1 scenario (1 failed) 119 | 2 steps (1 failed, 1 skipped) 120 | """ -------------------------------------------------------------------------------- /features/bootstrap/FeatureContext.php: -------------------------------------------------------------------------------- 1 | find()) === false) { 54 | throw new RuntimeException('Unable to find the PHP executable.'); 55 | } 56 | 57 | $this->workingDir = $dir; 58 | $this->phpBin = $bin; 59 | } 60 | 61 | /** 62 | * Creates a file with specified name and content in the current working dir 63 | * 64 | * @param string $filename Name of the file relative to the working dir 65 | * @param PyStringNode $content Content of the file 66 | * @param bool $readable Whether or not the created file is readable 67 | * 68 | * @Given a file named :filename with: 69 | */ 70 | public function createFile(string $filename, PyStringNode $content, bool $readable = true): void 71 | { 72 | $filename = rtrim((string) $this->workingDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($filename, DIRECTORY_SEPARATOR); 73 | $path = dirname($filename); 74 | $content = str_replace("'''", '"""', (string) $content); 75 | 76 | if (!is_dir($path)) { 77 | mkdir($path, 0777, true); 78 | } 79 | 80 | file_put_contents($filename, $content); 81 | 82 | if (!$readable) { 83 | chmod($filename, 0000); 84 | } 85 | } 86 | 87 | /** 88 | * Creates a non-readable file with specified name and content in the current working dir 89 | * 90 | * @param string $filename Name of the file relative to the working dir 91 | * @param PyStringNode $content Content of the file 92 | * 93 | * @Given a non-readable file named :filename with: 94 | */ 95 | public function createNonReadableFile(string $filename, PyStringNode $content): void 96 | { 97 | $this->createFile($filename, $content, false); 98 | } 99 | 100 | /** 101 | * Runs Behat 102 | * 103 | * @throws RuntimeException 104 | * 105 | * @When /^I run "behat(?: ((?:\"|[^"])*))?"$/ 106 | */ 107 | public function runBehat(string $args = ''): void 108 | { 109 | if (!defined('BEHAT_BIN_PATH')) { 110 | throw new RuntimeException('Missing BEHAT_BIN_PATH constant'); 111 | } 112 | 113 | $args = strtr($args, ['\'' => '"']); 114 | 115 | $this->process = Process::fromShellCommandline( 116 | sprintf( 117 | '%s %s %s %s', 118 | (string) $this->phpBin, 119 | escapeshellarg((string) BEHAT_BIN_PATH), 120 | $args, 121 | '--format-settings="{\"timer\": false}" --no-colors', 122 | ), 123 | $this->workingDir, 124 | ); 125 | 126 | 127 | $this->process->start(); 128 | $this->process->wait(); 129 | } 130 | 131 | /** 132 | * Checks whether the command failed or passed, with output 133 | * 134 | * @Then /^it should (fail|pass) with:$/ 135 | */ 136 | public function assertCommandResultWithOutput(string $result, PyStringNode $output): void 137 | { 138 | $this->assertCommandResult($result); 139 | $this->assertCommandOutputMatches($output); 140 | } 141 | 142 | /** 143 | * Assert command output contains a string 144 | * 145 | * @Then the output should contain: 146 | */ 147 | public function assertCommandOutputMatches(PyStringNode $content): void 148 | { 149 | Assertion::contains( 150 | $this->getOutput(), 151 | str_replace("'''", '"""', (string) $content), 152 | sprintf('Command output does not match. Actual output: %s', $this->getOutput()), 153 | ); 154 | } 155 | 156 | /** 157 | * Checks whether the command failed or passed 158 | * 159 | * @Then /^it should (fail|pass)$/ 160 | */ 161 | public function assertCommandResult(string $result): void 162 | { 163 | $exitCode = $this->getExitCode(); 164 | 165 | // Escape % as the callback will pass this value to sprintf() if the assertion fails, and 166 | // sprintf might complain about too few arguments as the output might contain stuff like %s 167 | // or %d. 168 | $output = str_replace('%', '%%', $this->getOutput()); 169 | 170 | if ($result === 'fail') { 171 | $callback = 'notEq'; 172 | $errorMessage = sprintf( 173 | 'Invalid exit code, did not expect 0. Command output: %s', 174 | $output, 175 | ); 176 | } else { 177 | $callback = 'eq'; 178 | $errorMessage = sprintf( 179 | 'Expected exit code 0, got %d. Command output: %s', 180 | $exitCode, 181 | $output, 182 | ); 183 | } 184 | 185 | Assertion::$callback(0, $exitCode, $errorMessage); 186 | } 187 | 188 | /** 189 | * Get the exit code of the process 190 | * 191 | * @throws RuntimeException 192 | */ 193 | private function getExitCode(): int 194 | { 195 | if (null === $this->process) { 196 | throw new RuntimeException('No process is running'); 197 | } 198 | 199 | $code = $this->process->getExitCode(); 200 | 201 | if (null === $code) { 202 | throw new RuntimeException('Process is not finished'); 203 | } 204 | 205 | return $code; 206 | } 207 | 208 | /** 209 | * Get output from the process 210 | * 211 | * @throws RuntimeException 212 | */ 213 | private function getOutput(): string 214 | { 215 | if (null === $this->process) { 216 | throw new RuntimeException('No process is running'); 217 | } 218 | 219 | $output = $this->process->getErrorOutput() . $this->process->getOutput(); 220 | 221 | return trim((string) preg_replace('/ +$/m', '', $output)); 222 | } 223 | 224 | /** 225 | * Recursively delete a directory 226 | * 227 | * @param string $path Path to a file or a directory 228 | */ 229 | private static function rmdir(string $path): void 230 | { 231 | /** @var array */ 232 | $files = glob(sprintf('%s/*', $path)); 233 | 234 | foreach ($files as $file) { 235 | if (is_dir($file)) { 236 | self::rmdir($file); 237 | } else { 238 | unlink($file); 239 | } 240 | } 241 | 242 | // Remove the remaining directory 243 | rmdir($path); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /features/bootstrap/index.php: -------------------------------------------------------------------------------- 1 | add(new HttpBasicAuthentication([ 14 | 'path' => '/basicAuth', 15 | 'realm' => 'Protected', 16 | 'users' => [ 17 | 'foo' => 'bar', 18 | ], 19 | ])); 20 | 21 | /** 22 | * Front page 23 | */ 24 | $app->any('/', function (Request $request, Response $response): Response { 25 | $response->getBody()->write((string) json_encode([ 26 | 'null' => null, 27 | 'string' => 'value', 28 | 'integer' => 42, 29 | 'float' => 4.2, 30 | 'bool true' => true, 31 | 'bool false' => false, 32 | 'list' => [1, 2, 3, [1], ['foo' => 'bar']], 33 | 'sub' => [ 34 | 'string' => 'value', 35 | 'integer' => 42, 36 | 'float' => 4.2, 37 | 'bool true' => true, 38 | 'bool false' => false, 39 | 'list' => [1, 2, 3, [1], ['foo' => 'bar']], 40 | ], 41 | 'types' => [ 42 | 'string' => 'string', 43 | 'integer' => 123, 44 | 'double' => 1.23, 45 | 'array' => [1, '2', 3], 46 | 'bool' => true, 47 | 'null' => null, 48 | 'scalar' => '123', 49 | ], 50 | ])); 51 | return $response 52 | ->withHeader('Content-Type', 'application/json') 53 | ->withHeader('X-Foo', 'foo'); 54 | }); 55 | 56 | /** 57 | * List with objects 58 | */ 59 | $app->any('/list', function (Request $request, Response $response): Response { 60 | $response->getBody()->write((string) json_encode([ 61 | [ 62 | 'integer' => 123, 63 | 'string' => 'value', 64 | ], 65 | ])); 66 | 67 | return $response 68 | ->withHeader('Content-Type', 'application/json'); 69 | }); 70 | 71 | /** 72 | * Echo the request body 73 | */ 74 | $app->any('/echo', function (Request $request, Response $response): Response { 75 | // Set the same Content-Type header in the response as found in the request 76 | if ($contentType = $request->getHeaderLine('Content-Type')) { 77 | $response = $response->withHeader('Content-Type', $contentType); 78 | } 79 | 80 | $requestBody = (string) $request->getBody(); 81 | 82 | if (array_key_exists('json', $request->getQueryParams())) { 83 | $response->getBody()->write((string) json_encode(json_decode($requestBody, true))); 84 | $response = $response->withHeader('Content-Type', 'application/json'); 85 | } else { 86 | $response->getBody()->write($requestBody); 87 | } 88 | 89 | return $response; 90 | }); 91 | 92 | /** 93 | * Return information about uploaded files 94 | */ 95 | $app->post('/files', function (Request $request, Response $response): Response { 96 | $response->getBody()->write((string) json_encode($_FILES)); 97 | return $response->withHeader('Content-Type', 'application/json'); 98 | }); 99 | 100 | /** 101 | * Return information about the request 102 | */ 103 | $app->any('/requestInfo', function (Request $request, Response $response): Response { 104 | $response->getBody()->write((string) json_encode([ 105 | '_GET' => $_GET, 106 | '_POST' => $_POST, 107 | '_FILES' => $_FILES, 108 | '_SERVER' => $_SERVER, 109 | 'requestBody' => (string) $request->getBody(), 110 | ])); 111 | return $response->withHeader('Content-Type', 'application/json'); 112 | }); 113 | 114 | /** 115 | * Return the HTTP method 116 | */ 117 | $app->any('/echoHttpMethod', function (Request $request, Response $response): Response { 118 | $response->getBody()->write((string) json_encode([ 119 | 'method' => $request->getMethod(), 120 | ])); 121 | return $response->withHeader('Content-Type', 'application/json'); 122 | }); 123 | 124 | /** 125 | * Return the authenticated user name 126 | */ 127 | $app->any('/basicAuth', function (Request $request, Response $response) { 128 | $response->getBody()->write((string) json_encode([ 129 | 'user' => explode(':', $request->getUri()->getUserInfo())[0], 130 | ])); 131 | 132 | return $response->withHeader('Content-Type', 'application/json'); 133 | }); 134 | 135 | /** 136 | * Return access token given the correct credentials 137 | */ 138 | $app->any('/oauth/token', function (Request $request, Response $response) { 139 | /** @var array{username: string, password: string} */ 140 | $body = $request->getParsedBody(); 141 | 142 | if ('foo' === $body['username'] && 'bar' === $body['password']) { 143 | $responseBody = [ 144 | 'access_token' => 'some_access_token', 145 | ]; 146 | } else { 147 | $response = $response->withStatus(401); 148 | $responseBody = [ 149 | 'error' => 'invalid_request', 150 | ]; 151 | } 152 | 153 | $response->getBody()->write((string) json_encode($responseBody)); 154 | 155 | return $response->withHeader('Content-Type', 'application/json'); 156 | }); 157 | 158 | /** 159 | * Return secured resource if Authorization header is valid. 160 | */ 161 | $app->any('/securedWithOAuth', function (Request $request, Response $response) { 162 | if ('Bearer some_access_token' === $request->getHeaderLine('Authorization')) { 163 | $responseBody = [ 164 | 'users' => [ 165 | 'foo' => 'bar', 166 | ], 167 | ]; 168 | } else { 169 | $response = $response->withStatus(401); 170 | $responseBody = [ 171 | 'error' => 'invalid_request', 172 | ]; 173 | } 174 | 175 | $response->getBody()->write((string) json_encode($responseBody)); 176 | 177 | return $response->withHeader('Content-Type', 'application/json'); 178 | }); 179 | 180 | /** 181 | * Return a client error 182 | */ 183 | $app->any('/clientError', function (Request $request, Response $response): Response { 184 | $response->getBody()->write((string) json_encode([ 185 | 'error' => 'client error', 186 | ])); 187 | return $response 188 | ->withHeader('Content-Type', 'appliation/json') 189 | ->withStatus(400); 190 | }); 191 | 192 | /** 193 | * @see https://github.com/imbo/behat-api-extension/issues/13 194 | */ 195 | $app->any('/issue-13', function (Request $request, Response $response): Response { 196 | $response->getBody()->write((string) json_encode([ 197 | 'customer' => [ 198 | 'id' => '12345', 199 | 'name' => 'Behat Testing API', 200 | 'images' => [ 201 | [ 202 | 'id' => '5678', 203 | 'filename_client' => 'tech.ai', 204 | 'filename_preview' => 'testimage-converted.png', 205 | 'filename_print' => 'testimage.ai', 206 | 'url' => '\/media\/testimage-converted.png', 207 | 'created_time' => '2016-10-10 07 => 28 => 42', 208 | ], [ 209 | 'id' => '7890', 210 | 'filename_client' => 'demo.ai', 211 | 'filename_preview' => 'demoimage-converted.png', 212 | 'filename_print' => 'demoimage.ai', 213 | 'url' => '\/media\/demoimage-converted.png', 214 | 'created_time' => '2016-10-10 07 => 38 => 22', 215 | ], 216 | ], 217 | ], 218 | ])); 219 | return $response->withHeader('Content-Type', 'application/json'); 220 | }); 221 | 222 | /** 223 | * Return a response with a custom reason phrase 224 | */ 225 | $app->get('/customReasonPhrase', function (Request $request, Response $response): Response { 226 | $params = $request->getQueryParams(); 227 | 228 | return $response->withStatus( 229 | !empty($params['code']) ? (int) $params['code'] : 200, 230 | !empty($params['phrase']) ? (string) $params['phrase'] : '', 231 | ); 232 | }); 233 | 234 | /** 235 | * Return a response with an empty array 236 | */ 237 | $app->get('/emptyArray', function (Request $request, Response $response): Response { 238 | $response->getBody()->write((string) json_encode([])); 239 | return $response->withHeader('Content-Type', 'application/json'); 240 | }); 241 | 242 | /** 243 | * Return a response with an empty object 244 | */ 245 | $app->get('/emptyObject', function (Request $request, Response $response): Response { 246 | $response->getBody()->write((string) json_encode(new stdClass())); 247 | return $response->withHeader('Content-Type', 'application/json'); 248 | }); 249 | 250 | /** 251 | * Return a response with an empty body 252 | */ 253 | $app->get('/empty', function (Request $request, Response $response): Response { 254 | return $response->withStatus(204); 255 | }); 256 | 257 | /** 258 | * Return a response with 403 Forbidden 259 | */ 260 | $app->get('/403', function (Request $request, Response $response): Response { 261 | return $response->withStatus(403); 262 | }); 263 | 264 | // Run the application 265 | $app->run(); 266 | -------------------------------------------------------------------------------- /features/built-in-custom-matcher-functions-failures.feature: -------------------------------------------------------------------------------- 1 | Feature: Test built in matcher functions failures 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Assert that @arrayLength can fail 23 | Given a file named "features/array-length-failure.feature" with: 24 | """ 25 | Feature: Verify failure 26 | Scenario: Use custom matcher function 27 | Given the request body is: 28 | ''' 29 | { 30 | "list": [1, 2, 3] 31 | } 32 | ''' 33 | When I request "/echo?json" using HTTP POST 34 | Then the response body contains JSON: 35 | ''' 36 | { 37 | "list": "@arrayLength(2)" 38 | } 39 | ''' 40 | """ 41 | When I run "behat features/array-length-failure.feature" 42 | Then it should fail with: 43 | """ 44 | Function "arrayLength" failed with error message: "Expected array to have exactly 2 entries, actual length: 3.". 45 | """ 46 | 47 | Scenario: Assert that @arrayMaxLength can fail 48 | Given a file named "features/array-max-length-failure.feature" with: 49 | """ 50 | Feature: Verify failure 51 | Scenario: Use custom matcher function 52 | Given the request body is: 53 | ''' 54 | { 55 | "list": [1, 2, 3] 56 | } 57 | ''' 58 | When I request "/echo?json" using HTTP POST 59 | Then the response body contains JSON: 60 | ''' 61 | { 62 | "list": "@arrayMaxLength(2)" 63 | } 64 | ''' 65 | """ 66 | When I run "behat features/array-max-length-failure.feature" 67 | Then it should fail with: 68 | """ 69 | Function "arrayMaxLength" failed with error message: "Expected array to have less than or equal to 2 entries, actual length: 3.". 70 | """ 71 | 72 | Scenario: Assert that @arrayMinLength can fail 73 | Given a file named "features/array-min-length-failure.feature" with: 74 | """ 75 | Feature: Verify failure 76 | Scenario: Use custom matcher function 77 | Given the request body is: 78 | ''' 79 | { 80 | "list": [1, 2, 3] 81 | } 82 | ''' 83 | When I request "/echo?json" using HTTP POST 84 | Then the response body contains JSON: 85 | ''' 86 | { 87 | "list": "@arrayMinLength(4)" 88 | } 89 | ''' 90 | """ 91 | When I run "behat features/array-min-length-failure.feature" 92 | Then it should fail with: 93 | """ 94 | Function "arrayMinLength" failed with error message: "Expected array to have more than or equal to 4 entries, actual length: 3.". 95 | """ 96 | 97 | Scenario: Assert that @variableType can fail 98 | Given a file named "features/variable-type-failure.feature" with: 99 | """ 100 | Feature: Verify failure 101 | Scenario: Use custom matcher function 102 | Given the request body is: 103 | ''' 104 | { 105 | "list": [1, 2, 3] 106 | } 107 | ''' 108 | When I request "/echo?json" using HTTP POST 109 | Then the response body contains JSON: 110 | ''' 111 | { 112 | "list": "@variableType(string)" 113 | } 114 | ''' 115 | """ 116 | When I run "behat features/variable-type-failure.feature" 117 | Then it should fail with: 118 | """ 119 | Function "variableType" failed with error message: "Expected variable type "string", got "array".". 120 | """ 121 | 122 | Scenario: Assert that @variableType with multiple types can fail 123 | Given a file named "features/variable-type-multiple-types-failure.feature" with: 124 | """ 125 | Feature: Verify failure 126 | Scenario: Use custom matcher function 127 | Given the request body is: 128 | ''' 129 | { 130 | "list": [1, 2, 3] 131 | } 132 | ''' 133 | When I request "/echo?json" using HTTP POST 134 | Then the response body contains JSON: 135 | ''' 136 | { 137 | "list": "@variableType(string|int|double|object|null)" 138 | } 139 | ''' 140 | """ 141 | When I run "behat features/variable-type-multiple-types-failure.feature" 142 | Then it should fail with: 143 | """ 144 | Function "variableType" failed with error message: "Expected variable type "string|integer|double|object|null", got "array".". 145 | """ 146 | 147 | Scenario: Assert that @regExp can fail 148 | Given a file named "features/reg-exp-failure.feature" with: 149 | """ 150 | Feature: Verify failure 151 | Scenario: Use custom matcher function 152 | Given the request body is: 153 | ''' 154 | { 155 | "key": "value" 156 | } 157 | ''' 158 | When I request "/echo?json" using HTTP POST 159 | Then the response body contains JSON: 160 | ''' 161 | { 162 | "key": "@regExp(/foo/)" 163 | } 164 | ''' 165 | """ 166 | When I run "behat features/reg-exp-failure.feature" 167 | Then it should fail with: 168 | """ 169 | Function "regExp" failed with error message: "Subject "value" did not match pattern "/foo/".". 170 | """ 171 | 172 | Scenario: Assert that @gt can fail 173 | Given a file named "features/greater-than-failure.feature" with: 174 | """ 175 | Feature: Verify failure 176 | Scenario: Use custom matcher function 177 | Given the request body is: 178 | ''' 179 | { 180 | "number": 123 181 | } 182 | ''' 183 | When I request "/echo?json" using HTTP POST 184 | Then the response body contains JSON: 185 | ''' 186 | { 187 | "number": "@gt(456)" 188 | } 189 | ''' 190 | """ 191 | When I run "behat features/greater-than-failure.feature" 192 | Then it should fail with: 193 | """ 194 | Function "gt" failed with error message: ""123" is not greater than "456". 195 | """ 196 | 197 | Scenario: Assert that @lt can fail 198 | Given a file named "features/less-than-failure.feature" with: 199 | """ 200 | Feature: Verify failure 201 | Scenario: Use custom matcher function 202 | Given the request body is: 203 | ''' 204 | { 205 | "number": 123 206 | } 207 | ''' 208 | When I request "/echo?json" using HTTP POST 209 | Then the response body contains JSON: 210 | ''' 211 | { 212 | "number": "@lt(120)" 213 | } 214 | ''' 215 | """ 216 | When I run "behat features/less-than-failure.feature" 217 | Then it should fail with: 218 | """ 219 | Function "lt" failed with error message: ""123" is not less than "120". 220 | """ 221 | -------------------------------------------------------------------------------- /features/built-in-custom-matcher-functions.feature: -------------------------------------------------------------------------------- 1 | Feature: Test built in matcher functions 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Use custom built in matcher functions 23 | Given a file named "features/custom-matcher-functions.feature" with: 24 | """ 25 | Feature: Verify response with matcher functions 26 | Scenario: Use custom matcher functions 27 | Given the response body contains a JWT identified by "my first jwt", signed with "secret": 28 | ''' 29 | { 30 | "foo": "bar" 31 | } 32 | ''' 33 | And the response body contains a JWT identified by "my second jwt", signed with "secret": 34 | ''' 35 | { 36 | "bar": "foo" 37 | } 38 | ''' 39 | And the request body is: 40 | ''' 41 | { 42 | "emptyList": [], 43 | "list": [1, 2, 3], 44 | "types": { 45 | "string": "some string", 46 | "integer": 123, 47 | "double": 1.23, 48 | "array": [1, 2, 3], 49 | "bool": true, 50 | "null": null, 51 | "scalar": "some string" 52 | }, 53 | "number": 123, 54 | "jwts": [ 55 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIifQ.dtxWM6MIcgoeMgH87tGvsNDY6cHWL6MGW4LeYvnm1JA", 56 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJiYXIiOiJmb28ifQ.iGIMsZebMkO_0_xs1SpQVf7lRB6da72b6xu3RyqOIc8" 57 | ] 58 | } 59 | ''' 60 | When I request "/echo?json" using HTTP POST 61 | Then the response body contains JSON: 62 | ''' 63 | { 64 | "emptyList": "@arrayLength(0)", 65 | "list": "@arrayLength(3)" 66 | } 67 | ''' 68 | And the response body contains JSON: 69 | ''' 70 | { 71 | "emptyList": "@arrayMinLength(0)", 72 | "list": "@arrayMinLength(2)" 73 | } 74 | ''' 75 | And the response body contains JSON: 76 | ''' 77 | { 78 | "emptyList": "@arrayMaxLength(0)", 79 | "list": "@arrayMaxLength(4)" 80 | } 81 | ''' 82 | And the response body contains JSON: 83 | ''' 84 | { 85 | "types": { 86 | "string": "@variableType(string)", 87 | "integer": "@variableType(integer)", 88 | "double": "@variableType(double)", 89 | "array": "@variableType(array)", 90 | "bool": "@variableType(bool)", 91 | "null": "@variableType(null)", 92 | "scalar": "@variableType(scalar)" 93 | } 94 | } 95 | ''' 96 | And the response body contains JSON: 97 | ''' 98 | { 99 | "types": { 100 | "string": "@variableType(any)", 101 | "integer": "@variableType(any)", 102 | "double": "@variableType(any)", 103 | "array": "@variableType(any)", 104 | "bool": "@variableType(any)", 105 | "null": "@variableType(any)", 106 | "scalar": "@variableType(any)" 107 | } 108 | } 109 | ''' 110 | And the response body contains JSON: 111 | ''' 112 | { 113 | "types": { 114 | "string": "@variableType(int|bool|string)", 115 | "integer": "@variableType(double|array|object|integer)", 116 | "double": "@variableType(bool|double)", 117 | "array": "@variableType(int|string|any)", 118 | "bool": "@variableType(double | int | bool)", 119 | "null": "@variableType(string|int|null)", 120 | "scalar": "@variableType(string|bool|scalar|array)" 121 | } 122 | } 123 | ''' 124 | And the response body contains JSON: 125 | ''' 126 | { 127 | "types": { 128 | "string": "@regExp(/SOME STRING/i)", 129 | "integer": "@regExp(/\\d\\d\\d/)", 130 | "double": "@regExp(/[\\d\\.]+/)" 131 | } 132 | } 133 | ''' 134 | And the response body contains JSON: 135 | ''' 136 | { 137 | "number": "@gt(120)" 138 | } 139 | ''' 140 | And the response body contains JSON: 141 | ''' 142 | { 143 | "number": "@lt(125)" 144 | } 145 | ''' 146 | And the response body contains JSON: 147 | ''' 148 | { 149 | "jwts[0]": "@jwt(my first jwt)" 150 | } 151 | ''' 152 | And the response body contains JSON: 153 | ''' 154 | { 155 | "jwts[1]": "@jwt(my second jwt)" 156 | } 157 | ''' 158 | """ 159 | When I run "behat features/custom-matcher-functions.feature" 160 | Then it should pass with: 161 | """ 162 | ............... 163 | 164 | 1 scenario (1 passed) 165 | 15 steps (15 passed) 166 | """ 167 | -------------------------------------------------------------------------------- /features/client-errors.feature: -------------------------------------------------------------------------------- 1 | Feature: Test client errors 2 | In order to test client errors 3 | As a developer 4 | I want to be able to test all related steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Assert a response code of 403 23 | Given a file named "features/403.feature" with: 24 | """ 25 | Feature: Request an endpoint that responds with HTTP 403 26 | Scenario: Request endpoint 27 | When I request "/403" 28 | Then the response code is 403 29 | And the response reason phrase is "Forbidden" 30 | And the response status line is "403 Forbidden" 31 | """ 32 | When I run "behat features/403.feature" 33 | Then it should pass with: 34 | """ 35 | .... 36 | 37 | 1 scenario (1 passed) 38 | 4 steps (4 passed) 39 | """ 40 | -------------------------------------------------------------------------------- /features/configure-client.feature: -------------------------------------------------------------------------------- 1 | Feature: Configure internal client 2 | In order to write custom scenario steps for API testing 3 | As a developer 4 | I need to configure the internal Guzzle client in the feature context 5 | 6 | Scenario: Context parameters 7 | Given a file named "features/bootstrap/FeatureContext.php" with: 8 | """ 9 | push(Middleware::mapRequest( 20 | fn ($req) => $req->withAddedHeader('Some-Custom-Header', 'some value') 21 | )); 22 | $config['handler'] = $stack; 23 | return parent::initializeClient($config); 24 | } 25 | } 26 | """ 27 | And a file named "behat.yml" with: 28 | """ 29 | default: 30 | formatters: 31 | progress: ~ 32 | extensions: 33 | Imbo\BehatApiExtension: ~ 34 | """ 35 | And a file named "features/check-request-headers.feature" with: 36 | """ 37 | Feature: Request data from endpoint 38 | Scenario: Request data from endpoint 39 | When I request "/requestInfo" 40 | Then the response body contains JSON: 41 | ''' 42 | { 43 | "_SERVER": { 44 | "HTTP_SOME_CUSTOM_HEADER": "some value" 45 | } 46 | } 47 | ''' 48 | 49 | """ 50 | When I run "behat features/check-request-headers.feature" 51 | Then it should pass with: 52 | """ 53 | .. 54 | 55 | 1 scenario (1 passed) 56 | 2 steps (2 passed) 57 | """ 58 | -------------------------------------------------------------------------------- /features/context.feature: -------------------------------------------------------------------------------- 1 | Feature: Client aware context 2 | In order to write scenario steps for API testing 3 | As a developer 4 | I need the Guzzle client in the feature context 5 | 6 | Background: 7 | Given a file named "features/bootstrap/FeatureContext.php" with: 8 | """ 9 | set = true; 19 | return $this; 20 | } 21 | 22 | /** 23 | * @Then the client should be set 24 | */ 25 | public function theClientShouldBeSet() { 26 | Assertion::true($this->set); 27 | } 28 | } 29 | """ 30 | 31 | Scenario: Context parameters 32 | Given a file named "behat.yml" with: 33 | """ 34 | default: 35 | extensions: 36 | Imbo\BehatApiExtension: ~ 37 | """ 38 | And a file named "features/client.feature" with: 39 | """ 40 | Feature: API client 41 | In order to call the API 42 | As a feature runner 43 | I need to be able to access the client 44 | 45 | Scenario: client is set 46 | Then the client should be set 47 | """ 48 | When I run "behat -f progress features/client.feature" 49 | Then it should pass with: 50 | """ 51 | . 52 | 53 | 1 scenario (1 passed) 54 | 1 step (1 passed) 55 | """ 56 | -------------------------------------------------------------------------------- /features/file-uploads.feature: -------------------------------------------------------------------------------- 1 | Feature: Test file uploading 2 | In order to test file uploads 3 | As a developer 4 | I want to be able to test all related steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Attach files to the request 23 | Given a file named "features/attach-files.feature" with: 24 | """ 25 | Feature: Set up the request 26 | Scenario: Use the Given step to attach files 27 | Given I attach "behat.yml" to the request as file1 28 | And I attach "features/attach-files.feature" to the request as file2 29 | When I request "/files" using HTTP POST 30 | Then the response body contains JSON: 31 | ''' 32 | { 33 | "file1": { 34 | "name": "behat.yml", 35 | "type": "text/yaml", 36 | "tmp_name": "@regExp(/.*/)", 37 | "error": 0, 38 | "size": "@regExp(/[0-9]+/)" 39 | }, 40 | "file2": { 41 | "name": "attach-files.feature", 42 | "type": "application/octet-stream", 43 | "tmp_name": "@regExp(/.*/)", 44 | "error": 0, 45 | "size": "@regExp(/[0-9]+/)" 46 | } 47 | } 48 | ''' 49 | """ 50 | When I run "behat features/attach-files.feature" 51 | Then it should pass with: 52 | """ 53 | .... 54 | 55 | 1 scenario (1 passed) 56 | 4 steps (4 passed) 57 | """ 58 | -------------------------------------------------------------------------------- /features/form-data.feature: -------------------------------------------------------------------------------- 1 | Feature: Test form-data handling 2 | In order to test form-data handling 3 | As a developer 4 | I want to be able to test all related steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Attach form data to the request with no HTTP method specified 23 | Given a file named "features/attach-form-data.feature" with: 24 | """ 25 | Feature: Set up the request 26 | Scenario: Use the Given step to attach form-data 27 | Given the following form parameters are set: 28 | | name | value | 29 | | foo | bar | 30 | | bar | foo | 31 | | bar | bar | 32 | When I request "/requestInfo" 33 | Then the response body contains JSON: 34 | ''' 35 | { 36 | "_POST": { 37 | "foo": "bar", 38 | "bar": ["foo", "bar"] 39 | }, 40 | "_FILES": "@arrayLength(0)", 41 | "_SERVER": { 42 | "REQUEST_METHOD": "POST" 43 | } 44 | } 45 | ''' 46 | 47 | """ 48 | When I run "behat features/attach-form-data.feature" 49 | Then it should pass with: 50 | """ 51 | ... 52 | 53 | 1 scenario (1 passed) 54 | 3 steps (3 passed) 55 | """ 56 | 57 | Scenario: Attach form data to the request with custom HTTP method 58 | Given a file named "features/attach-form-data-http-patch.feature" with: 59 | """ 60 | Feature: Set up the request 61 | Scenario: Use the Given step to attach form-data 62 | Given the following form parameters are set: 63 | | name | value | 64 | | foo | bar | 65 | | bar | foo | 66 | | bar | bar | 67 | When I request "/requestInfo" using HTTP PATCH 68 | Then the response body contains JSON: 69 | ''' 70 | { 71 | "_POST": "@arrayLength(0)", 72 | "_SERVER": { 73 | "REQUEST_METHOD": "PATCH" 74 | }, 75 | "requestBody": "foo=bar&bar%5B0%5D=foo&bar%5B1%5D=bar" 76 | } 77 | ''' 78 | 79 | """ 80 | When I run "behat features/attach-form-data-http-patch.feature" 81 | Then it should pass with: 82 | """ 83 | ... 84 | 85 | 1 scenario (1 passed) 86 | 3 steps (3 passed) 87 | """ 88 | 89 | Scenario: Attach form data and files to the request 90 | Given a file named "features/attach-form-data-and-files.feature" with: 91 | """ 92 | Feature: Set up the request 93 | Scenario: Use the Given step to attach form-data 94 | Given the following form parameters are set: 95 | | name | value | 96 | | foo | bar | 97 | | bar | foo | 98 | | bar | bar | 99 | And I attach "behat.yml" to the request as file 100 | When I request "/requestInfo" 101 | Then the response body contains JSON: 102 | ''' 103 | { 104 | "_POST": { 105 | "foo": "bar", 106 | "bar": ["foo", "bar"] 107 | }, 108 | "_FILES": { 109 | "file": { 110 | "name": "behat.yml", 111 | "type": "text/yaml", 112 | "tmp_name": "@regExp(/.*/)", 113 | "error": 0, 114 | "size": "@regExp(/[0-9]+/)" 115 | } 116 | }, 117 | "_SERVER": { 118 | "REQUEST_METHOD": "POST" 119 | } 120 | } 121 | ''' 122 | 123 | """ 124 | When I run "behat features/attach-form-data-and-files.feature" 125 | Then it should pass with: 126 | """ 127 | .... 128 | 129 | 1 scenario (1 passed) 130 | 4 steps (4 passed) 131 | """ 132 | 133 | Scenario: Attach multipart form data 134 | Given a file named "features/multipart-form-data.feature" with: 135 | """ 136 | Feature: Set up the request 137 | Scenario: Verify form data 138 | Given the following multipart form parameters are set: 139 | | name | value | 140 | | username | admin | 141 | | password | password | 142 | When I request "/requestInfo" using HTTP "POST" 143 | Then the response body contains JSON: 144 | ''' 145 | { 146 | "_POST": { 147 | "username": "admin", 148 | "password": "password" 149 | }, 150 | "_SERVER": { 151 | "CONTENT_TYPE": "@regExp(/^multipart\\/form-data;/)" 152 | } 153 | } 154 | ''' 155 | 156 | """ 157 | When I run "behat features/multipart-form-data.feature" 158 | Then it should pass with: 159 | """ 160 | ... 161 | 162 | 1 scenario (1 passed) 163 | 3 steps (3 passed) 164 | """ -------------------------------------------------------------------------------- /features/issue-13.feature: -------------------------------------------------------------------------------- 1 | Feature: Fix issue #13 2 | In order to check multidimensional arrays 3 | As an extension 4 | I need to recursively compare arrays 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Recursively compare arrays 23 | Given a file named "features/issue-13.feature" with: 24 | """ 25 | Feature: Recursively check arrays 26 | Scenario: Check the value for a sub-array 27 | When I request "/issue-13" 28 | Then the response code is 200 29 | And the response body contains JSON: 30 | ''' 31 | { 32 | "customer": { 33 | "images[0]": { 34 | "filename_client": "tech.ai" 35 | } 36 | } 37 | } 38 | ''' 39 | """ 40 | When I run "behat features/issue-13.feature" 41 | Then it should pass with: 42 | """ 43 | ... 44 | 45 | 1 scenario (1 passed) 46 | 3 steps (3 passed) 47 | """ 48 | -------------------------------------------------------------------------------- /features/issue-34.feature: -------------------------------------------------------------------------------- 1 | Feature: Fix issue #34 2 | In order to validate a list of objects 3 | As an extension 4 | I need to recursively compare arrays / lists 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Recursively compare objects in a list 23 | Given a file named "features/issue-34.feature" with: 24 | """ 25 | Feature: 26 | Scenario: 27 | Given the request body is: 28 | ''' 29 | [ 30 | { 31 | "id": 1, 32 | "gameId": 1 33 | }, 34 | { 35 | "id": 2, 36 | "gameId": 2 37 | } 38 | ] 39 | ''' 40 | When I request "/echo?json" using HTTP POST 41 | Then the response code is 200 42 | And the response body contains JSON: 43 | ''' 44 | [ 45 | { 46 | "id": 1, 47 | "gameId": 1 48 | }, 49 | { 50 | "id": 2, 51 | "gameId": 2 52 | } 53 | ] 54 | ''' 55 | """ 56 | When I run "behat features/issue-34.feature" 57 | Then it should pass with: 58 | """ 59 | .... 60 | 61 | 1 scenario (1 passed) 62 | 4 steps (4 passed) 63 | """ 64 | 65 | Scenario: Recursively compare objects in a list with failed result 66 | Given a file named "features/issue-34-failure.feature" with: 67 | """ 68 | Feature: 69 | Scenario: 70 | Given the request body is: 71 | ''' 72 | [ 73 | { 74 | "id": 1, 75 | "gameId": 1 76 | }, 77 | { 78 | "id": 2, 79 | "gameId": 2 80 | } 81 | ] 82 | ''' 83 | When I request "/echo?json" using HTTP POST 84 | Then the response code is 200 85 | And the response body contains JSON: 86 | ''' 87 | [ 88 | { 89 | "id": 1, 90 | "gameId": 1 91 | }, 92 | { 93 | "id": 1, 94 | "gameId": 2 95 | } 96 | ] 97 | ''' 98 | """ 99 | When I run "behat features/issue-34-failure.feature" 100 | Then it should fail with: 101 | """ 102 | The object in needle was not found in the object elements in the haystack. 103 | """ 104 | -------------------------------------------------------------------------------- /features/jwt-matcher.feature: -------------------------------------------------------------------------------- 1 | Feature: Test built in jwt matcher functions 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Use array contains comparator with JWT 23 | Given a file named "features/jwt.feature" with: 24 | """ 25 | Feature: 26 | Scenario: 27 | Given the response body contains a JWT identified by "jwt1", signed with "secret": 28 | ''' 29 | { 30 | "sub": "1234567890", 31 | "name": "John Doe", 32 | "admin": true 33 | } 34 | ''' 35 | And the response body contains a JWT identified by "jwt2", signed with "secret": 36 | ''' 37 | { 38 | "sub": "@variableType(string)", 39 | "name": "@variableType(string)", 40 | "admin": "@variableType(bool)" 41 | } 42 | ''' 43 | And the response body contains a JWT identified by "jwt3", signed with "secret": 44 | ''' 45 | { 46 | "sub": "@regExp(/^[0-9]+$/)" 47 | } 48 | ''' 49 | And the request body is: 50 | ''' 51 | { 52 | "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ" 53 | } 54 | ''' 55 | When I request "/echo?json" using HTTP POST 56 | Then the response body contains JSON: 57 | ''' 58 | { 59 | "jwt": "@jwt(jwt1)" 60 | } 61 | ''' 62 | And the response body contains JSON: 63 | ''' 64 | { 65 | "jwt": "@jwt(jwt2)" 66 | } 67 | ''' 68 | And the response body contains JSON: 69 | ''' 70 | { 71 | "jwt": "@jwt(jwt3)" 72 | } 73 | ''' 74 | """ 75 | When I run "behat features/jwt.feature" 76 | Then it should pass with: 77 | """ 78 | ........ 79 | 80 | 1 scenario (1 passed) 81 | 8 steps (8 passed) 82 | """ 83 | 84 | Scenario: Use array contains comparator with JWT with failures 85 | Given a file named "features/jwt-failures.feature" with: 86 | """ 87 | Feature: 88 | Scenario: 89 | Given the response body contains a JWT identified by "jwt1", signed with "secret": 90 | ''' 91 | { 92 | "sub1": "1234567890" 93 | } 94 | ''' 95 | And the request body is: 96 | ''' 97 | { 98 | "jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ" 99 | } 100 | ''' 101 | When I request "/echo?json" using HTTP POST 102 | Then the response body contains JSON: 103 | ''' 104 | { 105 | "jwt": "@jwt(jwt1)" 106 | } 107 | ''' 108 | """ 109 | When I run "behat features/jwt-failures.feature" 110 | Then it should fail with: 111 | """ 112 | Function "jwt" failed with error message: "Haystack object is missing the "sub1" key. 113 | """ 114 | -------------------------------------------------------------------------------- /features/manipulate-query-string.feature: -------------------------------------------------------------------------------- 1 | Feature: Manipulate query string 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Set single parameters 23 | Given a file named "features/query-params.feature" with: 24 | """ 25 | Feature: Set up the request 26 | Scenario: Add query params 27 | Given the query parameter "foo" is "bar" 28 | Given the query parameter "bar" is "foo" 29 | Given the query parameter "bar" is: 30 | | value | 31 | | foo | 32 | | bar | 33 | Given the following query parameters are set: 34 | | name | value | 35 | | foo | bar | 36 | | bar | foo | 37 | """ 38 | When I run "behat features/query-params.feature" 39 | Then it should pass with: 40 | """ 41 | .... 42 | 43 | 1 scenario (1 passed) 44 | 4 steps (4 passed) 45 | """ 46 | 47 | Scenario: Make a request with query parameters set 48 | Given a file named "features/query-params.feature" with: 49 | """ 50 | Feature: Set up the request 51 | Scenario: Add query params 52 | Given the query parameter "p1" is "v1" 53 | Given the query parameter "p2" is "v2" 54 | Given the query parameter "p3" is "v3" 55 | Given the query parameter "p4" is "v4" 56 | Given the query parameter "p4" is "v5" 57 | Given the query parameter "p1" is: 58 | | value | 59 | | v6 | 60 | | v7 | 61 | Given the query parameter "p2" is: 62 | | value | 63 | | v8 | 64 | Given the following query parameters are set: 65 | | name | value | 66 | | p3 | v9 | 67 | | p5 | v10 | 68 | | p6 | v11 | 69 | When I request "/requestInfo?p5=v12" 70 | Then the response body contains JSON: 71 | ''' 72 | { 73 | "_GET": { 74 | "p1": ["v6", "v7"], 75 | "p2": ["v8"], 76 | "p3": "v9", 77 | "p4": "v5", 78 | "p5": "v10", 79 | "p6": "v11" 80 | } 81 | } 82 | ''' 83 | 84 | """ 85 | When I run "behat features/query-params.feature" 86 | Then it should pass with: 87 | """ 88 | .......... 89 | 90 | 1 scenario (1 passed) 91 | 10 steps (10 passed) 92 | """ -------------------------------------------------------------------------------- /features/request-body.feature: -------------------------------------------------------------------------------- 1 | Feature: Test steps to set a request body 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Set the request body to a string 23 | Given a file named "features/givens.feature" with: 24 | """ 25 | Feature: Set the request body to a string 26 | Scenario: Use steps to set the request body 27 | Given the request body is: 28 | ''' 29 | foobar 30 | ''' 31 | When I request "/echo" 32 | Then the response body is: 33 | ''' 34 | foobar 35 | ''' 36 | 37 | """ 38 | When I run "behat features/givens.feature" 39 | Then it should pass with: 40 | """ 41 | ... 42 | 43 | 1 scenario (1 passed) 44 | 3 steps (3 passed) 45 | """ 46 | 47 | Scenario: Set the request body to the contents of a file 48 | Given a file named "some/file.txt" with: 49 | """ 50 | some file 51 | """ 52 | And a file named "features/givens.feature" with: 53 | """ 54 | Feature: Set the request body to a path 55 | Scenario: Use steps to set the request body 56 | Given the request body contains "some/file.txt" 57 | When I request "/echo" 58 | Then the response body is: 59 | ''' 60 | some file 61 | ''' 62 | And the "Content-Type" response header is "text/plain;charset=UTF-8" 63 | 64 | """ 65 | When I run "behat features/givens.feature" 66 | Then it should pass with: 67 | """ 68 | .... 69 | 70 | 1 scenario (1 passed) 71 | 4 steps (4 passed) 72 | """ 73 | -------------------------------------------------------------------------------- /features/setup-request-failures.feature: -------------------------------------------------------------------------------- 1 | Feature: Setup steps can fail 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps and outcomes 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Attach multipart file that does not exist 23 | Given a file named "features/attach-multipart-file-that-does-not-exist.feature" with: 24 | """ 25 | Feature: Set up the request 26 | Scenario: Attach file 27 | Given I attach "/foo/bar" to the request as "file" 28 | """ 29 | When I run "behat features/attach-multipart-file-that-does-not-exist.feature" 30 | Then it should fail with: 31 | """ 32 | File does not exist: "/foo/bar" (InvalidArgumentException) 33 | 34 | 1 scenario (1 failed) 35 | """ 36 | 37 | Scenario: Set request body when multipart exists 38 | Given a file named "file.txt" with: 39 | """ 40 | some content 41 | """ 42 | And a file named "features/request-body-and-multipart.feature" with: 43 | """ 44 | Feature: Set up the request 45 | Scenario: Attach file and set request body 46 | Given I attach "file.txt" to the request as "file" 47 | And the request body is: 48 | ''' 49 | some body 50 | ''' 51 | """ 52 | When I run "behat features/request-body-and-multipart.feature" 53 | Then it should fail with: 54 | """ 55 | It's not allowed to set a request body when using multipart/form-data or form parameters. (InvalidArgumentException) 56 | """ 57 | 58 | Scenario: Set request body when form_params exists 59 | Given a file named "features/request-body-and-form-params.feature" with: 60 | """ 61 | Feature: Set up the request 62 | Scenario: Set form params and request body 63 | Given the following form parameters are set: 64 | | name | value | 65 | | key | value | 66 | And the request body is: 67 | ''' 68 | some body 69 | ''' 70 | """ 71 | When I run "behat features/request-body-and-form-params.feature" 72 | Then it should fail with: 73 | """ 74 | It's not allowed to set a request body when using multipart/form-data or form parameters. (InvalidArgumentException) 75 | """ 76 | 77 | Scenario: Attach file to request body that is not readable 78 | Given a non-readable file named "file.txt" with: 79 | """ 80 | some content 81 | """ 82 | And a file named "features/non-readable-file-as-request-body.feature" with: 83 | """ 84 | Feature: Set up the request 85 | Scenario: Set request body 86 | Given the request body contains "file.txt" 87 | """ 88 | When I run "behat features/non-readable-file-as-request-body.feature" 89 | Then it should fail with: 90 | """ 91 | File is not readable: "file.txt" (InvalidArgumentException) 92 | """ 93 | 94 | Scenario: Attach file to request body that does not exist 95 | Given a file named "features/non-existing-file-as-request-body.feature" with: 96 | """ 97 | Feature: Set up the request 98 | Scenario: Set request body 99 | Given the request body contains "file.txt" 100 | """ 101 | When I run "behat features/non-existing-file-as-request-body.feature" 102 | Then it should fail with: 103 | """ 104 | File does not exist: "file.txt" (InvalidArgumentException) 105 | """ 106 | -------------------------------------------------------------------------------- /features/setup-request.feature: -------------------------------------------------------------------------------- 1 | Feature: Test Given steps 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Use all Given steps to set up the request 23 | Given a file named "features/givens.feature" with: 24 | """ 25 | Feature: Set up the request 26 | Scenario: Use all Given steps in a scenario 27 | Given I am authenticating as "user" with password "password" 28 | Given the "header" request header is "value" 29 | Given I attach "features/givens.feature" to the request as "file" 30 | 31 | """ 32 | When I run "behat features/givens.feature" 33 | Then it should pass with: 34 | """ 35 | ... 36 | 37 | 1 scenario (1 passed) 38 | 3 steps (3 passed) 39 | """ 40 | -------------------------------------------------------------------------------- /features/verify-response.feature: -------------------------------------------------------------------------------- 1 | Feature: Test Then steps 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Use Then steps to verify responses 23 | Given a file named "features/thens.feature" with: 24 | """ 25 | Feature: Set up the request 26 | Scenario: Use all Given steps in a scenario 27 | When I request "/" 28 | Then the response code is 200 29 | And the response code is not 400 30 | And the response reason phrase is "OK" 31 | And the response reason phrase matches "/ok/i" 32 | And the response reason phrase is not "Not Modified" 33 | And the response status line is "200 OK" 34 | And the response status line is not "304 Not Modified" 35 | And the response status line matches "/200 ok/i" 36 | And the response is "success" 37 | And the response is not "client error" 38 | And the "X-Foo" response header exists 39 | And the "X-Bar" response header does not exist 40 | And the "x-foo" response header is "foo" 41 | And the "x-foo" response header is not "bar" 42 | And the "x-foo" response header matches "/FOO/i" 43 | And the response body is: 44 | ''' 45 | {"null":null,"string":"value","integer":42,"float":4.2,"bool true":true,"bool false":false,"list":[1,2,3,[1],{"foo":"bar"}],"sub":{"string":"value","integer":42,"float":4.2,"bool true":true,"bool false":false,"list":[1,2,3,[1],{"foo":"bar"}]},"types":{"string":"string","integer":123,"double":1.23,"array":[1,"2",3],"bool":true,"null":null,"scalar":"123"}} 46 | ''' 47 | And the response body is not: 48 | ''' 49 | foobar 50 | ''' 51 | And the response body matches: 52 | ''' 53 | /"list":\[.*?\]/ 54 | ''' 55 | And the response body contains JSON: 56 | ''' 57 | { 58 | "null": null, 59 | "string": "value", 60 | "integer": 42, 61 | "float": 4.2, 62 | "bool true": true, 63 | "bool false": false, 64 | "list": [1, 2, 3, [1], {"foo": "bar"}], 65 | "list[0]": 1, 66 | "list[1]": 2, 67 | "list[2]": 3, 68 | "list[3]": [1], 69 | "list[4]": {"foo": "bar"}, 70 | "sub": { 71 | "string": "value", 72 | "integer": 42, 73 | "float": 4.2, 74 | "bool true": true, 75 | "bool false": false, 76 | "list": [1, 2, 3, [1], {"foo": "bar"}], 77 | "list[0]": 1, 78 | "list[1]": 2, 79 | "list[2]": 3, 80 | "list[3]": [1], 81 | "list[4]": {"foo": "bar"} 82 | } 83 | } 84 | ''' 85 | """ 86 | When I run "behat features/thens.feature" 87 | Then it should pass with: 88 | """ 89 | .................... 90 | 91 | 1 scenario (1 passed) 92 | 20 steps (20 passed) 93 | """ 94 | 95 | Scenario: Use Then steps to verify responses with arrays 96 | Given a file named "features/thens-array.feature" with: 97 | """ 98 | Feature: Set up the request 99 | Scenario: Use all Given steps in a scenario 100 | Given the request body is: 101 | ''' 102 | [1, 2, 3] 103 | ''' 104 | When I request "/echo?json" using HTTP POST 105 | Then the response body is a JSON array of length 3 106 | And the response body is a JSON array with a length of at most 3 107 | And the response body is a JSON array with a length of at most 4 108 | And the response body is a JSON array with a length of at least 1 109 | And the response body is a JSON array with a length of at least 2 110 | And the response body is a JSON array with a length of at least 3 111 | """ 112 | When I run "behat features/thens-array.feature" 113 | Then it should pass with: 114 | """ 115 | ........ 116 | 117 | 1 scenario (1 passed) 118 | 8 steps (8 passed) 119 | """ 120 | 121 | Scenario: Use Then step to verify responses with empty JSON object 122 | Given a file named "features/thens-empty-json-object.feature" with: 123 | """ 124 | Feature: Test for empty JSON object in response body 125 | Scenario: Assert that the response body is an empty object 126 | When I request "/emptyObject" 127 | Then the response body is an empty JSON object 128 | """ 129 | When I run "behat features/thens-empty-json-object.feature" 130 | Then it should pass with: 131 | """ 132 | .. 133 | 134 | 1 scenario (1 passed) 135 | 2 steps (2 passed) 136 | """ 137 | 138 | Scenario: Use Then step to verify responses with empty JSON array 139 | Given a file named "features/thens-empty-json-array.feature" with: 140 | """ 141 | Feature: Test for empty JSON array in response body 142 | Scenario: Assert that the response body is an empty JSON array 143 | When I request "/emptyArray" 144 | Then the response body is a JSON array of length 0 145 | And the response body is an empty JSON array 146 | """ 147 | When I run "behat features/thens-empty-json-array.feature" 148 | Then it should pass with: 149 | """ 150 | ... 151 | 152 | 1 scenario (1 passed) 153 | 3 steps (3 passed) 154 | """ 155 | 156 | Scenario: Use Then step to verify responses with empty body 157 | Given a file named "features/thens-empty-response-body.feature" with: 158 | """ 159 | Feature: Test for empty response body 160 | Scenario: Assert that the response body is empty 161 | When I request "/empty" 162 | Then the response body is empty 163 | """ 164 | When I run "behat features/thens-empty-response-body.feature" 165 | Then it should pass with: 166 | """ 167 | .. 168 | 169 | 1 scenario (1 passed) 170 | 2 steps (2 passed) 171 | """ 172 | 173 | Scenario: Use Then steps to verify responses with numerical array as root 174 | Given a file named "features/response-with-numerical-array.feature" with: 175 | """ 176 | Feature: Test response body with numerical array as root 177 | Scenario: Response returns numerical array 178 | Given the request body is: 179 | ''' 180 | [ 181 | 1, 182 | "foo", 183 | { 184 | "foo": "bar", 185 | "bar": "foo" 186 | }, 187 | [1, 2, 3] 188 | ] 189 | ''' 190 | When I request "/echo?json" using HTTP POST 191 | Then the response body is a JSON array of length 4 192 | And the response body contains JSON: 193 | ''' 194 | { 195 | "[0]": 1, 196 | "[1]": "foo", 197 | "[2]": {"foo": "bar", "bar": "foo"}, 198 | "[3]": [1, 2, 3] 199 | } 200 | ''' 201 | """ 202 | When I run "behat features/response-with-numerical-array.feature" 203 | Then it should pass with: 204 | """ 205 | .... 206 | 207 | 1 scenario (1 passed) 208 | 4 steps (4 passed) 209 | """ 210 | 211 | Scenario: Verify a custom response reason phrase 212 | Given a file named "features/thens.feature" with: 213 | """ 214 | Feature: Set up the request 215 | Scenario: Verify a custom response reason phrase 216 | When I request "/customReasonPhrase?phrase=foo" 217 | Then the response reason phrase is "foo" 218 | """ 219 | When I run "behat features/thens.feature" 220 | Then it should pass with: 221 | """ 222 | .. 223 | 224 | 1 scenario (1 passed) 225 | 2 steps (2 passed) 226 | """ 227 | -------------------------------------------------------------------------------- /features/whens.feature: -------------------------------------------------------------------------------- 1 | Feature: Test When steps 2 | In order to test the extension 3 | As a developer 4 | I want to be able to test all available steps 5 | 6 | Background: 7 | Given a file named "behat.yml" with: 8 | """ 9 | default: 10 | formatters: 11 | progress: ~ 12 | extensions: 13 | Imbo\BehatApiExtension: 14 | apiClient: 15 | base_uri: http://localhost:8080 16 | 17 | suites: 18 | default: 19 | contexts: ['Imbo\BehatApiExtension\Context\ApiContext'] 20 | """ 21 | 22 | Scenario: Use all When steps to request paths 23 | Given a file named "features/whens.feature" with: 24 | """ 25 | Feature: Set up the request 26 | Scenario: Use all Given steps in a scenario 27 | When I request "/" 28 | When I request "/" using HTTP "POST" 29 | """ 30 | When I run "behat features/whens.feature" 31 | Then it should pass with: 32 | """ 33 | .. 34 | 35 | 1 scenario (1 passed) 36 | 2 steps (2 passed) 37 | """ 38 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src 5 | - tests 6 | ignoreErrors: 7 | - 8 | message: '#Method [a-zA-Z0-9\\_]+::test#' 9 | identifier: missingType.iterableValue 10 | path: tests 11 | - 12 | message: '#Property [a-zA-Z0-9\\_]+::\$historyContainer#' 13 | identifier: assign.propertyType 14 | path: tests/Context/ApiContextTest.php -------------------------------------------------------------------------------- /phpunit.dist.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | tests 7 | 8 | 9 | 10 | 11 | src 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/ArrayContainsComparator/Matcher/ArrayLength.php: -------------------------------------------------------------------------------- 1 | $array An array 15 | * @param int|string $length The expected exact length of $array 16 | * @throws InvalidArgumentException 17 | */ 18 | public function __invoke(array $array, int|string $length): bool 19 | { 20 | // Encode / decode to make sure we have a "list" 21 | /** @var mixed */ 22 | $array = json_decode((string) json_encode($array)); 23 | 24 | if (!is_array($array)) { 25 | throw new InvalidArgumentException(sprintf( 26 | 'Only numerically indexed arrays are supported, got "%s".', 27 | gettype($array), 28 | )); 29 | } 30 | 31 | $length = (int) $length; 32 | $actualLength = count($array); 33 | 34 | if ($actualLength !== $length) { 35 | throw new InvalidArgumentException(sprintf( 36 | 'Expected array to have exactly %d entries, actual length: %d.', 37 | $length, 38 | $actualLength, 39 | )); 40 | } 41 | 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ArrayContainsComparator/Matcher/ArrayMaxLength.php: -------------------------------------------------------------------------------- 1 | $array An array 15 | * @param int|string $maxLength The expected maximum length of $array 16 | * @throws InvalidArgumentException 17 | */ 18 | public function __invoke(array $array, int|string $maxLength): bool 19 | { 20 | // Encode / decode to make sure we have a "list" 21 | /** @var mixed */ 22 | $array = json_decode((string) json_encode($array)); 23 | 24 | if (!is_array($array)) { 25 | throw new InvalidArgumentException(sprintf( 26 | 'Only numerically indexed arrays are supported, got "%s".', 27 | gettype($array), 28 | )); 29 | } 30 | 31 | $maxLength = (int) $maxLength; 32 | $actualLength = count($array); 33 | 34 | if ($actualLength > $maxLength) { 35 | throw new InvalidArgumentException(sprintf( 36 | 'Expected array to have less than or equal to %d entries, actual length: %d.', 37 | $maxLength, 38 | $actualLength, 39 | )); 40 | } 41 | 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ArrayContainsComparator/Matcher/ArrayMinLength.php: -------------------------------------------------------------------------------- 1 | $array An array 15 | * @param int|string $minLength The expected minimum length of $array 16 | * @throws InvalidArgumentException 17 | */ 18 | public function __invoke(array $array, int|string $minLength): bool 19 | { 20 | // Encode / decode to make sure we have a "list" 21 | /** @var mixed */ 22 | $array = json_decode((string) json_encode($array)); 23 | 24 | if (!is_array($array)) { 25 | throw new InvalidArgumentException(sprintf( 26 | 'Only numerically indexed arrays are supported, got "%s".', 27 | gettype($array), 28 | )); 29 | } 30 | 31 | $minLength = (int) $minLength; 32 | $actualLength = count($array); 33 | 34 | if ($actualLength < $minLength) { 35 | throw new InvalidArgumentException(sprintf( 36 | 'Expected array to have more than or equal to %d entries, actual length: %d.', 37 | $minLength, 38 | $actualLength, 39 | )); 40 | } 41 | 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ArrayContainsComparator/Matcher/GreaterThan.php: -------------------------------------------------------------------------------- 1 | ,secret:string}> 23 | */ 24 | private array $jwtTokens = []; 25 | 26 | /** 27 | * Allowed algorithms for the JWT decoder 28 | * 29 | * @var array 30 | */ 31 | protected array $allowedAlgorithms = [ 32 | 'HS256', 33 | 'HS384', 34 | 'HS512', 35 | ]; 36 | 37 | public function __construct(Comparator $comparator) 38 | { 39 | $this->comparator = $comparator; 40 | } 41 | 42 | /** 43 | * Add a JWT token that can be matched 44 | * 45 | * @param array $payload 46 | */ 47 | public function addToken(string $name, array $payload, string $secret): self 48 | { 49 | $this->jwtTokens[$name] = [ 50 | 'payload' => $payload, 51 | 'secret' => $secret, 52 | ]; 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * Match an array against a JWT 59 | * 60 | * @throws InvalidArgumentException 61 | */ 62 | public function __invoke(string $jwt, string $name): bool 63 | { 64 | if (!isset($this->jwtTokens[$name])) { 65 | throw new InvalidArgumentException(sprintf('No JWT registered for "%s".', $name)); 66 | } 67 | 68 | $token = $this->jwtTokens[$name]; 69 | 70 | foreach ($this->allowedAlgorithms as $algorithm) { 71 | try { 72 | $result = (array) Firebase\JWT\JWT::decode($jwt, new Firebase\JWT\Key($token['secret'], $algorithm)); 73 | } catch (UnexpectedValueException $e) { 74 | // try next algorithm 75 | continue; 76 | } 77 | 78 | if ($this->comparator->compare($token['payload'], $result)) { 79 | return true; 80 | } 81 | } 82 | 83 | throw new InvalidArgumentException('JWT mismatch.'); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/ArrayContainsComparator/Matcher/LessThan.php: -------------------------------------------------------------------------------- 1 | = $max) { 35 | throw new InvalidArgumentException(sprintf( 36 | '"%s" is not less than "%s".', 37 | $number, 38 | $max, 39 | )); 40 | } 41 | 42 | return true; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ArrayContainsComparator/Matcher/RegExp.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected array $validTypes = [ 17 | 'int', 'integer', 18 | 'bool', 'boolean', 19 | 'double', 'float', 20 | 'string', 21 | 'array', 22 | 'object', 23 | 'null', 24 | 'scalar', 25 | 'any', 26 | ]; 27 | 28 | /** 29 | * Match a variable type 30 | * 31 | * @param mixed $variable A variable 32 | * @param string $expectedTypes The expected types of $variable, separated by | 33 | * @throws InvalidArgumentException 34 | */ 35 | public function __invoke(mixed $variable, string $expectedTypes): bool 36 | { 37 | $expectedTypes = $this->normalizeTypes($expectedTypes); 38 | 39 | foreach ($expectedTypes as $expectedType) { 40 | if (!in_array($expectedType, $this->validTypes)) { 41 | throw new InvalidArgumentException(sprintf( 42 | 'Unsupported variable type: "%s".', 43 | $expectedType, 44 | )); 45 | } 46 | } 47 | 48 | if (in_array('any', $expectedTypes)) { 49 | return true; 50 | } 51 | 52 | // Encode / decode the value to easier check for objects 53 | /** @var mixed */ 54 | $variable = json_decode((string) json_encode($variable)); 55 | 56 | // Get the actual type of the value 57 | $actualType = strtolower(gettype($variable)); 58 | 59 | foreach ($expectedTypes as $expectedType) { 60 | if ( 61 | ($expectedType === 'scalar' && is_scalar($variable)) || 62 | $expectedType === $actualType 63 | ) { 64 | return true; 65 | } 66 | } 67 | 68 | throw new InvalidArgumentException(sprintf( 69 | 'Expected variable type "%s", got "%s".', 70 | join('|', $expectedTypes), 71 | $actualType, 72 | )); 73 | } 74 | 75 | /** 76 | * Normalize the type 77 | * 78 | * @param string $types The types from the scenario 79 | * @return array Returns an array of normalized types 80 | */ 81 | protected function normalizeTypes(string $types): array 82 | { 83 | $types = array_map( 84 | fn (string $type): string => trim(strtolower($type)), 85 | explode('|', $types), 86 | ); 87 | 88 | /** @var array */ 89 | return preg_replace( 90 | ['/^bool$/i', '/^int$/i', '/^float$/i'], 91 | ['boolean', 'integer', 'double'], 92 | $types, 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Context/ApiClientAwareContext.php: -------------------------------------------------------------------------------- 1 | $config 15 | */ 16 | public function initializeClient(array $config): self; 17 | } 18 | -------------------------------------------------------------------------------- /src/Context/ArrayContainsComparatorAwareContext.php: -------------------------------------------------------------------------------- 1 | Guzzle client configuration array 17 | * @see http://docs.guzzlephp.org/ Check out the Guzzle docs for a complete overview of available configuration parameters 18 | */ 19 | private array $config = []; 20 | 21 | /** 22 | * Class constructor 23 | * 24 | * @param array $config Guzzle client configuration array 25 | */ 26 | public function __construct(array $config) 27 | { 28 | $this->config = $config; 29 | } 30 | 31 | /** 32 | * Initialize the context 33 | * 34 | * Inject the Guzzle client if the context implements the ApiClientAwareContext interface 35 | */ 36 | public function initializeContext(Context $context): void 37 | { 38 | if ($context instanceof ApiClientAwareContext) { 39 | $context->initializeClient($this->config); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Context/Initializer/ArrayContainsComparatorAwareInitializer.php: -------------------------------------------------------------------------------- 1 | addFunction('arrayLength', new Matcher\ArrayLength()) 21 | ->addFunction('arrayMinLength', new Matcher\ArrayMinLength()) 22 | ->addFunction('arrayMaxLength', new Matcher\ArrayMaxLength()) 23 | ->addFunction('variableType', new Matcher\VariableType()) 24 | ->addFunction('regExp', new Matcher\RegExp()) 25 | ->addFunction('gt', new Matcher\GreaterThan()) 26 | ->addFunction('lt', new Matcher\LessThan()) 27 | ->addFunction('jwt', new Matcher\JWT($comparator)); 28 | 29 | $this->comparator = $comparator; 30 | } 31 | 32 | public function initializeContext(Context $context): void 33 | { 34 | if ($context instanceof ArrayContainsComparatorAwareContext) { 35 | $context->setArrayContainsComparator($this->comparator); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exception/ArrayContainsComparatorException.php: -------------------------------------------------------------------------------- 1 | children() 57 | ->arrayNode('apiClient') 58 | ->addDefaultsIfNotSet() 59 | ->ignoreExtraKeys(false) 60 | ->children() 61 | ->scalarNode('base_uri') 62 | ->isRequired() 63 | ->cannotBeEmpty() 64 | ->defaultValue('http://localhost:8080'); 65 | } 66 | 67 | /** 68 | * @param array{apiClient:array} $config Guzzle client configuration array 69 | * @see http://docs.guzzlephp.org/ Check out the Guzzle docs for a complete overview of available configuration parameters 70 | */ 71 | public function load(ContainerBuilder $container, array $config): void 72 | { 73 | $clientInitializerDefinition = new Definition( 74 | ApiClientAwareInitializer::class, 75 | [ 76 | $config['apiClient'], 77 | ], 78 | ); 79 | $clientInitializerDefinition->addTag(ContextExtension::INITIALIZER_TAG); 80 | $comparatorDefinition = new Definition(ArrayContainsComparator::class); 81 | $comparatorInitializerDefinition = new Definition( 82 | ArrayContainsComparatorAwareInitializer::class, 83 | [ 84 | new Reference(self::COMPARATOR_SERVICE_ID), 85 | ], 86 | ); 87 | $comparatorInitializerDefinition->addTag(ContextExtension::INITIALIZER_TAG); 88 | 89 | $container->setDefinition(self::APICLIENT_INITIALIZER_SERVICE_ID, $clientInitializerDefinition); 90 | $container->setDefinition(self::COMPARATOR_SERVICE_ID, $comparatorDefinition); 91 | $container->setDefinition(self::COMPARATOR_INITIALIZER_SERVICE_ID, $comparatorInitializerDefinition); 92 | } 93 | 94 | public function process(ContainerBuilder $container): void 95 | { 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/ArrayContainsComparator/Matcher/ArrayLengthTest.php: -------------------------------------------------------------------------------- 1 | matcher = new ArrayLength(); 17 | } 18 | 19 | /** 20 | * @return array,length:int}> 21 | */ 22 | public static function getArraysAndLengths(): array 23 | { 24 | return [ 25 | [ 26 | 'list' => [], 27 | 'length' => 0, 28 | ], 29 | [ 30 | 'list' => [1, 2, 3], 31 | 'length' => 3, 32 | ], 33 | [ 34 | 'list' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 35 | 'length' => 10, 36 | ], 37 | ]; 38 | } 39 | 40 | /** 41 | * @return array,maxLength:int,message:string}> 42 | */ 43 | public static function getValuesThatFail(): array 44 | { 45 | return [ 46 | [ 47 | 'list' => [1, 2], 48 | 'maxLength' => 1, 49 | 'message' => 'Expected array to have exactly 1 entries, actual length: 2.', 50 | ], 51 | [ 52 | 'list' => [], 53 | 'maxLength' => 2, 54 | 'message' => 'Expected array to have exactly 2 entries, actual length: 0.', 55 | ], 56 | ]; 57 | } 58 | 59 | #[DataProvider('getArraysAndLengths')] 60 | public function testCanMatchLengthOfArrays(array $list, int $length): void 61 | { 62 | $matcher = $this->matcher; 63 | 64 | $this->assertTrue( 65 | $matcher($list, $length), 66 | 'Matcher is supposed to return true.', 67 | ); 68 | } 69 | 70 | public function testThrowsExceptionWhenMatchingLengthAgainstAnythingOtherThanAnArray(): void 71 | { 72 | $this->expectException(InvalidArgumentException::class); 73 | $this->expectExceptionMessage('Only numerically indexed arrays are supported, got "object".'); 74 | $matcher = $this->matcher; 75 | $matcher(['foo' => 'bar'], 123); 76 | } 77 | 78 | #[DataProvider('getValuesThatFail')] 79 | public function testThrowsExceptionWhenLengthIsNotCorrect(array $list, int $maxLength, string $message): void 80 | { 81 | $this->expectException(InvalidArgumentException::class); 82 | $this->expectExceptionMessage($message); 83 | $matcher = $this->matcher; 84 | $matcher($list, $maxLength); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/ArrayContainsComparator/Matcher/ArrayMaxLengthTest.php: -------------------------------------------------------------------------------- 1 | matcher = new ArrayMaxLength(); 17 | } 18 | 19 | /** 20 | * @return array,length:int}> 21 | */ 22 | public static function getArraysAndLengths(): array 23 | { 24 | return [ 25 | [ 26 | 'list' => [], 27 | 'length' => 0, 28 | ], 29 | [ 30 | 'list' => [1, 2, 3], 31 | 'length' => 3, 32 | ], 33 | [ 34 | 'list' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 35 | 'length' => 10, 36 | ], 37 | ]; 38 | } 39 | 40 | /** 41 | * @return array,maxLength:int,message:string}> 42 | */ 43 | public static function getValuesThatFail(): array 44 | { 45 | return [ 46 | [ 47 | 'array' => [1, 2], 48 | 'maxLength' => 0, 49 | 'message' => 'Expected array to have less than or equal to 0 entries, actual length: 2.', 50 | ], 51 | [ 52 | 'array' => [1, 2, 3], 53 | 'maxLength' => 2, 54 | 'message' => 'Expected array to have less than or equal to 2 entries, actual length: 3.', 55 | ], 56 | ]; 57 | } 58 | 59 | #[DataProvider('getArraysAndLengths')] 60 | public function testCanMatchMaxLengthOfArrays(array $list, int $length): void 61 | { 62 | $matcher = $this->matcher; 63 | $this->assertTrue( 64 | $matcher($list, $length), 65 | 'Matcher is supposed to return true.', 66 | ); 67 | } 68 | 69 | public function testThrowsExceptionWhenMatchingAgainstAnythingOtherThanAnArray(): void 70 | { 71 | $this->expectException(InvalidArgumentException::class); 72 | $this->expectExceptionMessage('Only numerically indexed arrays are supported, got "object".'); 73 | $matcher = $this->matcher; 74 | $matcher(['foo' => 'bar'], 123); 75 | } 76 | 77 | #[DataProvider('getValuesThatFail')] 78 | public function testThrowsExceptionWhenLengthIsTooShort(array $array, int $maxLength, string $message): void 79 | { 80 | $this->expectException(InvalidArgumentException::class); 81 | $this->expectExceptionMessage($message); 82 | $matcher = $this->matcher; 83 | $matcher($array, $maxLength); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/ArrayContainsComparator/Matcher/ArrayMinLengthTest.php: -------------------------------------------------------------------------------- 1 | matcher = new ArrayMinLength(); 17 | } 18 | 19 | /** 20 | * @return array,min:int}> 21 | */ 22 | public static function getArraysAndMinLengths(): array 23 | { 24 | return [ 25 | [ 26 | 'list' => [], 27 | 'min' => 0, 28 | ], 29 | [ 30 | 'list' => [1], 31 | 'min' => 0, 32 | ], 33 | [ 34 | 'list' => [1, 2], 35 | 'min' => 2, 36 | ], 37 | ]; 38 | } 39 | 40 | /** 41 | * @return array,minLength:int,message:string}> 42 | */ 43 | public static function getValuesThatFail(): array 44 | { 45 | return [ 46 | [ 47 | 'array' => [], 48 | 'minLength' => 2, 49 | 'message' => 'Expected array to have more than or equal to 2 entries, actual length: 0.', 50 | ], 51 | [ 52 | 'array' => [1, 2, 3], 53 | 'minLength' => 4, 54 | 'message' => 'Expected array to have more than or equal to 4 entries, actual length: 3.', 55 | ], 56 | ]; 57 | } 58 | 59 | #[DataProvider('getArraysAndMinLengths')] 60 | public function testCanMatchMinLengthOfArrays(array $list, int $min): void 61 | { 62 | $matcher = $this->matcher; 63 | $this->assertTrue( 64 | $matcher($list, $min), 65 | 'Matcher is supposed to return true.', 66 | ); 67 | } 68 | 69 | public function testThrowsExceptionWhenMatchingAgainstAnythingOtherThanAnArray(): void 70 | { 71 | $this->expectException(InvalidArgumentException::class); 72 | $this->expectExceptionMessage('Only numerically indexed arrays are supported, got "object".'); 73 | $matcher = $this->matcher; 74 | $matcher(['foo' => 'bar'], 123); 75 | } 76 | 77 | #[DataProvider('getValuesThatFail')] 78 | public function testThrowsExceptionWhenLengthIsTooLong(array $array, int $minLength, string $message): void 79 | { 80 | $this->expectException(InvalidArgumentException::class); 81 | $this->expectExceptionMessage($message); 82 | $matcher = $this->matcher; 83 | $matcher($array, $minLength); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/ArrayContainsComparator/Matcher/GreaterThanTest.php: -------------------------------------------------------------------------------- 1 | matcher = new GreaterThan(); 17 | } 18 | 19 | /** 20 | * @return array 21 | */ 22 | public static function getValuesForMatching(): array 23 | { 24 | return [ 25 | 'integer' => [ 26 | 'number' => 2, 27 | 'min' => 1, 28 | ], 29 | 'float / double' => [ 30 | 'number' => 1.1, 31 | 'min' => 1, 32 | ], 33 | 'string' => [ 34 | 'number' => '2', 35 | 'min' => '1', 36 | ], 37 | ]; 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public static function getFailingValues(): array 44 | { 45 | return [ 46 | [ 47 | 'number' => 123, 48 | 'min' => 123, 49 | 'errorMessage' => '"123" is not greater than "123".', 50 | ], 51 | [ 52 | 'number' => 123, 53 | 'min' => 456, 54 | 'errorMessage' => '"123" is not greater than "456".', 55 | ], 56 | [ 57 | 'number' => 1.23, 58 | 'min' => 4.56, 59 | 'errorMessage' => '"1.23" is not greater than "4.56".', 60 | ], 61 | [ 62 | 'number' => "1.23", 63 | 'min' => "4.56", 64 | 'errorMessage' => '"1.23" is not greater than "4.56".', 65 | ], 66 | ]; 67 | } 68 | 69 | #[DataProvider('getValuesForMatching')] 70 | public function testCanCompareValuesOfType(int|float|string $number, int|float|string $min): void 71 | { 72 | $matcher = $this->matcher; 73 | $this->assertTrue( 74 | $matcher($number, $min), 75 | 'Matcher is supposed to return true.', 76 | ); 77 | } 78 | 79 | public function testThrowsExceptionIfNumberIsNotNumeric(): void 80 | { 81 | $matcher = $this->matcher; 82 | $this->expectException(InvalidArgumentException::class); 83 | $this->expectExceptionMessage('"foo" is not numeric.'); 84 | $matcher('foo', 123); 85 | } 86 | 87 | public function testThrowsExceptionIfMinimumNumberIsNotNumeric(): void 88 | { 89 | $matcher = $this->matcher; 90 | $this->expectException(InvalidArgumentException::class); 91 | $this->expectExceptionMessage('"foo" is not numeric.'); 92 | $matcher(123, 'foo'); 93 | } 94 | 95 | #[DataProvider('getFailingValues')] 96 | public function testThrowsExceptionWhenComparisonFails(int|string|float $number, int|string|float $min, string $errorMessage): void 97 | { 98 | $matcher = $this->matcher; 99 | $this->expectException(InvalidArgumentException::class); 100 | $this->expectExceptionMessage($errorMessage); 101 | $matcher($number, $min); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/ArrayContainsComparator/Matcher/JWTTest.php: -------------------------------------------------------------------------------- 1 | matcher = new JWT(new ArrayContainsComparator()); 19 | } 20 | 21 | /** 22 | * @return array,secret:string}> 23 | */ 24 | public static function getJwt(): array 25 | { 26 | return [ 27 | [ 28 | 'jwt' => 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ', 29 | 'name' => 'my jwt', 30 | 'payload' => [ 31 | 'sub' => '1234567890', 32 | 'name' => 'John Doe', 33 | 'admin' => true, 34 | ], 35 | 'secret' => 'secret', 36 | ], 37 | [ 38 | 'jwt' => 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJmb28iOiJiYXIifQ.xnzcLUO-0DuBw9Do3JqtQPyclUpJtdPSG8B8GsglLJAn-hMH-NIQD5eoMbctwEGrkL5bvynD8PZ5kq-sGJTIlg', 39 | 'name' => 'my other jwt', 40 | 'payload' => [ 41 | 'foo' => 'bar', 42 | ], 43 | 'secret' => 'supersecret', 44 | ], 45 | ]; 46 | } 47 | 48 | public function testThrowsExceptionWhenMatchingAgainstJwtThatDoesNotExist(): void 49 | { 50 | $matcher = $this->matcher; 51 | $this->expectException(InvalidArgumentException::class); 52 | $this->expectExceptionMessage('No JWT registered for "some name".'); 53 | $matcher('some jwt', 'some name'); 54 | } 55 | 56 | public function testThrowsExceptionWhenJwtDoesNotMatch(): void 57 | { 58 | $matcher = $this->matcher->addToken('some name', ['some' => 'data'], 'secret'); 59 | $this->expectException(ArrayContainsComparatorException::class); 60 | $this->expectExceptionMessage('Haystack object is missing the "some" key.'); 61 | $matcher( 62 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ', 63 | 'some name', 64 | ); 65 | } 66 | 67 | #[DataProvider('getJwt')] 68 | public function testCanMatchJwt(string $jwt, string $name, array $payload, string $secret): void 69 | { 70 | $matcher = $this->matcher->addToken($name, $payload, $secret); 71 | $this->assertTrue($matcher( 72 | $jwt, 73 | $name, 74 | )); 75 | } 76 | 77 | public function testThrowsExceptionWhenComparatorDoesNotReturnSuccess(): void 78 | { 79 | $comparator = $this->createConfiguredMock(ArrayContainsComparator::class, [ 80 | 'compare' => false, 81 | ]); 82 | $matcher = (new JWT($comparator))->addToken( 83 | 'token', 84 | [ 85 | 'sub' => '1234567890', 86 | 'name' => 'John Doe', 87 | 'admin' => true, 88 | ], 89 | 'secret', 90 | ); 91 | $this->expectException(InvalidArgumentException::class); 92 | $this->expectExceptionMessage('JWT mismatch.'); 93 | $matcher( 94 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ', 95 | 'token', 96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/ArrayContainsComparator/Matcher/LessThanTest.php: -------------------------------------------------------------------------------- 1 | matcher = new LessThan(); 17 | } 18 | 19 | /** 20 | * @return array 21 | */ 22 | public static function getValuesForMatching(): array 23 | { 24 | return [ 25 | 'integer' => [ 26 | 'number' => 1, 27 | 'max' => 2, 28 | ], 29 | 'float / double' => [ 30 | 'number' => 1, 31 | 'max' => 1.1, 32 | ], 33 | 'string' => [ 34 | 'number' => '1', 35 | 'max' => '2', 36 | ], 37 | ]; 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public static function getFailingValues(): array 44 | { 45 | return [ 46 | [ 47 | 'number' => 123, 48 | 'max' => 123, 49 | 'errorMessage' => '"123" is not less than "123".', 50 | ], 51 | [ 52 | 'number' => 456, 53 | 'max' => 123, 54 | 'errorMessage' => '"456" is not less than "123".', 55 | ], 56 | [ 57 | 'number' => 4.56, 58 | 'max' => 1.23, 59 | 'errorMessage' => '"4.56" is not less than "1.23".', 60 | ], 61 | [ 62 | 'number' => "4.56", 63 | 'max' => "1.23", 64 | 'errorMessage' => '"4.56" is not less than "1.23".', 65 | ], 66 | ]; 67 | } 68 | 69 | #[DataProvider('getValuesForMatching')] 70 | public function testCanCompareValuesOfType(int|string $number, int|string|float $max): void 71 | { 72 | $matcher = $this->matcher; 73 | $this->assertTrue( 74 | $matcher($number, $max), 75 | 'Matcher is supposed to return true.', 76 | ); 77 | } 78 | 79 | public function testThrowsExceptionIfNumberIsNotNumeric(): void 80 | { 81 | $matcher = $this->matcher; 82 | $this->expectException(InvalidArgumentException::class); 83 | $this->expectExceptionMessage('"foo" is not numeric.'); 84 | $matcher('foo', 123); 85 | } 86 | 87 | public function testThrowsExceptionIfMaximumNumberIsNotNumeric(): void 88 | { 89 | $matcher = $this->matcher; 90 | $this->expectException(InvalidArgumentException::class); 91 | $this->expectExceptionMessage('"foo" is not numeric.'); 92 | $matcher(123, 'foo'); 93 | } 94 | 95 | #[DataProvider('getFailingValues')] 96 | public function testThrowsExceptionWhenComparisonFails(int|float|string $number, int|float|string $max, string $errorMessage): void 97 | { 98 | $this->expectException(InvalidArgumentException::class); 99 | $this->expectExceptionMessage($errorMessage); 100 | $matcher = $this->matcher; 101 | $matcher($number, $max); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/ArrayContainsComparator/Matcher/RegExpTest.php: -------------------------------------------------------------------------------- 1 | matcher = new RegExp(); 17 | } 18 | 19 | /** 20 | * @return array 21 | */ 22 | public static function getSubjectsAndPatterns(): array 23 | { 24 | return [ 25 | 'a regular string' => [ 26 | 'subject' => 'some string', 27 | 'pattern' => '/^SOME STRING$/i', 28 | ], 29 | 'an integer' => [ 30 | 'subject' => 666, 31 | 'pattern' => '/^666$/', 32 | ], 33 | 'a float' => [ 34 | 'subject' => 3.14, 35 | 'pattern' => '/^3\.14$/', 36 | ], 37 | ]; 38 | } 39 | 40 | #[DataProvider('getSubjectsAndPatterns')] 41 | public function testCanMatchRegularExpressionPatternsAgainst(float|int|string $subject, string $pattern): void 42 | { 43 | $matcher = $this->matcher; 44 | $this->assertTrue($matcher($subject, $pattern)); 45 | } 46 | 47 | public function testThrowsExceptionIfPatternDoesNotMatchSubject(): void 48 | { 49 | $matcher = $this->matcher; 50 | $this->expectException(InvalidArgumentException::class); 51 | $this->expectExceptionMessage('Subject "some string" did not match pattern "/SOME STRING/".'); 52 | $matcher('some string', '/SOME STRING/'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/ArrayContainsComparator/Matcher/VariableTypeTest.php: -------------------------------------------------------------------------------- 1 | matcher = new VariableType(); 17 | } 18 | 19 | /** 20 | * @return array 21 | */ 22 | public static function getValuesAndTypes(): array 23 | { 24 | return [ 25 | 'int' => [ 26 | 'value' => 1, 27 | 'type' => 'integer', 28 | ], 29 | 'integer' => [ 30 | 'value' => 1, 31 | 'type' => 'int', 32 | ], 33 | 'string' => [ 34 | 'value' => 'some string', 35 | 'type' => 'string', 36 | ], 37 | 'double' => [ 38 | 'value' => 1.1, 39 | 'type' => 'double', 40 | ], 41 | 'float' => [ 42 | 'value' => 1.1, 43 | 'type' => 'float', 44 | ], 45 | 'boolean (true)' => [ 46 | 'value' => true, 47 | 'type' => 'boolean', 48 | ], 49 | 'boolean (false)' => [ 50 | 'value' => false, 51 | 'type' => 'boolean', 52 | ], 53 | 'bool (true)' => [ 54 | 'value' => true, 55 | 'type' => 'bool', 56 | ], 57 | 'bool (false)' => [ 58 | 'value' => false, 59 | 'type' => 'bool', 60 | ], 61 | 'null' => [ 62 | 'value' => null, 63 | 'type' => 'null', 64 | ], 65 | 'scalar (integer)' => [ 66 | 'value' => 123, 67 | 'type' => 'scalar', 68 | ], 69 | 'scalar (double)' => [ 70 | 'value' => 1.1, 71 | 'type' => 'scalar', 72 | ], 73 | 'scalar (string)' => [ 74 | 'value' => '123', 75 | 'type' => 'scalar', 76 | ], 77 | 'scalar (bool true)' => [ 78 | 'value' => true, 79 | 'type' => 'scalar', 80 | ], 81 | 'scalar (bool false)' => [ 82 | 'value' => true, 83 | 'type' => 'scalar', 84 | ], 85 | 'array (list)' => [ 86 | 'value' => [1, 2, 3], 87 | 'type' => 'array', 88 | ], 89 | 'array (object)' => [ 90 | 'value' => ['foo' => 'bar'], 91 | 'type' => 'object', 92 | ], 93 | 'bool (any)' => [ 94 | 'value' => true, 95 | 'type' => 'any', 96 | ], 97 | 'integer (any)' => [ 98 | 'value' => 123, 99 | 'type' => 'any', 100 | ], 101 | 'double (any)' => [ 102 | 'value' => 1.1, 103 | 'type' => 'any', 104 | ], 105 | 'string (any)' => [ 106 | 'value' => 'some string', 107 | 'type' => 'any', 108 | ], 109 | 'array (any)' => [ 110 | 'value' => [1, 2, 3], 111 | 'type' => 'any', 112 | ], 113 | 'object (any)' => [ 114 | 'value' => ['foo' => 'bar'], 115 | 'type' => 'any', 116 | ], 117 | 'int (multiple)' => [ 118 | 'value' => 1, 119 | 'type' => 'string|array|integer', 120 | ], 121 | 'integer (multiple)' => [ 122 | 'value' => 1, 123 | 'type' => 'int|bool|double', 124 | ], 125 | 'string (multiple)' => [ 126 | 'value' => 'some string', 127 | 'type' => 'integer | bool | array | string', // spaces are intentional 128 | ], 129 | ]; 130 | } 131 | 132 | /** 133 | * @return array 134 | */ 135 | public static function getInvalidMatches(): array 136 | { 137 | return [ 138 | [ 139 | 'value' => 123, 140 | 'type' => 'string', 141 | 'message' => 'Expected variable type "string", got "integer".', 142 | ], 143 | [ 144 | 'value' => '123', 145 | 'type' => 'integer', 146 | 'message' => 'Expected variable type "integer", got "string".', 147 | ], 148 | [ 149 | 'value' => [1, 2, 3], 150 | 'type' => 'object', 151 | 'message' => 'Expected variable type "object", got "array".', 152 | ], 153 | [ 154 | 'value' => ['foo' => 'bar'], 155 | 'type' => 'array', 156 | 'message' => 'Expected variable type "array", got "object".', 157 | ], 158 | ]; 159 | } 160 | 161 | #[DataProvider('getValuesAndTypes')] 162 | public function testCanMatchValuesOfType(mixed $value, string $type): void 163 | { 164 | $matcher = $this->matcher; 165 | $this->assertTrue( 166 | $matcher($value, $type), 167 | 'Matcher is supposed to return true.', 168 | ); 169 | } 170 | 171 | public function testThrowsExceptionWhenGivenInvalidType(): void 172 | { 173 | $matcher = $this->matcher; 174 | $this->expectException(InvalidArgumentException::class); 175 | $this->expectExceptionMessage('Unsupported variable type: "resource".'); 176 | $matcher('foo', 'resource'); 177 | } 178 | 179 | #[DataProvider('getInvalidMatches')] 180 | public function testThrowsExceptionWhenTypeOfValueDoesNotMatchExpectedType(mixed $value, string $type, string $message): void 181 | { 182 | $matcher = $this->matcher; 183 | $this->expectException(InvalidArgumentException::class); 184 | $this->expectExceptionMessage($message); 185 | $matcher($value, $type); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /tests/Context/Initializer/ApiClientAwareInitializerTest.php: -------------------------------------------------------------------------------- 1 | true); 18 | $sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); 19 | 20 | if (false === $sock) { 21 | $this->fail('Unable to create socket'); 22 | } 23 | 24 | $result = false; 25 | 26 | for ($port = 8000; $port < 8079; $port++) { 27 | if ($result = socket_bind($sock, 'localhost', $port)) { 28 | break; 29 | } 30 | } 31 | 32 | restore_error_handler(); 33 | 34 | if (!$result) { 35 | // No port was available 36 | $this->markTestSkipped('Could not create a socket, skipping test for now.'); 37 | } 38 | 39 | // Listen for connections 40 | if (!socket_listen($sock)) { 41 | $this->markTestSkipped('Unable to listen for a connection, skipping test for now.'); 42 | } 43 | 44 | $baseUri = sprintf('http://localhost:%d', $port); 45 | 46 | /** @var MockObject&ApiClientAwareContext */ 47 | $context = $this->createMock(ApiClientAwareContext::class); 48 | $context 49 | ->expects($this->once()) 50 | ->method('initializeClient') 51 | ->with(['base_uri' => $baseUri]); 52 | 53 | $initializer = new ApiClientAwareInitializer([ 54 | 'base_uri' => $baseUri, 55 | ]); 56 | $initializer->initializeContext($context); 57 | 58 | // Close socket used in test case 59 | socket_close($sock); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Context/Initializer/ArrayContainsComparatorAwareInitializerTest.php: -------------------------------------------------------------------------------- 1 | createMock(ArrayContainsComparator::class); 18 | $comparator 19 | ->expects($this->exactly(8)) 20 | ->method('addFunction') 21 | ->willReturnCallback( 22 | fn (string $matcher, object $impl) => 23 | match ([$matcher, get_class($impl)]) { 24 | ['arrayLength', Matcher\ArrayLength::class] => $comparator, 25 | ['arrayMinLength', Matcher\ArrayMinLength::class] => $comparator, 26 | ['arrayMaxLength', Matcher\ArrayMaxLength::class] => $comparator, 27 | ['variableType', Matcher\VariableType::class] => $comparator, 28 | ['regExp', Matcher\RegExp::class] => $comparator, 29 | ['gt', Matcher\GreaterThan::class] => $comparator, 30 | ['lt', Matcher\LessThan::class] => $comparator, 31 | ['jwt', Matcher\JWT::class] => $comparator, 32 | default => $this->fail("Unexpected matcher: " . $matcher) 33 | }, 34 | ); 35 | 36 | new ArrayContainsComparatorAwareInitializer($comparator); 37 | } 38 | 39 | public function testInjectsComparatorWhenInitializingContext(): void 40 | { 41 | /** @var ArrayContainsComparator&MockObject */ 42 | $comparator = $this->createMock(ArrayContainsComparator::class); 43 | $comparator 44 | ->expects($this->exactly(8)) 45 | ->method('addFunction') 46 | ->willReturnSelf(); 47 | 48 | /** @var ArrayContainsComparatorAwareContext&MockObject */ 49 | $context = $this->createMock(ArrayContainsComparatorAwareContext::class); 50 | $context->expects($this->once())->method('setArrayContainsComparator')->with($comparator); 51 | 52 | (new ArrayContainsComparatorAwareInitializer($comparator))->initializeContext($context); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Exception/ArrayContainsComparatorExceptionTest.php: -------------------------------------------------------------------------------- 1 | ,haystack:array,formattedMessage:string}> 13 | */ 14 | public static function getExceptionData(): array 15 | { 16 | return [ 17 | 'with no needle / haystack' => [ 18 | 'message' => $someMessage = 'some message', 19 | 'needle' => [], 20 | 'haystack' => [], 21 | 'formattedMessage' => << [ 36 | 'message' => $someMessage = 'some message', 37 | 'needle' => ['needle' => 'value'], 38 | 'haystack' => ['haystack' => 'value'], 39 | 'formattedMessage' => <<expectException(ArrayContainsComparatorException::class); 64 | $this->expectExceptionMessage($formattedMessage); 65 | throw new ArrayContainsComparatorException($message, 0, null, $needle, $haystack); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/ServiceContainer/BehatApiExtensionTest.php: -------------------------------------------------------------------------------- 1 | extension = new BehatApiExtension(); 18 | } 19 | 20 | public function testCanBuildConfiguration(): void 21 | { 22 | /** @var ArrayNodeDefinition */ 23 | $rootNode = (new TreeBuilder($this->extension->getConfigKey()))->getRootNode(); 24 | 25 | // Configure the root node builder 26 | $this->extension->configure($rootNode); 27 | 28 | // Process the configuration 29 | $config = (new Processor())->process($rootNode->getNode(true), []); 30 | 31 | $this->assertSame([ 32 | 'apiClient' => [ 33 | 'base_uri' => 'http://localhost:8080', 34 | ], 35 | ], $config); 36 | } 37 | 38 | public function testCanOverrideDefaultValuesWhenBuildingConfiguration(): void 39 | { 40 | /** @var ArrayNodeDefinition */ 41 | $rootNode = (new TreeBuilder($this->extension->getConfigKey()))->getRootNode(); 42 | 43 | // Configure the root node builder 44 | $this->extension->configure($rootNode); 45 | 46 | $baseUri = 'http://localhost:8888'; 47 | $config = (new Processor())->process($rootNode->getNode(true), [ 48 | 'api_extension' => [ 49 | 'apiClient' => [ 50 | 'base_uri' => $baseUri, 51 | ], 52 | ], 53 | ]); 54 | 55 | $this->assertSame([ 56 | 'apiClient' => [ 57 | 'base_uri' => $baseUri, 58 | ], 59 | ], $config); 60 | } 61 | } 62 | --------------------------------------------------------------------------------