diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6b92b38 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..b398670 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,30 @@ +name: PHPUnit + +on: + push: + branches: [ master ] + pull_request: + workflow_dispatch: ~ + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: auto1-oss/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, xml + + - name: Validate composer.json + run: composer validate + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Run tests + run: ./vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7579f74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor +composer.lock diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..dfccd74 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2024 Auto1 Group + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf33faf --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# php-behat-context-wiremock + +This package provides a seamless integration between Behat tests and Wiremock, offering a straightforward method for mocking HTTP requests. It acts as a conduit between Behat scenarios and a Wiremock instance, enabling the creation of HTTP request expectations and mock responses without sacrificing Wiremock's inherent flexibility. + +## Configuration Example + +Below is an example of how to configure the Wiremock context within your Behat setup: + +```yaml +- Auto1\BehatContext\Wiremock\WiremockContext: + baseUrl: 'http://wiremock' + stubsDirectory: '%paths.base%/features/stubs' + cleanWiremockBeforeEachScenario: false # Optional, defaults to false + allStubsMatchedAfterEachScenario: false # Optional, defaults to false + stubsDirectoryIsFeatureDirectory: false # Optional, defaults to false +``` + +- `baseUrl`: The URL of your Wiremock instance. +- `stubsDirectory`: The base directory for Wiremock stubs. This cannot be specified if `stubsDirectoryIsFeatureDirectory` is enabled. +- `cleanWiremockBeforeEachScenario`: If true, Wiremock will be cleared before each scenario. +- `allStubsMatchedAfterEachScenario`: If true, ensures that all stubs are matched after each scenario. +- `stubsDirectoryIsFeatureDirectory`: If true, stubs will be sourced from the directory of a Behat feature file. This can only be enabled if `stubsDirectory` is not specified. +- Note: `stubsDirectory` and `stubsDirectoryIsFeatureDirectory` cannot be used simultaneously. + +## Docker Integration + +For those running Behat within Docker, integrating a Wiremock container is straightforward. The following configuration ensures that your tests wait for Wiremock to be fully initialized before running: + +```yaml + php-fpm: + depends_on: + wiremock: + condition: service_healthy + + wiremock: + image: wiremock/wiremock:latest + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/__admin/mappings"] + interval: 10s + timeout: 10s + retries: 5 +``` + +## Context Steps + +### Defining Wiremock Stubs + +- **Given wiremock stub**: This step allows you to define a Wiremock stub directly within your scenario. + + **Example**: + ```gherkin + Given wiremock stub: + """ + { + "request": { + "method": "GET", + "url": "/some/thing" + }, + + "response": { + "status": 200, + "body": "Hello, world!", + "headers": { + "Content-Type": "text/plain" + } + } + } + ``` + +- **Given wiremock stubs from**: This step loads stubs from a specified file or directory and sends them to Wiremock. + + **Example**: + ```gherkin + Given wiremock stubs from "dir/awesome-stub.json" + And wiremock stubs from "dir2" + ``` + +### Managing Wiremock State + +- **Given clean wiremock**: Resets Wiremock to its initial state. + + **Example**: + ```gherkin + Given clean wiremock + ``` + +### Validating Stubs + +- **Then all stubs should be matched**: Ensures that all added stubs were matched at least once and fails if there were any unexpected (unmatched) calls to Wiremock. + + **Example**: + ```gherkin + Then all stubs should be matched + ``` + +This integration aims to simplify the process of testing HTTP interactions within your Behat scenarios, leveraging Wiremock's powerful mocking capabilities to enhance your testing suite. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..81bc9d7 --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "auto1-oss/php-behat-context-wiremock", + "license": "Apache-2.0", + "type": "library", + "description": "Behat context for Wiremock support", + "minimum-stability": "stable", + "require": { + "php": ">=8.0", + "behat/behat": ">=3.0", + "symfony/http-client": ">=3.0", + "symfony/contracts": ">=1.0" + }, + "autoload": { + "psr-4": { + "Auto1\\BehatContext\\Wiremock\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Auto1\\BehatContext\\Tests\\": "tests/" + } + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a0d7ac3 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + + + + + + tests + + + + + src + + + diff --git a/src/Exception/WiremockContextException.php b/src/Exception/WiremockContextException.php new file mode 100644 index 0000000..65537db --- /dev/null +++ b/src/Exception/WiremockContextException.php @@ -0,0 +1,16 @@ + + */ + private array $stubs = []; + + public function __construct( + private string $baseUrl, + HttpClientInterface $client = null, + private ?string $stubsDirectory = null, + private bool $cleanWiremockBeforeEachScenario = false, + private bool $allStubsMatchedAfterEachScenario = false, + private bool $stubsDirectoryIsFeatureDirectory = false, + ) { + if ($stubsDirectoryIsFeatureDirectory === true && $stubsDirectory !== null) { + throw new WiremockContextException('Only one of these arguments can be passed: stubsDirectory, stubsDirectoryIsFeatureDirectory'); + } + + $this->client = $client ?: HttpClient::create(); + } + + #[Given('/^wiremock stub:$/')] + public function addWiremockStubStep(PyStringNode $body): void + { + $this->addStub($body->getRaw()); + } + + /** + * @throws WiremockContextException + */ + #[Given('/^wiremock stubs from "([^"]+)"$/')] + public function addWiremockStubFromFileStep(string $path): void + { + $absolutePath = $this->stubsDirectory . '/' . $path; + + if (is_dir($absolutePath)) { + $files = scandir($absolutePath); + + foreach ($files as $file) { + $filePath = $absolutePath . '/' . $file; + if (is_dir($filePath)) { + continue; + } + + try { + $this->loadStubFromFile($filePath); + } catch (Throwable $exception) { + throw new WiremockContextException( + sprintf( + 'Unable to load file "%s"', + $filePath + ) + , 0, $exception); + } + } + } else { + $this->loadStubFromFile($absolutePath); + } + } + + #[Given('/^clean wiremock$/')] + public function cleanWiremockStep(): void + { + $this->cleanWiremock(); + } + + #[Then('/^all stubs should be matched$/')] + public function allStubsMatchedStep(): void + { + $this->allStubsMatched(); + } + + #[Then('/^start wiremock recording with redirection to "([^"]+)"$/')] + public function startRecordingStep(string $url): void + { + $this->sendRequest( + 'POST', + self::PATH_RECORDINGS_START, + sprintf(self::BODY_RECORDINGS_START, $url) + ); + } + + #[Then('/^stop wiremock recording$/')] + public function stopRecordingStep(): void + { + $this->sendRequest( + 'POST', + self::PATH_RECORDINGS_STOP + ); + } + + #[Then('/^stop wiremock recording and save mocks to "([^"]+)"$/')] + public function stopRecordingAndSaveStep(string $path): void + { + $result = $this->sendRequest( + 'POST', + self::PATH_RECORDINGS_STOP + ); + + $mappings = $result['mappings']; + array_walk($mappings, function (array &$mapping) { + $urlData = parse_url($mapping['request']['url']); + + unset($mapping['request']['url']); + $mapping['request']['urlPath'] = $urlData['path']; + + $queryParams = []; + parse_str($urlData['query'], $queryParams); + unset($queryParams['wa_key']); + + $stubQueryParameters = []; + foreach ($queryParams as $name => $value) { + $stubQueryParameters[$name] = ['equalTo' => $value]; + } + if ($stubQueryParameters) { + $mapping['request']['queryParameters'] = $stubQueryParameters; + } + + if (isset($mapping['response']['body'])) { + $jsonBody = @json_decode($mapping['response']['body']); + if ($jsonBody) { + $mapping['response']['jsonBody'] = $jsonBody; + unset($mapping['response']['body']); + } + } + + if (isset($mapping['request']['bodyPatterns'])) { + foreach ($mapping['request']['bodyPatterns'] as &$bodyPattern) { + if (isset($bodyPattern['equalToJson'])) { + $bodyPattern['equalToJson'] = json_decode($bodyPattern['equalToJson']); + } + } + } + + unset($mapping['id']); + unset($mapping['uuid']); + unset($mapping['persistent']); + unset($mapping['response']['headers']); + }); + + array_walk($mappings, function (array &$mapping, $key) use ($path) { + $filename = sprintf("%02d_%s.json", $key, $mapping['name']); + + file_put_contents( + join('/', [$absolutePath = $this->stubsDirectory, $path, $filename]), + json_encode($mapping, JSON_PRETTY_PRINT) + ); + }); + } + + #[AfterScenario] + public function allStubsMatchedForEachScenario(): void + { + if ($this->allStubsMatchedAfterEachScenario) { + $this->allStubsMatched(); + } + } + + #[BeforeScenario] + public function cleanWiremockForEachScenario(): void + { + if ($this->cleanWiremockBeforeEachScenario) { + $this->cleanWiremock(); + } + } + + #[BeforeScenario] + public function setFeatureDirectory(BeforeScenarioScope $scope): void + { + if ($this->stubsDirectoryIsFeatureDirectory) { + $featureFilePath = $scope->getFeature()->getFile(); + $this->stubsDirectory = dirname($featureFilePath); + } + } + + private function cleanWiremock(): void + { + $this->sendRequest( + 'DELETE', + self::PATH_MAPPINGS + ); + $this->sendRequest( + 'DELETE', + self::PATH_REQUESTS + ); + } + + /** + * @throws WiremockContextException + */ + private function sendRequest(string $method, string $url, ?string $body = null): array + { + $options = []; + if ($body) { + $options['body'] = $body; + } + + try { + $response = $this->client->request($method, $this->baseUrl . $url, $options); + } catch (Throwable $exception) { + throw new WiremockContextException('Exception occurred during sending request', 0, $exception); + } + + try { + return $response->toArray(); + } catch (Throwable $exception) { + throw new WiremockContextException('Exception occurred during deserialization process', 0, $exception); + } + } + + public function addStub(string $body): void + { + $response = $this->sendRequest( + 'POST', + self::PATH_MAPPINGS, + $body + ); + + $this->stubs[$response['id']] = $response; + } + + private function loadStubFromFile(string $filePath): void + { + $this->addStub(file_get_contents($filePath)); + } + + private function allStubsMatched(): void + { + $response = $this->sendRequest( + 'GET', + self::PATH_REQUESTS + ); + + $requestedStubsIds = []; + + foreach ($response['requests'] as $requestData) { + if (!isset($requestData['stubMapping'])) { + throw new WiremockContextException(sprintf( + 'Unexpected request found: %s %s', + $requestData["request"]["method"], + $requestData["request"]["absoluteUrl"] + )); + } + + if (!array_key_exists($requestData['stubMapping']['id'], $this->stubs)) { + throw new WiremockContextException(sprintf( + 'Unexpected stub found: %s %s', + $requestData["request"]["method"], + $requestData["request"]["absoluteUrl"] + )); + } + + $requestedStubsIds[] = $requestData['stubMapping']['id']; + } + + $requestedStubsIds = array_unique($requestedStubsIds); + + if ($diff = array_diff(array_keys($this->stubs), $requestedStubsIds)) { + $unrequestedStubs = []; + foreach ($diff as $stubId) { + $unrequestedStubs[] = $this->stubs[$stubId]; + } + throw new WiremockContextException('Unrequested stub(s) found: ' . json_encode($unrequestedStubs, JSON_PRETTY_PRINT)); + } + } +} diff --git a/tests/WiremockContextTest.php b/tests/WiremockContextTest.php new file mode 100644 index 0000000..96b6e50 --- /dev/null +++ b/tests/WiremockContextTest.php @@ -0,0 +1,157 @@ +httpClient = $this->createMock(HttpClientInterface::class); + $this->wiremockContext = new WiremockContext( + 'http://wiremock:8080', + $this->httpClient + ); + } + + public function testAddStub(): void + { + $dummyResponse = $this->createMock(ResponseInterface::class); + $dummyResponse->expects($this->once())->method('toArray')->willReturn(['id' => 'dummy-id']); + $this->httpClient->method('request')->willReturn($dummyResponse); + $stubBody = '{"request": {"method": "GET", "url": "/some/path"}, "response": {"status": 200, "body": "OK"}}'; + $this->wiremockContext->addStub($stubBody); + } + + public function testAddWiremockStubStep(): void + { + $dummyResponse = $this->createMock(ResponseInterface::class); + $dummyResponse->expects($this->once())->method('toArray')->willReturn(['id' => 'dummy-id']); + $this->httpClient->method('request')->willReturn($dummyResponse); + $stubBody = '{"request": {"method": "GET", "url": "/test"}, "response": {"status": 200, "body": "Success"}}'; + $this->wiremockContext->addWiremockStubStep(new \Behat\Gherkin\Node\PyStringNode([$stubBody], 0)); + } + + public function testAllStubsMatchedStepNoRequestsNoStubs(): void + { + $response = $this->createMock(ResponseInterface::class); + $response->expects($this->once())->method('toArray')->willReturn([ + 'requests' => [], + ]); + $this->httpClient->method('request')->willReturn($response); + $this->wiremockContext->allStubsMatchedStep(); + } + + public function testAddStubWithHttpClientException(): void + { + $this->httpClient->method('request')->willThrowException(new \Exception('HTTP client error')); + $this->expectException(WiremockContextException::class); + $stubBody = '{"request": {"method": "GET", "url": "/error"}, "response": {"status": 500, "body": "Error"}}'; + $this->wiremockContext->addStub($stubBody); + } + + public function testAllStubsMatchedStepAfterAddingStub(): void + { + $addStubResponse = $this->createMock(ResponseInterface::class); + $addStubResponse->expects($this->once())->method('toArray')->willReturn(['id' => 'dummy-stub-id']); + $matchedStubsResponse = $this->createMock(ResponseInterface::class); + $matchedStubsResponse->expects($this->once())->method('toArray')->willReturn([ + 'requests' => [ + [ + 'stubMapping' => [ + 'id' => 'dummy-stub-id', + ], + 'request' => [ + 'method' => 'GET', + 'absoluteUrl' => 'http://wiremock:8080/test', + ], + ], + ], + ]); + $this->httpClient->method('request')->willReturnCallback( + function ($method, $url, $options) use ($addStubResponse, $matchedStubsResponse) { + if ($method === 'POST' && $url === 'http://wiremock:8080/__admin/mappings') { + return $addStubResponse; + } elseif ($method === 'GET' && $url === 'http://wiremock:8080/__admin/requests') { + return $matchedStubsResponse; + } + + return null; + } + ); + $stubBody = '{"request": {"method": "GET", "url": "/test"}, "response": {"status": 200, "body": "Success"}}'; + $this->wiremockContext->addStub($stubBody); + $this->wiremockContext->allStubsMatchedStep(); + } + + public function testAllStubsMatchedStepUnexpectedCall(): void + { + $addStubResponse = $this->createMock(ResponseInterface::class); + $addStubResponse->expects($this->once())->method('toArray')->willReturn(['id' => 'dummy-stub-id']); + $matchedStubsResponse = $this->createMock(ResponseInterface::class); + $matchedStubsResponse->expects($this->once())->method('toArray')->willReturn([ + 'requests' => [ + [ + 'stubMapping' => [ + 'id' => 'another-dummy-stub-id', + ], + 'request' => [ + 'method' => 'GET', + 'absoluteUrl' => 'http://wiremock:8080/test', + ], + ], + ], + ]); + $this->httpClient->method('request')->willReturnCallback( + function ($method, $url, $options) use ($addStubResponse, $matchedStubsResponse) { + if ($method === 'POST' && $url === 'http://wiremock:8080/__admin/mappings') { + return $addStubResponse; + } elseif ($method === 'GET' && $url === 'http://wiremock:8080/__admin/requests') { + return $matchedStubsResponse; + } + + return null; + } + ); + $stubBody = '{"request": {"method": "GET", "url": "/test"}, "response": {"status": 200, "body": "Success"}}'; + $this->wiremockContext->addStub($stubBody); + $this->expectException(WiremockContextException::class); + $this->expectExceptionMessage('Unexpected stub found: GET http://wiremock:8080/test'); + $this->wiremockContext->allStubsMatchedStep(); + } + + public function testAllStubsMatchedStepUnrequestedStub(): void + { + $addStubResponse = $this->createMock(ResponseInterface::class); + $addStubResponse->expects($this->once())->method('toArray')->willReturn(['id' => 'dummy-stub-id']); + $matchedStubsResponse = $this->createMock(ResponseInterface::class); + $matchedStubsResponse->expects($this->once())->method('toArray')->willReturn([ + 'requests' => [ + ], + ]); + $this->httpClient->method('request')->willReturnCallback( + function ($method, $url, $options) use ($addStubResponse, $matchedStubsResponse) { + if ($method === 'POST' && $url === 'http://wiremock:8080/__admin/mappings') { + return $addStubResponse; + } elseif ($method === 'GET' && $url === 'http://wiremock:8080/__admin/requests') { + return $matchedStubsResponse; + } + + return null; + } + ); + $stubBody = '{"request": {"method": "GET", "url": "/test"}, "response": {"status": 200, "body": "Success"}}'; + $this->wiremockContext->addStub($stubBody); + $this->expectException(WiremockContextException::class); + $this->expectExceptionMessage("Unrequested stub(s) found: [\n {\n \"id\": \"dummy-stub-id\"\n }\n]"); + $this->wiremockContext->allStubsMatchedStep(); + } +}