diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54b9fcd6..db2ab5b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,7 @@ -name: pact-php +name: Code Analysis & Test on: - push: + push: pull_request: # Once on the first of the month at 06:00 UTC schedule: @@ -16,8 +16,6 @@ jobs: runs-on: ubuntu-latest strategy: fail-fast: false - matrix: - php: [ '8.2' ] steps: - uses: actions/checkout@v4 @@ -26,7 +24,8 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.php }} + php-version: 8.1 + coverage: none - uses: ramsey/composer-install@v3 with: @@ -38,18 +37,104 @@ jobs: - name: Static Code Analysis run: composer run static-code-analysis - test: - runs-on: ${{ matrix.operating-system }} + examples: + runs-on: ${{ matrix.os }} needs: - php-cs strategy: fail-fast: false matrix: - operating-system: [ ubuntu-latest, macos-latest, windows-latest ] - php: [ '8.0', '8.1', '8.2' ] - dependencies: [ 'lowest', 'locked' ] + include: + - os: ubuntu-latest + php: 8.1 + - os: macos-12 + php: 8.3 + - os: macos-14 + php: 8.2 + - os: windows-latest + example: 'json' + php: 8.2 + - os: windows-latest + example: 'binary' + php: 8.2 + - os: windows-latest + example: 'multipart' + php: 8.2 + - os: windows-latest + example: 'xml' + php: 8.2 + - os: windows-latest + example: 'message' + php: 8.2 + - os: windows-latest + example: 'matchers' + php: 8.2 + - os: windows-latest + example: 'generators' + php: 8.2 + - os: windows-latest + example: 'csv' + php: 8.2 + - os: windows-latest + example: 'protobuf-sync-message' + php: 8.1 + - os: windows-latest + example: 'protobuf-async-message' + php: 8.2 + - os: windows-latest + example: 'stub-server' + php: 8.2 + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + name: Checkout repository + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + extensions: sockets, curl, zip, ffi ${{ (!matrix.example || matrix.example == 'protobuf-sync-message') && ', grpc' || '' }} + php-version: ${{ matrix.php }} + coverage: none + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Composer install + uses: ramsey/composer-install@v3 + with: + dependency-versions: 'locked' + + - name: Install Protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + if: ${{ !matrix.example || contains(matrix.example, 'protobuf') }} + + - name: Generate Library + run: composer gen-lib + if: ${{ !matrix.example || contains(matrix.example, 'protobuf') }} + + - name: Cache Pact Plugins + uses: actions/cache@v4 + with: + path: ~/.pact/plugins + key: ${{ matrix.os }}-pact-plugins + if: ${{ !matrix.example || matrix.example == 'csv' || contains(matrix.example, 'protobuf') }} + + - name: Run example(s) + run: composer test -- --exclude-testsuite unit ${{ matrix.example && format('--testsuite {0}-example', matrix.example) || '' }} + env: + PACT_DO_NOT_TRACK: true - name: PHP ${{ matrix.php }} on ${{ matrix.operating-system }} with ${{ matrix.dependencies }} dependencies + unit: + runs-on: ubuntu-latest + needs: + - php-cs + strategy: + fail-fast: false + matrix: + php: [ '8.1', '8.2', '8.3' ] + dependencies: [ 'lowest', 'locked' ] steps: - uses: actions/checkout@v4 @@ -58,13 +143,16 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - extensions: openssl, sockets, curl, zip + extensions: 'sockets, curl, zip, ffi' php-version: ${{ matrix.php }} + coverage: pcov - name: Composer install uses: ramsey/composer-install@v3 with: dependency-versions: ${{ matrix.dependencies }} - - name: Composer test - run: composer test + - name: Test Unit + run: vendor/bin/phpunit --testsuite unit + env: + PACT_DO_NOT_TRACK: true diff --git a/.github/workflows/compatibility-suite.yml b/.github/workflows/compatibility-suite.yml new file mode 100644 index 00000000..62a9abbe --- /dev/null +++ b/.github/workflows/compatibility-suite.yml @@ -0,0 +1,72 @@ +name: Compatibility Suite + +on: [push, pull_request] + +env: + PACT_DO_NOT_TRACK: true + +jobs: + v1: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + + - uses: ramsey/composer-install@v3 + + - name: Run Behat + run: vendor/bin/behat compatibility-suite/pact-compatibility-suite/features/V1 --colors + v2: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + + - uses: ramsey/composer-install@v3 + + - name: Run Behat + run: vendor/bin/behat compatibility-suite/pact-compatibility-suite/features/V2 --colors + v3: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + + - uses: ramsey/composer-install@v3 + + - name: Run Behat + run: vendor/bin/behat compatibility-suite/pact-compatibility-suite/features/V3 --name '/^((?!binary body \(negative|Message provider).)*$/' --colors + v4: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + + - uses: ramsey/composer-install@v3 + + - name: Run Behat + run: vendor/bin/behat compatibility-suite/pact-compatibility-suite/features/V4 --colors diff --git a/.gitignore b/.gitignore index 607926b0..0a714913 100644 --- a/.gitignore +++ b/.gitignore @@ -258,3 +258,4 @@ vendor/* test_results pact example/output/* +.phpunit.cache/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..470a2e3f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "compatibility-suite/pact-compatibility-suite"] + path = compatibility-suite/pact-compatibility-suite + url = https://github.com/pact-foundation/pact-compatibility-suite.git diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index cac6eabb..3bb3d850 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -4,10 +4,13 @@ ->in(__DIR__ . '/src') ->in(__DIR__ . '/tests') ->in(__DIR__ . '/example') + ->in(__DIR__ . '/compatibility-suite/tests') + ->exclude('library/src') ->name('*.php'); $config = new PhpCsFixer\Config(); -$config->setRules(['@PSR12' => true, +$config->setRules([ + '@PSR12' => true, 'strict_param' => false, 'array_syntax' => ['syntax' => 'short'], ]) diff --git a/DEVELOPING.md b/DEVELOPING.md new file mode 100644 index 00000000..31b4f294 --- /dev/null +++ b/DEVELOPING.md @@ -0,0 +1,57 @@ +# Pact-PHP + +## Pre Reqs + +- PHP 8.x or greater +- FFI and Sockets extensions enabled in your php.ini + +## Steps + +1. Run `composer install` + 1. This will install php dependencies to `vendor` + 2. This will install pact libraries to `bin` +2. Run `composer test` + 1. This will run our unit tests +3. Run `composer lint` + 1. This will run the phpcs-lint +4. Run `composer fix` + 1. This will correct any auto fixable linter errors +5. Run `composer static-code-analysis` + 1. Run static code analysis + +## CI Locally + +### MacOS ARM + +#### Pre Reqs + +- MacOS ARM +- Tart.run +- Cirrus-CLI + +#### Steps + +Run all versions of PHP + +- `cirrus run --output github-actions macos_arm64 -e CIRRUS_CLI=true` + +Run a specified version of PHP + +- `cirrus run --output github-actions 'macos_arm64 VERSION:8.2' -e CIRRUS_CLI=true` + +### Linux ARM + +#### Pre Reqs + +- Docker +- x86_64 or arm64/aarch64 host + +#### Steps + +Run all versions of PHP + +- `cirrus run --output github-actions linux_arm64` + +Run a specified version of PHP + +- `cirrus run --output github-actions 'macos_arm64 VERSION:8.2'` \ No newline at end of file diff --git a/README.md b/README.md index 0e867b49..4cf034b0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ Table of contents - [Specifications](#specifications) - [Installation](#installation) - [Basic Consumer Usage](#basic-consumer-usage) - - [Start and Stop the Mock Server](#start-and-stop-the-mock-server) - [Create Consumer Unit Test](#create-consumer-unit-test) - [Create Mock Request](#create-mock-request) - [Create Mock Response](#create-mock-response) @@ -25,12 +24,16 @@ Table of contents - [Make the Request](#make-the-request) - [Verify Interactions](#verify-interactions) - [Make Assertions](#make-assertions) + - [Delete Old Pact](#delete-old-pact) + - [Publish Contracts To Pact Broker](#publish-contracts-to-pact-broker) + - [CLI](#cli) + - [Github Actions](#github-actions) - [Basic Provider Usage](#basic-provider-usage) - [Create Unit Test](#create-unit-test) - [Start API](#start-api) - [Provider Verification](#provider-verification) - [Verify From Pact Broker](#verify-from-pact-broker) - - [Verify All from Pact Broker](#verify-all-from-pact-broker) + - [Verify Files in Directory](#verify-files-in-directory) - [Verify Files by Path](#verify-files-by-path) - [Tips](#tips) - [Starting API Asynchronously](#starting-api-asynchronously) @@ -43,6 +46,8 @@ Table of contents ## Versions +10.X updates internal dependencies and libraries + adds support for pact specification 3.X & 4.X via Pact FFI. + 9.X updates internal dependencies and libraries including pact-ruby-standalone v2.x which adds support for ARM64 CPU's for Linux/MacOS and providing x86 and x86_64 Windows via pact-ruby-standalone v2.x. This results in dropping PHP 7.4 8.X updates internal dependencies and libraries. This results in dropping PHP 7.3 @@ -60,10 +65,13 @@ If you wish to stick with the 2.X implementation, you can continue to pull from ## Specifications -The 3.X version is the version of Pact-PHP, not the pact specification version that it supports. Pact-Php 3.X-9.x supports up to [Pact-Specification 2.X](https://github.com/pact-foundation/pact-specification/tree/version-2). - -Looking for [Pact-Specification 3.X](https://github.com/pact-foundation/pact-specification/tree/version-3) and upwards. See [#326](https://github.com/pact-foundation/pact-php/pull/326) +The 3.X version is the version of Pact-PHP, not the pact specification version that it supports. +Pact-Php 3.X -> 9.X supports [Pact-Specification 2.X](https://github.com/pact-foundation/pact-specification/tree/version-2). +Pact-Php 10.X supports: + * [Pact-Specification 2.X](https://github.com/pact-foundation/pact-specification/tree/version-2) + * [Pact-Specification 3.X](https://github.com/pact-foundation/pact-specification/tree/version-3). + * [Pact-Specification 4.X](https://github.com/pact-foundation/pact-specification/tree/version-4). ##  Supported Platforms @@ -90,44 +98,11 @@ Composer hosts older versions under `mattersight/phppact`, which is abandoned. P All of the following code will be used exclusively for the Consumer. -### Start and Stop the Mock Server - -This library contains a wrapper for the [Ruby Standalone Mock Service](https://github.com/pact-foundation/pact-mock_service). - -The easiest way to configure this is to use a [PHPUnit Listener](https://phpunit.de/manual/current/en/appendixes.configuration.html#appendixes.configuration.test-listeners). A default listener is included in this project, see [PactTestListener.php](/src/PhpPact/Consumer/Listener/PactTestListener.php). This utilizes environmental variables for configurations. These env variables can either be added to the system or to the phpunit.xml configuration file. Here is an example [phpunit.xml](/example/phpunit.consumer.xml) file configured to use the default. Keep in mind that both the test suite and the arguments array must be the same value. - -Alternatively, you can start and stop as in whatever means you would like by following this example: - -```php -setHost('localhost'); - $config->setPort(7200); - $config->setConsumer('someConsumer'); - $config->setProvider('someProvider'); - $config->setCors(true); - - // Instantiate the mock server object with the config. This can be any - // instance of MockServerConfigInterface. - $server = new MockServer($config); - - // Create the process. - $server->start(); - - // Stop the process. - $server->stop(); -``` - ### Create Consumer Unit Test Create a standard PHPUnit test case class and function. -[Click here](/example/tests/Consumer/Service/ConsumerServiceHelloTest.php) to see the full sample file. +[Click here](/example/json/consumer/tests/Service/ConsumerServiceHelloTest.php) to see the full sample file. ### Create Mock Request @@ -188,6 +163,7 @@ timestampRFC3339 | Regex match a timestamp using the RFC3339 format. | Value (De like | Match a value against its data type. | Value | $matcher->like(12) somethingLike | Alias to like matcher. | Value | $matcher->somethingLike(12) eachLike | Match on an object like the example. | Value, Min (Defaults to 1) | $matcher->eachLike(12) +constrainedArrayLike | Behaves like the `eachLike` matcher, but also applies a minimum and maximum length validation on the length of the array. The optional `count` parameter controls the number of examples generated. | Value, Min, Max, count (Defaults to null) | $matcher->constrainedArrayLike('test', 1, 5, 3) boolean | Match against boolean true. | none | $matcher->boolean() integer | Match a value against integer. | Value (Defaults to 13) | $matcher->integer() decimal | Match a value against float. | Value (Defaults to 13.01) | $matcher->decimal() @@ -208,10 +184,10 @@ Now that we have the request and response, we need to build the interaction and $config = new MockServerEnvConfig(); $builder = new InteractionBuilder($config); $builder - ->given('a person exists') + ->given('a person exists', ['name' => 'Bob']) ->uponReceiving('a get request to /hello/{name}') ->with($request) - ->willRespondWith($response); // This has to be last. This is what makes an API request to the Mock Server to set the interaction. + ->willRespondWith($response); // This has to be last. This is what makes FFI calls to register the interaction and start the mock server. ``` ### Make the Request @@ -227,7 +203,8 @@ Verify that all interactions took place that were registered. This typically should be in each test, that way the test that failed to verify is marked correctly. ```php -$builder->verify(); +$verifyResult = $verifier->verify(); +$this->assertTrue($verifyResult); ``` ### Make Assertions @@ -238,6 +215,32 @@ Verify that the data you would expect given the response configured is correct. $this->assertEquals('Hello, Bob', $result); // Make your assertions. ``` +### Delete Old Pact + +If the value of `PACT_FILE_WRITE_MODE` is `merge`, before running the test, we need to delete the old pact manually: + +```shell +rm /path/to/pacts/consumer-provider.json +``` + +### Publish Contracts To Pact Broker + +When all tests in test suite are passed, you may want to publish generated contract files to pact broker. + +#### CLI + +Run this command using CLI tool: + +```shell +pact-broker publish /path/to/pacts/consumer-provider.json --consumer-app-version 1.0.0 --branch main --broker-base-url https://test.pactflow.io --broker-token SomeToken +``` + +See more at https://docs.pact.io/pact_broker/publishing_and_retrieving_pacts#publish-using-cli-tools + +#### Github Actions + +See how to use at https://github.com/pactflow/actions/tree/main/publish-pact-files + ## Basic Provider Usage All of the following code will be used exclusively for Providers. This will run the Pacts against the real Provider and either verify or fail validation on the Pact Broker. @@ -265,51 +268,62 @@ $config = new VerifierConfig(); $config ->setProviderName('someProvider') // Providers name to fetch. ->setProviderVersion('1.0.0') // Providers version. - ->setProviderBranch('main') // Providers git branch name. - ->setProviderBaseUrl(new Uri('http://localhost:58000')) // URL of the Provider. - ->setBrokerUri(new Uri('http://localhost')) // URL of the Pact Broker to publish results. - ->setPublishResults(true) // Flag the verifier service to publish the results to the Pact Broker. - ->setProcessTimeout(60) // Set process timeout (optional) - default 60 - ->setProcessIdleTimeout(10) // Set process idle timeout (optional) - default 10 - ->setEnablePending(true) // Flag to enable pending pacts feature (check pact docs for further info) - ->setIncludeWipPactSince('2020-01-30') //Start date of WIP Pacts (check pact docs for further info) - ->setRequestFilter( - function (RequestInterface $r) { - return $r->withHeader('MY_SPECIAL_HEADER', 'my special value'); - } - ); -// Verify that the Consumer 'someConsumer' that is tagged with 'master' is valid. + ->setProviderTags('prod' ,'dev') + ->setProviderBranch('main') + ->setScheme('http') + ->setHost('localhost') + ->setPort(58000) + ->setBasePath('/') + ->setStateChangeUrl(new Uri('http://localhost:58000/change-state')) + ->setBuildUrl(new Uri('http://build.domain.com')) + ->setFilterConsumerNames('someConsumer', 'otherConsumer') + ->setFilterDescription('Send POST to create') + ->setFilterNoState(true) + ->setFilterState('state') + ->setPublishResults(true) + ->setDisableSslVerification(true) + ->setStateChangeAsBody(false) + ->setStateChangeTeardown(true) + ->setRequestTimeout(500); + $verifier = new Verifier($config); -$verifier->verify('someConsumer', 'master'); // The tag is option. If no tag is set it will just grab the latest. -// This will not be reached if the PACT verifier throws an error, otherwise it was successful. -$this->assertTrue(true, 'Pact Verification has failed.'); +$selectors = (new ConsumerVersionSelectors()) + ->addSelector('{"tag":"foo","latest":true}') + ->addSelector('{"tag":"bar","latest":true}'); + +$broker = new Broker(); +$broker + ->setUrl(new Uri('http://localhost')) + ->setUsername('user') + ->setPassword('pass') + ->setToken('token') + ->setEnablePending(true) + ->setIncludeWipPactSince('2020-01-30') + ->setProviderTags(['prod']) + ->setProviderBranch('main') + ->setConsumerVersionSelectors($selectors) + ->setConsumerVersionTags(['dev']); + +$verifier->addBroker($broker); + +$verifyResult = $verifier->verify(); + +$this->assertTrue($verifyResult); ``` -##### Verify All from Pact Broker +##### Verify Files in Directory -This will grab every Pact file associated with the given provider. +This allows local Pact file testing. ```php -public function testPactVerifyAll() +public function testPactVerifyFilesInDirectory() { - $config = new VerifierConfig(); - $config - ->setProviderName('someProvider') // Providers name to fetch. - ->setProviderVersion('1.0.0') // Providers version. - ->setProviderBranch('main') // Providers git branch name. - ->setProviderBaseUrl(new Uri('http://localhost:58000')) // URL of the Provider. - ->setBrokerUri(new Uri('http://localhost')) // URL of the Pact Broker to publish results. - ->setPublishResults(true) // Flag the verifier service to publish the results to the Pact Broker. - ->setEnablePending(true) // Flag to enable pending pacts feature (check pact docs for further info) - ->setIncludeWipPactSince('2020-01-30') //Start date of WIP Pacts (check pact docs for further info) - - // Verify that all consumers of 'someProvider' are valid. - $verifier = new Verifier($config); - $verifier->verifyAll(); - - // This will not be reached if the PACT verifier throws an error, otherwise it was successful. - $this->assertTrue(true, 'Pact Verification has failed.'); + $verifier->addDirectory('C:\SomePath'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); } ``` @@ -318,25 +332,13 @@ public function testPactVerifyAll() This allows local Pact file testing. ```php -public function testPactVerifyAll() +public function testPactVerifyFiles() { - $config = new VerifierConfig(); - $config - ->setProviderName('someProvider') // Providers name to fetch. - ->setProviderVersion('1.0.0') // Providers version. - ->setProviderBranch('main') // Providers git branch name. - ->setProviderBaseUrl(new Uri('http://localhost:58000')) // URL of the Provider. - ->setBrokerUri(new Uri('http://localhost')) // URL of the Pact Broker to publish results. - ->setPublishResults(true); // Flag the verifier service to publish the results to the Pact Broker. - ->setEnablePending(true) // Flag to enable pending pacts feature (check pact docs for further info) - ->setIncludeWipPactSince('2020-01-30') //Start date of WIP Pacts (check pact docs for further info) - - // Verify that the files in the array are valid. - $verifier = new Verifier($config); - $verifier->verifyFiles(['C:\SomePath\consumer-provider.json']); - - // This will not be reached if the PACT verifier throws an error, otherwise it was successful. - $this->assertTrue(true, 'Pact Verification has failed.'); + $verifier->addFile('C:\SomePath\consumer-provider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); } ``` @@ -368,7 +370,6 @@ There is a separate repository with an end to end example for both the 2.X and 3 - [2.2.1 tag](https://github.com/mattermack/pact-php-example/tree/2.2.1) for 2.X examples ## Message support -This feature is preliminary as the Pact community as a whole is flushing this out. The goal is not to test the transmission of an object over a bus but instead vet the contents of the message. While examples included focus on a Rabbit MQ, the exact message queue is irrelevant. Initial comparisons require a certain object type to be created by the Publisher/Producer and the Consumer of the message. This includes a metadata set where you @@ -380,7 +381,7 @@ to processing class. Aside from changing default ports, this should be transpa Both the provider and consumer side make heavy use of lambda functions. ### Consumer Side Message Processing -The examples provided are pretty basic. See examples\tests\MessageConsumer. +The examples provided are pretty basic. See [example](/example/message/consumer/tests/ExampleMessageConsumerTest.php). 1. Create the content and metadata (array) 1. Annotate the MessageBuilder appropriate content and states 1. Given = Provider State @@ -408,68 +409,43 @@ $consumerMessage = new ExampleMessageConsumer(); $callback = [$consumerMessage, 'ProcessSong']; $builder->setCallback($callback); -$builder->verify(); +$verifyResult = $builder->verify(); + +$this->assertTrue($verifyResult); ``` ### Provider Side Message Validation -This may evolve as we work through this implementation. The provider relies heavily on callbacks. -Some of the complexity lies in a consumer and provider having many messages and states between the each other in a single pact. +Handle these requests on your provider: -For each message, one needs to provide a single provider state. The name of this provider state must be the key to run -a particular message callback on the provider side. See example\tests\MessageProvider +1. POST /pact-change-state + 1. Set up your database to meet the expectations of the request + 2. Reset the database to its original state. +2. POST /pact-messages + 1. Return message's content in body + 2. Return message's metadata in header `PACT-MESSAGE-METADATA` -1. Create your callbacks and states wrapped in a callable object - 1. The array key is a provider state / given() on the consumer side - 1. It is helpful to wrap the whole thing in a lambda if you need to customize paramaters to be passed in -1. Choose your verification method -1. If nothing explodes, #winning - -```php - - $callbacks = array(); - - // a hello message is a provider state / given() on the consumer side - $callbacks["a hello message"] = function() { - $content = new \stdClass(); - $content->text ="Hello Mary"; - - $metadata = array(); - $metadata['queue'] = "myKey"; - - $provider = (new ExampleMessageProvider()) - ->setContents($content) - ->setMetadata($metadata); - - return $provider->Build(); - }; - - $verifier = (new MessageVerifier($config)) - ->setCallbacks($callbacks) - ->verifyFiles([__DIR__ . '/../../output/test_consumer-test_provider.json']); - -``` +[Click here](/example/message/provider/public/index.php) to see the full sample file. ## Usage for the optional `pact-stub-service` If you would like to test with fixtures, you can use the `pact-stub-service` like this: ```php -$pactLocation = __DIR__ . '/someconsumer-someprovider.json'; -$host = 'localhost'; -$port = 7201; -$endpoint = 'test'; +$files = [__DIR__ . '/someconsumer-someprovider.json']; +$port = 7201; +$endpoint = 'test'; $config = (new StubServerConfig()) - ->setPactLocation($pactLocation) - ->setHost($host) - ->setPort($port) - ->setEndpoint($endpoint); + ->setFiles($files) + ->setPort($port); $stubServer = new StubServer($config); $stubServer->start(); -$service = new StubServerHttpService(new GuzzleClient(), $config); +$client = new \GuzzleHttp\Client(); + +$response = $client->get($this->config->getBaseUri() . '/' . $endpoint); -echo $service->getJson(); // output: {"results":[{"name":"Games"}]} +echo $response->getBody(); // output: {"results":[{"name":"Games"}]} ``` diff --git a/UPGRADE-10.0.md b/UPGRADE-10.0.md new file mode 100644 index 00000000..16880862 --- /dev/null +++ b/UPGRADE-10.0.md @@ -0,0 +1,139 @@ +# UPGRADE FROM 9.x to 10.0 + +We have migrated from the pact-ruby core, to the pact-reference(rust) core. + +This migrates from a CLI driven process for the Pact Framework, to an FFI process based framework. + +- Pre-requisites + + - PHP 8.x + + - PHP FFI Extension installed + +- Environment Variables + + - These environment variables are no longer required can be removed: + - PACT_CORS + - PACT_MOCK_SERVER_HEALTH_CHECK_TIMEOUT + - PACT_MOCK_SERVER_HEALTH_CHECK_RETRY_SEC + +- Consumer + + - The `PhpPact\Consumer\Listener\PactTestListener` listener should be removed from your phpunit config + - Default Pact file write mode has been changed from 'overwrite' to 'merge'. Make sure old pact files are removed before running tests. + + ```shell + rm /path/to/pacts/*.json + ``` + + - Pact files now can ONLY be uploaded to Pact Broker by downloading and running Pact CLI manually. + + ```shell + pact-broker publish /path/to/pacts/*.json --consumer-app-version 1.0.0 --branch main --broker-base-url https://test.pactflow.io --broker-token SomeToken + ``` + +- Verifier + + - `$config->setProviderName("providerName")` is now available via `$config->getProviderInfo()->setName("backend")` + - This is further chainable with the following options:- + - `->setHost('localhost')` + - `->setPort('8080')` + - `->setScheme('http')` + - `->setPath('/')` + - Different pacts sources can be configured via `addXxx` methods + - NB:- You must add at least one source, otherwise the verifier will pass, but not verify any Pact files. + - Types:- + - `addUrl` - Verify Provider by Pact Url retrieved by Broker (Webhooks) + - `addBroker` Verify Provider by dynamically fetched Pacts (Provider change) + - `addFile` / `addDir` - Verify Provider by local file or directory + + Example Usage: + + ```php + $config = new VerifierConfig(); + $config + ->setLogLevel('DEBUG'); + $config + ->getProviderInfo() + ->setName("personProvider") + ->setHost('localhost') + ->setPort('8080') + ->setScheme('http') + ->setPath('/'); + + if ($isCi = getenv('CI')) { + $publishOptions = new PublishOptions(); + $publishOptions + ->setProviderVersion(exec('git rev-parse --short HEAD')) + ->setProviderBranch(exec('git rev-parse --abbrev-ref HEAD')); + $config->setPublishOptions($publishOptions); + } + + $broker = new Broker(); + $broker->setUsername(getenv('PACT_BROKER_USERNAME')); + $broker->setPassword(getenv('PACT_BROKER_PASSWORD')); + $broker->setUsername(getenv('PACT_BROKER_TOKEN')); + $verifier = new Verifier($config); + + // 1. verify with a broker, but using a pact url to verify a specific pact + // PACT_URL=http://localhost:9292/pacts/provider/personProvider/consumer/personConsumer/latest + if ($pact_url = getenv('PACT_URL')) { + $url = new Url(); + $url->setUrl(new Uri($pact_url)); + $verifier->addUrl($url); + } + // 2. verify files from local directory or file + // results will not be published + else if ($pactDir = getenv('PACT_DIR')) { + $verifier->addDirectory($pactDir); + } else if ($pactFile = getenv('PACT_FILE')) { + $verifier->addFile($pactFile); + } else { + // 2. verify with broker by fetching dynamic pacts (with consumer version selectors) + // if you don't setConsumerVersionSelectors then it will fetch the latest pact for the named provider + if ($pactBrokerBaseUrl = getenv('PACT_BROKER_BASE_URL')) { + $broker->setUrl(new Uri($pactBrokerBaseUrl)); + } else { + $broker->setUrl(new Uri('http://localhost:9292')); + } + // we need to set the provider branch here for PactBrokerWithDynamicConfiguration + // as $publishOptions->setProviderBranch value set above isn't used. + $broker->setProviderBranch(exec('git rev-parse --abbrev-ref HEAD')); + $selectors = (new ConsumerVersionSelectors()) + ->addSelector(new Selector(mainBranch: true)) + ->addSelector(new Selector(deployedOrReleased: true)); + $broker->setConsumerVersionSelectors($selectors); + $broker->setEnablePending(true); + $broker->setIncludeWipPactSince('2020-01-30'); + $verifier->addBroker($broker); + } + + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + ``` + +- Stub Server + + - No longer defaults to port 7201, picks free port at random. + - `PhpPact\Standalone\StubService\Service\StubServerHttpService` is no longer available. Guzzle can be used to request to stub server directly: + + ```php + $client = new \GuzzleHttp\Client(); + $response = $client->get($this->config->getBaseUri() . '/' . $endpoint); + echo $response->getBody(); + ``` + +- Example Migrations to 10.x (Pull Request Diffs) + - PHP Verifier https://github.com/acmachado14/simple-pact/compare/main...YOU54F:simple-pact:ffi-next + - PHP Consumer https://github.com/YOU54F/pact-testing/compare/main...YOU54F:pact-testing:ffi-next + - PHP Consumer & Verifier + - Consumer https://github.com/YOU54F/014-pact-http-consumer-php/compare/main...YOU54F:014-pact-http-consumer-php:ffi-next + - Verifier https://github.com/YOU54F/015-pact-http-producer-php/compare/main...YOU54F:015-pact-http-producer-php:ffi-next + + +Examples of Additional Features now possible + +- Pact Plugins + - CSV https://github.com/tienvx/pact-php-csv + - Protobuf/gRPC https://github.com/tienvx/pact-php-protobuf \ No newline at end of file diff --git a/behat.yml b/behat.yml new file mode 100644 index 00000000..e0a02922 --- /dev/null +++ b/behat.yml @@ -0,0 +1,19 @@ +imports: + - 'compatibility-suite/suites/v1/http/consumer.yml' + - 'compatibility-suite/suites/v1/http/provider.yml' + - 'compatibility-suite/suites/v2/http/consumer.yml' + - 'compatibility-suite/suites/v2/http/provider.yml' + - 'compatibility-suite/suites/v3/http/consumer.yml' + - 'compatibility-suite/suites/v3/http/provider.yml' + - 'compatibility-suite/suites/v3/message/consumer.yml' + - 'compatibility-suite/suites/v3/message/provider.yml' + - 'compatibility-suite/suites/v3/generators.yml' + - 'compatibility-suite/suites/v3/matching-rules.yml' + - 'compatibility-suite/suites/v4/http/consumer.yml' + - 'compatibility-suite/suites/v4/http/provider.yml' + - 'compatibility-suite/suites/v4/message/consumer.yml' + - 'compatibility-suite/suites/v4/message/provider.yml' + - 'compatibility-suite/suites/v4/combined.yml' + - 'compatibility-suite/suites/v4/sync-message/consumer.yml' + - 'compatibility-suite/suites/v4/matching-rules.yml' + - 'compatibility-suite/suites/v4/generators.yml' diff --git a/compatibility-suite/pact-compatibility-suite b/compatibility-suite/pact-compatibility-suite new file mode 160000 index 00000000..416f3a64 --- /dev/null +++ b/compatibility-suite/pact-compatibility-suite @@ -0,0 +1 @@ +Subproject commit 416f3a64d49bee977b0f404a5ba3002eae570eb3 diff --git a/compatibility-suite/pacts/.gitignore b/compatibility-suite/pacts/.gitignore new file mode 100644 index 00000000..a6c57f5f --- /dev/null +++ b/compatibility-suite/pacts/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/compatibility-suite/public/generators/.gitignore b/compatibility-suite/public/generators/.gitignore new file mode 100644 index 00000000..35340fb5 --- /dev/null +++ b/compatibility-suite/public/generators/.gitignore @@ -0,0 +1,2 @@ +*.json +*.txt diff --git a/compatibility-suite/public/generators/index.php b/compatibility-suite/public/generators/index.php new file mode 100644 index 00000000..f74b84e2 --- /dev/null +++ b/compatibility-suite/public/generators/index.php @@ -0,0 +1,33 @@ +put('/request-generators', function (Request $request, Response $response) { + file_put_contents(__DIR__ . '/body.json', $request->getBody()->getContents()); + file_put_contents(__DIR__ . '/headers.json', json_encode($request->getHeaders())); + file_put_contents(__DIR__ . '/queryParams.json', json_encode($request->getQueryParams())); + + return $response; +}); + +$app->post('/return-provider-state-values', function (Request $request, Response $response) { + $values = $request->getQueryParams(); + + $response->getBody()->write(json_encode($values)); + + return $response->withHeader('Content-Type', 'application/json'); +}); + +$app->any('{path:.*}', function ($request, $response, array $args) { + file_put_contents(__DIR__ . '/path.txt', $args['path']); + + return $response; +}); + +$app->run(); diff --git a/compatibility-suite/public/provider-states/.gitignore b/compatibility-suite/public/provider-states/.gitignore new file mode 100644 index 00000000..a6c57f5f --- /dev/null +++ b/compatibility-suite/public/provider-states/.gitignore @@ -0,0 +1 @@ +*.json diff --git a/compatibility-suite/public/provider-states/index.php b/compatibility-suite/public/provider-states/index.php new file mode 100644 index 00000000..b7de797b --- /dev/null +++ b/compatibility-suite/public/provider-states/index.php @@ -0,0 +1,68 @@ +addBodyParsingMiddleware(); + +$path = __DIR__ . '/provider-states.json'; +$get = fn (): array => json_decode(file_get_contents($path), true); +$set = fn (array $providerStates) => file_put_contents($path, json_encode($providerStates)); + +if (!file_exists($path)) { + $set([]); +} + +$stateChangeHandler = function (Request $request, Response $response) use ($get, $set) { + $body = $request->getParsedBody(); + + $providerStates = $get(); + $providerStates[] = $body; + $set($providerStates); + + return $response; +}; + +$app->get('/has-action', function (Request $request, Response $response) use ($get) { + $action = $request->getQueryParams()['action']; + $hasAction = !empty(array_filter( + $get(), + fn (array $providerState) => $providerState['action'] === $action + )); + + $response->getBody()->write((string) $hasAction); + + return $response->withHeader('Content-Type', 'text/plain'); +}); + +$app->get('/has-state', function (Request $request, Response $response) use ($get) { + $params = $request->getQueryParams(); + $action = $params['action']; + $state = $params['state']; + unset($params['action'], $params['state']); + $hasState = !empty(array_filter( + $get(), + fn (array $providerState) => + $providerState['action'] === $action + && $providerState['state'] === $state + && $providerState['params'] == $params + )); + + $response->getBody()->write((string) $hasState); + + return $response->withHeader('Content-Type', 'text/plain'); +}); + +$app->post('/pact-change-state', $stateChangeHandler); + +$app->post('/failed-pact-change-state', function (Request $request, Response $response) use ($stateChangeHandler) { + $stateChangeHandler($request, $response); + + throw new \Exception('Cant do it'); +}); + +$app->run(); diff --git a/compatibility-suite/suites/v1/http/consumer.yml b/compatibility-suite/suites/v1/http/consumer.yml new file mode 100644 index 00000000..87dfeb31 --- /dev/null +++ b/compatibility-suite/suites/v1/http/consumer.yml @@ -0,0 +1,23 @@ +default: + suites: + v1_http_consumer: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V1/http_consumer.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\InteractionsContext': + - '@interactions_storage' + - '@request_matching_rule_builder' + - '@response_matching_rule_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Transform\InteractionsContext': + - '@interaction_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\V1\Http\ConsumerContext': + - '@server' + - '@request_builder' + - '@client' + - '@interactions_storage' + - '@fixture_loader' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V1 diff --git a/compatibility-suite/suites/v1/http/provider.yml b/compatibility-suite/suites/v1/http/provider.yml new file mode 100644 index 00000000..1e89c55f --- /dev/null +++ b/compatibility-suite/suites/v1/http/provider.yml @@ -0,0 +1,32 @@ +default: + suites: + v1_http_provider: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V1/http_provider.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\InteractionsContext': + - '@interactions_storage' + - '@request_matching_rule_builder' + - '@response_matching_rule_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Transform\InteractionsContext': + - '@interaction_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\PactBrokerContext': + - '@pact_broker' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\ProviderStateContext': + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\ProviderContext': + - '@server' + - '@provider_verifier' + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\V1\Http\ProviderContext': + - '@server' + - '@pact_writer' + - '@pact_broker' + - '@response_builder' + - '@interactions_storage' + - '@provider_verifier' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V1 diff --git a/compatibility-suite/suites/v2/http/consumer.yml b/compatibility-suite/suites/v2/http/consumer.yml new file mode 100644 index 00000000..e6872644 --- /dev/null +++ b/compatibility-suite/suites/v2/http/consumer.yml @@ -0,0 +1,29 @@ +default: + suites: + v2_http_consumer: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V2/http_consumer.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\InteractionsContext': + - '@interactions_storage' + - '@request_matching_rule_builder' + - '@response_matching_rule_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Transform\InteractionsContext': + - '@interaction_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\V1\Http\ConsumerContext': + - '@server' + - '@request_builder' + - '@client' + - '@interactions_storage' + - '@fixture_loader' + - 'PhpPactTest\CompatibilitySuite\Context\V2\Http\ConsumerContext': + - '@server' + - '@request_builder' + - '@request_matching_rule_builder' + - '@matching_rules_storage' + - '@interactions_storage' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V2 diff --git a/compatibility-suite/suites/v2/http/provider.yml b/compatibility-suite/suites/v2/http/provider.yml new file mode 100644 index 00000000..124d6b47 --- /dev/null +++ b/compatibility-suite/suites/v2/http/provider.yml @@ -0,0 +1,30 @@ +default: + suites: + v2_http_provider: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V2/http_provider.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\InteractionsContext': + - '@interactions_storage' + - '@request_matching_rule_builder' + - '@response_matching_rule_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Transform\InteractionsContext': + - '@interaction_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\ProviderStateContext': + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\ProviderContext': + - '@server' + - '@provider_verifier' + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\V1\Http\ProviderContext': + - '@server' + - '@pact_writer' + - '@pact_broker' + - '@response_builder' + - '@interactions_storage' + - '@provider_verifier' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V2 diff --git a/compatibility-suite/suites/v3/generators.yml b/compatibility-suite/suites/v3/generators.yml new file mode 100644 index 00000000..bab7c3b3 --- /dev/null +++ b/compatibility-suite/suites/v3/generators.yml @@ -0,0 +1,28 @@ +default: + suites: + v3_generators: + paths: + - '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V3/generators.feature' + - '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V3/http_generators.feature' + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V3\BodyGeneratorsContext': + - '@body_validator' + - 'PhpPactTest\CompatibilitySuite\Context\V3\RequestGeneratorsContext': + - '@interaction_builder' + - '@request_generator_builder' + - '@interactions_storage' + - '@pact_writer' + - '@generator_server' + - '@provider_verifier' + - '@body_storage' + - 'PhpPactTest\CompatibilitySuite\Context\V3\ResponseGeneratorsContext': + - '@interaction_builder' + - '@response_generator_builder' + - '@interactions_storage' + - '@server' + - '@client' + - '@body_storage' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V3 diff --git a/compatibility-suite/suites/v3/http/consumer.yml b/compatibility-suite/suites/v3/http/consumer.yml new file mode 100644 index 00000000..e164ed80 --- /dev/null +++ b/compatibility-suite/suites/v3/http/consumer.yml @@ -0,0 +1,13 @@ +default: + suites: + v3_http_consumer: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V3/http_consumer.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V3\Http\ConsumerContext': + - '@interaction_builder' + - '@pact_writer' + - '@interactions_storage' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V3 diff --git a/compatibility-suite/suites/v3/http/provider.yml b/compatibility-suite/suites/v3/http/provider.yml new file mode 100644 index 00000000..7d8c0111 --- /dev/null +++ b/compatibility-suite/suites/v3/http/provider.yml @@ -0,0 +1,34 @@ +default: + suites: + v3_http_provider: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V3/http_provider.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\InteractionsContext': + - '@interactions_storage' + - '@request_matching_rule_builder' + - '@response_matching_rule_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Transform\InteractionsContext': + - '@interaction_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\ProviderStateContext': + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\ProviderContext': + - '@server' + - '@provider_verifier' + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\V1\Http\ProviderContext': + - '@server' + - '@pact_writer' + - '@pact_broker' + - '@response_builder' + - '@interactions_storage' + - '@provider_verifier' + - 'PhpPactTest\CompatibilitySuite\Context\V3\Http\ProviderContext': + - '@pact_writer' + - '@provider_state_server' + - '@provider_verifier' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V3 diff --git a/compatibility-suite/suites/v3/matching-rules.yml b/compatibility-suite/suites/v3/matching-rules.yml new file mode 100644 index 00000000..090cb654 --- /dev/null +++ b/compatibility-suite/suites/v3/matching-rules.yml @@ -0,0 +1,18 @@ +default: + suites: + v3_matching_rules: + paths: + - '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V3/matching_rules.feature' + - '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V3/http_matching.feature' + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V3\RequestMatchingContext': + - '@interaction_builder' + - '@server' + - '@client' + - '@interactions_storage' + - '@request_builder' + - '@request_matching_rule_builder' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V3 diff --git a/compatibility-suite/suites/v3/message/consumer.yml b/compatibility-suite/suites/v3/message/consumer.yml new file mode 100644 index 00000000..662dc2ae --- /dev/null +++ b/compatibility-suite/suites/v3/message/consumer.yml @@ -0,0 +1,16 @@ +default: + suites: + v3_message_consumer: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V3/message_consumer.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V3\Message\ConsumerContext': + - '@specification' + - '@message_generator_builder' + - '@parser' + - '@body_validator' + - '@body_storage' + - '@fixture_loader' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V3 diff --git a/compatibility-suite/suites/v3/message/provider.yml b/compatibility-suite/suites/v3/message/provider.yml new file mode 100644 index 00000000..9a5d68d5 --- /dev/null +++ b/compatibility-suite/suites/v3/message/provider.yml @@ -0,0 +1,26 @@ +default: + suites: + v3_message_provider: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V3/message_provider.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\ProviderStateContext': + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\ProviderContext': + - '@server' + - '@provider_verifier' + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\V3\Message\ProviderContext': + - '@server' + - '@interaction_builder' + - '@interactions_storage' + - '@message_pact_writer' + - '@provider_verifier' + - '@parser' + - '@fixture_loader' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V3 + + filters: + tags: ~@wip diff --git a/compatibility-suite/suites/v4/combined.yml b/compatibility-suite/suites/v4/combined.yml new file mode 100644 index 00000000..1873d0b6 --- /dev/null +++ b/compatibility-suite/suites/v4/combined.yml @@ -0,0 +1,14 @@ +default: + suites: + v4_combined: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V4/v4.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V4\CombinedContext': + - '@interaction_builder' + - '@interactions_storage' + - '@pact_writer' + - '@message_pact_writer' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V4 diff --git a/compatibility-suite/suites/v4/generators.yml b/compatibility-suite/suites/v4/generators.yml new file mode 100644 index 00000000..22fcc247 --- /dev/null +++ b/compatibility-suite/suites/v4/generators.yml @@ -0,0 +1,28 @@ +default: + suites: + v4_generators: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V4/generators.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V3\BodyGeneratorsContext': + - '@body_validator' + - 'PhpPactTest\CompatibilitySuite\Context\V3\RequestGeneratorsContext': + - '@interaction_builder' + - '@request_generator_builder' + - '@interactions_storage' + - '@pact_writer' + - '@generator_server' + - '@provider_verifier' + - '@body_storage' + - 'PhpPactTest\CompatibilitySuite\Context\V4\BodyGeneratorsContext': + - '@body_validator' + - 'PhpPactTest\CompatibilitySuite\Context\V4\ResponseGeneratorsContext': + - '@interaction_builder' + - '@response_generator_builder' + - '@interactions_storage' + - '@server' + - '@client' + - '@body_storage' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V4 diff --git a/compatibility-suite/suites/v4/http/consumer.yml b/compatibility-suite/suites/v4/http/consumer.yml new file mode 100644 index 00000000..57b71dea --- /dev/null +++ b/compatibility-suite/suites/v4/http/consumer.yml @@ -0,0 +1,13 @@ +default: + suites: + v4_http_consumer: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V4/http_consumer.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V4\Http\ConsumerContext': + - '@interaction_builder' + - '@pact_writer' + - '@interactions_storage' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V4 diff --git a/compatibility-suite/suites/v4/http/provider.yml b/compatibility-suite/suites/v4/http/provider.yml new file mode 100644 index 00000000..cdfd7dec --- /dev/null +++ b/compatibility-suite/suites/v4/http/provider.yml @@ -0,0 +1,31 @@ +default: + suites: + v4_http_provider: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V4/http_provider.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\InteractionsContext': + - '@interactions_storage' + - '@request_matching_rule_builder' + - '@response_matching_rule_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Transform\InteractionsContext': + - '@interaction_builder' + - '@matching_rules_storage' + - 'PhpPactTest\CompatibilitySuite\Context\Shared\ProviderContext': + - '@server' + - '@provider_verifier' + - '@provider_state_server' + - 'PhpPactTest\CompatibilitySuite\Context\V1\Http\ProviderContext': + - '@server' + - '@pact_writer' + - '@pact_broker' + - '@response_builder' + - '@interactions_storage' + - '@provider_verifier' + - 'PhpPactTest\CompatibilitySuite\Context\V4\Http\ProviderContext': + - '@pact_writer' + - '@provider_verifier' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V4 diff --git a/compatibility-suite/suites/v4/matching-rules.yml b/compatibility-suite/suites/v4/matching-rules.yml new file mode 100644 index 00000000..34c4d7dc --- /dev/null +++ b/compatibility-suite/suites/v4/matching-rules.yml @@ -0,0 +1,23 @@ +default: + suites: + v4_matching_rules: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V4/matching_rules.feature' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V3\RequestMatchingContext': + - '@interaction_builder' + - '@server' + - '@client' + - '@interactions_storage' + - '@request_builder' + - '@request_matching_rule_builder' + - 'PhpPactTest\CompatibilitySuite\Context\V4\ResponseMatchingContext': + - '@interaction_builder' + - '@interactions_storage' + - '@response_matching_rule_builder' + - '@server' + - '@pact_writer' + - '@provider_verifier' + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V4 diff --git a/compatibility-suite/suites/v4/message/consumer.yml b/compatibility-suite/suites/v4/message/consumer.yml new file mode 100644 index 00000000..25ae890f --- /dev/null +++ b/compatibility-suite/suites/v4/message/consumer.yml @@ -0,0 +1,14 @@ +default: + suites: + v4_message_consumer: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V4' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V4\Message\ConsumerContext': + - '@message_pact_writer' + + filters: + tags: "@consumer&&@message" + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V4 diff --git a/compatibility-suite/suites/v4/message/provider.yml b/compatibility-suite/suites/v4/message/provider.yml new file mode 100644 index 00000000..31a14f5f --- /dev/null +++ b/compatibility-suite/suites/v4/message/provider.yml @@ -0,0 +1,19 @@ +default: + suites: + v4_message_provider: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V4' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V4\Message\ProviderContext': + - '@server' + - '@interaction_builder' + - '@interactions_storage' + - '@message_pact_writer' + - '@provider_verifier' + - '@parser' + + filters: + tags: "@provider&&@message" + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V4 diff --git a/compatibility-suite/suites/v4/sync-message/consumer.yml b/compatibility-suite/suites/v4/sync-message/consumer.yml new file mode 100644 index 00000000..ea263247 --- /dev/null +++ b/compatibility-suite/suites/v4/sync-message/consumer.yml @@ -0,0 +1,14 @@ +default: + suites: + v4_sync_message_consumer: + paths: [ '%paths.base%/compatibility-suite/pact-compatibility-suite/features/V4' ] + + contexts: + - 'PhpPactTest\CompatibilitySuite\Context\Shared\Hook\SetUpContext' + - 'PhpPactTest\CompatibilitySuite\Context\V4\SyncMessage\ConsumerContext': + - '@sync_message_pact_writer' + + filters: + tags: "@SynchronousMessage&&@message" + + services: PhpPactTest\CompatibilitySuite\ServiceContainer\V4 diff --git a/compatibility-suite/tests/Constant/Mismatch.php b/compatibility-suite/tests/Constant/Mismatch.php new file mode 100644 index 00000000..2b5ab806 --- /dev/null +++ b/compatibility-suite/tests/Constant/Mismatch.php @@ -0,0 +1,32 @@ + false, + 'PathMismatch' => false, + 'StatusMismatch' => 'Response status did not match', + 'QueryMismatch' => false, + 'HeaderMismatch' => 'Headers had differences', + 'BodyTypeMismatch' => 'Body type had differences', + 'BodyMismatch' => 'Body had differences', + 'MetadataMismatch' => 'Metadata had differences', + ]; + + public const VERIFIER_MISMATCH_ERROR_MAP = [ + 'One or more of the setup state change handlers has failed' => 'State change request failed', + ]; + + public const MOCK_SERVER_MISMATCH_TYPE_MAP = [ + 'method' => 'MethodMismatch', + 'path' => 'PathMismatch', + 'status' => 'StatusMismatch', + 'query' => 'QueryMismatch', + 'header' => 'HeaderMismatch', + 'body-content-type' => 'BodyTypeMismatch', + 'body' => 'BodyMismatch', + 'metadata' => 'MetadataMismatch', + ]; +} diff --git a/compatibility-suite/tests/Constant/Path.php b/compatibility-suite/tests/Constant/Path.php new file mode 100644 index 00000000..4d84efdf --- /dev/null +++ b/compatibility-suite/tests/Constant/Path.php @@ -0,0 +1,11 @@ +getScenario()->getTitle(), 'via a Pact broker')) { + $this->pactBroker->start(); + } + } + + /** + * @AfterScenario + */ + public function stopPactBroker(AfterScenarioScope $scope): void + { + if (str_contains($scope->getScenario()->getTitle(), 'via a Pact broker')) { + $this->pactBroker->stop(); + } + } +} diff --git a/compatibility-suite/tests/Context/Shared/Hook/ProviderStateContext.php b/compatibility-suite/tests/Context/Shared/Hook/ProviderStateContext.php new file mode 100644 index 00000000..c661a0d6 --- /dev/null +++ b/compatibility-suite/tests/Context/Shared/Hook/ProviderStateContext.php @@ -0,0 +1,36 @@ +getScenario()->getTitle())) { + $this->providerStateServer->start(); + } + } + + /** + * @AfterScenario + */ + public function stopProviderState(AfterScenarioScope $scope): void + { + if (preg_match('/^Verifying .* provider state/', $scope->getScenario()->getTitle())) { + $this->providerStateServer->stop(); + } + } +} diff --git a/compatibility-suite/tests/Context/Shared/Hook/SetUpContext.php b/compatibility-suite/tests/Context/Shared/Hook/SetUpContext.php new file mode 100644 index 00000000..3c2089ac --- /dev/null +++ b/compatibility-suite/tests/Context/Shared/Hook/SetUpContext.php @@ -0,0 +1,23 @@ + $interaction) { + $this->storeInteractionWithoutMatchingRules($id, $interaction); + $this->storeInteractionWithMatchingRules($id, $interaction); + } + } + + private function storeInteractionWithoutMatchingRules(int $id, Interaction $interaction): void + { + $this->storage->add(InteractionsStorageInterface::CLIENT_DOMAIN, $id, $interaction, true); + } + + private function storeInteractionWithMatchingRules(int $id, Interaction $interaction): void + { + $this->buildMatchingRules($id, $interaction); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $id, $interaction, true); + $this->storage->add(InteractionsStorageInterface::PACT_WRITER_DOMAIN, $id, $interaction, true); + } + + private function buildMatchingRules(int $id, Interaction $interaction): void + { + if ($file = $this->matchingRulesStorage->get(MatchingRulesStorageInterface::REQUEST_DOMAIN, $id)) { + $this->requestMatchingRuleBuilder->build($interaction->getRequest(), $file); + } + if ($file = $this->matchingRulesStorage->get(MatchingRulesStorageInterface::RESPONSE_DOMAIN, $id)) { + $this->responseMatchingRuleBuilder->build($interaction->getResponse(), $file); + } + } +} diff --git a/compatibility-suite/tests/Context/Shared/ProviderContext.php b/compatibility-suite/tests/Context/Shared/ProviderContext.php new file mode 100644 index 00000000..c0440460 --- /dev/null +++ b/compatibility-suite/tests/Context/Shared/ProviderContext.php @@ -0,0 +1,145 @@ +providerVerifier->getConfig()->getProviderInfo()->setPort($this->server->getPort()); + $this->providerVerifier->verify(); + } + + /** + * @Then the verification will be successful + */ + public function theVerificationWillBeSuccessful(): void + { + Assert::assertTrue($this->providerVerifier->getVerifyResult()->isSuccess()); + } + + /** + * @Then the verification will NOT be successful + */ + public function theVerificationWillNotBeSuccessful(): void + { + Assert::assertFalse($this->providerVerifier->getVerifyResult()->isSuccess()); + } + + /** + * @Given a provider state callback is configured + */ + public function aProviderStateCallbackIsConfigured(): void + { + $port = $this->providerStateServer->getPort(); + $this->providerVerifier + ->getConfig() + ->getProviderState() + ->setStateChangeUrl(new Uri("http://localhost:$port/pact-change-state")) + ->setStateChangeTeardown(true); + ; + } + + /** + * @Then the provider state callback will be called before the verification is run + */ + public function theProviderStateCallbackWillBeCalledBeforeTheVerificationIsRun(): void + { + Assert::assertTrue($this->providerStateServer->hasAction(ProviderStateServerInterface::ACTION_SETUP)); + } + + /** + * @Then the provider state callback will receive a setup call with :state as the provider state parameter + */ + public function theProviderStateCallbackWillReceiveASetupCallWithAsTheProviderStateParameter(string $state): void + { + Assert::assertTrue($this->providerStateServer->hasState(ProviderStateServerInterface::ACTION_SETUP, $state)); + } + + /** + * @Then the provider state callback will be called after the verification is run + */ + public function theProviderStateCallbackWillBeCalledAfterTheVerificationIsRun(): void + { + Assert::assertTrue($this->providerStateServer->hasAction(ProviderStateServerInterface::ACTION_TEARDOWN)); + } + + /** + * @Then the provider state callback will receive a teardown call :state as the provider state parameter + */ + public function theProviderStateCallbackWillReceiveATeardownCallAsTheProviderStateParameter(string $state): void + { + Assert::assertTrue($this->providerStateServer->hasState(ProviderStateServerInterface::ACTION_TEARDOWN, $state)); + } + + /** + * @Given a provider state callback is configured, but will return a failure + */ + public function aProviderStateCallbackIsConfiguredButWillReturnAFailure(): void + { + $port = $this->providerStateServer->getPort(); + $this->providerVerifier + ->getConfig() + ->getProviderState() + ->setStateChangeUrl(new Uri("http://localhost:$port/failed-pact-change-state")) + ->setStateChangeTeardown(true); + ; + } + + /** + * @Then the provider state callback will NOT receive a teardown call + */ + public function theProviderStateCallbackWillNotReceiveATeardownCall(): void + { + Assert::assertFalse($this->providerStateServer->hasAction(ProviderStateServerInterface::ACTION_TEARDOWN)); + } + + /** + * @Then the verification results will contain a :error error + */ + public function theVerificationResultsWillContainAError(string $error): void + { + $output = json_decode($this->providerVerifier->getVerifyResult()->getOutput(), true); + $errors = array_reduce( + $output['errors'], + function (array $errors, array $error) { + switch ($error['mismatch']['type']) { + case 'error': + $errors[] = Mismatch::VERIFIER_MISMATCH_ERROR_MAP[$error['mismatch']['message']]; + break; + + case 'mismatches': + foreach ($error['mismatch']['mismatches'] as $mismatch) { + $errors[] = Mismatch::VERIFIER_MISMATCH_TYPE_MAP[$mismatch['type']]; + } + break; + + default: + break; + } + + return $errors; + }, + [] + ); + Assert::assertContains($error, $errors); + } +} diff --git a/compatibility-suite/tests/Context/Shared/Transform/InteractionsContext.php b/compatibility-suite/tests/Context/Shared/Transform/InteractionsContext.php new file mode 100644 index 00000000..8de5c6f2 --- /dev/null +++ b/compatibility-suite/tests/Context/Shared/Transform/InteractionsContext.php @@ -0,0 +1,48 @@ + + */ + public function getInteractions(TableNode $table): array + { + $interactions = []; + foreach ($table->getHash() as $data) { + $id = (int) $data['No']; + $interactions[$id] = $this->builder->build($data); + $this->storeMatchingRules($id, $data); + } + + return $interactions; + } + + private function storeMatchingRules(int $id, array $data): void + { + if (isset($data['matching rules'])) { + $this->matchingRulesStorage->add(MatchingRulesStorageInterface::REQUEST_DOMAIN, $id, $data['matching rules']); + } + if (isset($data['response matching rules'])) { + $this->matchingRulesStorage->add(MatchingRulesStorageInterface::RESPONSE_DOMAIN, $id, $data['response matching rules']); + } + } +} diff --git a/compatibility-suite/tests/Context/V1/Http/ConsumerContext.php b/compatibility-suite/tests/Context/V1/Http/ConsumerContext.php new file mode 100644 index 00000000..a721d0df --- /dev/null +++ b/compatibility-suite/tests/Context/V1/Http/ConsumerContext.php @@ -0,0 +1,295 @@ +server->register($id); + } + + /** + * @When request :id is made to the mock server + */ + public function requestIsMadeToTheMockServer(int $id): void + { + $this->client->sendRequestToServer($id); + } + + /** + * @Then a :code success response is returned + */ + public function aSuccessResponseIsReturned(int $code): void + { + Assert::assertSame($code, $this->client->getResponse()->getStatusCode()); + } + + /** + * @Then the payload will contain the :name JSON document + */ + public function thePayloadWillContainTheJsonDocument(string $name): void + { + Assert::assertJsonStringEqualsJsonString($this->fixtureLoader->load($name . '.json'), (string) $this->client->getResponse()->getBody()); + } + + /** + * @Then the content type will be set as :contentType + */ + public function theContentTypeWillBeSetAs(string $contentType): void + { + Assert::assertSame($contentType, $this->client->getResponse()->getHeaderLine('Content-Type')); + } + + /** + * @When the pact test is done + */ + public function thePactTestIsDone(): void + { + $this->server->verify(); + } + + /** + * @Then the mock server status will be OK + */ + public function theMockServerStatusWillBeOk(): void + { + Assert::assertTrue($this->server->getVerifyResult()->isSuccess()); + } + + /** + * @Then the mock server will write out a Pact file for the interaction when done + */ + public function theMockServerWillWriteOutAPactFileForTheInteractionWhenDone(): void + { + Assert::assertTrue(file_exists($this->server->getPactPath())); + } + + /** + * @Then the pact file will contain {:num} interaction(s) + */ + public function thePactFileWillContainInteraction(int $num): void + { + $this->pact = json_decode(file_get_contents($this->server->getPactPath()), true); + Assert::assertEquals($num, count($this->pact['interactions'] ?? [])); + } + + /** + * @Then the {first} interaction request will be for a :method + */ + public function theFirstInteractionRequestWillBeForA(string $method): void + { + Assert::assertSame($method, $this->pact['interactions'][0]['request']['method'] ?? null); + } + + /** + * @Then the {first} interaction response will contain the :fixture document + */ + public function theFirstInteractionResponseWillContainTheDocument(string $fixture): void + { + Assert::assertEquals($this->fixtureLoader->loadJson($fixture), $this->pact['interactions'][0]['response']['body'] ?? null); + } + + /** + * @When the mock server is started with interactions :ids + */ + public function theMockServerIsStartedWithInteractions(string $ids): void + { + $ids = array_map(fn (string $id) => (int) trim($id), explode(',', $ids)); + $this->server->register(...$ids); + } + + /** + * @Then the mock server status will NOT be OK + */ + public function theMockServerStatusWillNotBeOk(): void + { + Assert::assertFalse($this->server->getVerifyResult()->isSuccess()); + } + + /** + * @Then the mock server will NOT write out a Pact file for the interactions when done + */ + public function theMockServerWillNotWriteOutAPactFileForTheInteractionsWhenDone(): void + { + Assert::assertFileDoesNotExist($this->server->getPactPath()); + } + + /** + * @Then the mock server status will be an expected but not received error for interaction {:id} + */ + public function theMockServerStatusWillBeAnExpectedButNotReceivedErrorForInteraction(int $id): void + { + $request = $this->storage->get(InteractionsStorageInterface::SERVER_DOMAIN, $id)->getRequest(); + $mismatches = $this->getMismatches(); + Assert::assertCount(1, $mismatches); + $mismatch = current($mismatches); + Assert::assertSame('missing-request', $mismatch['type']); + Assert::assertSame($request->getMethod(), $mismatch['request']['method']); + Assert::assertSame($request->getPath(), $mismatch['request']['path']); + Assert::assertSame($request->getQuery(), $mismatch['request']['query']); + // TODO assert headers, body + } + + /** + * @Then a :code error response is returned + */ + public function aErrorResponseIsReturned(int $code): void + { + Assert::assertSame($code, $this->client->getResponse()->getStatusCode()); + } + + /** + * @Then the mock server status will be an unexpected :method request received error for interaction {:id} + */ + public function theMockServerStatusWillBeAnUnexpectedRequestReceivedErrorForInteraction(string $method, int $id): void + { + $request = $this->storage->get(InteractionsStorageInterface::SERVER_DOMAIN, $id)->getRequest(); + $mismatches = $this->getMismatches(); + Assert::assertCount(2, $mismatches); + $notFoundRequests = array_filter($mismatches, fn (array $mismatch) => $mismatch['type'] === 'request-not-found'); + $mismatch = current($notFoundRequests); + Assert::assertSame($request->getMethod(), $mismatch['request']['method']); + Assert::assertSame($request->getPath(), $mismatch['request']['path']); + // TODO assert query, headers, body + } + + /** + * @Then the {first} interaction request query parameters will be :query + */ + public function theFirstInteractionRequestQueryParametersWillBe(string $query) + { + Assert::assertEquals($query, $this->pact['interactions'][0]['request']['query']); + } + + /** + * @When request :id is made to the mock server with the following changes: + */ + public function requestIsMadeToTheMockServerWithTheFollowingChanges(int $id, TableNode $table) + { + $request = $this->storage->get(InteractionsStorageInterface::CLIENT_DOMAIN, $id)->getRequest(); + $this->requestBuilder->build($request, $table->getHash()[0]); + $this->requestIsMadeToTheMockServer($id); + } + + /** + * @Then the mock server status will be mismatches + */ + public function theMockServerStatusWillBeMismatches(): void + { + $mismatches = $this->getMismatches(); + Assert::assertNotEmpty($mismatches); + } + + /** + * @Then the mismatches will contain a :type mismatch with error :error + */ + public function theMismatchesWillContainAMismatchWithError(string $type, string $error): void + { + $mismatches = $this->getMismatches(); + $mismatch = current($mismatches); + Assert::assertSame('request-mismatch', $mismatch['type']); + $mismatches = array_filter( + $mismatch['mismatches'], + fn (array $mismatch) => $mismatch['type'] === Mismatch::MOCK_SERVER_MISMATCH_TYPE_MAP[$type] + && str_contains($mismatch['mismatch'], $error) + ); + Assert::assertNotEmpty($mismatches); + } + + /** + * @Then the mock server will NOT write out a Pact file for the interaction when done + */ + public function theMockServerWillNotWriteOutAPactFileForTheInteractionWhenDone(): void + { + Assert::assertFileDoesNotExist($this->server->getPactPath()); + } + + /** + * @Then the mock server status will be an unexpected :method request received error for path :path + */ + public function theMockServerStatusWillBeAnUnexpectedRequestReceivedErrorForPath(string $method, string $path): void + { + $mismatches = $this->getMismatches(); + Assert::assertCount(2, $mismatches); + $notFoundRequests = array_filter($mismatches, fn (array $mismatch) => $mismatch['type'] === 'request-not-found'); + $mismatch = current($notFoundRequests); + Assert::assertSame($method, $mismatch['request']['method']); + Assert::assertSame($path, $mismatch['request']['path']); + } + + /** + * @Then the {first} interaction request will contain the header :header with value :value + */ + public function theFirstInteractionRequestWillContainTheHeaderWithValue(string $header, string $value): void + { + Assert::assertArrayHasKey($header, $this->pact['interactions'][0]['request']['headers']); + Assert::assertSame($value, $this->pact['interactions'][0]['request']['headers'][$header]); + } + + /** + * @Then the {first} interaction request content type will be :contentType + */ + public function theFirstInteractionRequestContentTypeWillBe(string $contentType): void + { + Assert::assertSame($contentType, $this->pact['interactions'][0]['request']['headers']['Content-Type']); + } + + /** + * @Then the {first} interaction request will contain the :fixture document + */ + public function theFirstInteractionRequestWillContainTheDocument(string $fixture): void + { + Assert::assertEquals($this->fixtureLoader->loadJson($fixture), $this->pact['interactions'][0]['request']['body'] ?? null); + } + + /** + * @Then the mismatches will contain a :type mismatch with path :path with error :error + */ + public function theMismatchesWillContainAMismatchWithPathWithError(string $type, string $path, string $error): void + { + $mismatches = $this->getMismatches(); + $mismatch = current($mismatches); + Assert::assertSame('request-mismatch', $mismatch['type']); + $mismatches = array_filter( + $mismatch['mismatches'], + fn (array $mismatch) => $mismatch['type'] === Mismatch::MOCK_SERVER_MISMATCH_TYPE_MAP[$type] + && $mismatch['path'] === $path + && str_contains($mismatch['mismatch'], $error) + ); + Assert::assertNotEmpty($mismatches); + } + + private function getMismatches(): array + { + if ($this->server->getVerifyResult()->isSuccess()) { + return []; + } + + return json_decode($this->server->getVerifyResult()->getOutput(), true); + } +} diff --git a/compatibility-suite/tests/Context/V1/Http/ProviderContext.php b/compatibility-suite/tests/Context/V1/Http/ProviderContext.php new file mode 100644 index 00000000..e3a55924 --- /dev/null +++ b/compatibility-suite/tests/Context/V1/Http/ProviderContext.php @@ -0,0 +1,154 @@ +server->register($id); + } + + /** + * @Given a Pact file for interaction :id is to be verified + */ + public function aPactFileForInteractionIsToBeVerified(int $id): void + { + $pactPath = new PactPath("c-$id"); + $this->pactWriter->write($id, $pactPath); + $this->providerVerifier->addSource($pactPath); + } + + /** + * @Given a provider is started that returns the responses from interactions :ids + */ + public function aProviderIsStartedThatReturnsTheResponsesFromInteractions(string $ids): void + { + $ids = array_map(fn (string $id) => (int) trim($id), explode(',', $ids)); + $this->server->register(...$ids); + } + + /** + * @Given a Pact file for interaction :id is to be verified from a Pact broker + */ + public function aPactFileForInteractionIsToBeVerifiedFromAPactBroker(int $id): void + { + $pactPath = new PactPath("c-$id"); + $this->pactWriter->write($id, $pactPath); + $this->pactBroker->publish($id); + $broker = new Broker(); + $broker->setUrl(new Uri('http:/localhost:9292')); + $this->providerVerifier->addSource($broker); + } + + /** + * @Then a verification result will NOT be published back + */ + public function aVerificationResultWillNotBePublishedBack(): void + { + Assert::assertSame(1, $this->pactBroker->getMatrix()['summary']['unknown']); + } + + /** + * @Given publishing of verification results is enabled + */ + public function publishingOfVerificationResultsIsEnabled(): void + { + $publishOptions = new PublishOptions(); + $publishOptions + ->setProviderVersion('1.2.3') + ; + $this->providerVerifier->getConfig()->setPublishOptions($publishOptions); + } + + /** + * @Then a successful verification result will be published back for interaction {:id} + */ + public function aSuccessfulVerificationResultWillBePublishedBackForInteraction(int $id): void + { + Assert::assertSame(1, $this->pactBroker->getMatrix()['summary']['success']); + } + + /** + * @Then a failed verification result will be published back for the interaction {:id} + */ + public function aFailedVerificationResultWillBePublishedBackForTheInteraction(int $id): void + { + Assert::assertSame(1, $this->pactBroker->getMatrix()['summary']['failed']); + } + + /** + * @Given a Pact file for interaction :id is to be verified with a provider state :state defined + */ + public function aPactFileForInteractionIsToBeVerifiedWithAProviderStateDefined(int $id, string $state): void + { + $pactPath = new PactPath("c-$id"); + $this->pactWriter->write($id, $pactPath); + $pact = json_decode(file_get_contents($pactPath), true); + $pact['interactions'][0]['providerStates'][] = ['name' => $state]; + file_put_contents($pactPath, json_encode($pact)); + $this->providerVerifier->addSource($pactPath); + } + + /** + * @Then a warning will be displayed that there was no provider state callback configured for provider state :state + */ + public function aWarningWillBeDisplayedThatThereWasNoProviderStateCallbackConfiguredForProviderState(string $state): void + { + throw new PendingException("Unable to verify this, as I can't find a way to assert this message from verifier's log: 'pact_verifier::callback_executors: State Change ignored as there is no state change URL provided for interaction'"); + } + + /** + * @Given a request filter is configured to make the following changes: + */ + public function aRequestFilterIsConfiguredToMakeTheFollowingChanges(TableNode $table): void + { + throw new PendingException("Unable to set request filter callback from ffi"); + } + + /** + * @Then the request to the provider will contain the header :header + */ + public function theRequestToTheProviderWillContainTheHeader(string $header): void + { + throw new PendingException('Unable to set request filter callback from ffi, so no need to implement this step'); + } + + /** + * @Given a provider is started that returns the response from interaction :id, with the following changes: + */ + public function aProviderIsStartedThatReturnsTheResponseFromInteractionWithTheFollowingChanges(int $id, TableNode $table): void + { + $response = $this->storage->get(InteractionsStorageInterface::SERVER_DOMAIN, $id)->getResponse(); + $this->responseBuilder->build($response, $table->getHash()[0]); + $this->server->register($id); + } +} diff --git a/compatibility-suite/tests/Context/V2/Http/ConsumerContext.php b/compatibility-suite/tests/Context/V2/Http/ConsumerContext.php new file mode 100644 index 00000000..ae9ee49f --- /dev/null +++ b/compatibility-suite/tests/Context/V2/Http/ConsumerContext.php @@ -0,0 +1,36 @@ +storage->get(InteractionsStorageInterface::SERVER_DOMAIN, $id)->getRequest(); + $this->requestBuilder->build($request, $table->getHash()[0]); + if ($file = $this->matchingRulesStorage->get(MatchingRulesStorageInterface::REQUEST_DOMAIN, $id)) { + $this->requestMatchingRuleBuilder->build($request, $file); + } + $this->server->register($id); + } +} diff --git a/compatibility-suite/tests/Context/V3/BodyGeneratorsContext.php b/compatibility-suite/tests/Context/V3/BodyGeneratorsContext.php new file mode 100644 index 00000000..b829e7f3 --- /dev/null +++ b/compatibility-suite/tests/Context/V3/BodyGeneratorsContext.php @@ -0,0 +1,22 @@ +validator->validateType($path, $type); + } +} diff --git a/compatibility-suite/tests/Context/V3/Http/ConsumerContext.php b/compatibility-suite/tests/Context/V3/Http/ConsumerContext.php new file mode 100644 index 00000000..417765e1 --- /dev/null +++ b/compatibility-suite/tests/Context/V3/Http/ConsumerContext.php @@ -0,0 +1,102 @@ +pactPath = new PactPath(); + } + + /** + * @Given an integration is being defined for a consumer test + */ + public function anIntegrationIsBeingDefinedForAConsumerTest(): void + { + $this->interaction = $this->builder->build([ + 'description' => 'interaction for a consumer test', + 'method' => 'GET', + 'path' => '/provider-states', + ]); + $this->storage->add(InteractionsStorageInterface::PACT_WRITER_DOMAIN, $this->id, $this->interaction); + } + + /** + * @Given a provider state :state is specified + */ + public function aProviderStateIsSpecified(string $state): void + { + $this->interaction->addProviderState($state, []); + } + + /** + * @When the Pact file for the test is generated + */ + public function thePactFileForTheTestIsGenerated(): void + { + $this->pactWriter->write($this->id, $this->pactPath); + } + + /** + * @Then the interaction in the Pact file will contain :states provider state(s) + */ + public function theInteractionInThePactFileWillContainProviderStates(int $states): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertCount($states, $pact['interactions'][0]['providerStates']); + } + + /** + * @Then the interaction in the Pact file will contain provider state :name + */ + public function theInteractionInThePactFileWillContainProviderState(string $name): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertNotEmpty(array_filter( + $pact['interactions'][0]['providerStates'], + fn (array $providerState) => $providerState['name'] === $name + )); + } + + /** + * @Given a provider state :state is specified with the following data: + */ + public function aProviderStateIsSpecifiedWithTheFollowingData(string $state, TableNode $table): void + { + $rows = $table->getHash(); + $row = reset($rows); + $this->interaction->addProviderState($state, $row); + } + + /** + * @Then the provider state :name in the Pact file will contain the following parameters: + */ + public function theProviderStateInThePactFileWillContainTheFollowingParameters(string $name, TableNode $table): void + { + $rows = $table->getHash(); + $row = reset($rows); + $params = json_decode($row['parameters'], true); + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertNotEmpty(array_filter( + $pact['interactions'][0]['providerStates'], + fn (array $providerState) => $providerState['name'] === $name && $providerState['params'] === $params + )); + } +} diff --git a/compatibility-suite/tests/Context/V3/Http/ProviderContext.php b/compatibility-suite/tests/Context/V3/Http/ProviderContext.php new file mode 100644 index 00000000..4dfad0ae --- /dev/null +++ b/compatibility-suite/tests/Context/V3/Http/ProviderContext.php @@ -0,0 +1,61 @@ +pactPath = new PactPath(); + } + + /** + * @Given a Pact file for interaction :id is to be verified with the following provider states defined: + */ + public function aPactFileForInteractionIsToBeVerifiedWithTheFollowingProviderStatesDefined(int $id, TableNode $table): void + { + $this->pactWriter->write($id, $this->pactPath); + $pact = json_decode(file_get_contents($this->pactPath)); + $rows = $table->getHash(); + $pact->interactions[0]->providerStates = array_map(fn (array $row): array => ['name' => $row['State Name'], 'params' => json_decode($row['Parameters'] ?? '{}', true)], $rows); + file_put_contents($this->pactPath, json_encode($pact)); + $this->providerVerifier->addSource($this->pactPath); + } + + /** + * @Then the provider state callback will receive a setup call with :state and the following parameters: + */ + public function theProviderStateCallbackWillReceiveASetupCallWithAndTheFollowingParameters(string $state, TableNode $table): void + { + $params = $table->getHash()[0]; + foreach ($params as &$value) { + $value = trim($value, '"'); + } + Assert::assertTrue($this->providerStateServer->hasState(ProviderStateServerInterface::ACTION_SETUP, $state, $params)); + } + + /** + * @Then the provider state callback will receive a teardown call :state and the following parameters: + */ + public function theProviderStateCallbackWillReceiveATeardownCallAndTheFollowingParameters(string $state, TableNode $table): void + { + $params = $table->getHash()[0]; + foreach ($params as &$value) { + $value = trim($value, '"'); + } + Assert::assertTrue($this->providerStateServer->hasState(ProviderStateServerInterface::ACTION_TEARDOWN, $state, $params)); + } +} diff --git a/compatibility-suite/tests/Context/V3/Message/ConsumerContext.php b/compatibility-suite/tests/Context/V3/Message/ConsumerContext.php new file mode 100644 index 00000000..a50dfc15 --- /dev/null +++ b/compatibility-suite/tests/Context/V3/Message/ConsumerContext.php @@ -0,0 +1,305 @@ +pactPath = new PactPath(sprintf('message_consumer_specification_%s', $specificationVersion)); + $config = new PactMessageConfig(); + $config + ->setConsumer($this->pactPath->getConsumer()) + ->setProvider(PactPath::PROVIDER) + ->setPactDir(Path::PACTS_PATH) + ->setPactSpecificationVersion($specificationVersion) + ->setPactFileWriteMode(PactConfigInterface::MODE_OVERWRITE); + $this->builder = new MessageBuilder($config); + } + + /** + * @Given a message integration is being defined for a consumer test + */ + public function aMessageIntegrationIsBeingDefinedForAConsumerTest(): void + { + $this->builder->expectsToReceive('a message'); + } + + /** + * @Given the message payload contains the :fixture JSON document + */ + public function theMessagePayloadContainsTheJsonDocument(string $fixture): void + { + $this->builder->withContent($this->fixtureLoader->loadJson($fixture . '.json')); + } + + /** + * @When the message is successfully processed + */ + public function theMessageIsSuccessfullyProcessed(): void + { + $this->process([$this, 'storeMessage']); + } + + /** + * @Then the received message payload will contain the :fixture JSON document + */ + public function theReceivedMessagePayloadWillContainTheJsonDocument(string $fixture): void + { + Assert::assertJsonStringEqualsJsonString( + $this->fixtureLoader->load($fixture . '.json'), + json_encode($this->receivedMessage->contents) + ); + } + + /** + * @Then the received message content type will be :contentType + */ + public function theReceivedMessageContentTypeWillBe(string $contentType): void + { + Assert::assertSame($contentType, $this->receivedMessage->metadata->contentType); + } + + /** + * @Then the consumer test will have passed + */ + public function theConsumerTestWillHavePassed(): void + { + Assert::assertTrue($this->verifyResult); + } + + /** + * @Then a Pact file for the message interaction will have been written + */ + public function aPactFileForTheMessageInteractionWillHaveBeenWritten(): void + { + Assert::assertTrue(file_exists($this->pactPath)); + $this->pact = json_decode(file_get_contents($this->pactPath), true); + } + + /** + * @Then the pact file will contain :messages message interaction(s) + */ + public function thePactFileWillContainMessageInteraction(int $messages): void + { + Assert::assertCount($messages, $this->pact['messages'] ?? []); + } + + /** + * @Then the first message in the pact file will contain the :fixture document + */ + public function theFirstMessageInThePactFileWillContainTheDocument(string $fixture): void + { + Assert::assertJsonStringEqualsJsonString( + $this->fixtureLoader->load($fixture), + json_encode($this->pact['messages'][0]['contents'] ?? null) + ); + } + + /** + * @Then the first message in the pact file content type will be :contentType + */ + public function theFirstMessageInThePactFileContentTypeWillBe(string $contentType): void + { + Assert::assertSame($contentType, $this->pact['messages'][0]['metadata']['contentType'] ?? null); + } + + /** + * @When the message is NOT successfully processed with a :error exception + */ + public function theMessageIsNotSuccessfullyProcessedWithAException(string $error): void + { + $this->process(fn () => throw new Exception($error)); + } + + /** + * @Then the consumer test will have failed + */ + public function theConsumerTestWillHaveFailed(): void + { + Assert::assertFalse($this->verifyResult); + } + + /** + * @Then the consumer test error will be :error + */ + public function theConsumerTestErrorWillBe(string $error): void + { + // TODO Modify MessageBuilder code to check this exception? + } + + /** + * @Then a Pact file for the message interaction will NOT have been written + */ + public function aPactFileForTheMessageInteractionWillNotHaveBeenWritten(): void + { + Assert::assertFalse(file_exists($this->pactPath)); + } + + /** + * @Given the message contains the following metadata: + */ + public function theMessageContainsTheFollowingMetadata(TableNode $table): void + { + $this->builder->withMetadata($this->parser->parseMetadataTable($table->getHash())); + } + + /** + * @Then /^the received message metadata will contain "([^"]+)" == "(.+)"$/ + */ + public function theReceivedMessageMetadataWillContain(string $key, string $value): void + { + $actual = $this->receivedMessage->metadata->{$key}; + if (is_string($actual)) { + Assert::assertSame($this->parser->parseMetadataValue($value), $actual); + } else { + Assert::assertJsonStringEqualsJsonString($this->parser->parseMetadataValue($value), json_encode($actual)); + } + } + + /** + * @Then /^the first message in the pact file will contain the message metadata "([^"]+)" == "(.+)"$/ + */ + public function theFirstMessageInThePactFileWillContainTheMessageMetadata(string $key, string $value): void + { + $actual = $this->pact['messages'][0]['metadata'][$key] ?? null; + if (is_string($actual)) { + Assert::assertSame($this->parser->parseMetadataValue($value), $actual); + } else { + Assert::assertJsonStringEqualsJsonString($this->parser->parseMetadataValue($value), json_encode($actual)); + } + } + + /** + * @Given a provider state :state for the message is specified + */ + public function aProviderStateForTheMessageIsSpecified(string $state): void + { + $this->builder->given($state, []); + } + + /** + * @Given a message is defined + */ + public function aMessageIsDefined(): void + { + $this->aMessageIntegrationIsBeingDefinedForAConsumerTest(); + } + + /** + * @Then the first message in the pact file will contain :states provider state(s) + */ + public function theFirstMessageInThePactFileWillContainProviderStates(int $states): void + { + Assert::assertCount($states, $this->pact['messages'][0]['providerStates'] ?? []); + } + + /** + * @Then the first message in the Pact file will contain provider state :state + */ + public function theFirstMessageInThePactFileWillContainProviderState(string $state): void + { + $states = array_map(fn (array $state): string => $state['name'], $this->pact['messages'][0]['providerStates']); + Assert::assertContains($state, $states); + } + + /** + * @Given a provider state :state for the message is specified with the following data: + */ + public function aProviderStateForTheMessageIsSpecifiedWithTheFollowingData(string $state, TableNode $table): void + { + $rows = $table->getHash(); + $row = reset($rows); + $this->builder->given($state, $row); + } + + /** + * @Then the provider state :state for the message will contain the following parameters: + */ + public function theProviderStateForTheMessageWillContainTheFollowingParameters(string $state, TableNode $table): void + { + $params = json_decode($table->getHash()[0]['parameters'], true); + Assert::assertContains([ + 'name' => $state, + 'params' => $params, + ], $this->pact['messages'][0]['providerStates']); + } + + /** + * @Given the message is configured with the following: + */ + public function theMessageIsConfiguredWithTheFollowing(TableNode $table): void + { + $rows = $table->getHash(); + $row = reset($rows); + $message = new Message(); + $message->setBody(isset($row['body']) ? $this->parser->parseBody($row['body']) : null); + $message->setMetadata(isset($row['metadata']) ? json_decode($row['metadata'], true) : null); + $this->messageGeneratorBuilder->build($message, $row['generators']); + if ($message->hasBody()) { + $this->builder->withContent($message->getBody()); + } + if ($message->hasMetadata()) { + $this->builder->withContent('not empty'); // any not empty text, doesn't matter. If empty or not provided, received message will be null. + $this->builder->withMetadata($message->getMetadata()); + } + } + + /** + * @Then the message contents for :path will have been replaced with a(n) :type + */ + public function theMessageContentsForWillHaveBeenReplacedWithAn(string $path, string $type): void + { + $this->bodyStorage->setBody(json_encode($this->receivedMessage->contents)); + $this->validator->validateType($path, $type); + } + + /** + * @Then the received message metadata will contain :key replaced with an :type + */ + public function theReceivedMessageMetadataWillContainReplacedWithAn(string $key, string $type): void + { + $this->bodyStorage->setBody(json_encode($this->receivedMessage->metadata)); + $this->validator->validateType("$.$key", $type); + } + + public function storeMessage(string $message): void + { + $this->receivedMessage = json_decode($message); + } + + private function process(callable $callback): void + { + $this->builder->setCallback($callback); + + $this->verifyResult = $this->builder->verify(); + } +} diff --git a/compatibility-suite/tests/Context/V3/Message/ProviderContext.php b/compatibility-suite/tests/Context/V3/Message/ProviderContext.php new file mode 100644 index 00000000..843f9875 --- /dev/null +++ b/compatibility-suite/tests/Context/V3/Message/ProviderContext.php @@ -0,0 +1,172 @@ +pactPath = new PactPath(); + } + + /** + * @Given /^a provider is started that can generate the "([^"]+)" message with "(.+)"$/ + */ + public function aProviderIsStartedThatCanGenerateTheMessageWith(string $name, string $fixture): void + { + $fixture = str_replace('\"', '"', $fixture); + $interaction = $this->builder->build([ + 'No' => $this->id, + 'description' => sprintf('Interaction for message %s', $name), + 'method' => 'POST', + 'path' => '/messages', + 'body' => 'JSON: ' . json_encode(['description' => $name]), + 'response body' => $fixture, + ]); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $this->id, $interaction); + $this->ids[] = $this->id++; + } + + /** + * @BeforeStep + */ + public function registerInteractions(BeforeStepScope $scope): void + { + if ( + $this->ids + && preg_match('/^a Pact file for .* is to be verified/', $scope->getStep()->getText()) + ) { + $this->server->register(...$this->ids); + $this->ids = []; + $this->providerVerifier + ->getConfig() + ->addProviderTransport( + (new ProviderTransport()) + ->setProtocol(ProviderTransport::MESSAGE_PROTOCOL) + ->setPort($this->server->getPort()) + ->setPath('/messages') + ->setScheme('http') + ); + ; + } + } + + /** + * @Given a Pact file for :name::fixture is to be verified + */ + public function aPactFileForIsToBeVerified(string $name, string $fixture): void + { + $message = new Message(); + $message->setDescription($name); + $message->setContents($this->parser->parseBody($fixture)); + $this->pactWriter->write($message, $this->pactPath); + $this->providerVerifier->addSource($this->pactPath); + } + + /** + * @Given a Pact file for :name::fixture is to be verified with provider state :state + */ + public function aPactFileForIsToBeVerifiedWithProviderState(string $name, string $fixture, string $state): void + { + $this->aPactFileForIsToBeVerified($name, $fixture); + $pact = json_decode(file_get_contents($this->pactPath), true); + $pact['messages'][0]['providerState'] = $state; + file_put_contents($this->pactPath, json_encode($pact)); + } + + /** + * @Given a provider is started that can generate the :name message with :fixture and the following metadata: + */ + public function aProviderIsStartedThatCanGenerateTheMessageWithAndTheFollowingMetadata(string $name, string $fixture, TableNode $table): void + { + $interaction = $this->builder->build([ + 'No' => $this->id, + 'description' => sprintf('Interaction for message %s', $name), + 'method' => 'POST', + 'path' => '/messages', + 'body' => 'JSON: ' . json_encode(['description' => $name]), + 'response body' => $fixture, + 'response headers' => 'Pact-Message-Metadata: ' . base64_encode(json_encode($this->parser->parseMetadataTable($table->getHash()))), + ]); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $this->id, $interaction); + $this->server->register($this->id); + $this->providerVerifier + ->getConfig() + ->addProviderTransport( + (new ProviderTransport()) + ->setProtocol(ProviderTransport::MESSAGE_PROTOCOL) + ->setPort($this->server->getPort()) + ->setPath('/messages') + ->setScheme('http') + ); + ; + } + + /** + * @Given a Pact file for :name::fixture is to be verified with the following metadata: + */ + public function aPactFileForIsToBeVerifiedWithTheFollowingMetadata(string $name, string $fixture, TableNode $table): void + { + $this->aPactFileForIsToBeVerified($name, $fixture); + $pact = json_decode(file_get_contents($this->pactPath), true); + $pact['messages'][0]['metaData'] = $this->parser->parseMetadataTable($table->getHash()); + file_put_contents($this->pactPath, json_encode($pact)); + } + + /** + * @Given a Pact file for :name is to be verified with the following: + */ + public function aPactFileForIsToBeVerifiedWithTheFollowing(string $name, TableNode $table): void + { + foreach ($table->getRowsHash() as $key => $value) { + switch ($key) { + case 'body': + $body = $value; + break; + + case 'matching rules': + $matchingRules = $this->fixtureLoader->loadJson($value); + break; + + case 'metadata': + $metadata = $value; + break; + + default: + break; + } + } + $this->aPactFileForIsToBeVerified($name, $body); + $pact = json_decode(file_get_contents($this->pactPath), true); + if (isset($metadata)) { + $pact['messages'][0]['metadata'] = array_merge($pact['messages'][0]['metadata'], $this->parser->parseMetadataMultiValues($metadata)); + } + $pact['messages'][0]['matchingRules'] = $matchingRules; + file_put_contents($this->pactPath, json_encode($pact)); + } +} diff --git a/compatibility-suite/tests/Context/V3/RequestGeneratorsContext.php b/compatibility-suite/tests/Context/V3/RequestGeneratorsContext.php new file mode 100644 index 00000000..3446cfeb --- /dev/null +++ b/compatibility-suite/tests/Context/V3/RequestGeneratorsContext.php @@ -0,0 +1,126 @@ +pactPath = new PactPath(); + } + + /** + * @Given a request configured with the following generators: + */ + public function aRequestConfiguredWithTheFollowingGenerators(TableNode $table): void + { + $rows = $table->getHash(); + $row = reset($rows); + $interaction = $this->builder->build([ + 'No' => $this->id, + 'method' => 'PUT', + 'path' => '/request-generators', + 'body' => $row['body'] ?? '', + ]); + $this->requestGeneratorBuilder->build($interaction->getRequest(), $row['generators']); + $this->storage->add(InteractionsStorageInterface::PACT_WRITER_DOMAIN, $this->id, $interaction); + } + + /** + * @When the request is prepared for use + */ + public function theRequestIsPreparedForUse(): void + { + $this->generatorServer->start(); + $this->pactWriter->write($this->id, $this->pactPath); + $this->providerVerifier->getConfig()->getProviderInfo()->setPort($this->generatorServer->getPort()); + $this->providerVerifier->addSource($this->pactPath); + $this->providerVerifier->verify(); + $this->generatorServer->stop(); + $this->bodyStorage->setBody($this->generatorServer->getBody()); + } + + /** + * @Given the generator test mode is set as :mode + */ + public function theGeneratorTestModeIsSetAs(string $mode): void + { + // There is nothing we can do using FFI call. + } + + /** + * @When the request is prepared for use with a "providerState" context: + */ + public function theRequestIsPreparedForUseWithAProviderStateContext(TableNode $table): void + { + $this->generatorServer->start(); + $this->pactWriter->write($this->id, $this->pactPath); + $port = $this->generatorServer->getPort(); + $this->providerVerifier->getConfig()->getProviderInfo()->setPort($port); + $params = json_decode($table->getRow(0)[0], true); + $this->providerVerifier + ->getConfig() + ->getProviderState() + ->setStateChangeUrl(new Uri("http://localhost:$port/return-provider-state-values?" . http_build_query($params))) + ->setStateChangeTeardown(false); + $this->providerVerifier->addSource($this->pactPath); + $this->providerVerifier->verify(); + $this->generatorServer->stop(); + $this->bodyStorage->setBody($this->generatorServer->getBody()); + } + + /** + * @Then the request :part will be set as :value + */ + public function theRequestWillBeSetAs(string $part, string $value): void + { + switch ($part) { + case 'path': + $path = $this->generatorServer->getPath(); + Assert::assertSame($value, $path); + break; + + default: + break; + } + } + + /** + * @Then the request :part will match :regex + */ + public function theRequestWillMatch(string $part, string $regex): void + { + if ($part === 'path') { + Assert::assertMatchesRegularExpression("/$regex/", $this->generatorServer->getPath()); + } elseif (preg_match('/header\[(.*)\]/', $part, $matches)) { + foreach ($this->generatorServer->getHeader($matches[1]) as $value) { + Assert::assertMatchesRegularExpression("/$regex/", $value); + } + } elseif (preg_match('/queryParameter\[(.*)\]/', $part, $matches)) { + Assert::assertMatchesRegularExpression("/$regex/", $this->generatorServer->getQueryParam($matches[1])); + } + } +} diff --git a/compatibility-suite/tests/Context/V3/RequestMatchingContext.php b/compatibility-suite/tests/Context/V3/RequestMatchingContext.php new file mode 100644 index 00000000..9bf139f7 --- /dev/null +++ b/compatibility-suite/tests/Context/V3/RequestMatchingContext.php @@ -0,0 +1,169 @@ +type = self::HEADER_TYPE; + $interaction = $this->builder->build([ + 'No' => $this->id, + 'method' => 'GET', + 'path' => '/matching', + 'headers' => implode(', ', array_map(fn (string $value) => "'$header: $value'", explode(', ', $value))), + ]); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $this->id, $interaction); + $this->storage->add(InteractionsStorageInterface::CLIENT_DOMAIN, $this->id, $interaction, true); + $this->server->register($this->id); + } + + /** + * @Given a request is received with a(n) :header header of :value + */ + public function aRequestIsReceivedWithAHeaderOf(string $header, string $value): void + { + $request = $this->storage->get(InteractionsStorageInterface::CLIENT_DOMAIN, $this->id)->getRequest(); + $request->addHeader($header, $value); + $this->client->sendRequestToServer($this->id); + } + + /** + * @When the request is compared to the expected one + */ + public function theRequestIsComparedToTheExpectedOne(): void + { + $this->server->verify(); + } + + /** + * @Then the comparison should be OK + */ + public function theComparisonShouldBeOk(): void + { + Assert::assertTrue($this->server->getVerifyResult()->isSuccess()); + } + + /** + * @Then the comparison should NOT be OK + */ + public function theComparisonShouldNotBeOk(): void + { + Assert::assertFalse($this->server->getVerifyResult()->isSuccess()); + } + + /** + * @Then /^the mismatches will contain a mismatch with error "([^"]+)" -> "(.+)"$/ + */ + public function theMismatchesWillContainAMismatchWithError(string $path, string $error): void + { + $error = str_replace('\"', '"', $error); + $key = $this->type === self::HEADER_TYPE ? 'key' : 'path'; + $mismatches = json_decode($this->server->getVerifyResult()->getOutput(), true); + $mismatches = array_reduce($mismatches, function (array $results, array $mismatch): array { + Assert::assertSame('request-mismatch', $mismatch['type']); + $results = array_merge($results, array_filter( + $mismatch['mismatches'], + fn (array $mismatch) => $mismatch['type'] === Mismatch::MOCK_SERVER_MISMATCH_TYPE_MAP[$this->type] + )); + + return $results; + }, []); + $mismatches = array_filter( + $mismatches, + fn (array $mismatch) => $mismatch[$key] === $path + && ( + str_contains($mismatch['mismatch'], $error) + || @preg_match("|$error|", $mismatch['mismatch']) + ) + ); + Assert::assertNotEmpty($mismatches); + } + + /** + * @Given an expected request configured with the following: + */ + public function anExpectedRequestConfiguredWithTheFollowing(TableNode $table): void + { + $this->type = self::BODY_TYPE; + $rows = $table->getHash(); + $row = reset($rows); + $interaction = $this->builder->build([ + 'No' => $this->id, + 'method' => 'POST', + 'path' => '/matching', + ] + $row); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $this->id, $interaction); + $this->storage->add(InteractionsStorageInterface::CLIENT_DOMAIN, $this->id, $interaction, true); + try { + $this->requestMatchingRuleBuilder->build($interaction->getRequest(), $row['matching rules']); + } catch (IntegrationJsonFormatException $exception) { + throw new PendingException($exception->getMessage()); + } + $this->server->register($this->id); + } + + /** + * @Given a request is received with the following: + */ + public function aRequestIsReceivedWithTheFollowing(TableNode $table): void + { + $rows = $table->getHash(); + $row = reset($rows); + $request = $this->storage->get(InteractionsStorageInterface::CLIENT_DOMAIN, $this->id)->getRequest(); + $this->requestBuilder->build($request, $row); + $this->client->sendRequestToServer($this->id); + } + + /** + * @Given the following requests are received: + */ + public function theFollowingRequestsAreReceived(TableNode $table): void + { + foreach ($table->getHash() as $row) { + $request = $this->storage->get(InteractionsStorageInterface::CLIENT_DOMAIN, $this->id)->getRequest(); + $this->requestBuilder->build($request, $row); + $this->client->sendRequestToServer($this->id); + } + } + + /** + * @When the requests are compared to the expected one + */ + public function theRequestsAreComparedToTheExpectedOne(): void + { + $this->server->verify(); + } +} diff --git a/compatibility-suite/tests/Context/V3/ResponseGeneratorsContext.php b/compatibility-suite/tests/Context/V3/ResponseGeneratorsContext.php new file mode 100644 index 00000000..c2a1ed30 --- /dev/null +++ b/compatibility-suite/tests/Context/V3/ResponseGeneratorsContext.php @@ -0,0 +1,86 @@ +getHash(); + $row = reset($rows); + $interaction = $this->builder->build([ + 'No' => $this->id, + 'method' => 'GET', + 'path' => '/response-generators', + 'response body' => $row['body'] ?? '', + ]); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $this->id, $interaction); + $this->storage->add(InteractionsStorageInterface::CLIENT_DOMAIN, $this->id, $interaction, true); + $this->responseGeneratorBuilder->build($interaction->getResponse(), $row['generators']); + } + + /** + * @When the response is prepared for use + */ + public function theResponseIsPreparedForUse(): void + { + $this->server->register($this->id); + $this->client->sendRequestToServer($this->id); + $this->bodyStorage->setBody($this->client->getResponse()->getBody()->getContents()); + } + + /** + * @Then the response :part will not be :value + */ + public function theResponseWillNotBe(string $part, string $value): void + { + switch ($part) { + case 'status': + $code = $this->client->getResponse()->getStatusCode(); + Assert::assertNotEquals($value, $code); + break; + + default: + break; + } + } + + /** + * @Then the response :part will match :regex + */ + public function theResponseWillMatch(string $part, string $regex): void + { + if ($part === 'status') { + Assert::assertMatchesRegularExpression("/$regex/", $this->client->getResponse()->getStatusCode()); + } elseif (preg_match('/header\[(.*)\]/', $part, $matches)) { + foreach ($this->client->getResponse()->getHeader($matches[1]) as $value) { + Assert::assertMatchesRegularExpression("/$regex/", $value); + } + } + } +} diff --git a/compatibility-suite/tests/Context/V4/BodyGeneratorsContext.php b/compatibility-suite/tests/Context/V4/BodyGeneratorsContext.php new file mode 100644 index 00000000..f72012f3 --- /dev/null +++ b/compatibility-suite/tests/Context/V4/BodyGeneratorsContext.php @@ -0,0 +1,22 @@ +validator->validateValue($path, $value); + } +} diff --git a/compatibility-suite/tests/Context/V4/CombinedContext.php b/compatibility-suite/tests/Context/V4/CombinedContext.php new file mode 100644 index 00000000..d0fbb9f1 --- /dev/null +++ b/compatibility-suite/tests/Context/V4/CombinedContext.php @@ -0,0 +1,69 @@ +pactPath = new PactPath(); + } + + /** + * @Given an HTTP interaction is being defined for a consumer test + */ + public function anHttpInteractionIsBeingDefinedForAConsumerTest(): void + { + $interaction = $this->builder->build([ + 'description' => 'http interaction', + 'method' => 'GET', + 'path' => '/v4-features', + ]); + $this->storage->add(InteractionsStorageInterface::PACT_WRITER_DOMAIN, $this->id, $interaction); + } + + /** + * @Given a message interaction is being defined for a consumer test + */ + public function aMessageInteractionIsBeingDefinedForAConsumerTest(): void + { + } + + /** + * @When the Pact file for the test is generated + */ + public function thePactFileForTheTestIsGenerated(): void + { + $this->pactWriter->write($this->id, $this->pactPath, PactConfigInterface::MODE_MERGE); + $message = new Message(); + $message->setDescription('message interaction'); + $this->messagePactWriter->write($message, $this->pactPath, PactConfigInterface::MODE_MERGE); + } + + /** + * @Then there will be an interaction in the Pact file with a type of :type + */ + public function thereWillBeAnInteractionInThePactFileWithATypeOf(string $type): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + $types = array_map(fn (array $interaction) => $interaction['type'], $pact['interactions']); + Assert::assertContains($type, $types); + } +} diff --git a/compatibility-suite/tests/Context/V4/Http/ConsumerContext.php b/compatibility-suite/tests/Context/V4/Http/ConsumerContext.php new file mode 100644 index 00000000..46d4de04 --- /dev/null +++ b/compatibility-suite/tests/Context/V4/Http/ConsumerContext.php @@ -0,0 +1,90 @@ +pactPath = new PactPath(); + } + + /** + * @Given an HTTP interaction is being defined for a consumer test + */ + public function anHttpInteractionIsBeingDefinedForAConsumerTest(): void + { + $this->interaction = $this->builder->build([ + 'description' => 'interaction for a consumer test', + 'method' => 'GET', + 'path' => '/v4-features', + ]); + $this->storage->add(InteractionsStorageInterface::PACT_WRITER_DOMAIN, $this->id, $this->interaction); + } + + /** + * @Then the first interaction in the Pact file will have a type of :type + */ + public function theFirstInteractionInThePactFileWillHaveATypeOf(string $type): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertSame($type, $pact['interactions'][0]['type']); + } + + /** + * @Given a key of :key is specified for the HTTP interaction + */ + public function aKeyOfIsSpecifiedForTheHttpInteraction(string $key): void + { + $this->interaction->setKey($key); + } + + /** + * @Then the first interaction in the Pact file will have :name = :value + */ + public function theFirstInteractionInThePactFileWillHave(string $name, string $value): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertJsonStringEqualsJsonString($value, json_encode($pact['interactions'][0][$name])); + } + + /** + * @Given the HTTP interaction is marked as pending + */ + public function theHttpInteractionIsMarkedAsPending(): void + { + $this->interaction->setPending(true); + } + + /** + * @Given a comment :value is added to the HTTP interaction + */ + public function aCommentIsAddedToTheHttpInteraction(string $value): void + { + $this->interaction->setComments(['text' => json_encode([$value])]); + } + + /** + * @When the Pact file for the test is generated + */ + public function thePactFileForTheTestIsGenerated(): void + { + $this->pactWriter->write($this->id, $this->pactPath); + } +} diff --git a/compatibility-suite/tests/Context/V4/Http/ProviderContext.php b/compatibility-suite/tests/Context/V4/Http/ProviderContext.php new file mode 100644 index 00000000..e2a3c9f8 --- /dev/null +++ b/compatibility-suite/tests/Context/V4/Http/ProviderContext.php @@ -0,0 +1,110 @@ +pactPath = new PactPath(); + } + + /** + * @Given a Pact file for interaction :id is to be verified, but is marked pending + */ + public function aPactFileForInteractionIsToBeVerifiedButIsMarkedPending(int $id): void + { + $this->pactWriter->write($id, $this->pactPath); + $pact = json_decode(file_get_contents($this->pactPath), true); + $pact['interactions'][0]['pending'] = true; + file_put_contents($this->pactPath, json_encode($pact)); + $this->providerVerifier->addSource($this->pactPath); + } + + /** + * @Then there will be a pending :error error + */ + public function thereWillBeAPendingError(string $error): void + { + $output = json_decode($this->providerVerifier->getVerifyResult()->getOutput(), true); + $errors = array_reduce( + $output['pendingErrors'], + function (array $errors, array $error) { + switch ($error['mismatch']['type']) { + case 'error': + $errors[] = Mismatch::VERIFIER_MISMATCH_ERROR_MAP[$error['mismatch']['message']]; + break; + + case 'mismatches': + foreach ($error['mismatch']['mismatches'] as $mismatch) { + $errors[] = Mismatch::VERIFIER_MISMATCH_TYPE_MAP[$mismatch['type']]; + } + break; + + default: + break; + } + + return $errors; + }, + [] + ); + Assert::assertContains($error, $errors); + } + + /** + * @Given a Pact file for interaction :id is to be verified with the following comments: + */ + public function aPactFileForInteractionIsToBeVerifiedWithTheFollowingComments(int $id, TableNode $table): void + { + $comments = []; + foreach ($table->getHash() as $row) { + switch ($row['type']) { + case 'text': + $comments['text'][] = $row['comment']; + break; + + case 'testname': + $comments['testname'] = $row['comment']; + break; + + default: + # code... + break; + } + } + $this->pactWriter->write($id, $this->pactPath); + $pact = json_decode(file_get_contents($this->pactPath), true); + $pact['interactions'][0]['comments'] = $comments; + file_put_contents($this->pactPath, json_encode($pact)); + $this->providerVerifier->addSource($this->pactPath); + } + + /** + * @Then the comment :comment will have been printed to the console + */ + public function theCommentWillHaveBeenPrintedToTheConsole(string $comment): void + { + Assert::assertStringContainsString($comment, $this->providerVerifier->getVerifyResult()->getOutput()); + } + + /** + * @Then the :name will displayed as the original test name + */ + public function theWillDisplayedAsTheOriginalTestName(string $name): void + { + Assert::assertStringContainsString(sprintf('Test Name: %s', $name), $this->providerVerifier->getVerifyResult()->getOutput()); + } +} diff --git a/compatibility-suite/tests/Context/V4/Message/ConsumerContext.php b/compatibility-suite/tests/Context/V4/Message/ConsumerContext.php new file mode 100644 index 00000000..21cd5799 --- /dev/null +++ b/compatibility-suite/tests/Context/V4/Message/ConsumerContext.php @@ -0,0 +1,81 @@ +pactPath = new PactPath(); + } + + /** + * @Given a message interaction is being defined for a consumer test + */ + public function aMessageInteractionIsBeingDefinedForAConsumerTest(): void + { + $this->message = new Message(); + $this->message->setDescription('a message'); + } + + /** + * @When the Pact file for the test is generated + */ + public function thePactFileForTheTestIsGenerated(): void + { + $this->pactWriter->write($this->message, $this->pactPath); + } + + /** + * @Then the first interaction in the Pact file will have a type of :type + */ + public function theFirstInteractionInThePactFileWillHaveATypeOf(string $type): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertSame($type, $pact['interactions'][0]['type']); + } + + /** + * @Given a key of :key is specified for the message interaction + */ + public function aKeyOfIsSpecifiedForTheMessageInteraction(string $key): void + { + $this->message->setKey($key); + } + + /** + * @Given the message interaction is marked as pending + */ + public function theMessageInteractionIsMarkedAsPending(): void + { + $this->message->setPending(true); + } + + /** + * @Given a comment :value is added to the message interaction + */ + public function aCommentIsAddedToTheMessageInteraction(string $value): void + { + $this->message->setComments(['text' => json_encode([$value])]); + } + + /** + * @Then the first interaction in the Pact file will have :name = :value + */ + public function theFirstInteractionInThePactFileWillHave(string $name, string $value): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertJsonStringEqualsJsonString($value, json_encode($pact['interactions'][0][$name])); + } +} diff --git a/compatibility-suite/tests/Context/V4/Message/ProviderContext.php b/compatibility-suite/tests/Context/V4/Message/ProviderContext.php new file mode 100644 index 00000000..460b6310 --- /dev/null +++ b/compatibility-suite/tests/Context/V4/Message/ProviderContext.php @@ -0,0 +1,171 @@ +pactPath = new PactPath(); + } + + /** + * @Given /^a provider is started that can generate the "([^"]+)" message with "(.+)"$/ + */ + public function aProviderIsStartedThatCanGenerateTheMessageWith(string $name, string $fixture): void + { + $interaction = $this->builder->build([ + 'No' => $this->id, + 'description' => sprintf('Interaction for message %s', $name), + 'method' => 'POST', + 'path' => '/messages', + 'body' => 'JSON: ' . json_encode(['description' => $name]), + 'response body' => $fixture, + ]); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $this->id, $interaction); + $this->server->register($this->id); + $this->providerVerifier + ->getConfig() + ->addProviderTransport( + (new ProviderTransport()) + ->setProtocol(ProviderTransport::MESSAGE_PROTOCOL) + ->setPort($this->server->getPort()) + ->setPath('/messages') + ->setScheme('http') + ); + ; + } + + /** + * @Given a Pact file for :name::fixture is to be verified, but is marked pending + */ + public function aPactFileForIsToBeVerifiedButIsMarkedPending(string $name, string $fixture): void + { + $message = new Message(); + $message->setDescription($name); + $message->setContents($this->parser->parseBody($fixture)); + $this->pactWriter->write($message, $this->pactPath); + $this->providerVerifier->addSource($this->pactPath); + $pact = json_decode(file_get_contents($this->pactPath), true); + $pact['interactions'][0]['pending'] = true; + file_put_contents($this->pactPath, json_encode($pact)); + } + + /** + * @Given a Pact file for :name::fixture is to be verified with the following comments: + */ + public function aPactFileForIsToBeVerifiedWithTheFollowingComments(string $name, string $fixture, TableNode $table): void + { + $comments = []; + foreach ($table->getHash() as $row) { + switch ($row['type']) { + case 'text': + $comments['text'][] = $row['comment']; + break; + + case 'testname': + $comments['testname'] = $row['comment']; + break; + + default: + # code... + break; + } + } + $message = new Message(); + $message->setDescription($name); + $message->setContents($this->parser->parseBody($fixture)); + $this->pactWriter->write($message, $this->pactPath); + $this->providerVerifier->addSource($this->pactPath); + $pact = json_decode(file_get_contents($this->pactPath), true); + $pact['interactions'][0]['comments'] = $comments; + file_put_contents($this->pactPath, json_encode($pact)); + } + + /** + * @When the verification is run + */ + public function theVerificationIsRun(): void + { + $this->providerVerifier->getConfig()->getProviderInfo()->setPort($this->server->getPort()); + $this->providerVerifier->verify(); + } + + /** + * @Then the verification will be successful + */ + public function theVerificationWillBeSuccessful(): void + { + Assert::assertTrue($this->providerVerifier->getVerifyResult()->isSuccess()); + } + + /** + * @Then there will be a pending :error error + */ + public function thereWillBeAPendingError(string $error): void + { + $output = json_decode($this->providerVerifier->getVerifyResult()->getOutput(), true); + $errors = array_reduce( + $output['pendingErrors'], + function (array $errors, array $error) { + switch ($error['mismatch']['type']) { + case 'error': + $errors[] = Mismatch::VERIFIER_MISMATCH_ERROR_MAP[$error['mismatch']['message']]; + break; + + case 'mismatches': + foreach ($error['mismatch']['mismatches'] as $mismatch) { + $errors[] = Mismatch::VERIFIER_MISMATCH_TYPE_MAP[$mismatch['type']]; + } + break; + + default: + break; + } + + return $errors; + }, + [] + ); + Assert::assertContains($error, $errors); + } + + /** + * @Then the comment :comment will have been printed to the console + */ + public function theCommentWillHaveBeenPrintedToTheConsole(string $comment): void + { + Assert::assertStringContainsString($comment, $this->providerVerifier->getVerifyResult()->getOutput()); + } + + /** + * @Then the :name will displayed as the original test name + */ + public function theWillDisplayedAsTheOriginalTestName(string $name): void + { + Assert::assertStringContainsString(sprintf('Test Name: %s', $name), $this->providerVerifier->getVerifyResult()->getOutput()); + } +} diff --git a/compatibility-suite/tests/Context/V4/ResponseGeneratorsContext.php b/compatibility-suite/tests/Context/V4/ResponseGeneratorsContext.php new file mode 100644 index 00000000..93dcb826 --- /dev/null +++ b/compatibility-suite/tests/Context/V4/ResponseGeneratorsContext.php @@ -0,0 +1,56 @@ +builder->build([ + 'No' => $this->id, + 'method' => 'GET', + 'path' => '/response-generators', + 'response body' => 'file: basic.json', + ]); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $this->id, $interaction); + $this->storage->add(InteractionsStorageInterface::CLIENT_DOMAIN, $this->id, $interaction, true); + $this->responseGeneratorBuilder->build($interaction->getResponse(), 'mockserver-generator.json'); + + $this->server->register($this->id); + $this->client->sendRequestToServer($this->id); + + $body = $this->client->getResponse()->getBody()->getContents(); + $href = json_decode($table->getRow(0)[0], true)['href']; + $serverBaseUri = $this->server->getBaseUri(); + $search = [ + (string) $serverBaseUri->withHost('127.0.0.1'), + (string) $serverBaseUri->withHost('::1'), + ]; + $body = str_replace($search, $href, $body); + $this->bodyStorage->setBody($body); + } +} diff --git a/compatibility-suite/tests/Context/V4/ResponseMatchingContext.php b/compatibility-suite/tests/Context/V4/ResponseMatchingContext.php new file mode 100644 index 00000000..fe66c372 --- /dev/null +++ b/compatibility-suite/tests/Context/V4/ResponseMatchingContext.php @@ -0,0 +1,114 @@ +getHash(); + $row = reset($rows); + $interaction = $this->builder->build([ + 'No' => $this->id, + 'method' => 'GET', + 'path' => '/matching', + ] + $row); + $this->storage->add(InteractionsStorageInterface::SERVER_DOMAIN, $this->id, $interaction); + $this->storage->add(InteractionsStorageInterface::PACT_WRITER_DOMAIN, $this->id, $interaction); + $this->responseMatchingRuleBuilder->build($interaction->getResponse(), $row['matching rules']); + $pactPath = new PactPath(); + $this->pactWriter->write($this->id, $pactPath); + $this->providerVerifier->addSource($pactPath); + } + + /** + * @Given a status :status response is received + */ + public function aStatusResponseIsReceived(int $status): void + { + $interaction = $this->storage->get(InteractionsStorageInterface::SERVER_DOMAIN, $this->id); + $interaction->getResponse()->setStatus($status); + $this->server->register($this->id); + } + + /** + * @When the response is compared to the expected one + */ + public function theResponseIsComparedToTheExpectedOne(): void + { + $this->providerVerifier->getConfig()->getProviderInfo()->setPort($this->server->getPort()); + $this->providerVerifier->verify(); + } + + /** + * @Then the response comparison should be OK + */ + public function theResponseComparisonShouldBeOk(): void + { + Assert::assertTrue($this->providerVerifier->getVerifyResult()->isSuccess()); + } + + /** + * @Then the response comparison should NOT be OK + */ + public function theResponseComparisonShouldNotBeOk(): void + { + Assert::assertFalse($this->providerVerifier->getVerifyResult()->isSuccess()); + } + + /** + * @Then the response mismatches will contain a :type mismatch with error :error + */ + public function theResponseMismatchesWillContainAMismatchWithError(string $type, string $error): void + { + $output = json_decode($this->providerVerifier->getVerifyResult()->getOutput(), true); + $errors = array_reduce( + $output['errors'], + function (array $errors, array $error) use ($type) { + switch ($error['mismatch']['type']) { + case 'mismatches': + foreach ($error['mismatch']['mismatches'] as $mismatch) { + if ($mismatch['type'] === Mismatch::MOCK_SERVER_MISMATCH_TYPE_MAP[$type]) { + $errors[] = $mismatch['mismatch']; + } + } + break; + + default: + break; + } + + return $errors; + }, + [] + ); + Assert::assertContains($error, $errors); + } +} diff --git a/compatibility-suite/tests/Context/V4/SyncMessage/ConsumerContext.php b/compatibility-suite/tests/Context/V4/SyncMessage/ConsumerContext.php new file mode 100644 index 00000000..c812108c --- /dev/null +++ b/compatibility-suite/tests/Context/V4/SyncMessage/ConsumerContext.php @@ -0,0 +1,331 @@ +pactPath = new PactPath(); + } + + /** + * @Given a synchronous message interaction is being defined for a consumer test + */ + public function aSynchronousMessageInteractionIsBeingDefinedForAConsumerTest(): void + { + $this->message = new Message(); + $this->message->setDescription('a synchronous message'); + } + + /** + * @When the Pact file for the test is generated + */ + public function thePactFileForTheTestIsGenerated(): void + { + $this->pactWriter->write($this->message, $this->pactPath); + } + + /** + * @Then the first interaction in the Pact file will have a type of :type + */ + public function theFirstInteractionInThePactFileWillHaveATypeOf(string $type): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertSame($type, $pact['interactions'][0]['type']); + } + + /** + * @Given a key of :key is specified for the synchronous message interaction + */ + public function aKeyOfIsSpecifiedForTheSynchronousMessageInteraction(string $key): void + { + $this->message->setKey($key); + } + + /** + * @Given the synchronous message interaction is marked as pending + */ + public function theSynchronousMessageInteractionIsMarkedAsPending(): void + { + $this->message->setPending(true); + } + + /** + * @Given a comment :value is added to the synchronous message interaction + */ + public function aCommentIsAddedToTheSynchronousMessageInteraction(string $value): void + { + $this->message->setComments(['text' => json_encode([$value])]); + } + + /** + * @Then the first interaction in the Pact file will have :name = :value + */ + public function theFirstInteractionInThePactFileWillHave(string $name, string $value): void + { + $pact = json_decode(file_get_contents($this->pactPath), true); + Assert::assertJsonStringEqualsJsonString($value, json_encode($pact['interactions'][0][$name])); + } + + /** + * @Given the message request payload contains the :fixture JSON document + */ + public function theMessageRequestPayloadContainsTheJsonDocument(string $fixture): void + { + throw new PendingException("Can't set sync message's request payload using FFI call"); + } + + /** + * @Given the message response payload contains the :fixture document + */ + public function theMessageResponsePayloadContainsTheDocument(string $fixture): void + { + throw new PendingException("Can't set sync message's response payload using FFI call"); + } + + /** + * @Then the received message payload will contain the :fixture document + */ + public function theReceivedMessagePayloadWillContainTheDocument(string $fixture): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then a Pact file for the message interaction will have been written + */ + public function aPactFileForTheMessageInteractionWillHaveBeenWritten(): void + { + Assert::assertTrue(file_exists($this->pactPath)); + $this->pact = json_decode(file_get_contents($this->pactPath), true); + } + + /** + * @Then the pact file will contain :num interaction + */ + public function thePactFileWillContainInteraction(int $num): void + { + Assert::assertCount($num, $this->pact['interactions']); + } + + /** + * @Then the first interaction in the pact file will contain the :fixture document as the request + */ + public function theFirstInteractionInThePactFileWillContainTheDocumentAsTheRequest(string $fixture): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the first interaction in the pact file request content type will be :contentType + */ + public function theFirstInteractionInThePactFileRequestContentTypeWillBe(string $contentType): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the first interaction in the pact file will contain the :fixture document as a response + */ + public function theFirstInteractionInThePactFileWillContainTheDocumentAsAResponse(string $fixture): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the first interaction in the pact file response content type will be :contentType + */ + public function theFirstInteractionInThePactFileResponseContentTypeWillBe(string $contentType): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the first interaction in the pact file will contain :num response messages + */ + public function theFirstInteractionInThePactFileWillContainResponseMessages(int $num): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the first interaction in the pact file will contain the :fixture document as the first response message + */ + public function theFirstInteractionInThePactFileWillContainTheDocumentAsTheFirstResponseMessage(string $fixture): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the first interaction in the pact file will contain the :fixture document as the second response message + */ + public function theFirstInteractionInThePactFileWillContainTheDocumentAsTheSecondResponseMessage(string $fixture): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Given the message request contains the following metadata: + */ + public function theMessageRequestContainsTheFollowingMetadata(TableNode $table): void + { + throw new PendingException("Can't set sync message's metadata using FFI call"); + } + + /** + * @Then /^the received message request metadata will contain "([^"]+)" == "(.+)"$/ + */ + public function theReceivedMessageRequestMetadataWillContain(string $key, string $value): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then /^the first message in the pact file will contain the request message metadata "([^"]+)" == "(.+)"$/ + */ + public function theFirstMessageInThePactFileWillContainTheRequestMessageMetadata(string $key, string $value): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Given a provider state :state for the synchronous message is specified + */ + public function aProviderStateForTheSynchronousMessageIsSpecified(string $state): void + { + $this->message->addProviderState($state, []); + } + + /** + * @Given a provider state :state for the synchronous message is specified with the following data: + */ + public function aProviderStateForTheSynchronousMessageIsSpecifiedWithTheFollowingData(string $state, TableNode $table): void + { + $rows = $table->getHash(); + $row = reset($rows); + $this->message->addProviderState($state, $row); + } + + /** + * @Then the first message in the pact file will contain :states provider state(s) + */ + public function theFirstMessageInThePactFileWillContainProviderStates(int $states): void + { + Assert::assertCount($states, $this->pact['interactions'][0]['providerStates'] ?? []); + } + + /** + * @Then the first message in the Pact file will contain provider state :state + */ + public function theFirstMessageInThePactFileWillContainProviderState(string $state): void + { + $states = array_map(fn (array $state): string => $state['name'], $this->pact['interactions'][0]['providerStates']); + Assert::assertContains($state, $states); + } + + /** + * @Then the provider state :state for the message will contain the following parameters: + */ + public function theProviderStateForTheMessageWillContainTheFollowingParameters(string $state, TableNode $table): void + { + $params = json_decode($table->getHash()[0]['parameters'], true); + Assert::assertContains([ + 'name' => $state, + 'params' => $params, + ], $this->pact['interactions'][0]['providerStates']); + } + + /** + * @Given the message request is configured with the following: + */ + public function theMessageRequestIsConfiguredWithTheFollowing(TableNode $table): void + { + throw new PendingException("Can't set sync message's request generators using FFI call"); + } + + /** + * @Given the message response is configured with the following: + */ + public function theMessageResponseIsConfiguredWithTheFollowing(TableNode $table): void + { + throw new PendingException("Can't set sync message's response generators using FFI call"); + } + + /** + * @Then the message request contents for :path will have been replaced with a(n) :type + */ + public function theMessageRequestContentsForWillHaveBeenReplacedWithAn(string $path, string $type): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the message response contents for :path will have been replaced with a(n) :type + */ + public function theMessageResponseContentsForWillHaveBeenReplacedWithAn(string $path, string $type): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the received message request metadata will contain :key replaced with a(n) :type + */ + public function theReceivedMessageRequestMetadataWillContainReplacedWithAn(string $key, string $type): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the received message response metadata will contain :key == :value + */ + public function theReceivedMessageResponseMetadataWillContain(string $key, string $value): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the received message response metadata will contain :key replaced with an :type + */ + public function theReceivedMessageResponseMetadataWillContainReplacedWithAn(string $key, string $type): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @When the message is successfully processed + */ + public function theMessageIsSuccessfullyProcessed(): void + { + $this->thePactFileForTheTestIsGenerated(); // TODO Implement other pending steps first then update this step + } + + /** + * @Then the consumer test will have passed + */ + public function theConsumerTestWillHavePassed(): void + { + throw new PendingException('Implement previous pending step first'); + } + + /** + * @Then the received message content type will be :contentType + */ + public function theReceivedMessageContentTypeWillBe(string $contentType): void + { + throw new PendingException('Implement previous pending step first'); + } +} diff --git a/compatibility-suite/tests/Exception/CompatibilitySuiteException.php b/compatibility-suite/tests/Exception/CompatibilitySuiteException.php new file mode 100644 index 00000000..405c95f4 --- /dev/null +++ b/compatibility-suite/tests/Exception/CompatibilitySuiteException.php @@ -0,0 +1,9 @@ +generator; + } + + public function getCategory(): string + { + return $this->category; + } + + public function getSubCategory(): ?string + { + return $this->subCategory; + } + + public function getGeneratorAttributes(): array + { + return $this->generatorAttributes; + } +} diff --git a/compatibility-suite/tests/Model/Logger.php b/compatibility-suite/tests/Model/Logger.php new file mode 100644 index 00000000..df437bd5 --- /dev/null +++ b/compatibility-suite/tests/Model/Logger.php @@ -0,0 +1,20 @@ +output = $output; + } + + public function getOutput(): string + { + return $this->output; + } +} diff --git a/compatibility-suite/tests/Model/MatchingRule.php b/compatibility-suite/tests/Model/MatchingRule.php new file mode 100644 index 00000000..40499a91 --- /dev/null +++ b/compatibility-suite/tests/Model/MatchingRule.php @@ -0,0 +1,39 @@ +matcher; + } + + public function getCategory(): string + { + return $this->category; + } + + public function getSubCategory(): string + { + return $this->subCategory; + } + + public function getMatcherAttributes(): array + { + return $this->matcherAttributes; + } + + public function getMatcherAttribute(string $attribute): mixed + { + return $this->matcherAttributes[$attribute] ?? null; + } +} diff --git a/compatibility-suite/tests/Model/Message.php b/compatibility-suite/tests/Model/Message.php new file mode 100644 index 00000000..58406380 --- /dev/null +++ b/compatibility-suite/tests/Model/Message.php @@ -0,0 +1,42 @@ +body; + } + + public function setBody(null|Binary|Text $body): void + { + $this->body = $body; + } + + public function hasBody(): bool + { + return null !== $this->body; + } + + public function getMetadata(): ?array + { + return $this->metadata; + } + + public function setMetadata(?array $metadata): void + { + $this->metadata = $metadata; + } + + public function hasMetadata(): bool + { + return null !== $this->metadata; + } +} diff --git a/compatibility-suite/tests/Model/PactPath.php b/compatibility-suite/tests/Model/PactPath.php new file mode 100644 index 00000000..d2293c08 --- /dev/null +++ b/compatibility-suite/tests/Model/PactPath.php @@ -0,0 +1,25 @@ +consumer; + } + + public function __toString(): string + { + return sprintf("%s/%s-%s.json", Path::PACTS_PATH, $this->consumer, self::PROVIDER); + } +} diff --git a/compatibility-suite/tests/Model/VerifyResult.php b/compatibility-suite/tests/Model/VerifyResult.php new file mode 100644 index 00000000..a0e59583 --- /dev/null +++ b/compatibility-suite/tests/Model/VerifyResult.php @@ -0,0 +1,20 @@ +success; + } + + public function getOutput(): string + { + return $this->output; + } +} diff --git a/compatibility-suite/tests/Model/Xml.php b/compatibility-suite/tests/Model/Xml.php new file mode 100644 index 00000000..88d3079d --- /dev/null +++ b/compatibility-suite/tests/Model/Xml.php @@ -0,0 +1,38 @@ + $this->xmlElementToArray($root), + ]); + } + + private function xmlElementToArray(SimpleXMLElement $element): array + { + $children = $element->children(); + if (0 !== $children->count()) { + $items = []; + foreach ($children as $child) { + $items[] = $this->xmlElementToArray($child); + } + + return [ + 'name' => $element->getName(), + 'children' => $items, + ]; + } else { + return [ + 'content' => (string) $element, + ]; + } + } +} diff --git a/compatibility-suite/tests/Service/BodyStorage.php b/compatibility-suite/tests/Service/BodyStorage.php new file mode 100644 index 00000000..66900aee --- /dev/null +++ b/compatibility-suite/tests/Service/BodyStorage.php @@ -0,0 +1,18 @@ +body = $body; + } + + public function getBody(): string + { + return $this->body; + } +} diff --git a/compatibility-suite/tests/Service/BodyStorageInterface.php b/compatibility-suite/tests/Service/BodyStorageInterface.php new file mode 100644 index 00000000..bf7865fa --- /dev/null +++ b/compatibility-suite/tests/Service/BodyStorageInterface.php @@ -0,0 +1,10 @@ +getActualValue($path); + Assert::assertTrue((bool) match ($type) { + 'integer' => is_numeric($value) && preg_match(self::INT_REGEX, $value), + 'decimal number' => is_string($value) && preg_match(self::DEC_REGEX, $value), + 'hexadecimal number' => is_string($value) && preg_match(self::HEX_REGEX, $value), + 'random string' => is_string($value), + 'string from the regex' => is_string($value) && preg_match(self::STR_REGEX, $value), + 'date' => is_string($value) && preg_match(self::DATE_REGEX, $value), + 'time' => is_string($value) && preg_match(self::TIME_REGEX, $value), + 'date-time' => is_string($value) && preg_match(self::DATETIME_REGEX, $value), + 'UUID', 'simple UUID', 'lower-case-hyphenated UUID', 'upper-case-hyphenated UUID', 'URN UUID' => Uuid::isValid($value), + 'boolean' => is_bool($value), + default => false, + }); + } + + public function validateValue(string $path, string $value): void + { + Assert::assertSame($value, $this->getActualValue($path)); + } + + private function getActualValue(string $path): mixed + { + $jsonObject = new JsonObject($this->bodyStorage->getBody(), true); + + return $jsonObject->{$path}; + } +} diff --git a/compatibility-suite/tests/Service/BodyValidatorInterface.php b/compatibility-suite/tests/Service/BodyValidatorInterface.php new file mode 100644 index 00000000..805d6bd7 --- /dev/null +++ b/compatibility-suite/tests/Service/BodyValidatorInterface.php @@ -0,0 +1,10 @@ +storage->get(InteractionsStorageInterface::CLIENT_DOMAIN, $id)->getRequest(); + $this->response = $this->httpClient->sendRequest($request, $this->server->getBaseUri()); + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } +} diff --git a/compatibility-suite/tests/Service/ClientInterface.php b/compatibility-suite/tests/Service/ClientInterface.php new file mode 100644 index 00000000..75a7523b --- /dev/null +++ b/compatibility-suite/tests/Service/ClientInterface.php @@ -0,0 +1,12 @@ +getFilePath($fileName)); + } + + public function loadJson(string $fileName): mixed + { + try { + return json_decode($this->load($fileName), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new InvalidJsonFixtureException(sprintf("Could not load json fixture '%s': %s", $fileName, $exception->getMessage())); + } + } + + public function isBinary(string $fileName): bool + { + $ext = pathinfo($this->getFilePath($fileName), PATHINFO_EXTENSION); + + // TODO Find a better way + return in_array($ext, ['jpg', 'pdf']); + } + + public function determineContentType(string $fileName): string + { + if (str_ends_with($fileName, '.json')) { + return 'application/json'; + } elseif (str_ends_with($fileName, '.xml')) { + return 'application/xml'; + } elseif (str_ends_with($fileName, '.jpg')) { + return 'image/jpeg'; + } elseif (str_ends_with($fileName, '.pdf')) { + return 'application/pdf'; + } else { + return 'text/plain'; + } + } + + public function getFilePath(string $fileName): string + { + $filePath = Path::FIXTURES_PATH . '/' . $fileName; + if (!file_exists($filePath)) { + throw new FixtureNotFoundException(sprintf("Could not load fixture '%s'", $fileName)); + } + + return $filePath; + } +} diff --git a/compatibility-suite/tests/Service/FixtureLoaderInterface.php b/compatibility-suite/tests/Service/FixtureLoaderInterface.php new file mode 100644 index 00000000..9e4eae77 --- /dev/null +++ b/compatibility-suite/tests/Service/FixtureLoaderInterface.php @@ -0,0 +1,16 @@ +getGenerator()); + + $matcher = new Integer(); // Doesn't matter. Any matcher doesn't require value and accept generator will be fine. + $matcher->setGenerator(new $class(...$generator->getGeneratorAttributes())); + + return $matcher; + } +} diff --git a/compatibility-suite/tests/Service/GeneratorConverterInterface.php b/compatibility-suite/tests/Service/GeneratorConverterInterface.php new file mode 100644 index 00000000..af71456e --- /dev/null +++ b/compatibility-suite/tests/Service/GeneratorConverterInterface.php @@ -0,0 +1,11 @@ +fixtureLoader->loadJson($value); + } + + return $this->loadFromMap($map); + } + + private function loadFromMap(array $map): array + { + $generators = []; + $removeType = fn (array $values): array => array_filter( + $values, + fn (mixed $v, string $k) => $k !== 'type', + ARRAY_FILTER_USE_BOTH + ); + foreach ($map as $category => $values) { + switch ($category) { + case 'path': + case 'method': + case 'status': + $generators[] = new Generator($values['type'], $category, null, $removeType($values)); + break; + + default: + foreach ($values as $subCategory => $value) { + $generators[] = new Generator($value['type'], $category, $subCategory, $removeType($value)); + } + break; + } + } + + return $generators; + } +} diff --git a/compatibility-suite/tests/Service/GeneratorParserInterface.php b/compatibility-suite/tests/Service/GeneratorParserInterface.php new file mode 100644 index 00000000..7b77cd82 --- /dev/null +++ b/compatibility-suite/tests/Service/GeneratorParserInterface.php @@ -0,0 +1,13 @@ + + */ + public function parse(string $value): array; +} diff --git a/compatibility-suite/tests/Service/GeneratorServer.php b/compatibility-suite/tests/Service/GeneratorServer.php new file mode 100644 index 00000000..dcc950be --- /dev/null +++ b/compatibility-suite/tests/Service/GeneratorServer.php @@ -0,0 +1,58 @@ +bodyFile, $this->pathFile, $this->headersFile, $this->queryParamsFile] as $file) { + @unlink($file); + } + $this->process = new PhpProcess(Path::PUBLIC_PATH . '/generators/'); + $this->process->start(); + } + + public function stop(): void + { + $this->process->stop(); + } + + public function getPort(): int + { + return $this->process->getPort(); + } + + public function getBody(): string + { + return @file_get_contents($this->bodyFile); + } + + public function getPath(): string + { + return file_get_contents($this->pathFile); + } + + public function getHeader(string $header): array + { + $headers = json_decode(file_get_contents($this->headersFile), true); + + return $headers[$header] ?? []; + } + + public function getQueryParam(string $name): string + { + $queryParams = json_decode(file_get_contents($this->queryParamsFile), true); + + return $queryParams[$name] ?? ''; + } +} diff --git a/compatibility-suite/tests/Service/GeneratorServerInterface.php b/compatibility-suite/tests/Service/GeneratorServerInterface.php new file mode 100644 index 00000000..05ae02af --- /dev/null +++ b/compatibility-suite/tests/Service/GeneratorServerInterface.php @@ -0,0 +1,20 @@ +client = new Client(); + } + + public function sendRequest(ConsumerRequest $request, UriInterface $uri): ResponseInterface + { + $options = []; + $options['query'] = $this->formatQueryString($request->getQuery()); + $options['headers'] = $request->getHeaders(); + $body = $request->getBody(); + if ($body instanceof Text || $body instanceof Binary) { + $options['body'] = match (true) { + $body instanceof Text => $body->getContents(), + $body instanceof Binary => file_get_contents($body->getPath()), + }; + $options['headers']['Content-Type'] = $body->getContentType(); + } + $options['http_errors'] = false; + + return $this->client->request($request->getMethod(), $uri->withPath($request->getPath()), $options); + } + + private function formatQueryString(array $query): string + { + $result = []; + + foreach($query as $key => $values) { + foreach ($values as $value) { + $result[] = urlencode($key) . '=' . urlencode($value); + } + } + + return implode('&', $result); + } +} diff --git a/compatibility-suite/tests/Service/HttpClientInterface.php b/compatibility-suite/tests/Service/HttpClientInterface.php new file mode 100644 index 00000000..b2c2057b --- /dev/null +++ b/compatibility-suite/tests/Service/HttpClientInterface.php @@ -0,0 +1,12 @@ +setDescription($data['description'] ?? 'Interaction ' . (int) $data['No']); + + $request = new ConsumerRequest(); + $this->requestBuilder->build($request, array_intersect_key($data, array_flip(['method', 'path', 'query', 'headers', 'body']))); + $interaction->setRequest($request); + + $response = new ProviderResponse(); + $this->responseBuilder->build($response, array_filter([ + 'status' => $data['response'] ?? null, + 'headers' => $data['response headers'] ?? null, + 'body' => $data['response body'] ?? null, + 'content-type' => $data['response content'] ?? null, + ])); + $interaction->setResponse($response); + + return $interaction; + } +} diff --git a/compatibility-suite/tests/Service/InteractionBuilderInterface.php b/compatibility-suite/tests/Service/InteractionBuilderInterface.php new file mode 100644 index 00000000..4e48840a --- /dev/null +++ b/compatibility-suite/tests/Service/InteractionBuilderInterface.php @@ -0,0 +1,10 @@ + + */ + private array $interactions = []; + + public function add(string $domain, int $id, Interaction $interaction, bool $clone = false): void + { + $this->interactions[$domain][$id] = $clone ? $this->cloneInteraction($interaction) : $interaction; + } + + public function get(string $domain, int $id): Interaction + { + if (!isset($this->interactions[$domain][$id])) { + throw new UndefinedInteractionException(sprintf('Interaction %s is not defined in domain %s', $id, $domain)); + } + + return $this->interactions[$domain][$id]; + } + + private function cloneInteraction(Interaction $interaction): Interaction + { + $result = clone $interaction; + $result->setRequest(clone $interaction->getRequest()); + if ($interaction->getRequest()->getBody()) { + $result->getRequest()->setBody(clone $interaction->getRequest()->getBody()); + } + $result->setResponse(clone $interaction->getResponse()); + if ($interaction->getResponse()->getBody()) { + $result->getResponse()->setBody(clone $interaction->getResponse()->getBody()); + } + + return $result; + } +} diff --git a/compatibility-suite/tests/Service/InteractionsStorageInterface.php b/compatibility-suite/tests/Service/InteractionsStorageInterface.php new file mode 100644 index 00000000..093f3d32 --- /dev/null +++ b/compatibility-suite/tests/Service/InteractionsStorageInterface.php @@ -0,0 +1,16 @@ +getMatcher()) { + case 'type': + $min = $rule->getMatcherAttribute('min'); + $max = $rule->getMatcherAttribute('max'); + if (null !== $min && null !== $max) { + return new MinMaxType($value, $min, $max); + } + if (null !== $min) { + return new MinType($value, $min); + } + if (null !== $max) { + return new MaxType($value, $max); + } + return new Type($value); + + case 'equality': + return new Equality($value); + + case 'include': + return new Includes($rule->getMatcherAttribute('value')); + + case 'number': + return new Number($this->getNumber($value)); + + case 'integer': + return new Integer($this->getNumber($value)); + + case 'decimal': + return new Decimal($this->getNumber($value)); + + case 'null': + return new NullValue(); + + case 'date': + return new Date($rule->getMatcherAttribute('format'), $value); + + case 'boolean': + return new Boolean($value); + + case 'contentType': + return new ContentType($rule->getMatcherAttribute('value')); + + case 'values': + return new Values($value); + + case 'notEmpty': + return new NotEmpty($value); + + case 'semver': + return new Semver($value); + + case 'eachKey': + return new EachKey($value, $rule->getMatcherAttribute('rules')); + + case 'eachValue': + return new EachValue($value, $rule->getMatcherAttribute('rules')); + + case 'arrayContains': + return new ArrayContains($rule->getMatcherAttribute('variants')); + + case 'regex': + $regex = $rule->getMatcherAttribute('regex'); + return new Regex($regex, $this->ignoreInvalidValue($regex, $value)); + + case 'statusCode': + return new StatusCode($rule->getMatcherAttribute('status')); + + default: + return null; + } + } + + private function getNumber(mixed $value): int|float|null + { + if (is_numeric($value)) { + $value = $value + 0; + } else { + $value = null; + } + + return $value; + } + + private function ignoreInvalidValue(string $regex, mixed $value): string|array|null + { + if (is_string($value)) { + if (!preg_match("/$regex/", $value)) { + $value = null; + } + } elseif (is_array($value)) { + foreach (array_keys($value) as $key) { + if (!preg_match("/$regex/", $value[$key])) { + $value[$key] = null; + } + } + } else { + $value = null; + } + + return $value; + } +} diff --git a/compatibility-suite/tests/Service/MatchingRuleConverterInterface.php b/compatibility-suite/tests/Service/MatchingRuleConverterInterface.php new file mode 100644 index 00000000..f992fa70 --- /dev/null +++ b/compatibility-suite/tests/Service/MatchingRuleConverterInterface.php @@ -0,0 +1,11 @@ +fixtureLoader->loadJson($fileName); + switch ($this->getSpecification($fileName)) { + case 'v2': + return $this->loadFromV2Map($map); + + case 'v3': + case 'v4': + return $this->loadFromV3Map($map); + + default: + return []; + } + } + + private function loadFromV2Map(array $map): array + { + $rules = []; + foreach ($map as $k => $v) { + if ($k === '$.body') { + $rules[] = new MatchingRule($v['match'], 'body', '$', $v); + } elseif (str_starts_with($k, '$.body')) { + $rules[] = new MatchingRule($v['match'], 'body', '$' . substr($k, 6), $v); + } elseif (str_starts_with($k, '$.headers')) { + $rules[] = new MatchingRule($v['match'], 'header', explode('.', $k, 3)[2], $v); + } else { + @[, $category, $subCategory] = explode('.', $k, 3); + $rules[] = new MatchingRule($v['match'], $category, $subCategory ?? '', $v); + } + } + + return $rules; + } + + private function loadFromV3Map(array $map): array + { + foreach ($map as $category => $subMap) { + switch ($category) { + case 'body': + return $this->getV3BodyMatchers($subMap); + + case 'status': + return $this->getV4StatusCodeMatchers($subMap); + + default: + break; + } + } + + return []; + } + + private function getV3BodyMatchers(array $map): array + { + $matchers = []; + foreach ($map as $subCategory => $subMap) { + if ($subMap['combine'] !== 'AND') { + throw new MatchingRuleConditionException("FFI call doesn't support OR matcher condition"); + } + foreach ($subMap['matchers'] as $matcher) { + switch ($matcher['match']) { + case 'eachKey': + case 'eachValue': + $matcher['rules'] = array_map(fn (array $rule) => $this->converter->convert(new MatchingRule($rule['match'], '', '', $rule), null), $matcher['rules']); + break; + + case 'arrayContains': + $items = []; + foreach ($matcher['variants'] as $variant) { + $value = []; + foreach ($variant['rules'] as $key => $rule) { + $key = str_replace('$.', '', $key); + if ($key === '*') { + // TODO It seems that IntegrationJson doesn't support '*'. Find a better way than hard coding like this. + $value['href'] = $this->converter->convert(new MatchingRule($rule['matchers'][0]['match'], '', '', $rule['matchers'][0]), 'http://api.x.io/orders/42/items'); + $value['title'] = $this->converter->convert(new MatchingRule($rule['matchers'][0]['match'], '', '', $rule['matchers'][0]), 'Delete Item'); + } else { + $regex = str_replace('\-', '-', $rule['matchers'][0]['regex']); + $value[$key] = $this->converter->convert(new MatchingRule($rule['matchers'][0]['match'], '', '', $rule['matchers'][0]), $regex); + } + } + $items[] = $value; + } + $matcher['variants'] = $items; + break; + + default: + break; + } + $matchers[] = new MatchingRule($matcher['match'], 'body', $subCategory, $matcher); + } + } + $this->sortMatchersByLevel($matchers); + + return $matchers; + } + + private function getV4StatusCodeMatchers(array $map): array + { + $matcher = $map['matchers'][0]; + + return [ + new MatchingRule($matcher['match'], 'status', '', $matcher), + ]; + } + + private function sortMatchersByLevel(array &$matchers): void + { + usort( + $matchers, + fn (MatchingRule $a, MatchingRule $b) => count(explode('.', $b->getSubCategory())) - count(explode('.', $a->getSubCategory())) + ); + } + + private function getSpecification(string $fileName): string + { + $basename = substr_replace($fileName, '', -5); + $parts = explode('-', $basename); + + return end($parts); + } +} diff --git a/compatibility-suite/tests/Service/MatchingRuleParserInterface.php b/compatibility-suite/tests/Service/MatchingRuleParserInterface.php new file mode 100644 index 00000000..9f89e0ee --- /dev/null +++ b/compatibility-suite/tests/Service/MatchingRuleParserInterface.php @@ -0,0 +1,13 @@ + + */ + public function parse(string $fileName): array; +} diff --git a/compatibility-suite/tests/Service/MatchingRulesStorage.php b/compatibility-suite/tests/Service/MatchingRulesStorage.php new file mode 100644 index 00000000..e2a4a92f --- /dev/null +++ b/compatibility-suite/tests/Service/MatchingRulesStorage.php @@ -0,0 +1,21 @@ + + */ + private array $files = []; + + public function add(string $domain, int $id, string $file): void + { + $this->files[$domain][$id] = $file; + } + + public function get(string $domain, int $id): ?string + { + return $this->files[$domain][$id] ?? null; + } +} diff --git a/compatibility-suite/tests/Service/MatchingRulesStorageInterface.php b/compatibility-suite/tests/Service/MatchingRulesStorageInterface.php new file mode 100644 index 00000000..32210d0c --- /dev/null +++ b/compatibility-suite/tests/Service/MatchingRulesStorageInterface.php @@ -0,0 +1,13 @@ +parser->parse($value) as $generator) { + switch ($generator->getCategory()) { + case 'metadata': + $metadata = $message->getMetadata() ?? []; + $metadata[$generator->getSubCategory()] = $this->converter->convert($generator); + $message->setMetadata($metadata); + break; + + case 'body': + $body = $message->getBody(); + if ($body instanceof Text && $body->getContentType() === 'application/json') { + $jsonObject = new JsonObject($body->getContents(), true); + $jsonObject->{$generator->getSubCategory()} = $this->converter->convert($generator); + $body->setContents($jsonObject); + } else { + throw new IntegrationJsonFormatException("Integration JSON format doesn't support non-JSON format"); + } + break; + + default: + break; + } + } + } +} diff --git a/compatibility-suite/tests/Service/MessageGeneratorBuilderInterface.php b/compatibility-suite/tests/Service/MessageGeneratorBuilderInterface.php new file mode 100644 index 00000000..45c446d9 --- /dev/null +++ b/compatibility-suite/tests/Service/MessageGeneratorBuilderInterface.php @@ -0,0 +1,10 @@ +setConsumer($pactPath->getConsumer()) + ->setProvider(PactPath::PROVIDER) + ->setPactDir(Path::PACTS_PATH) + ->setPactSpecificationVersion($this->specificationVersion) + ->setPactFileWriteMode($mode); + $driver = (new MessageDriverFactory())->create($config); + + $driver->registerMessage($message); + $driver->writePactAndCleanUp(); + } +} diff --git a/compatibility-suite/tests/Service/MessagePactWriterInterface.php b/compatibility-suite/tests/Service/MessagePactWriterInterface.php new file mode 100644 index 00000000..59d3ffd5 --- /dev/null +++ b/compatibility-suite/tests/Service/MessagePactWriterInterface.php @@ -0,0 +1,12 @@ +client = new Client(); + } + + public function publish(int $id): void + { + $this->pactPath = new PactPath("c-$id"); + $this->client->put(sprintf('http://localhost:9292/pacts/provider/%s/consumer/%s/version/1.0.0', PactPath::PROVIDER, $this->pactPath->getConsumer()), [ + 'body' => file_get_contents($this->pactPath), + 'headers' => ['Content-Type' => 'application/json'], + ]); + } + + public function start(): void + { + exec('docker run --rm --publish 9292:9292 --detach --env PACT_BROKER_DATABASE_URL=sqlite:////tmp/pact_broker.sqlite3 --name pact-broker pactfoundation/pact-broker:latest'); + while (true) { + try { + $response = $this->client->get('http://localhost:9292/diagnostic/status/heartbeat', ['http_errors' => false]); + if ($response->getStatusCode() !== 200) { + continue; + } + $status = json_decode($response->getBody(), true); + if ($status['ok']) { + break; + } + } catch (\Throwable) { + } finally { + sleep(1); + } + } + } + + public function stop(): void + { + exec('docker stop pact-broker'); + sleep(1); + } + + public function getMatrix(): array + { + return json_decode(file_get_contents(sprintf('http://localhost:9292/matrix.json?q[][pacticipant]=%s&q[][pacticipant]=%s', $this->pactPath->getConsumer(), PactPath::PROVIDER)), true); + } +} diff --git a/compatibility-suite/tests/Service/PactBrokerInterface.php b/compatibility-suite/tests/Service/PactBrokerInterface.php new file mode 100644 index 00000000..674ed062 --- /dev/null +++ b/compatibility-suite/tests/Service/PactBrokerInterface.php @@ -0,0 +1,14 @@ +setConsumer($pactPath->getConsumer()) + ->setProvider(PactPath::PROVIDER) + ->setPactDir(Path::PACTS_PATH) + ->setPactSpecificationVersion($this->specificationVersion) + ->setPactFileWriteMode($mode); + $driver = (new InteractionDriverFactory())->create($config); + + $interaction = $this->storage->get(InteractionsStorageInterface::PACT_WRITER_DOMAIN, $id); + $driver->registerInteraction($interaction); + $driver->writePactAndCleanUp(); + } +} diff --git a/compatibility-suite/tests/Service/PactWriterInterface.php b/compatibility-suite/tests/Service/PactWriterInterface.php new file mode 100644 index 00000000..482fbdc4 --- /dev/null +++ b/compatibility-suite/tests/Service/PactWriterInterface.php @@ -0,0 +1,11 @@ + trim($value), explode(',', $value))); + } + + return $values; + }, + [] + ); + } + + public function parseBody(string $body, ?string $contentType = null): Text|Binary|Multipart|null + { + if (empty($body)) { + return null; + } + if (str_starts_with($body, 'JSON:')) { + return new Text(trim(substr($body, 5)), 'application/json'); + } + if (str_starts_with($body, 'XML:')) { + return new Text(trim(substr($body, 4)), 'application/xml'); + } + if (str_starts_with($body, 'file:')) { + $fileName = trim(substr($body, 5)); + $contents = $this->fixtureLoader->load($fileName); + if (str_ends_with($fileName, '-body.xml')) { + $body = simplexml_load_string($contents); + if (!$body) { + throw new InvalidXmlFixtureException(sprintf("could not read fixture '%s'", $fileName)); + } + $contentType = (string) $body->contentType ?? 'text/plain'; + $contents = $body->contents ?? ''; + $lineEndings = (string) (iterator_to_array($contents->attributes())['eol'] ?? ''); + + if ($lineEndings === 'CRLF' && PHP_OS_FAMILY !== 'Windows') { + $contents = str_replace("\n", "\r\n", $contents); + } + + if ($this->specificationVersion !== '1.0.0' && $contentType === 'application/xml') { + return new Xml($contents, $contentType); + } else { + return new Text($contents, $contentType); + } + } else { + $contentType ??= $this->fixtureLoader->determineContentType($fileName); + $isBinary = $this->fixtureLoader->isBinary($fileName); + $filePath = $this->fixtureLoader->getFilePath($fileName); + + return $isBinary ? new Binary($filePath, $contentType) : new Text($contents, $contentType); + } + } + if ($body === 'EMPTY') { + $body = ''; + } + + return new Text($body, $contentType ?? 'text/plain'); + } + + public function parseQueryString(string $query): array + { + if (empty($query)) { + return []; + } + + return array_reduce( + explode('&', $query), + function (array $values, string $kv): array { + if (str_contains($kv, '=')) { + [$key, $value] = explode('=', $kv, 2); + $values[$key][] = $value; + } else { + $values[$kv][] = ''; + } + + return $values; + }, + [] + ); + } + + public function parseMetadataTable(array $rows): array + { + $metadata = []; + foreach ($rows as $row) { + $metadata[$row['key']] = str_starts_with($row['value'], 'JSON: ') ? substr($row['value'], 6) : $row['value']; + } + + return $metadata; + } + + public function parseMetadataValue(string $value): string + { + $value = str_starts_with($value, 'JSON: ') ? substr($value, 6) : $value; + $value = str_replace('\"', '"', $value); + + return $value; + } + + public function parseMetadataMultiValues(string $items): array + { + $metadata = []; + foreach (explode(';', $items) as $item) { + [$key, $value] = explode('=', trim($item)); + $metadata[$key] = str_starts_with($value, 'JSON: ') ? substr($value, 6) : $value; + } + + return $metadata; + } +} diff --git a/compatibility-suite/tests/Service/ParserInterface.php b/compatibility-suite/tests/Service/ParserInterface.php new file mode 100644 index 00000000..5e33c459 --- /dev/null +++ b/compatibility-suite/tests/Service/ParserInterface.php @@ -0,0 +1,22 @@ +process = new PhpProcess(Path::PUBLIC_PATH . '/provider-states/'); + $this->process->start(); + } + + public function stop(): void + { + $this->process->stop(); + } + + public function getPort(): int + { + return $this->process->getPort(); + } + + public function hasAction(string $action): bool + { + return file_get_contents(sprintf('http://localhost:%d/has-action?action=%s', $this->getPort(), urlencode($action))); + } + + public function hasState(string $action, string $state, array $params = []): bool + { + return file_get_contents(sprintf('http://localhost:%d/has-state?action=%s&state=%s%s', $this->getPort(), urlencode($action), urlencode($state), $params ? ('&' . http_build_query($params)) : '')); + } +} diff --git a/compatibility-suite/tests/Service/ProviderStateServerInterface.php b/compatibility-suite/tests/Service/ProviderStateServerInterface.php new file mode 100644 index 00000000..f2595c01 --- /dev/null +++ b/compatibility-suite/tests/Service/ProviderStateServerInterface.php @@ -0,0 +1,19 @@ +config = new VerifierConfig(); + $this->config + ->getProviderInfo() + ->setName(PactPath::PROVIDER) + ->setHost('localhost'); + } + + public function getConfig(): VerifierConfigInterface + { + return $this->config; + } + + public function verify(): void + { + $logger = new Logger(); + $verifier = new Verifier($this->config, $logger); + foreach ($this->sources as $source) { + if ($source instanceof Broker) { + $verifier->addBroker($source); + } else { + $verifier->addFile($source); + } + } + + $success = $verifier->verify(); + $this->verifyResult = new VerifyResult($success, $logger->getOutput()); + } + + public function addSource(string|Broker $source): void + { + if (in_array($source, $this->sources)) { + return; + } + if ($source instanceof Broker) { + $this->sources = array_filter($this->sources, fn (mixed $source) => !$source instanceof Broker); + } + $this->sources[] = $source; + } + + public function getVerifyResult(): VerifyResult + { + return $this->verifyResult; + } +} diff --git a/compatibility-suite/tests/Service/ProviderVerifierInterface.php b/compatibility-suite/tests/Service/ProviderVerifierInterface.php new file mode 100644 index 00000000..1f1f3e83 --- /dev/null +++ b/compatibility-suite/tests/Service/ProviderVerifierInterface.php @@ -0,0 +1,18 @@ + $value) { + switch ($key) { + case 'method': + $request->setMethod($data['method']); + break; + + case 'path': + $request->setPath($data['path']); + break; + + case 'query': + $request->setQuery($this->parser->parseQueryString($data['query'])); + break; + + case 'headers': + $request->setHeaders($this->parser->parseHeaders($data['headers'])); + break; + + case 'raw headers': + $request->setHeaders($this->parser->parseHeaders($data['raw headers'], true)); + break; + + case 'body': + $request->setBody($this->parser->parseBody($data['body'], $request->getBody()?->getContentType())); + break; + + case 'content type': + $request->addHeader('Content-Type', $data['content type']); + break; + + default: + break; + } + } + } +} diff --git a/compatibility-suite/tests/Service/RequestBuilderInterface.php b/compatibility-suite/tests/Service/RequestBuilderInterface.php new file mode 100644 index 00000000..0f9e30f3 --- /dev/null +++ b/compatibility-suite/tests/Service/RequestBuilderInterface.php @@ -0,0 +1,10 @@ +parser->parse($value) as $generator) { + switch ($generator->getCategory()) { + case 'method': + // Can't set generator to method + break; + + case 'path': + $request->setPath($this->converter->convert($generator)); + break; + + case 'query': + if ($generator->getSubCategory()) { + $request->addQueryParameter($generator->getSubCategory(), $this->converter->convert($generator)); + } + break; + + case 'header': + if ($generator->getSubCategory()) { + $request->addHeader($generator->getSubCategory(), $this->converter->convert($generator)); + } + break; + + case 'body': + $body = $request->getBody(); + if ($body instanceof Text && $body->getContentType() === 'application/json') { + $jsonObject = new JsonObject($body->getContents(), true); + $jsonObject->{$generator->getSubCategory()} = $this->converter->convert($generator); + $body->setContents($jsonObject); + } else { + throw new IntegrationJsonFormatException("Integration JSON format doesn't support non-JSON format"); + } + break; + + default: + break; + } + } + } +} diff --git a/compatibility-suite/tests/Service/RequestGeneratorBuilderInterface.php b/compatibility-suite/tests/Service/RequestGeneratorBuilderInterface.php new file mode 100644 index 00000000..8a409bb7 --- /dev/null +++ b/compatibility-suite/tests/Service/RequestGeneratorBuilderInterface.php @@ -0,0 +1,10 @@ +parser->parse($file) as $rule) { + switch ($rule->getCategory()) { + case 'method': + // I don't think method support matching rule, at least in pact-php. + break; + + case 'path': + $matcher = $this->converter->convert($rule, $request->getPath()); + if ($matcher) { + $request->setPath($matcher); + } + break; + + case 'query': + if ($rule->getSubCategory()) { + $queryValues = $request->getQuery()[$rule->getSubCategory()]; + $matcher = $this->converter->convert($rule, $queryValues); + if ($matcher) { + $request->addQueryParameter($rule->getSubCategory(), $matcher); + } + } + break; + + case 'header': + if ($rule->getSubCategory()) { + $headerValues = array_change_key_case($request->getHeaders())[$rule->getSubCategory()]; + $matcher = $this->converter->convert($rule, $headerValues); + if ($matcher) { + $request->addHeader($rule->getSubCategory(), $matcher); + } + } + break; + + case 'body': + $body = $request->getBody(); + if ($body instanceof Text && $body->getContentType() === 'application/json') { + $jsonObject = new JsonObject($body->getContents(), true); + $value = $jsonObject->{$rule->getSubCategory()}; + if (str_contains($rule->getSubCategory(), '*')) { + $value = reset($value); // This is for handling '$.two.*.ids' and '$.*' + } + $matcher = $this->converter->convert($rule, $value); + if ($matcher) { + $jsonObject->{$rule->getSubCategory()} = $matcher; + $body->setContents($jsonObject); + } + } else { + throw new IntegrationJsonFormatException("Integration JSON format doesn't support non-JSON format"); + } + break; + + default: + break; + } + } + } +} diff --git a/compatibility-suite/tests/Service/RequestMatchingRuleBuilderInterface.php b/compatibility-suite/tests/Service/RequestMatchingRuleBuilderInterface.php new file mode 100644 index 00000000..e2962f67 --- /dev/null +++ b/compatibility-suite/tests/Service/RequestMatchingRuleBuilderInterface.php @@ -0,0 +1,10 @@ + $value) { + switch ($key) { + case 'status': + $response->setStatus($data['status']); + break; + + case 'headers': + $response->setHeaders($this->parser->parseHeaders($data['headers'])); + break; + + case 'body': + $response->setBody($this->parser->parseBody($data['body'], $response->getBody()?->getContentType())); + break; + + case 'content-type': + $response->addHeader('Content-Type', $data['content-type']); + break; + + default: + break; + } + } + } +} diff --git a/compatibility-suite/tests/Service/ResponseBuilderInterface.php b/compatibility-suite/tests/Service/ResponseBuilderInterface.php new file mode 100644 index 00000000..28f46c9f --- /dev/null +++ b/compatibility-suite/tests/Service/ResponseBuilderInterface.php @@ -0,0 +1,10 @@ +parser->parse($value) as $generator) { + switch ($generator->getCategory()) { + case 'status': + $response->setStatus($this->converter->convert($generator)); + break; + + case 'header': + if ($generator->getSubCategory()) { + $response->addHeader($generator->getSubCategory(), $this->converter->convert($generator)); + } + break; + + case 'body': + $body = $response->getBody(); + if ($body instanceof Text && $body->getContentType() === 'application/json') { + $jsonObject = new JsonObject($body->getContents(), true); + $jsonObject->{$generator->getSubCategory()} = $this->converter->convert($generator); + $body->setContents($jsonObject); + } else { + throw new IntegrationJsonFormatException("Integration JSON format doesn't support non-JSON format"); + } + break; + + default: + break; + } + } + } +} diff --git a/compatibility-suite/tests/Service/ResponseGeneratorBuilderInterface.php b/compatibility-suite/tests/Service/ResponseGeneratorBuilderInterface.php new file mode 100644 index 00000000..b7f11aca --- /dev/null +++ b/compatibility-suite/tests/Service/ResponseGeneratorBuilderInterface.php @@ -0,0 +1,10 @@ +parser->parse($file) as $rule) { + switch ($rule->getCategory()) { + case 'status': + $response->setStatus($this->converter->convert($rule, $response->getStatus())); + break; + + case 'header': + if ($rule->getSubCategory()) { + $headerValues = array_change_key_case($response->getHeaders())[$rule->getSubCategory()]; + $matcher = $this->converter->convert($rule, $headerValues); + if ($matcher) { + $response->addHeader($rule->getSubCategory(), $matcher); + } + } + break; + + case 'body': + $body = $response->getBody(); + if ($body instanceof Text && $body->getContentType() === 'application/json') { + $jsonObject = new JsonObject($body->getContents(), true); + $matcher = $this->converter->convert($rule, $jsonObject->{$rule->getSubCategory()}); + if ($matcher) { + $jsonObject->{$rule->getSubCategory()} = $matcher; + $body->setContents($jsonObject); + } + } else { + throw new IntegrationJsonFormatException("Integration JSON format doesn't support non-JSON format"); + } + break; + + default: + break; + } + } + } +} diff --git a/compatibility-suite/tests/Service/ResponseMatchingRuleBuilderInterface.php b/compatibility-suite/tests/Service/ResponseMatchingRuleBuilderInterface.php new file mode 100644 index 00000000..37ed1614 --- /dev/null +++ b/compatibility-suite/tests/Service/ResponseMatchingRuleBuilderInterface.php @@ -0,0 +1,10 @@ +pactPath = new PactPath(sprintf('server_specification_%s', $specificationVersion)); + $this->config = new MockServerConfig(); + $this->config + ->setConsumer($this->pactPath->getConsumer()) + ->setProvider(PactPath::PROVIDER) + ->setPactDir(Path::PACTS_PATH) + ->setPactSpecificationVersion($specificationVersion) + ->setPactFileWriteMode(PactConfigInterface::MODE_OVERWRITE); + + $this->driver = (new InteractionDriverFactory())->create($this->config); + } + + public function register(int ...$ids): void + { + $interactions = array_map(fn (int $id) => $this->storage->get(InteractionsStorageInterface::SERVER_DOMAIN, $id), $ids); + foreach ($interactions as $index => $interaction) { + $startMockServer = $index === count($interactions) - 1; + $this->driver->registerInteraction($interaction, $startMockServer); + } + } + + public function getBaseUri(): UriInterface + { + return $this->config->getBaseUri(); + } + + public function verify(): void + { + $result = $this->driver->verifyInteractions(); + $this->verifyResult = new VerifyResult($result->matched, $result->mismatches); + } + + public function getVerifyResult(): VerifyResult + { + if (!isset($this->verifyResult)) { + $this->verify(); + } + + return $this->verifyResult; + } + + public function getPactPath(): PactPath + { + return $this->pactPath; + } + + public function getPort(): int + { + return $this->config->getPort(); + } +} diff --git a/compatibility-suite/tests/Service/ServerInterface.php b/compatibility-suite/tests/Service/ServerInterface.php new file mode 100644 index 00000000..3ba56290 --- /dev/null +++ b/compatibility-suite/tests/Service/ServerInterface.php @@ -0,0 +1,22 @@ +setConsumer($pactPath->getConsumer()) + ->setProvider(PactPath::PROVIDER) + ->setPactDir(Path::PACTS_PATH) + ->setPactSpecificationVersion($this->specificationVersion) + ->setPactFileWriteMode($mode); + $driver = (new SyncMessageDriverFactory())->create($config); + + $driver->registerMessage($message); + $driver->writePactAndCleanUp(); + } +} diff --git a/compatibility-suite/tests/Service/SyncMessagePactWriterInterface.php b/compatibility-suite/tests/Service/SyncMessagePactWriterInterface.php new file mode 100644 index 00000000..8ef29920 --- /dev/null +++ b/compatibility-suite/tests/Service/SyncMessagePactWriterInterface.php @@ -0,0 +1,12 @@ +services[$id]); + } + + public function get(string $id) + { + if (!$this->has($id)) { + throw new ServiceNotFoundException( + sprintf('Service `%s` not found.', $id), + $id + ); + } + + return $this->services[$id]; + } + + protected function set(string $id, mixed $service): void + { + $this->services[$id] = $service; + } + + abstract protected function getSpecification(): string; +} diff --git a/compatibility-suite/tests/ServiceContainer/V1.php b/compatibility-suite/tests/ServiceContainer/V1.php new file mode 100644 index 00000000..01003ecf --- /dev/null +++ b/compatibility-suite/tests/ServiceContainer/V1.php @@ -0,0 +1,53 @@ +set('specification', $this->getSpecification()); + $this->set('interactions_storage', new InteractionsStorage()); + $this->set('provider_state_server', new ProviderStateServer()); + $this->set('matching_rule_converter', new MatchingRuleConverter()); + $this->set('matching_rules_storage', new MatchingRulesStorage()); + $this->set('http_client', new HttpClient()); + $this->set('provider_verifier', new ProviderVerifier()); + $this->set('fixture_loader', new FixtureLoader()); + $this->set('parser', new Parser($this->get('fixture_loader'), $this->getSpecification())); + $this->set('pact_broker', new PactBroker($this->getSpecification())); + $this->set('matching_rule_parser', new MatchingRuleParser($this->get('matching_rule_converter'), $this->get('fixture_loader'))); + $this->set('server', new Server($this->getSpecification(), $this->get('interactions_storage'))); + $this->set('request_builder', new RequestBuilder($this->get('parser'))); + $this->set('response_builder', new ResponseBuilder($this->get('parser'))); + $this->set('request_matching_rule_builder', new RequestMatchingRuleBuilder($this->get('matching_rule_parser'), $this->get('matching_rule_converter'))); + $this->set('response_matching_rule_builder', new ResponseMatchingRuleBuilder($this->get('matching_rule_parser'), $this->get('matching_rule_converter'))); + $this->set('interaction_builder', new InteractionBuilder($this->get('request_builder'), $this->get('response_builder'))); + $this->set('client', new Client($this->get('server'), $this->get('interactions_storage'), $this->get('http_client'))); + $this->set('pact_writer', new PactWriter($this->get('interactions_storage'), $this->getSpecification())); + } + + protected function getSpecification(): string + { + return '1.0.0'; + } +} diff --git a/compatibility-suite/tests/ServiceContainer/V2.php b/compatibility-suite/tests/ServiceContainer/V2.php new file mode 100644 index 00000000..a4107cc7 --- /dev/null +++ b/compatibility-suite/tests/ServiceContainer/V2.php @@ -0,0 +1,11 @@ +set('generator_parser', new GeneratorParser($this->get('fixture_loader'))); + $this->set('generator_converter', new GeneratorConverter()); + $this->set('generator_server', new GeneratorServer()); + $this->set('body_storage', new BodyStorage()); + $this->set('body_validator', new BodyValidator($this->get('body_storage'))); + $this->set('message_pact_writer', new MessagePactWriter($this->get('parser'), $this->getSpecification())); + $this->set('request_generator_builder', new RequestGeneratorBuilder($this->get('generator_parser'), $this->get('generator_converter'))); + $this->set('response_generator_builder', new ResponseGeneratorBuilder($this->get('generator_parser'), $this->get('generator_converter'))); + $this->set('message_generator_builder', new MessageGeneratorBuilder($this->get('generator_parser'), $this->get('generator_converter'))); + } + + protected function getSpecification(): string + { + return '3.0.0'; + } +} diff --git a/compatibility-suite/tests/ServiceContainer/V4.php b/compatibility-suite/tests/ServiceContainer/V4.php new file mode 100644 index 00000000..39089c6f --- /dev/null +++ b/compatibility-suite/tests/ServiceContainer/V4.php @@ -0,0 +1,19 @@ +set('sync_message_pact_writer', new SyncMessagePactWriter($this->getSpecification())); + } + + protected function getSpecification(): string + { + return '4.0.0'; + } +} diff --git a/composer.json b/composer.json index 922d93ae..cd0bd71e 100644 --- a/composer.json +++ b/composer.json @@ -18,34 +18,28 @@ } ], "require": { - "php": "^8.0", + "php": "^8.1", "ext-openssl": "*", + "ext-ffi": "*", "ext-json": "*", "composer/semver": "^1.4.0|^3.2.0", - "amphp/amp": "^2.5.1", - "amphp/byte-stream": "^1.8", - "amphp/dns": "^1.2.3", - "amphp/hpack": "^3.1.0", - "amphp/http-server": "^2.1", - "amphp/log": "^1.1", - "amphp/process": "^1.1.1", - "amphp/serialization": "^1.0", - "amphp/socket": "^1.1.3", - "amphp/sync": "^1.4.0", - "amphp/cache": "^1.4.0", - "amphp/windows-registry": "v0.3.3", - "guzzlehttp/guzzle": "^6.5.8|^7.4.5", - "phpunit/phpunit": ">=8.5.23 <10", - "tienvx/composer-downloads-plugin": "^1.1.0" + "symfony/process": "^5.4|^6.0|^7.0", + "guzzlehttp/psr7": "^2.4.5", + "pact-foundation/composer-downloads-plugin": "^1.0.0" }, "require-dev": { + "ext-sockets": "*", "roave/security-advisories": "dev-latest", - "mockery/mockery": "^1.4.2", - "slim/slim": "^4.6", - "slim/psr7": "^1.2.0", + "slim/slim": "^4.13", "friendsofphp/php-cs-fixer": "^3.0", "php-amqplib/php-amqplib": "^3.0", - "phpstan/phpstan": "^1.9" + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^10.1|^11", + "guzzlehttp/guzzle": "^7.8", + "behat/behat": "^3.13", + "galbar/jsonpath": "^3.0", + "ramsey/uuid": "^4.7", + "pact-foundation/example-protobuf-sync-message-provider": "@dev" }, "autoload": { "psr-4": { @@ -55,44 +49,111 @@ "autoload-dev": { "psr-4": { "PhpPactTest\\": "tests/PhpPact", - "Consumer\\": [ - "example/src/Consumer", - "example/tests/Consumer" + "PhpPactTest\\CompatibilitySuite\\": "compatibility-suite/tests", + "JsonConsumer\\": "example/json/consumer/src", + "JsonConsumer\\Tests\\": "example/json/consumer/tests", + "JsonProvider\\": "example/json/provider/src", + "JsonProvider\\Tests\\": "example/json/provider/tests", + "MessageConsumer\\": "example/message/consumer/src", + "MessageConsumer\\Tests\\": "example/message/consumer/tests", + "MessageProvider\\": "example/message/provider/src", + "MessageProvider\\Tests\\": "example/message/provider/tests", + "BinaryConsumer\\": "example/binary/consumer/src", + "BinaryConsumer\\Tests\\": "example/binary/consumer/tests", + "BinaryProvider\\": "example/binary/provider/src", + "BinaryProvider\\Tests\\": "example/binary/provider/tests", + "MultipartConsumer\\": "example/multipart/consumer/src", + "MultipartConsumer\\Tests\\": "example/multipart/consumer/tests", + "MultipartProvider\\": "example/multipart/provider/src", + "MultipartProvider\\Tests\\": "example/multipart/provider/tests", + "XmlConsumer\\": "example/xml/consumer/src", + "XmlConsumer\\Tests\\": "example/xml/consumer/tests", + "XmlProvider\\": "example/xml/provider/src", + "XmlProvider\\Tests\\": "example/xml/provider/tests", + "MatchersConsumer\\": "example/matchers/consumer/src", + "MatchersConsumer\\Tests\\": "example/matchers/consumer/tests", + "MatchersProvider\\Tests\\": "example/matchers/provider/tests", + "GeneratorsConsumer\\": "example/generators/consumer/src", + "GeneratorsConsumer\\Tests\\": "example/generators/consumer/tests", + "GeneratorsProvider\\Tests\\": "example/generators/provider/tests", + "CsvConsumer\\": "example/csv/consumer/src", + "CsvConsumer\\Tests\\": "example/csv/consumer/tests", + "CsvProvider\\": "example/csv/provider/src", + "CsvProvider\\Tests\\": "example/csv/provider/tests", + "": [ + "example/protobuf-sync-message/library/src", + "example/protobuf-async-message/library/src" ], - "MessageConsumer\\": [ - "example/src/MessageConsumer", - "example/tests/MessageConsumer" - ], - "MessageProvider\\": [ - "example/src/MessageProvider", - "example/tests/MessageProvider" - ] + "ProtobufSyncMessageConsumer\\": "example/protobuf-sync-message/consumer/src", + "ProtobufSyncMessageConsumer\\Tests\\": "example/protobuf-sync-message/consumer/tests", + "ProtobufSyncMessageProvider\\": "example/protobuf-sync-message/provider/src", + "ProtobufSyncMessageProvider\\Tests\\": "example/protobuf-sync-message/provider/tests", + "ProtobufAsyncMessageConsumer\\": "example/protobuf-async-message/consumer/src", + "ProtobufAsyncMessageConsumer\\Tests\\": "example/protobuf-async-message/consumer/tests", + "ProtobufAsyncMessageProvider\\Tests\\": "example/protobuf-async-message/provider/tests", + "StubServerConsumer\\": "example/stub-server/consumer/src", + "StubServerConsumer\\Tests\\": "example/stub-server/consumer/tests" } }, "scripts": { - "start-provider": "php -S localhost:58000 -t example/src/Provider/public/", - "static-code-analysis": "phpstan analyse src/ --level=7", - "lint": "php-cs-fixer fix --config .php-cs-fixer.php --dry-run", - "fix": "php-cs-fixer fix --config .php-cs-fixer.php", - "test": "phpunit --debug -c example/phpunit.all.xml" + "start-provider": "echo 'removed in 10.x' & exit 1", + "static-code-analysis": "phpstan", + "lint": "php-cs-fixer fix --dry-run", + "fix": "php-cs-fixer fix", + "test": [ + "php -r \"array_map('unlink', glob('./example/*/pacts/*.json'));\" --", + "phpunit --no-coverage" + ], + "gen-lib": [ + "protoc --php_out=example/protobuf-sync-message/library/src example/protobuf-sync-message/library/proto/area_calculator.proto", + "protoc --php_out=example/protobuf-async-message/library/src example/protobuf-async-message/library/proto/say_hello.proto" + ], + "check-compatibility": "behat" }, "extra": { "downloads": { - "pact-ruby-standalone": { - "version": "2.4.1", + "pact-ffi-headers": { + "version": "0.4.18", + "url": "https://github.com/pact-foundation/pact-reference/releases/download/libpact_ffi-v{$version}/pact.h", + "path": "bin/pact-ffi-headers/pact.h" + }, + "pact-ffi-lib": { + "version": "0.4.18", + "variables": { + "{$prefix}": "PHP_OS_FAMILY === 'Windows' ? 'pact_ffi' : 'libpact_ffi'", + "{$os}": "PHP_OS === 'Darwin' ? 'osx' : strtolower(PHP_OS_FAMILY)", + "{$architecture}": "in_array(php_uname('m'), ['arm64', 'aarch64']) ? (PHP_OS === 'Darwin' ? 'aarch64-apple-darwin' : 'aarch64') : 'x86_64'", + "{$extension}": "PHP_OS_FAMILY === 'Windows' ? 'dll' : (PHP_OS === 'Darwin' ? 'dylib' : 'so')" + }, + "url": "https://github.com/pact-foundation/pact-reference/releases/download/libpact_ffi-v{$version}/{$prefix}-{$os}-{$architecture}.{$extension}.gz", + "path": "bin/pact-ffi-lib/pact.{$extension}" + }, + "pact-stub-server": { + "version": "0.5.3", "variables": { - "{$os}": "PHP_OS_FAMILY === 'Windows' ? 'windows' : (PHP_OS === 'Darwin' ? 'osx' : 'linux')", - "{$architecture}": "strtolower(php_uname('m')) === 'arm64' || strtolower(php_uname('m')) === 'aarch64' ? '-arm64' : (strtolower(php_uname('m')) === 'x86' && PHP_OS_FAMILY === 'Windows' ? '-x86' : '-x86_64')", - "{$extension}": "PHP_OS_FAMILY === 'Windows' ? 'zip' : 'tar.gz'" + "{$os}": "PHP_OS === 'Darwin' ? 'osx' : strtolower(PHP_OS_FAMILY)", + "{$architecture}": "in_array(php_uname('m'), ['arm64', 'aarch64']) ? 'aarch64' : 'x86_64'", + "{$extension}": "PHP_OS_FAMILY === 'Windows' ? '.exe' : ''" }, - "url": "https://github.com/pact-foundation/pact-ruby-standalone/releases/download/v{$version}/pact-{$version}-{$os}{$architecture}.{$extension}", - "path": "bin/pact-ruby-standalone" + "url": "https://github.com/pact-foundation/pact-stub-server/releases/download/v{$version}/pact-stub-server-{$os}-{$architecture}{$extension}.gz", + "path": "bin/pact-stub-server/pact-stub-server{$extension}", + "executable": true } } }, "config": { "allow-plugins": { - "tienvx/composer-downloads-plugin": true + "pact-foundation/composer-downloads-plugin": true + } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/pact-foundation/composer-downloads-plugin" + }, + { + "type": "path", + "url": "example/protobuf-sync-message/provider" } - } + ] } diff --git a/example/README.md b/example/README.md deleted file mode 100644 index bb79b88e..00000000 --- a/example/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Pact PHP Usage examples - -This folder contains some integration tests which demonstrate the functionality of `pact-php`. -All examples could be run within tests. - -## Consumer Tests - - docker-compose up -d - vendor/bin/phpunit -c example/phpunit.consumer.xml - docker-compose down - -## Provider Verification Tests - - vendor/bin/phpunit -c example/phpunit.provider.xml - -## Consumer Tests for Message Processing - - vendor/bin/phpunit -c example/phpunit.message.consumer.xml - -## Provider Verification Tests for Message Processing - - vendor/bin/phpunit -c example/phpunit.message.provider.xml - -## All tests together - - docker-compose up -d - vendor/bin/phpunit -c example/phpunit.all.xml - docker-compose down diff --git a/example/binary/consumer/phpunit.xml b/example/binary/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/binary/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/binary/consumer/src/Service/HttpClientService.php b/example/binary/consumer/src/Service/HttpClientService.php new file mode 100644 index 00000000..3f8d22a7 --- /dev/null +++ b/example/binary/consumer/src/Service/HttpClientService.php @@ -0,0 +1,31 @@ +httpClient = new Client(); + $this->baseUri = $baseUri; + } + + public function getImageContent(): string + { + $response = $this->httpClient->get(new Uri("{$this->baseUri}/image.jpg"), [ + 'headers' => ['Accept' => 'image/jpeg'] + ]); + + return $response->getBody(); + } +} diff --git a/example/binary/consumer/tests/Service/HttpClientServiceTest.php b/example/binary/consumer/tests/Service/HttpClientServiceTest.php new file mode 100644 index 00000000..40b3e22e --- /dev/null +++ b/example/binary/consumer/tests/Service/HttpClientServiceTest.php @@ -0,0 +1,53 @@ +setMethod('GET') + ->setPath('/image.jpg') + ->addHeader('Accept', 'image/jpeg'); + + $response = new ProviderResponse(); + $response + ->setStatus(200) + ->addHeader('Content-Type', 'image/jpeg') + ->setBody(new Binary($path, in_array(php_uname('m'), ['AMD64', 'arm64', 'aarch64']) ? 'application/octet-stream' : 'image/jpeg')); + + $config = new MockServerConfig(); + $config + ->setConsumer('binaryConsumer') + ->setProvider('binaryProvider') + ->setPactDir(__DIR__.'/../../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $builder = new InteractionBuilder($config); + $builder + ->given('Image file image.jpg exists') + ->uponReceiving('A get request to /image.jpg') + ->with($request) + ->willRespondWith($response); + + $service = new HttpClientService($config->getBaseUri()); + $imageContentResult = $service->getImageContent(); + $verifyResult = $builder->verify(); + + $this->assertTrue($verifyResult); + $this->assertEquals(file_get_contents($path), $imageContentResult); + } +} diff --git a/example/binary/consumer/tests/_resource/image.jpg b/example/binary/consumer/tests/_resource/image.jpg new file mode 100644 index 00000000..6e93db1c Binary files /dev/null and b/example/binary/consumer/tests/_resource/image.jpg differ diff --git a/example/binary/pacts/binaryConsumer-binaryProvider.json b/example/binary/pacts/binaryConsumer-binaryProvider.json new file mode 100644 index 00000000..7480e0e4 --- /dev/null +++ b/example/binary/pacts/binaryConsumer-binaryProvider.json @@ -0,0 +1,57 @@ +{ + "consumer": { + "name": "binaryConsumer" + }, + "interactions": [ + { + "description": "A get request to /image.jpg", + "providerStates": [ + { + "name": "Image file image.jpg exists" + } + ], + "request": { + "headers": { + "Accept": "image/jpeg" + }, + "method": "GET", + "path": "/image.jpg" + }, + "response": { + "body": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/wAALCAAPAA8BAREA/8QAFgABAQEAAAAAAAAAAAAAAAAAAgME/8QAIBAAAgIDAAIDAQAAAAAAAAAAAQIDBAUREhMhACIxUf/aAAgBAQAAPwBNlclNW6S0s7wbvv4K6iNI2HJcp9Rrnk+T0ByNofwZIjdnyYx0stWrYWAP5y03SBjt+GQ/YnmHbaUkfoA107Br40Wa+DrRLNXhjchECDwyjjlXPpdFSo3Gw5CbDFdmUVOrHcfEU4JYb2PWSJCzbVPUEmh7PQaOZHOyD0x/hHz/2Q==", + "headers": { + "Content-Type": "image/jpeg" + }, + "matchingRules": { + "body": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "image/jpeg" + } + ] + } + }, + "header": {}, + "status": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "mockserver": "1.2.5", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "binaryProvider" + } +} \ No newline at end of file diff --git a/example/binary/provider/phpunit.xml b/example/binary/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/binary/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/binary/provider/public/image.jpg b/example/binary/provider/public/image.jpg new file mode 100644 index 00000000..5477a6da Binary files /dev/null and b/example/binary/provider/public/image.jpg differ diff --git a/example/binary/provider/tests/PactVerifyTest.php b/example/binary/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..5d2516bb --- /dev/null +++ b/example/binary/provider/tests/PactVerifyTest.php @@ -0,0 +1,46 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + /** + * This test will run after the web server is started. + */ + public function testPactVerifyConsumer() + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('binaryProvider') // Providers name to fetch. + ->setHost('localhost') + ->setPort($this->process->getPort()); + if ($level = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($level); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/binaryConsumer-binaryProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/example/csv/consumer/phpunit.xml b/example/csv/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/csv/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/csv/consumer/src/Service/HttpClientService.php b/example/csv/consumer/src/Service/HttpClientService.php new file mode 100644 index 00000000..8052090f --- /dev/null +++ b/example/csv/consumer/src/Service/HttpClientService.php @@ -0,0 +1,28 @@ +httpClient = new Client(); + $this->baseUri = $baseUri; + } + + public function getReport(): array + { + $response = $this->httpClient->get(new Uri("{$this->baseUri}/report.csv"), [ + 'headers' => ['Accept' => 'text/csv'] + ]); + + return str_getcsv($response->getBody()); + } +} diff --git a/example/csv/consumer/tests/Service/HttpClientServiceTest.php b/example/csv/consumer/tests/Service/HttpClientServiceTest.php new file mode 100644 index 00000000..153fa029 --- /dev/null +++ b/example/csv/consumer/tests/Service/HttpClientServiceTest.php @@ -0,0 +1,68 @@ +setMethod('GET') + ->setPath('/report.csv') + ->addHeader('Accept', 'text/csv') + ; + + $response = new ProviderResponse(); + $response + ->setStatus(200) + ->setBody(new Text( + json_encode([ + 'csvHeaders' => false, + 'column:1' => $matcher->like('Name'), + 'column:2' => $matcher->number(100), + 'column:3' => $matcher->datetime('yyyy-MM-dd', '2000-01-01'), + ]), + 'text/csv' + )) + ; + + $config = new MockServerConfig(); + $config + ->setConsumer('csvConsumer') + ->setProvider('csvProvider') + ->setPactSpecificationVersion('4.0.0') + ->setPactDir(__DIR__.'/../../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $builder = new InteractionBuilder($config, new CsvInteractionDriverFactory(InteractionPart::RESPONSE)); + $builder + ->given('report.csv file exist') + ->uponReceiving('request for a report.csv') + ->with($request) + ->willRespondWith($response) + ; + + $service = new HttpClientService($config->getBaseUri()); + $columns = $service->getReport(); + + $this->assertTrue($builder->verify()); + $this->assertCount(3, $columns); + $this->assertSame(['Name', '100', '2000-01-01'], $columns); + } +} diff --git a/example/csv/pacts/csvConsumer-csvProvider.json b/example/csv/pacts/csvConsumer-csvProvider.json new file mode 100644 index 00000000..a554343f --- /dev/null +++ b/example/csv/pacts/csvConsumer-csvProvider.json @@ -0,0 +1,108 @@ +{ + "consumer": { + "name": "csvConsumer" + }, + "interactions": [ + { + "description": "request for a report.csv", + "interactionMarkup": { + "markup": "# Data\n\n|Name|100|2000-01-01|\n", + "markupType": "COMMON_MARK" + }, + "pending": false, + "pluginConfiguration": { + "csv": { + "csvHeaders": false + } + }, + "providerStates": [ + { + "name": "report.csv file exist" + } + ], + "request": { + "headers": { + "Accept": [ + "text/csv" + ] + }, + "method": "GET", + "path": "/report.csv" + }, + "response": { + "body": { + "content": "Name,100,2000-01-01\n", + "contentType": "text/csv;charset=utf-8", + "contentTypeHint": "DEFAULT", + "encoded": false + }, + "generators": { + "body": { + "column:3": { + "format": "yyyy-MM-dd", + "type": "DateTime" + } + } + }, + "headers": { + "content-type": [ + "text/csv" + ] + }, + "matchingRules": { + "body": { + "column:1": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "column:2": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "column:3": { + "combine": "AND", + "matchers": [ + { + "format": "yyyy-MM-dd", + "match": "datetime" + } + ] + } + }, + "status": {} + }, + "status": 200 + }, + "transport": "http", + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "mockserver": "1.2.5", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": {}, + "name": "csv", + "version": "0.0.5" + } + ] + }, + "provider": { + "name": "csvProvider" + } +} \ No newline at end of file diff --git a/example/csv/provider/phpunit.xml b/example/csv/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/csv/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/csv/provider/public/index.php b/example/csv/provider/public/index.php new file mode 100644 index 00000000..bfebea0d --- /dev/null +++ b/example/csv/provider/public/index.php @@ -0,0 +1,15 @@ +post('/pact-change-state', function (Request $request, Response $response) { + return $response; +}); + +$app->run(); diff --git a/example/csv/provider/public/report.csv b/example/csv/provider/public/report.csv new file mode 100644 index 00000000..17364fa4 --- /dev/null +++ b/example/csv/provider/public/report.csv @@ -0,0 +1,11 @@ +"Mack Greenholt Jr.",943,2003-02-05 +"Luigi Ruecker",654,1980-09-11 +"Mr. Unique Zieme",79,2020-04-30 +"Leif Price",367,2013-04-07 +"Destiny Rodriguez",995,1983-03-20 +"Lavinia Carroll",344,2010-08-01 +"Reta Schoen",113,2001-01-26 +"Mrs. Carolina Swift",494,1982-01-20 +"Gene Crona PhD",852,1992-11-16 +"Dr. Santino Koepp",79,1984-05-05 +"Luigi Sanford",969,1995-07-02 diff --git a/example/csv/provider/tests/PactVerifyTest.php b/example/csv/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..0089941e --- /dev/null +++ b/example/csv/provider/tests/PactVerifyTest.php @@ -0,0 +1,47 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + public function testPactVerifyConsumer(): void + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('csvProvider') + ->setHost('localhost') + ->setPort($this->process->getPort()); + $config->getProviderState() + ->setStateChangeUrl(new Uri(sprintf('http://localhost:%d/pact-change-state', $this->process->getPort()))) + ; + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/csvConsumer-csvProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/example/generators/consumer/phpunit.xml b/example/generators/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/generators/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/generators/consumer/src/Service/HttpClientService.php b/example/generators/consumer/src/Service/HttpClientService.php new file mode 100644 index 00000000..bebbdf37 --- /dev/null +++ b/example/generators/consumer/src/Service/HttpClientService.php @@ -0,0 +1,28 @@ +httpClient = new Client(); + $this->baseUri = $baseUri; + } + + public function sendRequest(): ResponseInterface + { + return $this->httpClient->get("{$this->baseUri}/generators", [ + 'headers' => ['Accept' => 'application/json'], + 'json' => ['id' => 112], + 'http_errors' => false, + ]); + } +} diff --git a/example/generators/consumer/tests/Service/GeneratorsTest.php b/example/generators/consumer/tests/Service/GeneratorsTest.php new file mode 100644 index 00000000..e166208f --- /dev/null +++ b/example/generators/consumer/tests/Service/GeneratorsTest.php @@ -0,0 +1,110 @@ +matcher = new Matcher(); + } + + public function testGetGenerators(): void + { + $request = new ConsumerRequest(); + $request + ->setMethod('GET') + ->setPath('/generators') + ->addHeader('Accept', 'application/json') + ->setBody([ + 'id' => $this->matcher->fromProviderState($this->matcher->integerV3(), '${id}') + ]); + + $response = new ProviderResponse(); + $response + ->setStatus($this->matcher->statusCode(HttpStatus::CLIENT_ERROR)) + ->addHeader('Content-Type', 'application/json') + ->setBody([ + 'regex' => $this->matcher->regex(null, $regexWithoutAnchors = '\d+ (miles|kilometers)'), + 'boolean' => $this->matcher->booleanV3(null), + 'integer' => $this->matcher->integerV3(null), + 'decimal' => $this->matcher->decimalV3(null), + 'hexadecimal' => $this->matcher->hexadecimal(null), + 'uuid' => $this->matcher->uuid(null), + 'date' => $this->matcher->date('yyyy-MM-dd', null), + 'time' => $this->matcher->time('HH:mm:ss', null), + 'datetime' => $this->matcher->datetime("yyyy-MM-dd'T'HH:mm:ss", null), + 'string' => $this->matcher->string(null), + 'number' => $this->matcher->number(null), + 'url' => $this->matcher->url('http://localhost/users/1234/posts/latest', '.*(\\/users\\/\\d+\\/posts\\/latest)$'), + 'requestId' => 222, + ]); + + $config = new MockServerConfig(); + $config + ->setConsumer('generatorsConsumer') + ->setProvider('generatorsProvider') + ->setPactDir(__DIR__.'/../../../pacts') + ->setPactSpecificationVersion('4.0.0'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $builder = new InteractionBuilder($config); + $builder + ->given('Get Generators') + ->uponReceiving('A get request to /generators') + ->with($request) + ->willRespondWith($response); + + $service = new HttpClientService($config->getBaseUri()); + $response = $service->sendRequest(); + $verifyResult = $builder->verify(); + + $statusCode = $response->getStatusCode(); + $body = \json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertTrue($verifyResult); + $this->assertThat( + $statusCode, + $this->logicalAnd( + $this->greaterThanOrEqual(400), + $this->lessThanOrEqual(499) + ) + ); + $this->assertMatchesRegularExpression('/^' . $regexWithoutAnchors . '$/', $body['regex']); + $this->assertIsBool($body['boolean']); + $this->assertIsInt($body['integer']); + $this->assertIsFloat($body['decimal'] + 0); + $this->assertMatchesRegularExpression('/' . Matcher::HEX_FORMAT . '/', $body['hexadecimal']); + $this->assertMatchesRegularExpression('/' . Matcher::UUID_V4_FORMAT . '/', $body['uuid']); + $this->assertTrue($this->validateDateTime($body['date'], 'Y-m-d')); + $this->assertTrue($this->validateDateTime($body['time'], 'H:i:s')); + $this->assertTrue($this->validateDateTime($body['datetime'], "Y-m-d\TH:i:s")); + $this->assertIsString($body['string']); + $this->assertNotSame(StringValue::DEFAULT_VALUE, $body['string']); + $this->assertIsNumeric($body['number']); + $this->assertNotSame('http://localhost/users/1234/posts/latest', $body['url']); + $this->assertMatchesRegularExpression('/.*(\\/users\\/\\d+\\/posts\\/latest)$/', $body['url']); + $this->assertSame(222, $body['requestId']); + } + + private function validateDateTime(string $datetime, string $format): bool + { + $value = DateTime::createFromFormat($format, $datetime); + + return $value && $value->format($format) === $datetime; + } +} diff --git a/example/generators/pacts/generatorsConsumer-generatorsProvider.json b/example/generators/pacts/generatorsConsumer-generatorsProvider.json new file mode 100644 index 00000000..9c9b83d3 --- /dev/null +++ b/example/generators/pacts/generatorsConsumer-generatorsProvider.json @@ -0,0 +1,275 @@ +{ + "consumer": { + "name": "generatorsConsumer" + }, + "interactions": [ + { + "description": "A get request to /generators", + "pending": false, + "providerStates": [ + { + "name": "Get Generators" + } + ], + "request": { + "body": { + "content": { + "id": null + }, + "contentType": "application/json", + "encoded": false + }, + "generators": { + "body": { + "$.id": { + "expression": "${id}", + "type": "ProviderState" + } + } + }, + "headers": { + "Accept": [ + "application/json" + ], + "Content-Type": [ + "application/json" + ] + }, + "matchingRules": { + "body": { + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + } + }, + "header": {} + }, + "method": "GET", + "path": "/generators" + }, + "response": { + "body": { + "content": { + "boolean": null, + "date": null, + "datetime": null, + "decimal": null, + "hexadecimal": null, + "integer": null, + "number": null, + "regex": null, + "requestId": 222, + "string": "some string", + "time": null, + "url": null, + "uuid": null + }, + "contentType": "application/json", + "encoded": false + }, + "generators": { + "body": { + "$.boolean": { + "type": "RandomBoolean" + }, + "$.date": { + "format": "yyyy-MM-dd", + "type": "Date" + }, + "$.datetime": { + "format": "yyyy-MM-dd'T'HH:mm:ss", + "type": "DateTime" + }, + "$.decimal": { + "digits": 10, + "type": "RandomDecimal" + }, + "$.hexadecimal": { + "digits": 10, + "type": "RandomHexadecimal" + }, + "$.integer": { + "max": 10, + "min": 0, + "type": "RandomInt" + }, + "$.number": { + "max": 10, + "min": 0, + "type": "RandomInt" + }, + "$.regex": { + "regex": "\\d+ (miles|kilometers)", + "type": "Regex" + }, + "$.string": { + "size": 10, + "type": "RandomString" + }, + "$.time": { + "format": "HH:mm:ss", + "type": "Time" + }, + "$.url": { + "example": "http://localhost/users/1234/posts/latest", + "regex": ".*(\\/users\\/\\d+\\/posts\\/latest)$", + "type": "MockServerURL" + }, + "$.uuid": { + "type": "Uuid" + } + }, + "status": { + "max": 499, + "min": 400, + "type": "RandomInt" + } + }, + "headers": { + "Content-Type": [ + "application/json" + ] + }, + "matchingRules": { + "body": { + "$.boolean": { + "combine": "AND", + "matchers": [ + { + "match": "boolean" + } + ] + }, + "$.date": { + "combine": "AND", + "matchers": [ + { + "format": "yyyy-MM-dd", + "match": "date" + } + ] + }, + "$.datetime": { + "combine": "AND", + "matchers": [ + { + "format": "yyyy-MM-dd'T'HH:mm:ss", + "match": "datetime" + } + ] + }, + "$.decimal": { + "combine": "AND", + "matchers": [ + { + "match": "decimal" + } + ] + }, + "$.hexadecimal": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-fA-F]+$" + } + ] + }, + "$.integer": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + }, + "$.number": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.regex": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "\\d+ (miles|kilometers)" + } + ] + }, + "$.string": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.time": { + "combine": "AND", + "matchers": [ + { + "format": "HH:mm:ss", + "match": "time" + } + ] + }, + "$.url": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": ".*(\\/users\\/\\d+\\/posts\\/latest)$" + } + ] + }, + "$.uuid": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + }, + "header": {}, + "status": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "statusCode", + "status": "clientError" + } + ] + } + } + }, + "status": 0 + }, + "transport": "http", + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "mockserver": "1.2.5", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "generatorsProvider" + } +} \ No newline at end of file diff --git a/example/generators/provider/phpunit.xml b/example/generators/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/generators/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/generators/provider/public/index.php b/example/generators/provider/public/index.php new file mode 100644 index 00000000..f0185d04 --- /dev/null +++ b/example/generators/provider/public/index.php @@ -0,0 +1,45 @@ +addBodyParsingMiddleware(); + +$app->get('/generators', function (Request $request, Response $response) { + $body = $request->getParsedBody(); + $response->getBody()->write(\json_encode([ + 'regex' => '800 kilometers', + 'boolean' => true, + 'integer' => 11, + 'decimal' => 25.1, + 'hexadecimal' => '20AC', + 'uuid' => 'e9d2f3a5-6ecc-4bff-8935-84bb6141325a', + 'date' => '1997-12-11', + 'time' => '11:01:02', + 'datetime' => '1997-07-16T19:20:30', + 'string' => 'another string', + 'number' => 112.3, + 'url' => 'https://www.example.com/users/1234/posts/latest', + 'requestId' => $body['id'], + ])); + + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus(400); +}); + +$app->post('/pact-change-state', function (Request $request, Response $response) { + $response->getBody()->write(\json_encode([ + 'id' => 222, + ])); + + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus(200); +}); + +$app->run(); diff --git a/example/generators/provider/tests/PactVerifyTest.php b/example/generators/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..e1648885 --- /dev/null +++ b/example/generators/provider/tests/PactVerifyTest.php @@ -0,0 +1,47 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + public function testPactVerifyConsumer() + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('generatorsProvider') + ->setHost('localhost') + ->setPort($this->process->getPort()); + $config->getProviderState() + ->setStateChangeUrl(new Uri(sprintf('http://localhost:%d/pact-change-state', $this->process->getPort()))) + ; + if ($level = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($level); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/generatorsConsumer-generatorsProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/example/json/consumer/phpunit.xml b/example/json/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/json/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/src/Consumer/Service/HttpClientService.php b/example/json/consumer/src/Service/HttpClientService.php similarity index 97% rename from example/src/Consumer/Service/HttpClientService.php rename to example/json/consumer/src/Service/HttpClientService.php index 85ab9c53..93831973 100644 --- a/example/src/Consumer/Service/HttpClientService.php +++ b/example/json/consumer/src/Service/HttpClientService.php @@ -1,6 +1,6 @@ 'Goodbye, Bob' ]); - $config = new MockServerEnvConfig(); - $builder = new InteractionBuilder($config); + $config = new MockServerConfig(); + $config + ->setConsumer('jsonConsumer') + ->setProvider('jsonProvider') + ->setPactDir(__DIR__.'/../../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $builder = new InteractionBuilder($config); $builder ->given('Get Goodbye') ->uponReceiving('A get request to /goodbye/{name}') @@ -39,10 +43,10 @@ public function testGetGoodbyeString() ->willRespondWith($response); $service = new HttpClientService($config->getBaseUri()); - $result = $service->getGoodbyeString('Bob'); + $goodbyeResult = $service->getGoodbyeString('Bob'); + $verifyResult = $builder->verify(); - $builder->verify(); - - $this->assertEquals('Goodbye, Bob', $result); + $this->assertTrue($verifyResult); + $this->assertEquals('Goodbye, Bob', $goodbyeResult); } } diff --git a/example/tests/Consumer/Service/ConsumerServiceHelloTest.php b/example/json/consumer/tests/Service/ConsumerServiceHelloTest.php similarity index 62% rename from example/tests/Consumer/Service/ConsumerServiceHelloTest.php rename to example/json/consumer/tests/Service/ConsumerServiceHelloTest.php index 2a80f394..605a3fb6 100644 --- a/example/tests/Consumer/Service/ConsumerServiceHelloTest.php +++ b/example/json/consumer/tests/Service/ConsumerServiceHelloTest.php @@ -1,12 +1,13 @@ setStatus(200) ->addHeader('Content-Type', 'application/json') ->setBody([ - 'message' => $matcher->term('Hello, Bob', '(Hello, )[A-Za-z]') + 'message' => $matcher->term('Hello, Bob', '(Hello, )[A-Za-z]+') ]); // Create a configuration that reflects the server that was started. You can create a custom MockServerConfigInterface if needed. - $config = new MockServerEnvConfig(); + $config = new MockServerConfig(); + $config + ->setConsumer('jsonConsumer') + ->setProvider('jsonProvider') + ->setPactDir(__DIR__.'/../../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } $builder = new InteractionBuilder($config); $builder ->uponReceiving('A get request to /hello/{name}') ->with($request) - ->willRespondWith($response); // This has to be last. This is what makes an API request to the Mock Server to set the interaction. + ->willRespondWith($response); // This has to be last. This is what makes FFI calls to register the interaction and start the mock server. $service = new HttpClientService($config->getBaseUri()); // Pass in the URL to the Mock Server. - $result = $service->getHelloString('Bob'); // Make the real API request against the Mock Server. + $helloResult = $service->getHelloString('Bob'); // Make the real API request against the Mock Server. + $verifyResult = $builder->verify(); // This will verify that the interactions took place. - $builder->verify(); // This will verify that the interactions took place. - - $this->assertEquals('Hello, Bob', $result); // Make your assertions. + $this->assertTrue($verifyResult); // Make your assertions. + $this->assertEquals('Hello, Bob', $helloResult); } } diff --git a/example/pacts/someconsumer-someprovider.json b/example/json/pacts/jsonConsumer-jsonProvider.json similarity index 53% rename from example/pacts/someconsumer-someprovider.json rename to example/json/pacts/jsonConsumer-jsonProvider.json index c502e5ba..739d22d8 100644 --- a/example/pacts/someconsumer-someprovider.json +++ b/example/json/pacts/jsonConsumer-jsonProvider.json @@ -1,62 +1,78 @@ { "consumer": { - "name": "someConsumer" - }, - "provider": { - "name": "someProvider" + "name": "jsonConsumer" }, "interactions": [ { - "description": "A get request to /goodbye/{name}", - "providerState": "Get Goodbye", + "description": "A get request to /hello/{name}", "request": { - "method": "GET", - "path": "/goodbye/Bob", "headers": { "Content-Type": "application/json" - } + }, + "method": "GET", + "path": "/hello/Bob" }, "response": { - "status": 200, + "body": { + "message": "Hello, Bob" + }, "headers": { "Content-Type": "application/json" }, - "body": { - "message": "Goodbye, Bob" - } - }, - "metadata": null + "matchingRules": { + "body": { + "$.message": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "(Hello, )[A-Za-z]+" + } + ] + } + }, + "header": {}, + "status": {} + }, + "status": 200 + } }, { - "description": "A get request to /hello/{name}", + "description": "A get request to /goodbye/{name}", + "providerStates": [ + { + "name": "Get Goodbye" + } + ], "request": { - "method": "GET", - "path": "/hello/Bob", "headers": { "Content-Type": "application/json" - } + }, + "method": "GET", + "path": "/goodbye/Bob" }, "response": { - "status": 200, + "body": { + "message": "Goodbye, Bob" + }, "headers": { "Content-Type": "application/json" }, - "body": { - "message": "Hello, Bob" - }, - "matchingRules": { - "$.body.message": { - "match": "regex", - "regex": "(Hello, )[A-Za-z]" - } - } - }, - "metadata": null + "status": 200 + } } ], "metadata": { + "pactRust": { + "ffi": "0.4.14", + "mockserver": "1.2.5", + "models": "1.1.17" + }, "pactSpecification": { - "version": "2.0.0" + "version": "3.0.0" } + }, + "provider": { + "name": "jsonProvider" } } \ No newline at end of file diff --git a/example/json/provider/phpunit.xml b/example/json/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/json/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/src/Provider/public/index.php b/example/json/provider/public/index.php similarity index 50% rename from example/src/Provider/public/index.php rename to example/json/provider/public/index.php index 2a5406ed..d4c57891 100644 --- a/example/src/Provider/public/index.php +++ b/example/json/provider/public/index.php @@ -1,5 +1,6 @@ addBodyParsingMiddleware(); -$app->get('/hello/{name}', function (Request $request, Response $response) { +$provider = new ExampleProvider(); + +$app->get('/hello/{name}', function (Request $request, Response $response) use ($provider) { $name = $request->getAttribute('name'); - $response->getBody()->write(\json_encode(['message' => "Hello, {$name}"])); + $response->getBody()->write(\json_encode(['message' => $provider->sayHello($name)])); return $response->withHeader('Content-Type', 'application/json'); }); -$app->get('/goodbye/{name}', function (Request $request, Response $response) { +$app->get('/goodbye/{name}', function (Request $request, Response $response) use ($provider) { $name = $request->getAttribute('name'); - $response->getBody()->write(\json_encode(['message' => "Goodbye, {$name}"])); + $response->getBody()->write(\json_encode(['message' => $provider->sayGoodbye($name)])); return $response->withHeader('Content-Type', 'application/json'); }); +$app->post('/pact-change-state', function (Request $request, Response $response) use ($provider) { + $body = $request->getParsedBody(); + $provider->changeSate($body['action'], $body['state'], $body['params']); + + return $response; +}); + $app->run(); diff --git a/example/json/provider/src/ExampleProvider.php b/example/json/provider/src/ExampleProvider.php new file mode 100644 index 00000000..70daec33 --- /dev/null +++ b/example/json/provider/src/ExampleProvider.php @@ -0,0 +1,27 @@ +currentState = [ + 'action' => $action, + 'state' => $state, + 'params' => $params, + ]; + } +} diff --git a/example/json/provider/tests/PactVerifyTest.php b/example/json/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..52eebcf7 --- /dev/null +++ b/example/json/provider/tests/PactVerifyTest.php @@ -0,0 +1,50 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + /** + * This test will run after the web server is started. + */ + public function testPactVerifyConsumer() + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('jsonProvider') // Providers name to fetch. + ->setHost('localhost') + ->setPort($this->process->getPort()); + $config->getProviderState() + ->setStateChangeUrl(new Uri(sprintf('http://localhost:%d/pact-change-state', $this->process->getPort()))) + ; + if ($level = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($level); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/jsonConsumer-jsonProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/example/matchers/consumer/phpunit.xml b/example/matchers/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/matchers/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/matchers/consumer/src/Service/HttpClientService.php b/example/matchers/consumer/src/Service/HttpClientService.php new file mode 100644 index 00000000..b3fb1a73 --- /dev/null +++ b/example/matchers/consumer/src/Service/HttpClientService.php @@ -0,0 +1,28 @@ +httpClient = new Client(); + $this->baseUri = $baseUri; + } + + public function sendRequest(): ResponseInterface + { + return $this->httpClient->get("{$this->baseUri}/matchers", [ + 'headers' => ['Accept' => 'application/json', 'Theme' => 'light'], + 'query' => 'pages=2&pages=3&locales[]=fr-BE&locales[]=ru-RU', + 'http_errors' => false, + ]); + } +} diff --git a/example/matchers/consumer/tests/Service/MatchersTest.php b/example/matchers/consumer/tests/Service/MatchersTest.php new file mode 100644 index 00000000..e0ceca67 --- /dev/null +++ b/example/matchers/consumer/tests/Service/MatchersTest.php @@ -0,0 +1,184 @@ +matcher = new Matcher(); + } + + public function testGetMatchers(): void + { + $request = new ConsumerRequest(); + $request + ->setMethod('GET') + ->setPath($this->matcher->regex('/matchers', '^\/matchers$')) + ->setQuery([ + 'pages' => $this->matcher->regex([1, 22], '\d+'), // arrayContains, eachKey, eachValue matchers are not working with query + 'locales[]' => $this->matcher->regex(['en-US', 'en-AU'], '^[a-z]{2}-[A-Z]{2}$'), // Use `locales[]` instead of `locales` syntax if provider use PHP language + ]) + ->addHeader('Accept', 'application/json') + ->addHeader('Theme', $this->matcher->regex('dark', 'light|dark')); // arrayContains, eachKey, eachValue matchers are not working with headers + + $response = new ProviderResponse(); + $response + ->setStatus($this->matcher->statusCode(HttpStatus::SERVER_ERROR, 512)) + ->addHeader('Content-Type', 'application/json') + ->addHeader('X-Powered-By', $this->matcher->string('PHP')) + ->setBody([ + 'like' => $this->matcher->like(['key' => 'value']), + 'likeNull' => $this->matcher->like(null), + 'eachLike' => $this->matcher->eachLike('item'), + 'atLeastLike' => $this->matcher->atLeastLike(1, 5), + 'atMostLike' => $this->matcher->atMostLike(1, 3), + 'constrainedArrayLike' => $this->matcher->constrainedArrayLike('item', 2, 4), + 'regex' => $this->matcher->regex('500 miles', '^\d+ (miles|kilometers)$'), + 'dateISO8601' => $this->matcher->dateISO8601(), + 'timeISO8601' => $this->matcher->timeISO8601(), + 'dateTimeISO8601' => $this->matcher->dateTimeISO8601(), + 'dateTimeWithMillisISO8601' => $this->matcher->dateTimeWithMillisISO8601(), + 'timestampRFC3339' => $this->matcher->timestampRFC3339(), + 'likeBool' => $this->matcher->boolean(), + 'likeInt' => $this->matcher->integer(), + 'likeDecimal' => $this->matcher->decimal(), + 'boolean' => $this->matcher->booleanV3(false), + 'integer' => $this->matcher->integerV3(9), + 'decimal' => $this->matcher->decimalV3(79.01), + 'hexadecimal' => $this->matcher->hexadecimal('F7A16'), + 'uuid' => $this->matcher->uuid('52c9585e-f345-4964-aa28-a45c64b2b2eb'), + 'ipv4Address' => $this->matcher->ipv4Address(), + 'ipv6Address' => $this->matcher->ipv6Address(), + 'email' => $this->matcher->email(), + 'nullValue' => $this->matcher->nullValue(), + 'date' => $this->matcher->date('yyyy-MM-dd', '2015-05-16'), + 'time' => $this->matcher->time('HH:mm:ss', '23:59::58'), + 'datetime' => $this->matcher->datetime("yyyy-MM-dd'T'HH:mm:ss", '2000-10-31T01:30:00'), + 'likeString' => $this->matcher->string('some string'), + 'equal' => $this->matcher->equal('exact this value'), + 'equalArray' => $this->matcher->equal([ + 'a', + 'bb', + 'ccc', + ]), + 'includes' => $this->matcher->includes('lazy dog'), + 'number' => $this->matcher->number(123), + 'arrayContaining' => $this->matcher->arrayContaining([ + 'text' => $this->matcher->string('some text'), + 'number' => $this->matcher->number(111), + 'uuid' => $this->matcher->uuid('2fbd41cc-4bbc-44ea-a419-67f767691407'), + ]), + 'notEmpty' => $this->matcher->notEmpty(['1','2','3']), + 'semver' => $this->matcher->semver('10.0.0-alpha4'), + 'contentType' => $this->matcher->contentType('text/html'), + 'eachKey' => $this->matcher->eachKey( + ['page 3' => 'example text'], + [$this->matcher->regex(null, '^page \d+$')] + ), + 'eachValue' => $this->matcher->eachValue( + ['vehicle 1' => 'car'], + [$this->matcher->regex(null, 'car|bike|motorbike')] + ), + 'url' => $this->matcher->url('http://localhost:8080/users/1234/posts/latest', '.*(\\/users\\/\\d+\\/posts\\/latest)$', false), + 'query' => [ + 'pages' => '22', + 'locales' => ['en-US', 'en-AU'], + ], + ]); + + $config = new MockServerConfig(); + $config + ->setConsumer('matchersConsumer') + ->setProvider('matchersProvider') + ->setPactDir(__DIR__.'/../../../pacts') + ->setPactSpecificationVersion('4.0.0'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $builder = new InteractionBuilder($config); + $builder + ->given('Get Matchers') + ->uponReceiving('A get request to /matchers') + ->with($request) + ->willRespondWith($response); + + $service = new HttpClientService($config->getBaseUri()); + $response = $service->sendRequest(); + $verifyResult = $builder->verify(); + + $statusCode = $response->getStatusCode(); + $body = \json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR); + + $this->assertTrue($verifyResult); + $this->assertSame(512, $statusCode); + $this->assertEquals([ + 'like' => ['key' => 'value'], + 'likeNull' => null, + 'eachLike' => ['item'], + 'atLeastLike' => [1, 1, 1, 1, 1], + 'atMostLike' => [1], + 'constrainedArrayLike' => ['item', 'item'], + 'regex' => '500 miles', + 'dateISO8601' => '2013-02-01', + 'timeISO8601' => 'T22:44:30.652Z', + 'dateTimeISO8601' => '2015-08-06T16:53:10+01:00', + 'dateTimeWithMillisISO8601' => '2015-08-06T16:53:10.123+01:00', + 'timestampRFC3339' => 'Mon, 31 Oct 2016 15:21:41 -0400', + 'likeBool' => true, + 'likeInt' => 13, + 'likeDecimal' => 13.01, + 'boolean' => false, + 'integer' => 9, + 'decimal' => 79.01, + 'hexadecimal' => 'F7A16', + 'uuid' => '52c9585e-f345-4964-aa28-a45c64b2b2eb', + 'ipv4Address' => '127.0.0.13', + 'ipv6Address' => '::ffff:192.0.2.128', + 'email' => 'hello@pact.io', + 'nullValue' => null, + 'date' => '2015-05-16', + 'time' => '23:59::58', + 'datetime' => '2000-10-31T01:30:00', + 'likeString' => 'some string', + 'equal' => 'exact this value', + 'equalArray' => [ + 'a', + 'bb', + 'ccc', + ], + 'includes' => 'lazy dog', + 'number' => 123, + 'arrayContaining' => [ + 'some text', + 111, + '2fbd41cc-4bbc-44ea-a419-67f767691407', + ], + 'notEmpty' => ['1', '2', '3'], + 'semver' => '10.0.0-alpha4', + 'contentType' => 'text/html', + 'eachKey' => [ + 'page 3' => 'example text', + ], + 'eachValue' => [ + 'vehicle 1' => 'car', + ], + 'url' => 'http://localhost:8080/users/1234/posts/latest', + 'query' => [ + 'pages' => '22', + 'locales' => ['en-US', 'en-AU'], + ], + ], $body); + } +} diff --git a/example/matchers/pacts/matchersConsumer-matchersProvider.json b/example/matchers/pacts/matchersConsumer-matchersProvider.json new file mode 100644 index 00000000..12478be0 --- /dev/null +++ b/example/matchers/pacts/matchersConsumer-matchersProvider.json @@ -0,0 +1,604 @@ +{ + "consumer": { + "name": "matchersConsumer" + }, + "interactions": [ + { + "description": "A get request to /matchers", + "pending": false, + "providerStates": [ + { + "name": "Get Matchers" + } + ], + "request": { + "headers": { + "Accept": [ + "application/json" + ], + "Theme": [ + "dark" + ] + }, + "matchingRules": { + "header": { + "Theme": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "light|dark" + } + ] + } + }, + "path": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\/matchers$" + } + ] + }, + "query": { + "locales[]": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[a-z]{2}-[A-Z]{2}$" + } + ] + }, + "pages": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "\\d+" + } + ] + } + } + }, + "method": "GET", + "path": "/matchers", + "query": { + "locales[]": [ + "en-US", + "en-AU" + ], + "pages": [ + "1", + "22" + ] + } + }, + "response": { + "body": { + "content": { + "arrayContaining": [ + "some text", + 111, + "2fbd41cc-4bbc-44ea-a419-67f767691407" + ], + "atLeastLike": [ + 1, + 1, + 1, + 1, + 1 + ], + "atMostLike": [ + 1 + ], + "boolean": false, + "constrainedArrayLike": [ + "item", + "item" + ], + "contentType": "text/html", + "date": "2015-05-16", + "dateISO8601": "2013-02-01", + "dateTimeISO8601": "2015-08-06T16:53:10+01:00", + "dateTimeWithMillisISO8601": "2015-08-06T16:53:10.123+01:00", + "datetime": "2000-10-31T01:30:00", + "decimal": 79.01, + "eachKey": { + "page 3": "example text" + }, + "eachLike": [ + "item" + ], + "eachValue": { + "vehicle 1": "car" + }, + "email": "hello@pact.io", + "equal": "exact this value", + "equalArray": [ + "a", + "bb", + "ccc" + ], + "hexadecimal": "F7A16", + "includes": "lazy dog", + "integer": 9, + "ipv4Address": "127.0.0.13", + "ipv6Address": "::ffff:192.0.2.128", + "like": { + "key": "value" + }, + "likeBool": true, + "likeDecimal": 13.01, + "likeInt": 13, + "likeNull": null, + "likeString": "some string", + "notEmpty": [ + "1", + "2", + "3" + ], + "nullValue": null, + "number": 123, + "query": { + "locales": [ + "en-US", + "en-AU" + ], + "pages": "22" + }, + "regex": "500 miles", + "semver": "10.0.0-alpha4", + "time": "23:59::58", + "timeISO8601": "T22:44:30.652Z", + "timestampRFC3339": "Mon, 31 Oct 2016 15:21:41 -0400", + "url": "http://localhost:8080/users/1234/posts/latest", + "uuid": "52c9585e-f345-4964-aa28-a45c64b2b2eb" + }, + "contentType": "application/json", + "encoded": false + }, + "headers": { + "Content-Type": [ + "application/json" + ], + "X-Powered-By": [ + "PHP" + ] + }, + "matchingRules": { + "body": { + "$.arrayContaining": { + "combine": "AND", + "matchers": [ + { + "match": "arrayContains", + "variants": [ + { + "index": 0, + "rules": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + { + "index": 1, + "rules": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + }, + { + "index": 2, + "rules": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + } + } + ] + } + ] + }, + "$.atLeastLike": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 5 + } + ] + }, + "$.atMostLike": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "max": 3 + } + ] + }, + "$.boolean": { + "combine": "AND", + "matchers": [ + { + "match": "boolean" + } + ] + }, + "$.constrainedArrayLike": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "max": 4, + "min": 2 + } + ] + }, + "$.contentType": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "text/html" + } + ] + }, + "$.date": { + "combine": "AND", + "matchers": [ + { + "format": "yyyy-MM-dd", + "match": "date" + } + ] + }, + "$.dateISO8601": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^([\\+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))?)$" + } + ] + }, + "$.dateTimeISO8601": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d(?:|:?[0-5]\\d)|Z)?$" + } + ] + }, + "$.dateTimeWithMillisISO8601": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d\\.\\d{3}([+-][0-2]\\d(?:|:?[0-5]\\d)|Z)?$" + } + ] + }, + "$.datetime": { + "combine": "AND", + "matchers": [ + { + "format": "yyyy-MM-dd'T'HH:mm:ss", + "match": "datetime" + } + ] + }, + "$.decimal": { + "combine": "AND", + "matchers": [ + { + "match": "decimal" + } + ] + }, + "$.eachKey": { + "combine": "AND", + "matchers": [ + { + "match": "eachKey", + "rules": [ + { + "match": "regex", + "regex": "^page \\d+$" + } + ], + "value": "{\"page 3\":\"example text\"}" + } + ] + }, + "$.eachLike": { + "combine": "AND", + "matchers": [ + { + "match": "type", + "min": 1 + } + ] + }, + "$.eachValue": { + "combine": "AND", + "matchers": [ + { + "match": "eachValue", + "rules": [ + { + "match": "regex", + "regex": "car|bike|motorbike" + } + ], + "value": "{\"vehicle 1\":\"car\"}" + } + ] + }, + "$.email": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$" + } + ] + }, + "$.equal": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "$.equalArray": { + "combine": "AND", + "matchers": [ + { + "match": "equality" + } + ] + }, + "$.hexadecimal": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-fA-F]+$" + } + ] + }, + "$.includes": { + "combine": "AND", + "matchers": [ + { + "match": "include", + "value": "lazy dog" + } + ] + }, + "$.integer": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + }, + "$.ipv4Address": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^(\\d{1,3}\\.)+\\d{1,3}$" + } + ] + }, + "$.ipv6Address": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$" + } + ] + }, + "$.like": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.likeBool": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.likeDecimal": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.likeInt": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.likeNull": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.likeString": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.notEmpty": { + "combine": "AND", + "matchers": [ + { + "match": "notEmpty" + } + ] + }, + "$.nullValue": { + "combine": "AND", + "matchers": [ + { + "match": "null" + } + ] + }, + "$.number": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.regex": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^\\d+ (miles|kilometers)$" + } + ] + }, + "$.semver": { + "combine": "AND", + "matchers": [ + { + "match": "semver" + } + ] + }, + "$.time": { + "combine": "AND", + "matchers": [ + { + "format": "HH:mm:ss", + "match": "time" + } + ] + }, + "$.timeISO8601": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^(T\\d\\d:\\d\\d(:\\d\\d)?(\\.\\d+)?([+-][0-2]\\d(?:|:?[0-5]\\d)|Z)?)$" + } + ] + }, + "$.timestampRFC3339": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\\s\\d{2}\\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s\\d{4}\\s\\d{2}:\\d{2}:\\d{2}\\s(\\+|-)\\d{4}$" + } + ] + }, + "$.url": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": ".*(\\/users\\/\\d+\\/posts\\/latest)$" + } + ] + }, + "$.uuid": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + } + }, + "header": { + "X-Powered-By": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "status": { + "$": { + "combine": "AND", + "matchers": [ + { + "match": "statusCode", + "status": "serverError" + } + ] + } + } + }, + "status": 512 + }, + "transport": "http", + "type": "Synchronous/HTTP" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "mockserver": "1.2.5", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "4.0" + } + }, + "provider": { + "name": "matchersProvider" + } +} \ No newline at end of file diff --git a/example/matchers/provider/phpunit.xml b/example/matchers/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/matchers/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/matchers/provider/public/index.php b/example/matchers/provider/public/index.php new file mode 100644 index 00000000..b33364bf --- /dev/null +++ b/example/matchers/provider/public/index.php @@ -0,0 +1,98 @@ +addBodyParsingMiddleware(); + +$app->get('/matchers', function (Request $request, Response $response) { + $response->getBody()->write(\json_encode([ + 'like' => ['key' => 'another value'], + 'likeNull' => null, + 'eachLike' => ['item 1', 'item 2'], + 'atLeastLike' => [1, 2, 3, 4, 5, 6], + 'atMostLike' => [1, 2], + 'constrainedArrayLike' => ['item 1', 'item 2', 'item 3'], + 'regex' => '800 kilometers', + 'dateISO8601' => '2001-11-21', + 'timeISO8601' => 'T11:22:15.153Z', + 'dateTimeISO8601' => '2004-02-12T15:19:21+00:00', + 'dateTimeWithMillisISO8601' => '2018-11-07T00:25:00.073+01:00', + 'timestampRFC3339' => 'Thu, 01 Dec 1994 16:00:00 +0700', + 'likeBool' => false, + 'likeInt' => 34, + 'likeDecimal' => 24.12, + 'boolean' => true, + 'integer' => 11, + 'decimal' => 25.1, + 'hexadecimal' => '20AC', + 'uuid' => 'e9d2f3a5-6ecc-4bff-8935-84bb6141325a', + 'ipv4Address' => '192.168.1.1', + 'ipv6Address' => '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + 'email' => 'pact@example.com', + 'nullValue' => null, + 'date' => '1997-12-11', + 'time' => '11:01:02', + 'datetime' => '1997-07-16T19:20:30', + 'likeString' => 'another string', + 'equal' => 'exact this value', + 'equalArray' => [ + 'a', + 'bb', + 'ccc', + ], + 'includes' => 'The quick brown fox jumps over the lazy dog', + 'number' => 112.3, + 'arrayContaining' => [ + 102.3, + 'eb375cad-48cc-4f7f-981b-ea4f1af90bf2', + ], + 'notEmpty' => [111], + 'semver' => '0.27.1-beta2', + 'contentType' => + << + + + +

My First Heading

+

My first paragraph.

+ + + + HTML, + 'eachKey' => [ + 'page 1' => 'Hello', + 'page 2' => 'World', + ], + 'eachValue' => [ + 'item 1' => 'bike', + 'item 2' => 'motorbike', + ], + 'url' => 'https://www.example.com/users/1234/posts/latest', + 'query' => $request->getQueryParams(), + ])); + + return $response + ->withHeader('Content-Type', 'application/json') + ->withStatus(503) + ->withHeader('X-Powered-By', [ + 'PHP', + 'Nginx', + 'Slim', + ]); +}); + +$app->post('/pact-change-state', function (Request $request, Response $response) { + $body = $request->getParsedBody(); + + printf('%s provider state %s with params: %s', $body['action'], $body['state'], json_encode($body['params'])); + + return $response; +}); + +$app->run(); diff --git a/example/matchers/provider/tests/PactVerifyTest.php b/example/matchers/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..726d6d36 --- /dev/null +++ b/example/matchers/provider/tests/PactVerifyTest.php @@ -0,0 +1,47 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + public function testPactVerifyConsumer() + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('matchersProvider') + ->setHost('localhost') + ->setPort($this->process->getPort()); + $config->getProviderState() + ->setStateChangeUrl(new Uri(sprintf('http://localhost:%d/pact-change-state', $this->process->getPort()))) + ; + if ($level = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($level); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/matchersConsumer-matchersProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/example/message/consumer/phpunit.xml b/example/message/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/message/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/message/consumer/src/ExampleMessageConsumer.php b/example/message/consumer/src/ExampleMessageConsumer.php new file mode 100644 index 00000000..edd1e6f2 --- /dev/null +++ b/example/message/consumer/src/ExampleMessageConsumer.php @@ -0,0 +1,21 @@ +contents->text, true) . "\n"; + print ' [x] Number: ' . \print_r($obj->contents->number, true) . "\n"; + print " [x] Metadata: \n"; + print ' [x] Queue: ' . \print_r($obj->metadata->queue, true) . "\n"; + print ' [x] Routing Key: ' . \print_r($obj->metadata->routing_key, true) . "\n"; + print " [x] Processed \n"; + } + } +} diff --git a/example/src/MessageConsumer/receive.php b/example/message/consumer/src/receive.php similarity index 88% rename from example/src/MessageConsumer/receive.php rename to example/message/consumer/src/receive.php index 66c12a5c..30848388 100644 --- a/example/src/MessageConsumer/receive.php +++ b/example/message/consumer/src/receive.php @@ -1,6 +1,6 @@ ProcessText($msg->body); + $processor->processMessage($msg->body); $msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']); }; diff --git a/example/message/consumer/tests/ExampleMessageConsumerTest.php b/example/message/consumer/tests/ExampleMessageConsumerTest.php new file mode 100644 index 00000000..8a15f6b6 --- /dev/null +++ b/example/message/consumer/tests/ExampleMessageConsumerTest.php @@ -0,0 +1,65 @@ +setConsumer('messageConsumer') + ->setProvider('messageProvider') + ->setPactDir(__DIR__.'/../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + self::$config->setLogLevel($logLevel); + } + } + + public function setUp(): void + { + $this->matcher = new Matcher(); + } + + /** + * @throws Exception + */ + public function testProcessText() + { + $builder = new MessageBuilder(self::$config); + + $contents = new stdClass(); + $contents->text = 'Hello Mary'; + $contents->number = $this->matcher->integerV3(); + + $metadata = ['queue' => 'wind cries', 'routing_key' => $this->matcher->string()]; + + $builder + ->given('a message', ['foo' => 'bar']) + ->expectsToReceive('an alligator named Mary exists') + ->withMetadata($metadata) + ->withContent($contents); + + // established mechanism to this via callbacks + $consumerMessage = new ExampleMessageConsumer(); + $callback = [$consumerMessage, 'processMessage']; + $builder->setCallback($callback); + + $verifyResult = $builder->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/example/message/pacts/messageConsumer-messageProvider.json b/example/message/pacts/messageConsumer-messageProvider.json new file mode 100644 index 00000000..8d8a6179 --- /dev/null +++ b/example/message/pacts/messageConsumer-messageProvider.json @@ -0,0 +1,76 @@ +{ + "consumer": { + "name": "messageConsumer" + }, + "messages": [ + { + "contents": { + "number": null, + "text": "Hello Mary" + }, + "description": "an alligator named Mary exists", + "generators": { + "body": { + "$.number": { + "max": 10, + "min": 0, + "type": "RandomInt" + } + }, + "metadata": { + "routing_key": { + "size": 10, + "type": "RandomString" + } + } + }, + "matchingRules": { + "body": { + "$.number": { + "combine": "AND", + "matchers": [ + { + "match": "integer" + } + ] + } + }, + "metadata": { + "routing_key": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "metadata": { + "contentType": "application/json", + "queue": "wind cries", + "routing_key": "some string" + }, + "providerStates": [ + { + "name": "a message", + "params": { + "foo": "bar" + } + } + ] + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "messageProvider" + } +} \ No newline at end of file diff --git a/example/message/provider/phpunit.xml b/example/message/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/message/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/message/provider/public/index.php b/example/message/provider/public/index.php new file mode 100644 index 00000000..9f4ae661 --- /dev/null +++ b/example/message/provider/public/index.php @@ -0,0 +1,36 @@ +addBodyParsingMiddleware(); + +$provider = new ExampleProvider(); + +$app->post('/pact-messages', function (Request $request, Response $response) use ($provider) { + $body = $request->getParsedBody(); + $message = $provider->dispatchMessage($body['description'], $body['providerStates']); + if ($message) { + $response->getBody()->write(\json_encode($message->getContents())); + + return $response + ->withHeader('Content-Type', 'application/json') + ->withHeader('Pact-Message-Metadata', \base64_encode(\json_encode($message->getMetadata()))); + } + + return $response; +}); + +$app->post('/pact-change-state', function (Request $request, Response $response) use ($provider) { + $body = $request->getParsedBody(); + $provider->changeSate($body['action'], $body['state'], $body['params']); + + return $response; +}); + +$app->run(); diff --git a/example/message/provider/src/ExampleMessage.php b/example/message/provider/src/ExampleMessage.php new file mode 100644 index 00000000..2a477b77 --- /dev/null +++ b/example/message/provider/src/ExampleMessage.php @@ -0,0 +1,28 @@ +metadata; + } + + public function getContents(): mixed + { + return $this->contents; + } + + public function __toString(): string + { + return json_encode([ + 'metadata' => $this->metadata, + 'contents' => $this->contents, + ]); + } +} diff --git a/example/message/provider/src/ExampleProvider.php b/example/message/provider/src/ExampleProvider.php new file mode 100644 index 00000000..711653b0 --- /dev/null +++ b/example/message/provider/src/ExampleProvider.php @@ -0,0 +1,37 @@ + [ + 'queue' => 'wind cries', + 'routing_key' => 'wind cries', + ], + 'contents' => [ + 'text' => 'Hello Mary', + 'number' => 123, + ] + ]; + + private array $currentState = []; + + public function dispatchMessage(string $description, array $providerStates): ?ExampleMessage + { + if ($description !== 'an alligator named Mary exists') { + return null; + } + + return (new ExampleMessage($this->message['contents'], $this->message['metadata'])); + } + + public function changeSate(string $action, string $state, array $params): void + { + $this->currentState = [ + 'action' => $action, + 'state' => $state, + 'params' => $params, + ]; + } +} diff --git a/example/src/MessageProvider/send.php b/example/message/provider/src/send.php similarity index 71% rename from example/src/MessageProvider/send.php rename to example/message/provider/src/send.php index 125db85e..0b3f7a66 100644 --- a/example/src/MessageProvider/send.php +++ b/example/message/provider/src/send.php @@ -1,24 +1,24 @@ 'myKey', 'routing_key' => 'myKey']); $content = new \stdClass(); $content->text = 'Hello Mary'; -$providerMessage->setContents($content); +$metadata = ['queue' => 'myKey', 'routing_key' => 'myKey']; +$providerMessage = new ExampleMessage($content, $metadata); $channel = $connection->channel(); $channel->queue_declare($providerMessage->getMetadata()['queue'], false, false, false, false); // transform message to AMQP -$msg = new AMQPMessage($providerMessage->Build()); +$msg = new AMQPMessage($providerMessage); // publish it $channel->basic_publish($msg, '', $providerMessage->getMetadata()['routing_key']); diff --git a/example/message/provider/tests/PactVerifyTest.php b/example/message/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..e496b138 --- /dev/null +++ b/example/message/provider/tests/PactVerifyTest.php @@ -0,0 +1,58 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + /** + * This test will run after the web server is started. + */ + public function testPactVerifyConsumer() + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('messageProvider') // Providers name to fetch. + ->setHost('localhost') + ->setPort($this->process->getPort()); + $config->getProviderState() + ->setStateChangeUrl(new Uri(sprintf('http://localhost:%d/pact-change-state', $this->process->getPort()))) + ; + $config->addProviderTransport( + (new ProviderTransport()) + ->setProtocol(ProviderTransport::MESSAGE_PROTOCOL) + ->setPort($this->process->getPort()) + ->setPath('/pact-messages') + ->setScheme('http') + ); + if ($level = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($level); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/messageConsumer-messageProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/example/multipart/consumer/phpunit.xml b/example/multipart/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/multipart/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/multipart/consumer/src/Service/HttpClientService.php b/example/multipart/consumer/src/Service/HttpClientService.php new file mode 100644 index 00000000..8a1ee65f --- /dev/null +++ b/example/multipart/consumer/src/Service/HttpClientService.php @@ -0,0 +1,50 @@ +httpClient = new Client(); + $this->baseUri = $baseUri; + } + + public function updateUserProfile(): string + { + $response = $this->httpClient->post("{$this->baseUri}/user-profile", [ + 'multipart' => [ + [ + 'name' => 'full_name', + 'contents' => 'Zoey Turcotte', + 'filename' => 'full_name.txt', + ], + [ + 'name' => 'profile_image', + 'contents' => file_get_contents(__DIR__ . '/../_resource/image.jpg'), + 'filename' => 'image.jpg', + ], + [ + 'name' => 'personal_note', + 'contents' => 'testing', + 'filename' => 'note.txt', + ], + ], + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ZmluLWFwaTphcGktc2VjcmV0', + ], + ]); + + return $response->getBody(); + } +} diff --git a/example/multipart/consumer/src/_resource/image.jpg b/example/multipart/consumer/src/_resource/image.jpg new file mode 100644 index 00000000..76cec75c Binary files /dev/null and b/example/multipart/consumer/src/_resource/image.jpg differ diff --git a/example/multipart/consumer/tests/Service/HttpClientServiceTest.php b/example/multipart/consumer/tests/Service/HttpClientServiceTest.php new file mode 100644 index 00000000..e53e1c9f --- /dev/null +++ b/example/multipart/consumer/tests/Service/HttpClientServiceTest.php @@ -0,0 +1,81 @@ +setMethod('POST') + ->setPath('/user-profile') + ->setHeaders([ + 'Accept' => 'application/json', + 'Authorization' => [ + \json_encode($matcher->like('Bearer eyJhbGciOiJIUzI1NiIXVCJ9')) + ], + ]) + ->setBody(new Multipart( + [ + new Part(__DIR__ . '/../_resource/full_name.txt', 'full_name', 'text/plain'), + new Part(__DIR__ . '/../_resource/image.jpg', 'profile_image', in_array(php_uname('m'), ['AMD64', 'arm64', 'aarch64']) ? 'application/octet-stream' : 'image/jpeg'), + new Part(__DIR__ . '/../_resource/note.txt', 'personal_note', 'text/plain'), + ], + 'ktJmeYHbkTSa1jxD' + )); + + $response = new ProviderResponse(); + $response + ->setStatus(200) + ->addHeader('Content-Type', 'application/json') + ->setBody([ + 'full_name' => $matcher->like($fullName), + 'profile_image' => $matcher->regex($profileImageUrl, self::URL_FORMAT), + 'personal_note' => $matcher->like($personalNote), + ]); + + $config = new MockServerConfig(); + $config + ->setConsumer('multipartConsumer') + ->setProvider('multipartProvider') + ->setPactDir(__DIR__.'/../../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $builder = new InteractionBuilder($config); + $builder + ->given('User exists') + ->uponReceiving('A put request to /user-profile') + ->with($request) + ->willRespondWith($response); + + $service = new HttpClientService($config->getBaseUri()); + $userProfileResponse = $service->updateUserProfile(); + $verifyResult = $builder->verify(); + + $this->assertTrue($verifyResult); + $this->assertEquals([ + 'full_name' => $fullName, + 'profile_image' => $profileImageUrl, + 'personal_note' => $personalNote, + ], \json_decode($userProfileResponse, true, 512, JSON_THROW_ON_ERROR)); + } +} diff --git a/example/multipart/consumer/tests/_resource/full_name.txt b/example/multipart/consumer/tests/_resource/full_name.txt new file mode 100644 index 00000000..de749767 --- /dev/null +++ b/example/multipart/consumer/tests/_resource/full_name.txt @@ -0,0 +1 @@ +Colten Ziemann \ No newline at end of file diff --git a/example/multipart/consumer/tests/_resource/image.jpg b/example/multipart/consumer/tests/_resource/image.jpg new file mode 100644 index 00000000..82b33626 Binary files /dev/null and b/example/multipart/consumer/tests/_resource/image.jpg differ diff --git a/example/multipart/consumer/tests/_resource/note.txt b/example/multipart/consumer/tests/_resource/note.txt new file mode 100644 index 00000000..9a2c7732 --- /dev/null +++ b/example/multipart/consumer/tests/_resource/note.txt @@ -0,0 +1 @@ +testing \ No newline at end of file diff --git a/example/multipart/pacts/multipartConsumer-multipartProvider.json b/example/multipart/pacts/multipartConsumer-multipartProvider.json new file mode 100644 index 00000000..c094318a --- /dev/null +++ b/example/multipart/pacts/multipartConsumer-multipartProvider.json @@ -0,0 +1,130 @@ +{ + "consumer": { + "name": "multipartConsumer" + }, + "interactions": [ + { + "description": "A put request to /user-profile", + "providerStates": [ + { + "name": "User exists" + } + ], + "request": { + "body": "LS1rdEptZVlIYmtUU2ExanhEDQpDb250ZW50LURpc3Bvc2l0aW9uOiBmb3JtLWRhdGE7IG5hbWU9ImZ1bGxfbmFtZSI7IGZpbGVuYW1lPSJmdWxsX25hbWUudHh0Ig0KQ29udGVudC1UeXBlOiB0ZXh0L3BsYWluDQoNCkNvbHRlbiBaaWVtYW5uDQotLWt0Sm1lWUhia1RTYTFqeEQNCkNvbnRlbnQtRGlzcG9zaXRpb246IGZvcm0tZGF0YTsgbmFtZT0icHJvZmlsZV9pbWFnZSI7IGZpbGVuYW1lPSJpbWFnZS5qcGciDQpDb250ZW50LVR5cGU6IGltYWdlL2pwZWcNCg0K/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/wAALCAAMAAwBAREA/8QAFgABAQEAAAAAAAAAAAAAAAAABAAF/8QAIhAAAgEDAwUBAAAAAAAAAAAAAQMCBBESAAUhBhMiI0EU/9oACAEBAAA/AFGtY3alVUoTpkymGOKltTAtLMorN4yE8oxMQYgY2Fx5A6ttPTJSwo6qntx7hDKeU5JxmLAke0CQNgcrDkkfNHrmPTty96DspimpqwJKlhYzhBhXxES7YMiAMr2+351gJqKpzKkfurVdupbD0Vjkxl5k3xhIRB5+ADgca//ZDQotLWt0Sm1lWUhia1RTYTFqeEQNCkNvbnRlbnQtRGlzcG9zaXRpb246IGZvcm0tZGF0YTsgbmFtZT0icGVyc29uYWxfbm90ZSI7IGZpbGVuYW1lPSJub3RlLnR4dCINCkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpbg0KDQp0ZXN0aW5nDQotLWt0Sm1lWUhia1RTYTFqeEQtLQ0K", + "headers": { + "Accept": "application/json", + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIXVCJ9", + "Content-Type": "multipart/form-data; boundary=ktJmeYHbkTSa1jxD" + }, + "matchingRules": { + "body": { + "$.full_name": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "text/plain" + } + ] + }, + "$.personal_note": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "text/plain" + } + ] + }, + "$.profile_image": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "image/jpeg" + } + ] + } + }, + "header": { + "Authorization": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "multipart/form-data;(\\s*charset=[^;]*;)?\\s*boundary=.*" + } + ] + } + } + }, + "method": "POST", + "path": "/user-profile" + }, + "response": { + "body": { + "full_name": "Colten Ziemann", + "personal_note": "testing", + "profile_image": "http://example.test/profile-image.jpg" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$.full_name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.personal_note": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.profile_image": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()!@:%_\\+.~#?&\\/\\/=]*)" + } + ] + } + }, + "header": {}, + "status": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "mockserver": "1.2.5", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "multipartProvider" + } +} \ No newline at end of file diff --git a/example/multipart/provider/phpunit.xml b/example/multipart/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/multipart/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/multipart/provider/public/index.php b/example/multipart/provider/public/index.php new file mode 100644 index 00000000..6111dfc8 --- /dev/null +++ b/example/multipart/provider/public/index.php @@ -0,0 +1,23 @@ +addBodyParsingMiddleware(); + +$app->post('/user-profile', function (Request $request, Response $response) { + $fileName = (string)$request->getUploadedFiles()['profile_image']->getClientFilename(); + $response->getBody()->write(\json_encode([ + 'full_name' => (string)$request->getUploadedFiles()['full_name']->getStream(), + 'profile_image' => "http://example.test/$fileName", + 'personal_note' => (string)$request->getUploadedFiles()['personal_note']->getStream(), + ])); + + return $response->withHeader('Content-Type', 'application/json'); +}); + +$app->run(); diff --git a/example/multipart/provider/tests/PactVerifyTest.php b/example/multipart/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..664012b0 --- /dev/null +++ b/example/multipart/provider/tests/PactVerifyTest.php @@ -0,0 +1,46 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + /** + * This test will run after the web server is started. + */ + public function testPactVerifyConsumer() + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('multipartProvider') // Providers name to fetch. + ->setHost('localhost') + ->setPort($this->process->getPort()); + if ($level = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($level); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/multipartConsumer-multipartProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/example/pacts/README.md b/example/pacts/README.md deleted file mode 100644 index 0d5d2a0b..00000000 --- a/example/pacts/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Example Pacts - -The json files in this folder are explicitly here for an easy-to-read output of the test examples. These are *not* the actual test results from running all these tests of this project. By default, the pact files of this project's examples are written to example/output. The tests themselves need to generate the appropropriate files as part of the tests. - -To run the tests locally, try `composer test` - - - - - - diff --git a/example/pacts/test_consumer-test_provider.json b/example/pacts/test_consumer-test_provider.json deleted file mode 100644 index a015e9ae..00000000 --- a/example/pacts/test_consumer-test_provider.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "consumer": { - "name": "test_consumer" - }, - "provider": { - "name": "test_provider" - }, - "messages": [ - { - "description": "an alligator named Mary exists", - "providerStates": [ - { - "name": "a message" - } - ], - "contents": { - "text": "Hello Mary" - }, - "matchingRules": { - "body": { - } - }, - "metaData": { - "queue": "wind cries", - "routing_key": "wind cries" - } - }, - { - "description": "footprints dressed in red", - "providerStates": [ - { - "name": "You can hear happiness staggering on down the street" - } - ], - "contents": { - "song": "And the wind whispers Mary" - }, - "matchingRules": { - "body": { - } - }, - "metaData": { - "queue": "And the clowns have all gone to bed", - "routing_key": "And the clowns have all gone to bed" - } - } - ], - "metadata": { - "pactSpecification": { - "version": "2.0.0" - } - } -} diff --git a/example/phpunit.all.xml b/example/phpunit.all.xml deleted file mode 100644 index 554c8a46..00000000 --- a/example/phpunit.all.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - ../tests - - - ./tests/Consumer - - - ./tests/Provider - - - ./tests/MessageConsumer - - - ./tests/MessageProvider - - - - - - - - PhpPact Consumer Example Tests - - - - - - - - - - - - - - - - - diff --git a/example/phpunit.consumer.xml b/example/phpunit.consumer.xml deleted file mode 100644 index 6e88b251..00000000 --- a/example/phpunit.consumer.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - ./tests/Consumer - - - - - - - - PhpPact Example Tests - - - - - - - - - - - - - - - - - diff --git a/example/phpunit.core.xml b/example/phpunit.core.xml deleted file mode 100644 index aefb2d26..00000000 --- a/example/phpunit.core.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - ../tests - - - - - - - - PhpPact Consumer Example Tests - - - - - - - - - - - - - - - - - diff --git a/example/phpunit.message.consumer.xml b/example/phpunit.message.consumer.xml deleted file mode 100644 index 7170d67e..00000000 --- a/example/phpunit.message.consumer.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - ./tests/MessageConsumer - - - diff --git a/example/phpunit.message.provider.xml b/example/phpunit.message.provider.xml deleted file mode 100644 index 2844c97d..00000000 --- a/example/phpunit.message.provider.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - ./tests/MessageProvider - - - diff --git a/example/phpunit.provider.xml b/example/phpunit.provider.xml deleted file mode 100644 index 8f143865..00000000 --- a/example/phpunit.provider.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - ./tests/Provider - - - diff --git a/example/protobuf-async-message/consumer/phpunit.xml b/example/protobuf-async-message/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/protobuf-async-message/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/protobuf-async-message/consumer/src/MessageHandler/PersonMessageHandler.php b/example/protobuf-async-message/consumer/src/MessageHandler/PersonMessageHandler.php new file mode 100644 index 00000000..26c51aad --- /dev/null +++ b/example/protobuf-async-message/consumer/src/MessageHandler/PersonMessageHandler.php @@ -0,0 +1,18 @@ +service->sayHello($person->getName()->getGiven(), $person->getName()->getSurname()); + } +} diff --git a/example/protobuf-async-message/consumer/src/Service/SayHelloService.php b/example/protobuf-async-message/consumer/src/Service/SayHelloService.php new file mode 100644 index 00000000..a0939825 --- /dev/null +++ b/example/protobuf-async-message/consumer/src/Service/SayHelloService.php @@ -0,0 +1,11 @@ +createMock(SayHelloService::class); + $service + ->expects($this->once()) + ->method('sayHello') + ->with($this->given, $this->surname); + $this->service = $service; + } + + public function testInvoke(): void + { + $id = 'd1f077b5-0f91-40aa-b8f9-568b50ee4dd9'; + + $config = (new PactMessageConfig()) + ->setConsumer('protobufAsyncMessageConsumer') + ->setProvider('protobufAsyncMessageProvider') + ->setPactSpecificationVersion('4.0.0') + ->setPactDir(__DIR__.'/../../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + + $builder = new MessageBuilder($config, new ProtobufMessageDriverFactory()); + + $builder + ->given('A person with fixed id exists', ['id' => $id, 'reuse' => '0']) + ->expectsToReceive('Person message sent') + ->withContent(new Text( + json_encode([ + 'pact:proto' => __DIR__ . '/../../../library/proto/say_hello.proto', + 'pact:message-type' => 'Person', + 'pact:content-type' => 'application/protobuf', + 'id' => "matching(regex, '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$', '{$id}')", + 'name' => [ + 'given' => "matching(type, '{$this->given}')", + 'surname' => "matching(type, '{$this->surname}')" + ], + ]), + 'application/protobuf' + )); + + $builder->setCallback(function (string $pactJson): void { + $message = \json_decode($pactJson); + $person = new Person(); + $decoded = base64_decode($message->contents->content); + $person->mergeFromString($decoded); + $handler = new PersonMessageHandler($this->service); + $handler($person); + }); + + $this->assertTrue($builder->verify()); + } +} diff --git a/example/protobuf-async-message/library/proto/say_hello.proto b/example/protobuf-async-message/library/proto/say_hello.proto new file mode 100644 index 00000000..7db28ef6 --- /dev/null +++ b/example/protobuf-async-message/library/proto/say_hello.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +package library; + +message Person { + string id = 1; + Name name = 2; +} + +message Name { + string given = 1; + string surname = 2; +} diff --git a/example/protobuf-async-message/library/src/.gitignore b/example/protobuf-async-message/library/src/.gitignore new file mode 100644 index 00000000..cde8069e --- /dev/null +++ b/example/protobuf-async-message/library/src/.gitignore @@ -0,0 +1 @@ +*.php diff --git a/example/protobuf-async-message/pacts/protobufAsyncMessageConsumer-protobufAsyncMessageProvider.json b/example/protobuf-async-message/pacts/protobufAsyncMessageConsumer-protobufAsyncMessageProvider.json new file mode 100644 index 00000000..b6bf9bce --- /dev/null +++ b/example/protobuf-async-message/pacts/protobufAsyncMessageConsumer-protobufAsyncMessageProvider.json @@ -0,0 +1,93 @@ +{ + "consumer": { + "name": "protobufAsyncMessageConsumer" + }, + "interactions": [ + { + "contents": { + "content": "CiRkMWYwNzdiNS0wZjkxLTQwYWEtYjhmOS01NjhiNTBlZTRkZDkSEAoFR2l2ZW4SB1N1cm5hbWU=", + "contentType": "application/protobuf;message=Person", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "description": "Person message sent", + "interactionMarkup": { + "markup": "```protobuf\nmessage Person {\n string id = 1;\n message .library.Name name = 2;\n}\n```\n", + "markupType": "COMMON_MARK" + }, + "matchingRules": { + "body": { + "$.id": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$" + } + ] + }, + "$.name.given": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.name.surname": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + } + }, + "metadata": { + "contentType": "application/protobuf;message=Person" + }, + "pending": false, + "pluginConfiguration": { + "protobuf": { + "descriptorKey": "f77f40284a5ed1f38188ed943aca6938", + "message": "Person" + } + }, + "providerStates": [ + { + "name": "A person with fixed id exists", + "params": { + "id": "d1f077b5-0f91-40aa-b8f9-568b50ee4dd9", + "reuse": 0 + } + } + ], + "type": "Asynchronous/Messages" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "f77f40284a5ed1f38188ed943aca6938": { + "protoDescriptors": "CpcBCg9zYXlfaGVsbG8ucHJvdG8SB2xpYnJhcnkiOwoGUGVyc29uEg4KAmlkGAEgASgJUgJpZBIhCgRuYW1lGAIgASgLMg0ubGlicmFyeS5OYW1lUgRuYW1lIjYKBE5hbWUSFAoFZ2l2ZW4YASABKAlSBWdpdmVuEhgKB3N1cm5hbWUYAiABKAlSB3N1cm5hbWViBnByb3RvMw==", + "protoFile": "syntax = \"proto3\";\n\npackage library;\n\nmessage Person {\n string id = 1;\n Name name = 2;\n}\n\nmessage Name {\n string given = 1;\n string surname = 2;\n}\n" + } + }, + "name": "protobuf", + "version": "0.3.13" + } + ] + }, + "provider": { + "name": "protobufAsyncMessageProvider" + } +} \ No newline at end of file diff --git a/example/protobuf-async-message/provider/phpunit.xml b/example/protobuf-async-message/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/protobuf-async-message/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/protobuf-async-message/provider/public/index.php b/example/protobuf-async-message/provider/public/index.php new file mode 100644 index 00000000..cea0e98d --- /dev/null +++ b/example/protobuf-async-message/provider/public/index.php @@ -0,0 +1,50 @@ +addBodyParsingMiddleware(); + +$app->post('/', function (Request $request, Response $response) { + $body = $request->getParsedBody(); + if ($body['description'] === 'Person message sent') { + $person = new Person(); + $person->setId('2d5554cd-22da-43ce-8842-2b42cf20661d'); + $name = new Name(); + $name->setGiven('Hettie'); + $name->setSurname('Toy'); + $person->setName($name); + $response->getBody()->write($person->serializeToString()); + + return $response + ->withHeader('Content-Type', 'application/protobuf;message=Person') + ->withHeader('Pact-Message-Metadata', \base64_encode(\json_encode([]))); + } + + $response->getBody()->write('Hello world!'); + + return $response + ->withHeader('Content-Type', 'text/plain') + ; +}); + +$app->post('/pact-change-state', function (Request $request, Response $response) { + $body = $request->getParsedBody(); + $response->getBody()->write(sprintf('State changed: %s', \json_encode([ + 'action' => $body['action'], + 'state' => $body['state'], + 'params' => $body['params'], + ]))); + + return $response + ->withHeader('Content-Type', 'text/plain') + ; +}); + +$app->run(); diff --git a/example/protobuf-async-message/provider/tests/PactVerifyTest.php b/example/protobuf-async-message/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..21b52368 --- /dev/null +++ b/example/protobuf-async-message/provider/tests/PactVerifyTest.php @@ -0,0 +1,47 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + public function testPactVerifyConsumer(): void + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('protobufAsyncMessageProvider') + ->setHost('localhost') + ->setPort($this->process->getPort()); + $config->getProviderState() + ->setStateChangeUrl(new Uri(sprintf('http://localhost:%d/pact-change-state', $this->process->getPort()))) + ->setStateChangeTeardown(true) + ->setStateChangeAsBody(true) + ; + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/protobufAsyncMessageConsumer-protobufAsyncMessageProvider.json'); + + $this->assertTrue($verifier->verify()); + } +} diff --git a/example/protobuf-sync-message/consumer/phpunit.xml b/example/protobuf-sync-message/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/protobuf-sync-message/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/protobuf-sync-message/consumer/src/CalculatorClient.php b/example/protobuf-sync-message/consumer/src/CalculatorClient.php new file mode 100644 index 00000000..4ce7497a --- /dev/null +++ b/example/protobuf-sync-message/consumer/src/CalculatorClient.php @@ -0,0 +1,23 @@ +_simpleRequest( + '/plugins.Calculator/calculate', + $request, + [AreaResponse::class, 'decode'], + $metadata, + [] + )->wait(); + + return $response; + } +} diff --git a/example/protobuf-sync-message/consumer/src/ProtobufClient.php b/example/protobuf-sync-message/consumer/src/ProtobufClient.php new file mode 100644 index 00000000..44602d6e --- /dev/null +++ b/example/protobuf-sync-message/consumer/src/ProtobufClient.php @@ -0,0 +1,23 @@ +baseUrl, [ + 'credentials' => ChannelCredentials::createInsecure(), + ]); + + return $client->calculate($shapeMessage); + } +} diff --git a/example/protobuf-sync-message/consumer/tests/ProtobufClientTest.php b/example/protobuf-sync-message/consumer/tests/ProtobufClientTest.php new file mode 100644 index 00000000..5bf4784c --- /dev/null +++ b/example/protobuf-sync-message/consumer/tests/ProtobufClientTest.php @@ -0,0 +1,64 @@ +setConsumer('protobufSyncMessageConsumer'); + $config->setProvider('protobufSyncMessageProvider'); + $config->setPactSpecificationVersion('4.0.0'); + $config->setPactDir(__DIR__.'/../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $config->setHost('127.0.0.1'); + $builder = new SyncMessageBuilder($config, new ProtobufSyncMessageDriverFactory()); + $builder + ->expectsToReceive('request for calculate shape area') + ->withMetadata([]) + ->withContent(new Text( + json_encode([ + 'pact:proto' => $protoPath, + 'pact:content-type' => 'application/grpc', + 'pact:proto-service' => 'Calculator/calculate', + + 'request' => [ + 'rectangle' => [ + 'length' => $matcher->number(3), + 'width' => $matcher->number(4), + ], + ], + 'response' => [ + 'value' => $matcher->number(12), + ] + ]), + 'application/grpc' + )); + $builder->registerMessage(); + + $service = new ProtobufClient("{$config->getHost()}:{$config->getPort()}"); + $rectangle = (new Rectangle())->setLength(3)->setWidth(4); + $message = (new ShapeMessage())->setRectangle($rectangle); + $response = $service->calculate($message); + + $this->assertTrue($builder->verify()); + $this->assertEquals(3 * 4, $response->getValue()); + } +} diff --git a/example/protobuf-sync-message/library/proto/area_calculator.proto b/example/protobuf-sync-message/library/proto/area_calculator.proto new file mode 100644 index 00000000..8bce6c99 --- /dev/null +++ b/example/protobuf-sync-message/library/proto/area_calculator.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +package plugins; + +option php_generic_services = true; + +service Calculator { + rpc calculate (ShapeMessage) returns (AreaResponse) {} +} + +message ShapeMessage { + oneof shape { + Square square = 1; + Rectangle rectangle = 2; + Circle circle = 3; + Triangle triangle = 4; + Parallelogram parallelogram = 5; + } +} + +message Square { + float edge_length = 1; +} + +message Rectangle { + float length = 1; + float width = 2; +} + +message Circle { + float radius = 1; +} + +message Triangle { + float edge_a = 1; + float edge_b = 2; + float edge_c = 3; +} + +message Parallelogram { + float base_length = 1; + float height = 2; +} + +message AreaResponse { + float value = 1; +} diff --git a/example/protobuf-sync-message/library/src/.gitignore b/example/protobuf-sync-message/library/src/.gitignore new file mode 100644 index 00000000..cde8069e --- /dev/null +++ b/example/protobuf-sync-message/library/src/.gitignore @@ -0,0 +1 @@ +*.php diff --git a/example/protobuf-sync-message/pacts/protobufSyncMessageConsumer-protobufSyncMessageProvider.json b/example/protobuf-sync-message/pacts/protobufSyncMessageConsumer-protobufSyncMessageProvider.json new file mode 100644 index 00000000..0cc74d33 --- /dev/null +++ b/example/protobuf-sync-message/pacts/protobufSyncMessageConsumer-protobufSyncMessageProvider.json @@ -0,0 +1,104 @@ +{ + "consumer": { + "name": "protobufSyncMessageConsumer" + }, + "interactions": [ + { + "description": "request for calculate shape area", + "interactionMarkup": { + "markup": "```protobuf\nmessage AreaResponse {\n float value = 1;\n}\n```\n", + "markupType": "COMMON_MARK" + }, + "pending": false, + "pluginConfiguration": { + "protobuf": { + "descriptorKey": "6b90c212dfe22dc3c119d1c3fe42b5e1", + "service": "Calculator/calculate" + } + }, + "request": { + "contents": { + "content": "EgoNAABAQBUAAIBA", + "contentType": "application/protobuf;message=ShapeMessage", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.rectangle.length": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + }, + "$.rectangle.width": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + }, + "metadata": { + "contentType": "application/protobuf;message=ShapeMessage" + } + }, + "response": [ + { + "contents": { + "content": "DQAAQEE=", + "contentType": "application/protobuf;message=AreaResponse", + "contentTypeHint": "BINARY", + "encoded": "base64" + }, + "matchingRules": { + "body": { + "$.value": { + "combine": "AND", + "matchers": [ + { + "match": "number" + } + ] + } + } + }, + "metadata": { + "contentType": "application/protobuf;message=AreaResponse" + } + } + ], + "transport": "grpc", + "type": "Synchronous/Messages" + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "mockserver": "1.2.5", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "4.0" + }, + "plugins": [ + { + "configuration": { + "6b90c212dfe22dc3c119d1c3fe42b5e1": { + "protoDescriptors": "CtYFChVhcmVhX2NhbGN1bGF0b3IucHJvdG8SB3BsdWdpbnMikgIKDFNoYXBlTWVzc2FnZRIpCgZzcXVhcmUYASABKAsyDy5wbHVnaW5zLlNxdWFyZUgAUgZzcXVhcmUSMgoJcmVjdGFuZ2xlGAIgASgLMhIucGx1Z2lucy5SZWN0YW5nbGVIAFIJcmVjdGFuZ2xlEikKBmNpcmNsZRgDIAEoCzIPLnBsdWdpbnMuQ2lyY2xlSABSBmNpcmNsZRIvCgh0cmlhbmdsZRgEIAEoCzIRLnBsdWdpbnMuVHJpYW5nbGVIAFIIdHJpYW5nbGUSPgoNcGFyYWxsZWxvZ3JhbRgFIAEoCzIWLnBsdWdpbnMuUGFyYWxsZWxvZ3JhbUgAUg1wYXJhbGxlbG9ncmFtQgcKBXNoYXBlIikKBlNxdWFyZRIfCgtlZGdlX2xlbmd0aBgBIAEoAlIKZWRnZUxlbmd0aCI5CglSZWN0YW5nbGUSFgoGbGVuZ3RoGAEgASgCUgZsZW5ndGgSFAoFd2lkdGgYAiABKAJSBXdpZHRoIiAKBkNpcmNsZRIWCgZyYWRpdXMYASABKAJSBnJhZGl1cyJPCghUcmlhbmdsZRIVCgZlZGdlX2EYASABKAJSBWVkZ2VBEhUKBmVkZ2VfYhgCIAEoAlIFZWRnZUISFQoGZWRnZV9jGAMgASgCUgVlZGdlQyJICg1QYXJhbGxlbG9ncmFtEh8KC2Jhc2VfbGVuZ3RoGAEgASgCUgpiYXNlTGVuZ3RoEhYKBmhlaWdodBgCIAEoAlIGaGVpZ2h0IiQKDEFyZWFSZXNwb25zZRIUCgV2YWx1ZRgBIAEoAlIFdmFsdWUySQoKQ2FsY3VsYXRvchI7CgljYWxjdWxhdGUSFS5wbHVnaW5zLlNoYXBlTWVzc2FnZRoVLnBsdWdpbnMuQXJlYVJlc3BvbnNlIgBCA9ACAWIGcHJvdG8z", + "protoFile": "syntax = \"proto3\";\n\npackage plugins;\n\noption php_generic_services = true;\n\nservice Calculator {\n rpc calculate (ShapeMessage) returns (AreaResponse) {}\n}\n\nmessage ShapeMessage {\n oneof shape {\n Square square = 1;\n Rectangle rectangle = 2;\n Circle circle = 3;\n Triangle triangle = 4;\n Parallelogram parallelogram = 5;\n }\n}\n\nmessage Square {\n float edge_length = 1;\n}\n\nmessage Rectangle {\n float length = 1;\n float width = 2;\n}\n\nmessage Circle {\n float radius = 1;\n}\n\nmessage Triangle {\n float edge_a = 1;\n float edge_b = 2;\n float edge_c = 3;\n}\n\nmessage Parallelogram {\n float base_length = 1;\n float height = 2;\n}\n\nmessage AreaResponse {\n float value = 1;\n}\n" + } + }, + "name": "protobuf", + "version": "0.3.13" + } + ] + }, + "provider": { + "name": "protobufSyncMessageProvider" + } +} \ No newline at end of file diff --git a/example/protobuf-sync-message/provider/.rr.yaml b/example/protobuf-sync-message/provider/.rr.yaml new file mode 100644 index 00000000..25f94af2 --- /dev/null +++ b/example/protobuf-sync-message/provider/.rr.yaml @@ -0,0 +1,11 @@ +version: "2.7" + +server: + command: "php worker.php" + +grpc: + listen: "tcp://127.0.0.1:9001" + proto: + - "../library/proto/area_calculator.proto" + pool: + num_workers: 1 diff --git a/example/protobuf-sync-message/provider/composer.json b/example/protobuf-sync-message/provider/composer.json new file mode 100644 index 00000000..247c45fc --- /dev/null +++ b/example/protobuf-sync-message/provider/composer.json @@ -0,0 +1,33 @@ +{ + "name": "pact-foundation/example-protobuf-sync-message-provider", + "require": { + "grpc/grpc": "^1.57", + "spiral/roadrunner-grpc": "^3.2", + "pact-foundation/composer-downloads-plugin": "^1.0" + }, + "require-dev": { + "ext-grpc": "*" + }, + "suggest": { + "ext-grpc": "If you want to use gRPC then you must install and use this" + }, + "extra": { + "downloads": { + "rr": { + "version": "2023.3.10", + "variables": { + "{$os}": "strtolower(PHP_OS_FAMILY)", + "{$architecture}": "in_array(php_uname('m'), ['x86_64', 'AMD64']) ? 'amd64' : 'arm64'", + "{$extension}": "(PHP_OS_FAMILY === 'Windows' || (PHP_OS_FAMILY === 'Darwin' && php_uname('m') === 'x86_64')) ? 'zip' : 'tar.gz'" + }, + "url": "https://github.com/roadrunner-server/roadrunner/releases/download/v{$version}/roadrunner-{$version}-{$os}-{$architecture}.{$extension}", + "path": "bin/roadrunner" + } + } + }, + "config": { + "allow-plugins": { + "pact-foundation/composer-downloads-plugin": true + } + } +} diff --git a/example/protobuf-sync-message/provider/phpunit.xml b/example/protobuf-sync-message/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/protobuf-sync-message/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/protobuf-sync-message/provider/src/Service/Calculator.php b/example/protobuf-sync-message/provider/src/Service/Calculator.php new file mode 100644 index 00000000..6ba7f07a --- /dev/null +++ b/example/protobuf-sync-message/provider/src/Service/Calculator.php @@ -0,0 +1,71 @@ +getShape()) { + case 'square': + $area = $this->calculateSquareArea($request->getSquare()); + break; + case 'rectangle': + $area = $this->calculateRectangleArea($request->getRectangle()); + break; + case 'circle': + $area = $this->calculateCircleArea($request->getCircle()); + break; + case 'triangle': + $area = $this->calculateTriangleArea($request->getTriangle()); + break; + case 'parallelogram': + $area = $this->calculateParallelogramArea($request->getParallelogram()); + break; + default: + throw new Exception(sprintf('Shape %s is not supported', $request->getShape())); + } + + return new AreaResponse(['value' => $area]); + } + + private function calculateSquareArea(Square $square): float + { + return pow($square->getEdgeLength(), 2); + } + + private function calculateRectangleArea(Rectangle $rectangle): float + { + return $rectangle->getWidth() * $rectangle->getLength(); + } + + private function calculateCircleArea(Circle $circle): float + { + return pi() * pow($circle->getRadius(), 2); + } + + /** + * Use Heron's formula. + */ + private function calculateTriangleArea(Triangle $triangle): float + { + $p = ($triangle->getEdgeA() + $triangle->getEdgeB() + $triangle->getEdgeC()) / 2; + + return sqrt($p * ($p - $triangle->getEdgeA()) * ($p - $triangle->getEdgeB()) * ($p - $triangle->getEdgeC())); + } + + private function calculateParallelogramArea(Parallelogram $parallelogram): float + { + return $parallelogram->getBaseLength() * $parallelogram->getHeight(); + } +} diff --git a/example/protobuf-sync-message/provider/src/Service/CalculatorInterface.php b/example/protobuf-sync-message/provider/src/Service/CalculatorInterface.php new file mode 100644 index 00000000..990b455a --- /dev/null +++ b/example/protobuf-sync-message/provider/src/Service/CalculatorInterface.php @@ -0,0 +1,15 @@ +process = new RoadRunnerProcess(); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + public function testPactVerifyConsumer(): void + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('protobufSyncMessageProvider') + ->setHost('127.0.0.1'); + $providerTransport = new ProviderTransport(); + $providerTransport + ->setProtocol('grpc') + ->setScheme('tcp') + ->setPort($this->process->getPort()) + ->setPath('/') + ; + $config->addProviderTransport($providerTransport); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/protobufSyncMessageConsumer-protobufSyncMessageProvider.json'); + + $this->assertTrue($verifier->verify()); + } +} diff --git a/example/protobuf-sync-message/provider/tests/RoadRunnerProcess.php b/example/protobuf-sync-message/provider/tests/RoadRunnerProcess.php new file mode 100644 index 00000000..acfa52b7 --- /dev/null +++ b/example/protobuf-sync-message/provider/tests/RoadRunnerProcess.php @@ -0,0 +1,22 @@ +setTimeout(120); + + return $process; + } +} diff --git a/example/protobuf-sync-message/provider/worker.php b/example/protobuf-sync-message/provider/worker.php new file mode 100644 index 00000000..55c59fb3 --- /dev/null +++ b/example/protobuf-sync-message/provider/worker.php @@ -0,0 +1,17 @@ + false, // optional (default: false) +]); + +$server->registerService(CalculatorInterface::class, new Calculator()); + +$server->serve(Worker::create()); diff --git a/example/src/Consumer/publish_json_example.php b/example/src/Consumer/publish_json_example.php deleted file mode 100644 index 9e91327b..00000000 --- a/example/src/Consumer/publish_json_example.php +++ /dev/null @@ -1,16 +0,0 @@ - 'someConsumer', - 'provider' => 'someProvider' -]); - -$httpService->publishJson('1.0.0', $json); diff --git a/example/src/MessageConsumer/ExampleMessageConsumer.php b/example/src/MessageConsumer/ExampleMessageConsumer.php deleted file mode 100644 index 0a821944..00000000 --- a/example/src/MessageConsumer/ExampleMessageConsumer.php +++ /dev/null @@ -1,22 +0,0 @@ -contents->text, true) . "\n"; - - return $obj; - } - - public function ProcessSong($message) - { - $obj = \json_decode($message); - print ' [x] Processed ' . \print_r($obj->contents->song, true) . "\n"; - - return $obj; - } -} diff --git a/example/src/MessageProvider/ExampleMessageProvider.php b/example/src/MessageProvider/ExampleMessageProvider.php deleted file mode 100644 index d038e8f2..00000000 --- a/example/src/MessageProvider/ExampleMessageProvider.php +++ /dev/null @@ -1,73 +0,0 @@ -metadata = $metadata; - } - - /** - * @return array - */ - public function getMetadata(): array - { - return $this->metadata; - } - - /** - * @param array $metadata - * - * @return ExampleMessageProvider - */ - public function setMetadata(array $metadata): self - { - $this->metadata = $metadata; - - return $this; - } - - /** - * @return mixed - */ - public function getContents() - { - return $this->contents; - } - - /** - * @param mixed $contents - * - * @return ExampleMessageProvider - */ - public function setContents($contents) - { - $this->contents = $contents; - - return $this; - } - - /** - * Build metadata and content for message - * - * @return string - */ - public function Build() - { - $obj = new \stdClass(); - $obj->metadata = $this->metadata; - $obj->contents = $this->contents; - - return \json_encode($obj); - } -} diff --git a/tests/_resources/someconsumer-someprovider.json b/example/stub-server/consumer/_resources/someconsumer-someprovider.json similarity index 100% rename from tests/_resources/someconsumer-someprovider.json rename to example/stub-server/consumer/_resources/someconsumer-someprovider.json diff --git a/example/stub-server/consumer/phpunit.xml b/example/stub-server/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/stub-server/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/stub-server/consumer/src/Service/HttpClientService.php b/example/stub-server/consumer/src/Service/HttpClientService.php new file mode 100644 index 00000000..0dbe59a6 --- /dev/null +++ b/example/stub-server/consumer/src/Service/HttpClientService.php @@ -0,0 +1,28 @@ +httpClient = new Client(); + $this->baseUri = $baseUri; + } + + public function getResults(): array + { + $response = $this->httpClient->get(new Uri("{$this->baseUri}/test")); + $body = $response->getBody(); + $object = \json_decode($body, null, 512, JSON_THROW_ON_ERROR); + + return $object->results; + } +} diff --git a/example/stub-server/consumer/tests/StubServerTest.php b/example/stub-server/consumer/tests/StubServerTest.php new file mode 100644 index 00000000..f39d22f3 --- /dev/null +++ b/example/stub-server/consumer/tests/StubServerTest.php @@ -0,0 +1,42 @@ +setDirs($dirs) + ->setExtension($extension) + ->setPort($port) + ->setLogLevel($logLevel); + + $stubServer = new StubServer($config); + $pid = $stubServer->start(); + $this->assertIsInt($pid); + + $service = new HttpClientService($config->getBaseUri()); + $results = $service->getResults(); + $this->assertEquals([ + (object) [ + 'name' => 'Games' + ] + ], $results); + } finally { + $result = $stubServer->stop(); + $this->assertTrue($result); + } + } +} diff --git a/example/tests/MessageConsumer/ExampleMessageConsumerTest.php b/example/tests/MessageConsumer/ExampleMessageConsumerTest.php deleted file mode 100644 index b0cb7973..00000000 --- a/example/tests/MessageConsumer/ExampleMessageConsumerTest.php +++ /dev/null @@ -1,106 +0,0 @@ -setConsumer('test_consumer') - ->setProvider('test_provider') - ->setPactDir(__DIR__ . '/../../output/'); - } - - public static function tearDownAfterClass(): void - { - parent::tearDownAfterClass(); - - // build out brokerHttpService as your example - /* - $brokerHttpService = new BrokerHttpClient(new GuzzleClient(), new Uri($pactBrokerUri)); - $brokerHttpService->publishJson($json, $consumerVersion); - $brokerHttpService->tag($this->mockServerConfig->getConsumer(), $consumerVersion, $tag); - */ - } - - /** - * @throws Exception - */ - public function testProcessText() - { - $builder = new MessageBuilder(self::$config); - - $contents = new stdClass(); - $contents->text = 'Hello Mary'; - - $metadata = ['queue' => 'wind cries', 'routing_key' => 'wind cries']; - - $builder - ->given('a message', ['foo']) - ->expectsToReceive('an alligator named Mary exists') - ->withMetadata($metadata) - ->withContent($contents); - - // established mechanism to this via callbacks - $consumerMessage = new ExampleMessageConsumer(); - $callback = [$consumerMessage, 'ProcessText']; - $builder->setCallback($callback); - - $hasException = false; - - $builder->verify(); - - $this->assertTrue(true, 'Expects to reach this true statement by running verify()'); - } - - /** - * @throws Exception - */ - public function testProcessSong() - { - $builder = new MessageBuilder(self::$config); - - $contents = new stdClass(); - $contents->song = 'And the wind whispers Mary'; - - $metadata = ['queue' => 'And the clowns have all gone to bed', 'routing_key' => 'And the clowns have all gone to bed']; - - $builder - ->given('You can hear happiness staggering on down the street') - ->expectsToReceive('footprints dressed in red') - ->withMetadata($metadata) - ->withContent($contents); - - // established mechanism to this via callbacks - $consumerMessage = new ExampleMessageConsumer(); - $callback = [$consumerMessage, 'ProcessSong']; - $builder->setCallback($callback); - - $hasException = false; - - try { - $builder->verify(); - } catch (Exception $e) { - $hasException = true; - } - - $this->assertFalse($hasException, 'Expects verification to pass without exceptions being thrown'); - } -} diff --git a/example/tests/MessageProvider/ExampleMessageProviderTest.php b/example/tests/MessageProvider/ExampleMessageProviderTest.php deleted file mode 100644 index 8d332061..00000000 --- a/example/tests/MessageProvider/ExampleMessageProviderTest.php +++ /dev/null @@ -1,61 +0,0 @@ -text = 'Hello Mary'; - - $metadata = []; - $metadata['queue'] = 'myKey'; - - $provider = (new ExampleMessageProvider()) - ->setContents($content) - ->setMetadata($metadata); - - return $provider->Build(); - }; - - $callbacks['footprints dressed in red'] = function () { - $content = new \stdClass(); - $content->song = 'And the wind whispers Mary'; - - $metadata = []; - $metadata['queue'] = 'myKey'; - - $provider = (new ExampleMessageProvider()) - ->setContents($content) - ->setMetadata($metadata); - - return $provider->Build(); - }; - - $config = new VerifierConfig(); - $config - ->setProviderName('someProvider') // Providers name to fetch. - ->setPublishResults(false); // Flag the verifier service to publish the results to the Pact Broker. - - // Verify that the Consumer 'someConsumer' that is tagged with 'master' is valid. - $verifier = (new MessageVerifier($config)) - ->setCallbacks($callbacks) - ->verifyFiles([__DIR__ . '/../../pacts/test_consumer-test_provider.json']); - - // This will not be reached if the PACT verifier throws an error, otherwise it was successful. - $this->assertTrue(true, 'Expects to reach true by running verification'); - } -} diff --git a/example/tests/Provider/PactVerifyTest.php b/example/tests/Provider/PactVerifyTest.php deleted file mode 100644 index f1927caa..00000000 --- a/example/tests/Provider/PactVerifyTest.php +++ /dev/null @@ -1,60 +0,0 @@ -processRunner = new ProcessRunner('php', ['-S', 'localhost:7202', '-t', $publicPath]); - - $this->processRunner->run(); - } - - /** - * Stop the web server process once complete. - */ - protected function tearDown(): void - { - $this->processRunner->stop(); - } - - /** - * This test will run after the web server is started. - */ - public function testPactVerifyConsumer() - { - $config = new VerifierConfig(); - $config - ->setProviderName('someProvider') // Providers name to fetch. - ->setProviderVersion('1.0.0') // Providers version. - ->setProviderBranch('main') // Providers git branch - ->setProviderBaseUrl(new Uri('http://localhost:7202')) // URL of the Provider. - ; // Flag the verifier service to publish the results to the Pact Broker. - - // Verify that the Consumer 'someConsumer' that is tagged with 'master' is valid. - $verifier = new Verifier($config); - $verifier->verifyFiles([__DIR__ . '/../../pacts/someconsumer-someprovider.json']); - - // This will not be reached if the PACT verifier throws an error, otherwise it was successful. - $this->assertTrue(true, 'Pact Verification has failed.'); - } -} diff --git a/example/xml/consumer/phpunit.xml b/example/xml/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/xml/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/xml/consumer/src/Service/HttpClientService.php b/example/xml/consumer/src/Service/HttpClientService.php new file mode 100644 index 00000000..f13a2e16 --- /dev/null +++ b/example/xml/consumer/src/Service/HttpClientService.php @@ -0,0 +1,28 @@ +httpClient = new Client(); + $this->baseUri = $baseUri; + } + + public function getMovies(): string + { + $response = $this->httpClient->get(new Uri("{$this->baseUri}/movies"), [ + 'headers' => ['Accept' => 'application/xml'] + ]); + + return $response->getBody(); + } +} diff --git a/example/xml/consumer/tests/Service/HttpClientServiceTest.php b/example/xml/consumer/tests/Service/HttpClientServiceTest.php new file mode 100644 index 00000000..d00993b7 --- /dev/null +++ b/example/xml/consumer/tests/Service/HttpClientServiceTest.php @@ -0,0 +1,125 @@ +setMethod('GET') + ->setPath('/movies') + ->addHeader('Accept', $matcher->regex('application/xml', 'application\/.*xml')); + + $xmlBuilder = new XmlBuilder('1.0', 'UTF-8'); + $xmlBuilder + ->root( + $xmlBuilder->name('movies'), + $xmlBuilder->content('List of movies'), + $xmlBuilder->eachLike( + $xmlBuilder->examples(1), + $xmlBuilder->name('movie'), + $xmlBuilder->add( + $xmlBuilder->name('title'), + $xmlBuilder->contentLike('Big Buck Bunny'), + ), + $xmlBuilder->add( + $xmlBuilder->name('characters'), + $xmlBuilder->eachLike( + $xmlBuilder->examples(2), + $xmlBuilder->name('character'), + $xmlBuilder->add( + $xmlBuilder->name('name'), + $xmlBuilder->contentLike('Big Buck Bunny'), + ), + $xmlBuilder->add( + $xmlBuilder->name('actor'), + $xmlBuilder->contentLike('Jan Morgenstern'), + ), + ), + ), + $xmlBuilder->add( + $xmlBuilder->name('plot'), + $xmlBuilder->contentLike( + $plot = <<add( + $xmlBuilder->name('great-lines'), + $xmlBuilder->eachLike( + $xmlBuilder->name('line'), + $xmlBuilder->contentLike('Open source movie'), + ), + ), + $xmlBuilder->add( + $xmlBuilder->name('rating'), + $xmlBuilder->attribute('type', $matcher->regex('stars', 'stars|thumbs')), + $xmlBuilder->contentLike(6), + ), + // TODO: implement XML generators + // $xmlBuilder->add( + // $xmlBuilder->name('release-date'), + // $xmlBuilder->content($matcher->date('dd-MM-yyyy')), + // ), + ), + ); + + $response = new ProviderResponse(); + $response + ->setStatus(200) + ->addHeader('Content-Type', $matcher->regex('application/xml', 'application\/.*xml')) + ->setBody( + json_encode($xmlBuilder) + ); + + $config = new MockServerConfig(); + $config + ->setConsumer('xmlConsumer') + ->setProvider('xmlProvider') + ->setPactDir(__DIR__.'/../../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $builder = new InteractionBuilder($config); + $builder + ->given('Movies exist') + ->uponReceiving('A get request to /movies') + ->with($request) + ->willRespondWith($response); + + $service = new HttpClientService($config->getBaseUri()); + $movies = new \SimpleXMLElement($service->getMovies()); + $verifyResult = $builder->verify(); + + $this->assertTrue($verifyResult); + $this->assertCount(1, $movies->movie); + $this->assertEquals('Big Buck Bunny', $movies->movie[0]->title); + // TODO: investigate why mock server replace "\r\n" by "\n" on Windows + $this->assertEquals(str_replace("\r\n", "\n", $plot), $movies->movie[0]->plot); + $this->assertCount(1, $movies->movie[0]->{'great-lines'}->line); + $this->assertEquals('Open source movie', $movies->movie[0]->{'great-lines'}->line[0]); + $this->assertEquals('6', $movies->movie[0]->rating); + $this->assertCount(2, $movies->movie[0]->characters->character); + $this->assertEquals('Big Buck Bunny', $movies->movie[0]->characters->character[0]->name); + $this->assertEquals('Jan Morgenstern', $movies->movie[0]->characters->character[0]->actor); + // TODO: implement XML generators + $this->assertEquals('', $movies->movie[0]->characters->character[1]->name); + $this->assertEquals('', $movies->movie[0]->characters->character[1]->actor); + //$this->assertEquals('', $movies->movie[0]->{'release-date'}[0]); + } +} diff --git a/example/xml/pacts/xmlConsumer-xmlProvider.json b/example/xml/pacts/xmlConsumer-xmlProvider.json new file mode 100644 index 00000000..94856eb6 --- /dev/null +++ b/example/xml/pacts/xmlConsumer-xmlProvider.json @@ -0,0 +1,152 @@ +{ + "consumer": { + "name": "xmlConsumer" + }, + "interactions": [ + { + "description": "A get request to /movies", + "providerStates": [ + { + "name": "Movies exist" + } + ], + "request": { + "headers": { + "Accept": "application/xml" + }, + "matchingRules": { + "header": { + "Accept": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application\\/.*xml" + } + ] + } + } + }, + "method": "GET", + "path": "/movies" + }, + "response": { + "body": "Big Buck BunnyBig Buck BunnyJan MorgensternThe plot follows a day in the life of Big Buck Bunny, during which time he meets three bullying rodents: the leader, Frank the flying squirrel, and his sidekicks Rinky the red squirrel and Gimera the chinchilla.\nThe rodents amuse themselves by harassing helpless creatures of the forest by throwing fruits, nuts, and rocks at them.Open source movie6List of movies", + "headers": { + "Content-Type": "application/xml" + }, + "matchingRules": { + "body": { + "$.movies.movie": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.movies.movie.characters.character": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.movies.movie.characters.character.actor.#text": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.movies.movie.characters.character.name.#text": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.movies.movie.great-lines.line": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.movies.movie.great-lines.line.#text": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.movies.movie.plot.#text": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.movies.movie.rating.#text": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.movies.movie.rating['@type']": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "stars|thumbs" + } + ] + }, + "$.movies.movie.title.#text": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + } + }, + "header": { + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "application\\/.*xml" + } + ] + } + }, + "status": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.14", + "mockserver": "1.2.5", + "models": "1.1.17" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "xmlProvider" + } +} \ No newline at end of file diff --git a/example/xml/provider/phpunit.xml b/example/xml/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/xml/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/xml/provider/public/index.php b/example/xml/provider/public/index.php new file mode 100644 index 00000000..f9878e33 --- /dev/null +++ b/example/xml/provider/public/index.php @@ -0,0 +1,51 @@ +get('/movies', function (Request $request, Response $response) { + $response->getBody()->write( + << + + List of movies + + PHP: Behind the Parser + + + Ms. Coder + Onlivia Actora + + + Mr. Coder + El ActÓr + + + + So, this language. It's like, a programming language. Or is it a + scripting language? All is revealed in this thrilling horror spoof + of a documentary. + + + PHP solves all my web problems + + 7 + 5 + + + XML + ); + + return $response->withHeader('Content-Type', 'application/movies+xml'); +}); + +$app->post('/pact-change-state', function (Request $request, Response $response) { + return $response; +}); + +$app->run(); diff --git a/example/xml/provider/tests/PactVerifyTest.php b/example/xml/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..6116e68c --- /dev/null +++ b/example/xml/provider/tests/PactVerifyTest.php @@ -0,0 +1,50 @@ +process = new PhpProcess(__DIR__ . '/../public/'); + $this->process->start(); + } + + protected function tearDown(): void + { + $this->process->stop(); + } + + /** + * This test will run after the web server is started. + */ + public function testPactVerifyConsumer() + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('xmlProvider') // Providers name to fetch. + ->setHost('localhost') + ->setPort($this->process->getPort()); + $config->getProviderState() + ->setStateChangeUrl(new Uri(sprintf('http://localhost:%d/pact-change-state', $this->process->getPort()))) + ; + if ($level = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($level); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/xmlConsumer-xmlProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..1d5823a9 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 7 + paths: + - src diff --git a/phpunit.xml b/phpunit.xml index 35d425c2..c44f4f24 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,31 +1,60 @@ - - - - ./src/PhpPact - + + - - ./tests/PhpPact + + ./tests + + + ./example/json + + + ./example/binary + + + ./example/multipart + + + ./example/xml + + + ./example/message + + + ./example/matchers + + + ./example/generators + + + ./example/csv + + + ./example/protobuf-sync-message + + + ./example/protobuf-async-message + + + ./example/stub-server - - - - - - + + + ./src/PhpPact + + diff --git a/src/PhpPact/Broker/Service/BrokerHttpClient.php b/src/PhpPact/Broker/Service/BrokerHttpClient.php deleted file mode 100644 index 53be68e7..00000000 --- a/src/PhpPact/Broker/Service/BrokerHttpClient.php +++ /dev/null @@ -1,109 +0,0 @@ - - */ - private array $headers; - - /** - * {@inheritdoc} - */ - public function __construct(ClientInterface $httpClient, UriInterface $baseUri, array $headers = []) - { - $this->httpClient = $httpClient; - $this->baseUri = $baseUri; - $this->headers = $headers; - - if (!\array_key_exists('Content-Type', $headers)) { - $this->headers['Content-Type'] = 'application/json'; - } - } - - /** - * {@inheritdoc} - */ - public function publishJson(string $version, string $json): void - { - $array = \json_decode($json, true, 512, JSON_THROW_ON_ERROR); - $consumer = $array['consumer']['name']; - $provider = $array['provider']['name']; - - $uri = $this->baseUri->withPath("/pacts/provider/{$provider}/consumer/{$consumer}/version/{$version}"); - - $this->httpClient->put($uri, [ - 'headers' => $this->headers, - 'body' => $json, - ]); - } - - /** - * {@inheritdoc} - */ - public function tag(string $consumer, string $version, string $tag): void - { - $uri = $this->baseUri->withPath("/pacticipants/{$consumer}/versions/{$version}/tags/{$tag}"); - $this->httpClient->put($uri, [ - 'headers' => $this->headers, - ]); - } - - /** - * {@inheritdoc} - */ - public function getAllConsumerUrls(string $provider, string $version = 'latest'): array - { - if ($version !== 'latest') { - @\trigger_error(\sprintf('The second argument "version" in "%s()" method makes no sense and will be removed in any upcoming major version', __METHOD__), E_USER_DEPRECATED); - } - - $uri = $this->baseUri->withPath("/pacts/provider/{$provider}/latest"); - - $response = $this->httpClient->get($uri, [ - 'headers' => $this->headers, - ]); - - $json = \json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); - - $urls = []; - foreach ($json['_links']['pacts'] as $pact) { - $urls[] = $pact['href']; - } - - return $urls; - } - - /** - * {@inheritdoc} - */ - public function getAllConsumerUrlsForTag(string $provider, string $tag): array - { - $uri = $this->baseUri->withPath("/pacts/provider/{$provider}/latest/{$tag}"); - - $response = $this->httpClient->get($uri, [ - 'headers' => $this->headers, - ]); - - $json = \json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR); - - $urls = []; - foreach ($json['_links']['pacts'] as $pact) { - $urls[] = $pact['href']; - } - - return $urls; - } -} diff --git a/src/PhpPact/Broker/Service/BrokerHttpClientInterface.php b/src/PhpPact/Broker/Service/BrokerHttpClientInterface.php deleted file mode 100644 index e4baa16a..00000000 --- a/src/PhpPact/Broker/Service/BrokerHttpClientInterface.php +++ /dev/null @@ -1,49 +0,0 @@ - $headers additional headers - */ - public function __construct(ClientInterface $client, UriInterface $baseUri, array $headers); - - /** - * Publish JSON. - * - * @param string $version Consumer version - * @param string $json PACT File JSON - */ - public function publishJson(string $version, string $json): void; - - /** - * Tag a consumer version with a tag. - */ - public function tag(string $consumer, string $version, string $tag): void; - - /** - * Get all Pact urls for the consumer. - * - * @param string $provider provider name - * @param string $version version of the provider - * - * @return array - */ - public function getAllConsumerUrls(string $provider, string $version = 'latest'): array; - - /** - * Get all Pact URLs for a specific tag. - * - * @return array - */ - public function getAllConsumerUrlsForTag(string $provider, string $tag): array; -} diff --git a/src/PhpPact/Config/LogLevelTrait.php b/src/PhpPact/Config/LogLevelTrait.php new file mode 100644 index 00000000..4d109e87 --- /dev/null +++ b/src/PhpPact/Config/LogLevelTrait.php @@ -0,0 +1,24 @@ +logLevel; + } + + public function setLogLevel(string $logLevel): self + { + $logLevel = \strtoupper($logLevel); + if (!\in_array($logLevel, ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'OFF', 'NONE'])) { + throw new \InvalidArgumentException('LogLevel ' . $logLevel . ' not supported.'); + } + $this->logLevel = $logLevel; + + return $this; + } +} diff --git a/src/PhpPact/Config/PactConfig.php b/src/PhpPact/Config/PactConfig.php new file mode 100644 index 00000000..2d964dab --- /dev/null +++ b/src/PhpPact/Config/PactConfig.php @@ -0,0 +1,180 @@ +consumer; + } + + /** + * {@inheritdoc} + */ + public function setConsumer(string $consumer): self + { + $this->consumer = $consumer; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getProvider(): string + { + return $this->provider; + } + + /** + * {@inheritdoc} + */ + public function setProvider(string $provider): self + { + $this->provider = $provider; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getPactDir(): string + { + if ($this->pactDir === null) { + return \sys_get_temp_dir(); + } + + return $this->pactDir; + } + + /** + * {@inheritdoc} + */ + public function setPactDir(?string $pactDir): self + { + if ($pactDir === null) { + return $this; + } + + if ('\\' !== \DIRECTORY_SEPARATOR) { + $pactDir = \str_replace('\\', \DIRECTORY_SEPARATOR, $pactDir); + } + + $this->pactDir = $pactDir; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getPactSpecificationVersion(): string + { + return $this->pactSpecificationVersion; + } + + /** + * {@inheritdoc} + * + * @throws \UnexpectedValueException + */ + public function setPactSpecificationVersion(string $pactSpecificationVersion): self + { + /* + * Parse the version but do not assign it. If it is an invalid version, an exception is thrown + */ + $parser = new VersionParser(); + $parser->normalize($pactSpecificationVersion); + + $this->pactSpecificationVersion = $pactSpecificationVersion; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getLog(): ?string + { + return $this->log; + } + + /** + * {@inheritdoc} + */ + public function setLog(string $log): self + { + $this->log = $log; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getPactFileWriteMode(): string + { + return $this->pactFileWriteMode; + } + + /** + * {@inheritdoc} + */ + public function setPactFileWriteMode(string $pactFileWriteMode): self + { + $options = [self::MODE_OVERWRITE, self::MODE_MERGE]; + + if (!\in_array($pactFileWriteMode, $options)) { + $implodedOptions = \implode(', ', $options); + + throw new \InvalidArgumentException("Invalid PhpPact File Write Mode, value must be one of the following: {$implodedOptions}."); + } + + $this->pactFileWriteMode = $pactFileWriteMode; + + return $this; + } +} diff --git a/src/PhpPact/Standalone/PactConfigInterface.php b/src/PhpPact/Config/PactConfigInterface.php similarity index 62% rename from src/PhpPact/Standalone/PactConfigInterface.php rename to src/PhpPact/Config/PactConfigInterface.php index 63b6b818..71f76b3c 100644 --- a/src/PhpPact/Standalone/PactConfigInterface.php +++ b/src/PhpPact/Config/PactConfigInterface.php @@ -1,12 +1,20 @@ message = new Message(); + } + + /** + * @param string $name what is given to the request + * @param array $params for that request + * @param bool $overwrite clear pass states completely and start this array + */ + public function given(string $name, array $params = [], bool $overwrite = false): self + { + $this->message->setProviderState($name, $params, $overwrite); + + return $this; + } + + /** + * @param string $description what is received when the request is made + */ + public function expectsToReceive(string $description): self + { + $this->message->setDescription($description); + + return $this; + } + + /** + * @param array $metadata what is the additional metadata of the message + */ + public function withMetadata(array $metadata): self + { + $this->message->setMetadata($metadata); + + return $this; + } + + /** + * Make the http request to the Mock Service to register the message. Content is required. + * + * @param mixed $contents required to be in the message + */ + public function withContent(mixed $contents): self + { + $this->message->setContents($contents); + + return $this; + } + + /** + * Set key for message interaction. This feature only work with specification v4. It doesn't affect pact file with specification <= v3. + */ + public function key(?string $key): self + { + $this->message->setKey($key); + + return $this; + } + + /** + * Mark the message interaction as pending. This feature only work with specification v4. It doesn't affect pact file with specification <= v3. + */ + public function pending(?bool $pending): self + { + $this->message->setPending($pending); + + return $this; + } + + /** + * Add comments to the message interaction. This feature only work with specification v4. It doesn't affect pact file with specification <= v3. + * + * @param array $comments + */ + public function comments(array $comments): self + { + $this->message->setComments($comments); + + return $this; + } +} diff --git a/src/PhpPact/Consumer/BuilderInterface.php b/src/PhpPact/Consumer/BuilderInterface.php index 5fc039dc..58ef8eda 100644 --- a/src/PhpPact/Consumer/BuilderInterface.php +++ b/src/PhpPact/Consumer/BuilderInterface.php @@ -11,9 +11,4 @@ interface BuilderInterface * Verify that the interactions are valid. */ public function verify(): bool; - - /** - * Write the Pact without deleting the interactions. - */ - public function writePact(): bool; } diff --git a/src/PhpPact/Consumer/Driver/Body/InteractionBodyDriver.php b/src/PhpPact/Consumer/Driver/Body/InteractionBodyDriver.php new file mode 100644 index 00000000..77bad1ec --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Body/InteractionBodyDriver.php @@ -0,0 +1,56 @@ +getBody($interactionPart); + $partId = match ($interactionPart) { + InteractionPart::REQUEST => $this->client->get('InteractionPart_Request'), + InteractionPart::RESPONSE => $this->client->get('InteractionPart_Response'), + }; + switch (true) { + case $body instanceof Binary: + $data = $body->getData(); + $success = $this->client->call('pactffi_with_binary_file', $interaction->getHandle(), $partId, $body->getContentType(), $data->getValue(), $data->getSize()); + break; + + case $body instanceof Text: + $success = $this->client->call('pactffi_with_body', $interaction->getHandle(), $partId, $body->getContentType(), $body->getContents()); + break; + + case $body instanceof Multipart: + foreach ($body->getParts() as $part) { + $result = $this->client->call('pactffi_with_multipart_file_v2', $interaction->getHandle(), $partId, $part->getContentType(), $part->getPath(), $part->getName(), $body->getBoundary()); + if ($result->failed instanceof CData) { + throw new PartNotAddedException(sprintf("Can not add part '%s': %s", $part->getName(), FFI::string($result->failed))); + } + } + $success = true; + break; + + default: + break; + }; + if (isset($success) && false === $success) { + throw new InteractionBodyNotAddedException(); + } + } +} diff --git a/src/PhpPact/Consumer/Driver/Body/InteractionBodyDriverInterface.php b/src/PhpPact/Consumer/Driver/Body/InteractionBodyDriverInterface.php new file mode 100644 index 00000000..49f797b5 --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Body/InteractionBodyDriverInterface.php @@ -0,0 +1,11 @@ +getContents(); + $partId = $this->client->get('InteractionPart_Request'); + switch (true) { + case $body instanceof Binary: + $data = $body->getData(); + $success = $this->client->call('pactffi_with_binary_file', $message->getHandle(), $partId, $body->getContentType(), $data->getValue(), $data->getSize()); + break; + + case $body instanceof Text: + $success = $this->client->call('pactffi_with_body', $message->getHandle(), $partId, $body->getContentType(), $body->getContents()); + break; + + default: + break; + }; + if (isset($success) && false === $success) { + throw new MessageContentsNotAddedException(); + } + } +} diff --git a/src/PhpPact/Consumer/Driver/Body/MessageBodyDriverInterface.php b/src/PhpPact/Consumer/Driver/Body/MessageBodyDriverInterface.php new file mode 100644 index 00000000..5ebd774d --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Body/MessageBodyDriverInterface.php @@ -0,0 +1,10 @@ + 'The function panicked.', + 2 => 'The pact file was not able to be written.', + 3 => 'The pact for the given handle was not found.', + default => 'Unknown error', + }; + parent::__construct($message, $code); + } +} diff --git a/src/PhpPact/Consumer/Driver/Exception/PartNotAddedException.php b/src/PhpPact/Consumer/Driver/Exception/PartNotAddedException.php new file mode 100644 index 00000000..9e474e2d --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Exception/PartNotAddedException.php @@ -0,0 +1,7 @@ +getKey(); + if (null === $key) { + return; + } + $success = $this->client->call('pactffi_set_key', $interaction->getHandle(), $key); + if (!$success) { + throw new InteractionKeyNotSetException(sprintf("Can not set the key '%s' for the interaction '%s'", $key, $interaction->getDescription())); + } + } + + protected function setPending(Interaction|Message $interaction): void + { + $pending = $interaction->getPending(); + if (null === $pending) { + return; + } + $success = $this->client->call('pactffi_set_pending', $interaction->getHandle(), $pending); + if (!$success) { + throw new InteractionPendingNotSetException(sprintf("Can not mark interaction '%s' as pending", $interaction->getDescription())); + } + } + + protected function setComments(Interaction|Message $interaction): void + { + foreach ($interaction->getComments() as $key => $value) { + $success = $this->client->call('pactffi_set_comment', $interaction->getHandle(), $key, $value); + if (!$success) { + throw new InteractionCommentNotSetException(sprintf("Can add comment '%s' to the interaction '%s'", $key, $interaction->getDescription())); + } + } + } +} diff --git a/src/PhpPact/Consumer/Driver/Interaction/AbstractMessageDriver.php b/src/PhpPact/Consumer/Driver/Interaction/AbstractMessageDriver.php new file mode 100644 index 00000000..6025ec62 --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Interaction/AbstractMessageDriver.php @@ -0,0 +1,75 @@ +messageBodyDriver = $messageBodyDriver ?? new MessageBodyDriver($client); + } + + public function registerMessage(Message $message): void + { + $this->pactDriver->setUp(); + $this->newInteraction($message); + $this->given($message); + $this->expectsToReceive($message); + $this->withMetadata($message); + $this->withContents($message); + $this->setKey($message); + $this->setPending($message); + $this->setComments($message); + } + + public function writePactAndCleanUp(): void + { + $this->pactDriver->writePact(); + $this->pactDriver->cleanUp(); + } + + protected function newInteraction(Message $message): void + { + $handle = $this->client->call('pactffi_new_message_interaction', $this->pactDriver->getPact()->handle, $message->getDescription()); + $message->setHandle($handle); + } + + private function withContents(Message $message): void + { + $this->messageBodyDriver->registerBody($message); + } + + private function expectsToReceive(Message $message): void + { + $this->client->call('pactffi_message_expects_to_receive', $message->getHandle(), $message->getDescription()); + } + + protected function given(Message $message): void + { + foreach ($message->getProviderStates() as $providerState) { + $this->client->call('pactffi_message_given', $message->getHandle(), $providerState->getName()); + foreach ($providerState->getParams() as $key => $value) { + $this->client->call('pactffi_message_given_with_param', $message->getHandle(), $providerState->getName(), (string) $key, (string) $value); + } + } + } + + private function withMetadata(Message $message): void + { + foreach ($message->getMetadata() as $key => $value) { + $this->client->call('pactffi_message_with_metadata_v2', $message->getHandle(), (string) $key, (string) $value); + } + } +} diff --git a/src/PhpPact/Consumer/Driver/Interaction/DriverInterface.php b/src/PhpPact/Consumer/Driver/Interaction/DriverInterface.php new file mode 100644 index 00000000..d1e2c80a --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Interaction/DriverInterface.php @@ -0,0 +1,8 @@ +requestDriver = $requestDriver ?? new RequestDriver($client); + $this->responseDriver = $responseDriver ?? new ResponseDriver($client); + } + + public function verifyInteractions(): VerifyResult + { + return $this->mockServer->verify(); + } + + public function registerInteraction(Interaction $interaction, bool $startMockServer = true): bool + { + $this->pactDriver->setUp(); + $this->newInteraction($interaction); + $this->given($interaction); + $this->uponReceiving($interaction); + $this->withRequest($interaction); + $this->willRespondWith($interaction); + $this->setKey($interaction); + $this->setPending($interaction); + $this->setComments($interaction); + + if ($startMockServer) { + $this->mockServer->start(); + } + + return true; + } + + public function writePactAndCleanUp(): void + { + $this->mockServer->writePact(); + $this->mockServer->cleanUp(); + } + + protected function newInteraction(Interaction $interaction): void + { + $handle = $this->client->call('pactffi_new_interaction', $this->pactDriver->getPact()->handle, $interaction->getDescription()); + $interaction->setHandle($handle); + } + + private function uponReceiving(Interaction $interaction): void + { + $this->client->call('pactffi_upon_receiving', $interaction->getHandle(), $interaction->getDescription()); + } + + private function given(Interaction $interaction): void + { + foreach ($interaction->getProviderStates() as $providerState) { + $this->client->call('pactffi_given', $interaction->getHandle(), $providerState->getName()); + foreach ($providerState->getParams() as $key => $value) { + $this->client->call('pactffi_given_with_param', $interaction->getHandle(), $providerState->getName(), (string) $key, (string) $value); + } + } + } + + private function withRequest(Interaction $interaction): void + { + $this->requestDriver->registerRequest($interaction); + } + + private function willRespondWith(Interaction $interaction): void + { + $this->responseDriver->registerResponse($interaction); + } +} diff --git a/src/PhpPact/Consumer/Driver/Interaction/InteractionDriverInterface.php b/src/PhpPact/Consumer/Driver/Interaction/InteractionDriverInterface.php new file mode 100644 index 00000000..5b592473 --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Interaction/InteractionDriverInterface.php @@ -0,0 +1,14 @@ +client->call('pactffi_message_reify', $message->getHandle()); + } +} diff --git a/src/PhpPact/Consumer/Driver/Interaction/MessageDriverInterface.php b/src/PhpPact/Consumer/Driver/Interaction/MessageDriverInterface.php new file mode 100644 index 00000000..008a5ac1 --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Interaction/MessageDriverInterface.php @@ -0,0 +1,10 @@ +bodyDriver = $bodyDriver ?? new InteractionBodyDriver($client); + } + + protected function withBody(Interaction $interaction, InteractionPart $part): void + { + $this->bodyDriver->registerBody($interaction, $part); + } + + protected function withHeaders(Interaction $interaction, InteractionPart $interactionPart): void + { + $headers = $interaction->getHeaders($interactionPart); + $partId = match ($interactionPart) { + InteractionPart::REQUEST => $this->client->get('InteractionPart_Request'), + InteractionPart::RESPONSE => $this->client->get('InteractionPart_Response'), + }; + foreach ($headers as $header => $values) { + foreach (array_values($values) as $index => $value) { + $this->client->call('pactffi_with_header_v2', $interaction->getHandle(), $partId, (string) $header, (int) $index, (string) $value); + } + } + } +} diff --git a/src/PhpPact/Consumer/Driver/InteractionPart/RequestDriver.php b/src/PhpPact/Consumer/Driver/InteractionPart/RequestDriver.php new file mode 100644 index 00000000..7f25fb2e --- /dev/null +++ b/src/PhpPact/Consumer/Driver/InteractionPart/RequestDriver.php @@ -0,0 +1,32 @@ +withBody($interaction, InteractionPart::REQUEST); + $this->withHeaders($interaction, InteractionPart::REQUEST); + $this->withQueryParameters($interaction); + $this->withRequest($interaction); + } + + private function withQueryParameters(Interaction $interaction): void + { + foreach ($interaction->getRequest()->getQuery() as $key => $values) { + foreach (array_values($values) as $index => $value) { + $this->client->call('pactffi_with_query_parameter_v2', $interaction->getHandle(), (string) $key, (int) $index, (string) $value); + } + } + } + + private function withRequest(Interaction $interaction): void + { + $request = $interaction->getRequest(); + $this->client->call('pactffi_with_request', $interaction->getHandle(), $request->getMethod(), $request->getPath()); + } +} diff --git a/src/PhpPact/Consumer/Driver/InteractionPart/RequestDriverInterface.php b/src/PhpPact/Consumer/Driver/InteractionPart/RequestDriverInterface.php new file mode 100644 index 00000000..2cc6d04c --- /dev/null +++ b/src/PhpPact/Consumer/Driver/InteractionPart/RequestDriverInterface.php @@ -0,0 +1,10 @@ +withHeaders($interaction, InteractionPart::RESPONSE); + $this->withBody($interaction, InteractionPart::RESPONSE); + $this->withResponse($interaction); + } + + private function withResponse(Interaction $interaction): void + { + $this->client->call('pactffi_response_status_v2', $interaction->getHandle(), $interaction->getResponse()->getStatus()); + } +} diff --git a/src/PhpPact/Consumer/Driver/InteractionPart/ResponseDriverInterface.php b/src/PhpPact/Consumer/Driver/InteractionPart/ResponseDriverInterface.php new file mode 100644 index 00000000..8fd9eebf --- /dev/null +++ b/src/PhpPact/Consumer/Driver/InteractionPart/ResponseDriverInterface.php @@ -0,0 +1,10 @@ +validatePact(); + $this->client->call('pactffi_free_pact_handle', $this->pact->handle); + $this->pact = null; + } + + public function writePact(): void + { + $this->validatePact(); + $error = $this->client->call( + 'pactffi_pact_handle_write_file', + $this->pact->handle, + $this->config->getPactDir(), + $this->config->getPactFileWriteMode() === PactConfigInterface::MODE_OVERWRITE + ); + if ($error) { + throw new PactFileNotWrittenException($error); + } + } + + public function getPact(): Pact + { + $this->validatePact(); + + return $this->pact; + } + + public function setUp(): void + { + if ($this->pact) { + return; + } + $this->initWithLogLevel(); + $this->newPact(); + $this->withSpecification(); + } + + protected function getSpecification(): int + { + return match (true) { + $this->versionEqualTo('1.0.0') => $this->client->get('PactSpecification_V1'), + $this->versionEqualTo('1.1.0') => $this->client->get('PactSpecification_V1_1'), + $this->versionEqualTo('2.0.0') => $this->client->get('PactSpecification_V2'), + $this->versionEqualTo('3.0.0') => $this->client->get('PactSpecification_V3'), + $this->versionEqualTo('4.0.0') => $this->client->get('PactSpecification_V4'), + default => call_user_func(function () { + trigger_error(sprintf("Specification version '%s' is unknown", $this->config->getPactSpecificationVersion()), E_USER_WARNING); + + return $this->client->get('PactSpecification_Unknown'); + }), + }; + } + + protected function validatePact(): void + { + if (!$this->pact) { + throw new MissingPactException(); + } + } + + private function versionEqualTo(string $version): bool + { + return Comparator::equalTo($this->config->getPactSpecificationVersion(), $version); + } + + private function initWithLogLevel(): void + { + $logLevel = $this->config->getLogLevel(); + if ($logLevel) { + $this->client->call('pactffi_init_with_log_level', $logLevel); + } + } + + private function newPact(): void + { + $this->pact = new Pact($this->client->call('pactffi_new_pact', $this->config->getConsumer(), $this->config->getProvider())); + } + + private function withSpecification(): void + { + $this->client->call('pactffi_with_specification', $this->pact->handle, $this->getSpecification()); + } +} diff --git a/src/PhpPact/Consumer/Driver/Pact/PactDriverInterface.php b/src/PhpPact/Consumer/Driver/Pact/PactDriverInterface.php new file mode 100644 index 00000000..15f1b90a --- /dev/null +++ b/src/PhpPact/Consumer/Driver/Pact/PactDriverInterface.php @@ -0,0 +1,16 @@ + 'An invalid handle was received. Handles should be created with `pactffi_new_pact`', + -2 => 'Transport_config is not valid JSON', + -3 => 'The mock server could not be started', + -4 => 'The method panicked', + -5 => 'The address is not valid', + default => 'Unknown error', + }; + parent::__construct($message, $code); + } +} diff --git a/src/PhpPact/Consumer/Exception/MockServerPactFileNotWrittenException.php b/src/PhpPact/Consumer/Exception/MockServerPactFileNotWrittenException.php new file mode 100644 index 00000000..9f763d05 --- /dev/null +++ b/src/PhpPact/Consumer/Exception/MockServerPactFileNotWrittenException.php @@ -0,0 +1,17 @@ + 'A general panic was caught', + 2 => 'The pact file was not able to be written', + 3 => 'A mock server with the provided port was not found', + default => 'Unknown error', + }; + parent::__construct($message, $code); + } +} diff --git a/src/PhpPact/Consumer/Factory/InteractionDriverFactory.php b/src/PhpPact/Consumer/Factory/InteractionDriverFactory.php new file mode 100644 index 00000000..61859c6d --- /dev/null +++ b/src/PhpPact/Consumer/Factory/InteractionDriverFactory.php @@ -0,0 +1,22 @@ +mockServerConfig = new MockServerEnvConfig(); - } - - /** - * @throws AssertionFailedError - * @throws RuntimeException - */ - public function executeAfterLastTest(): void - { - try { - $this->getMockServerService()->verifyInteractions(); - } catch (Exception $e) { - throw new AssertionFailedError('Pact interaction verification failed', 0, $e); - } - - try { - \file_put_contents($this->getPactFilename(), $this->getPactJson()); - } catch (Exception $e) { - throw new RuntimeException('Pact contract generation failed', 0, $e); - } - } - - private function getMockServerService(): MockServerHttpService - { - return new MockServerHttpService( - $this->getClient(), - $this->mockServerConfig - ); - } - - private function getClient(): ClientInterface - { - if (!$this->client) { - $this->client = new GuzzleClient(); - } - - return $this->client; - } - - private function getPactFilename(): string - { - return $this->mockServerConfig->getPactDir() - . DIRECTORY_SEPARATOR - . $this->mockServerConfig->getConsumer() - . '-' - . $this->mockServerConfig->getProvider() . '.json'; - } - - /** - * @throws \JsonException - */ - private function getPactJson(): string - { - $uri = $this->mockServerConfig->getBaseUri()->withPath('/pact'); - $response = $this->getClient()->post( - $uri, - [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Pact-Mock-Service' => true, - ], - 'body' => \json_encode([ - 'consumer' => ['name' => $this->mockServerConfig->getConsumer()], - 'provider' => ['name' => $this->mockServerConfig->getProvider()] - ]) - ] - ); - - return \json_encode(\json_decode($response->getBody()->getContents(), null, 512, JSON_THROW_ON_ERROR), JSON_THROW_ON_ERROR); - } -} diff --git a/src/PhpPact/Consumer/InteractionBuilder.php b/src/PhpPact/Consumer/InteractionBuilder.php index 0e25dab5..614eb8f2 100644 --- a/src/PhpPact/Consumer/InteractionBuilder.php +++ b/src/PhpPact/Consumer/InteractionBuilder.php @@ -2,37 +2,41 @@ namespace PhpPact\Consumer; +use PhpPact\Consumer\Driver\Interaction\InteractionDriverInterface; +use PhpPact\Consumer\Factory\InteractionDriverFactory; +use PhpPact\Consumer\Factory\InteractionDriverFactoryInterface; use PhpPact\Consumer\Model\ConsumerRequest; use PhpPact\Consumer\Model\Interaction; use PhpPact\Consumer\Model\ProviderResponse; -use PhpPact\Http\GuzzleClient; use PhpPact\Standalone\MockService\MockServerConfigInterface; -use PhpPact\Standalone\MockService\Service\MockServerHttpService; /** * Build an interaction and send it to the Ruby Standalone Mock Service */ class InteractionBuilder implements BuilderInterface { - protected MockServerHttpService $mockServerHttpService; - - protected MockServerConfigInterface $config; - + private InteractionDriverInterface $driver; private Interaction $interaction; - public function __construct(MockServerConfigInterface $config) + public function __construct(MockServerConfigInterface $config, ?InteractionDriverFactoryInterface $driverFactory = null) + { + $this->driver = ($driverFactory ?? new InteractionDriverFactory())->create($config); + $this->newInteraction(); + } + + public function newInteraction(): void { - $this->config = $config; - $this->mockServerHttpService = new MockServerHttpService(new GuzzleClient(), $config); - $this->interaction = new Interaction(); + $this->interaction = new Interaction(); } /** * @param string $providerState what is given to the request + * @param array $params for that request + * @param bool $overwrite clear pass states completely and start this array */ - public function given(string $providerState): self + public function given(string $providerState, array $params = [], bool $overwrite = false): self { - $this->interaction->setProviderState($providerState); + $this->interaction->setProviderState($providerState, $params, $overwrite); return $this; } @@ -58,18 +62,16 @@ public function with(ConsumerRequest $request): self } /** - * Make the http request to the Mock Service to register the interaction. - * * @param ProviderResponse $response mock of response received + * @param bool $startMockServer start mock server. Can't register more interaction if mock server is started * * @return bool returns true on success - * @throws \JsonException */ - public function willRespondWith(ProviderResponse $response): bool + public function willRespondWith(ProviderResponse $response, bool $startMockServer = true): bool { $this->interaction->setResponse($response); - return $this->mockServerHttpService->registerInteraction($this->interaction); + return $this->driver->registerInteraction($this->interaction, $startMockServer); } /** @@ -77,33 +79,38 @@ public function willRespondWith(ProviderResponse $response): bool */ public function verify(): bool { - return $this->mockServerHttpService->verifyInteractions(); + return $this->driver->verifyInteractions()->matched; } /** - * Writes the file to disk and deletes interactions from mock server. - * @throws \JsonException + * Set key for interaction. This feature only work with specification v4. It doesn't affect pact file with specification <= v3. */ - public function finalize(): bool + public function key(?string $key): self { - // Write the pact file to disk. - $this->mockServerHttpService->getPactJson(); + $this->interaction->setKey($key); + + return $this; + } - // Delete the interactions. - $this->mockServerHttpService->deleteAllInteractions(); + /** + * Mark the interaction as pending. This feature only work with specification v4. It doesn't affect pact file with specification <= v3. + */ + public function pending(?bool $pending): self + { + $this->interaction->setPending($pending); - return true; + return $this; } /** - * {@inheritdoc} - * @throws \JsonException + * Add comments to the interaction. This feature only work with specification v4. It doesn't affect pact file with specification <= v3. + * + * @param array $comments */ - public function writePact(): bool + public function comments(array $comments): self { - // Write the pact file to disk. - $this->mockServerHttpService->getPactJson(); + $this->interaction->setComments($comments); - return true; + return $this; } } diff --git a/src/PhpPact/Consumer/Listener/PactTestListener.php b/src/PhpPact/Consumer/Listener/PactTestListener.php deleted file mode 100644 index a27a1622..00000000 --- a/src/PhpPact/Consumer/Listener/PactTestListener.php +++ /dev/null @@ -1,128 +0,0 @@ - - */ - private array $testSuiteNames = []; - - private MockServerEnvConfig $mockServerConfig; - - private bool $failed = false; - - /** - * @param array $testSuiteNames test suite names that need evaluated with the listener - * - * @throws MissingEnvVariableException - */ - public function __construct(array $testSuiteNames) - { - $this->testSuiteNames = $testSuiteNames; - $this->mockServerConfig = new MockServerEnvConfig(); - } - - /** - * @throws Exception - */ - public function startTestSuite(TestSuite $suite): void - { - if (in_array($suite->getName(), $this->testSuiteNames)) { - $this->server = new MockServer($this->mockServerConfig); - $this->server->start(); - } - } - - public function addError(Test $test, Throwable $t, float $time): void - { - $this->failed = true; - } - - public function addFailure(Test $test, AssertionFailedError $e, float $time): void - { - $this->failed = true; - } - - /** - * Publish JSON results to PACT Broker and stop the Mock Server. - * @throws JsonException|ProcessException - */ - public function endTestSuite(TestSuite $suite): void - { - if (in_array($suite->getName(), $this->testSuiteNames)) { - try { - $httpService = new MockServerHttpService(new GuzzleClient(), $this->mockServerConfig); - $httpService->verifyInteractions(); - - $json = $httpService->getPactJson(); - } finally { - $this->server->stop(); - } - - if ($this->failed === true) { - print 'A unit test has failed. Skipping PACT file upload.'; - } elseif (!($pactBrokerUri = getenv('PACT_BROKER_URI'))) { - print 'PACT_BROKER_URI environment variable was not set. Skipping PACT file upload.'; - } elseif (!($consumerVersion = getenv('PACT_CONSUMER_VERSION'))) { - print 'PACT_CONSUMER_VERSION environment variable was not set. Skipping PACT file upload.'; - } elseif (!($tag = getenv('PACT_CONSUMER_TAG'))) { - print 'PACT_CONSUMER_TAG environment variable was not set. Skipping PACT file upload.'; - } else { - $clientConfig = []; - if (($user = getenv('PACT_BROKER_HTTP_AUTH_USER')) && - ($pass = getenv('PACT_BROKER_HTTP_AUTH_PASS')) - ) { - $clientConfig = [ - 'auth' => [$user, $pass], - ]; - } - - if (($sslVerify = getenv('PACT_BROKER_SSL_VERIFY'))) { - $clientConfig['verify'] = $sslVerify !== 'no'; - } - - $headers = []; - if ($bearerToken = getenv('PACT_BROKER_BEARER_TOKEN')) { - $headers['Authorization'] = 'Bearer ' . $bearerToken; - } - - $client = new GuzzleClient($clientConfig); - - $brokerHttpService = new BrokerHttpClient($client, new Uri($pactBrokerUri), $headers); - $brokerHttpService->tag($this->mockServerConfig->getConsumer(), $consumerVersion, $tag); - $brokerHttpService->publishJson($consumerVersion, $json); - print 'Pact file has been uploaded to the Broker successfully.'; - } - } - } -} diff --git a/src/PhpPact/Consumer/Matcher/Exception/AttributeConflictException.php b/src/PhpPact/Consumer/Matcher/Exception/AttributeConflictException.php new file mode 100644 index 00000000..5ea3d126 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Exception/AttributeConflictException.php @@ -0,0 +1,7 @@ + + */ + public function format(MatcherInterface $matcher): array + { + return [ + 'pact:matcher:type' => $matcher->getType(), + ...$matcher->getAttributes()->getData(), + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Formatters/PluginFormatter.php b/src/PhpPact/Consumer/Matcher/Formatters/PluginFormatter.php new file mode 100644 index 00000000..4552bacf --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Formatters/PluginFormatter.php @@ -0,0 +1,104 @@ +getGenerator()) { + throw new GeneratorNotRequiredException('Generator is not support in plugin'); + } + + if ($matcher instanceof MatchingField) { + return $this->formatMatchingFieldMatcher($matcher); + } + if ($matcher instanceof NotEmpty) { + return $this->formatNotEmptyMatcher($matcher); + } + if ($matcher instanceof EachKey || $matcher instanceof EachValue) { + return $this->formatEachKeyAndEachValueMatchers($matcher); + } + if ($matcher instanceof NullValue) { + return $this->formatMatchersWithoutConfig(new Type(null)); + } + + if (in_array($matcher->getType(), self::MATCHERS_WITHOUT_CONFIG)) { + return $this->formatMatchersWithoutConfig($matcher); + } + if ($matcher instanceof AbstractDateTime || $matcher instanceof Regex || $matcher instanceof ContentType) { + return $this->formatMatchersWithConfig($matcher); + } + + throw new MatcherNotSupportedException(sprintf("Matcher '%s' is not supported by plugin", $matcher->getType())); + } + + private function formatMatchingFieldMatcher(MatchingField $matcher): string + { + return sprintf("matching($%s)", $this->normalize($matcher->getFieldName())); + } + + private function formatMatchersWithoutConfig(MatcherInterface $matcher): string + { + $type = $matcher->getType() === 'equality' ? 'equalTo' : $matcher->getType(); + + return sprintf('matching(%s, %s)', $type, $this->normalize($matcher->getValue())); + } + + private function formatMatchersWithConfig(AbstractDateTime|Regex|ContentType $matcher): string + { + $config = match (true) { + $matcher instanceof AbstractDateTime => $matcher->getFormat(), + $matcher instanceof Regex => $matcher->getRegex(), + $matcher instanceof ContentType => $matcher->getValue(), + }; + + return sprintf("matching(%s, %s, %s)", $matcher->getType(), $this->normalize($config), $this->normalize($matcher->getValue())); + } + + private function formatNotEmptyMatcher(NotEmpty $matcher): string + { + return sprintf('notEmpty(%s)', $this->normalize($matcher->getValue())); + } + + private function formatEachKeyAndEachValueMatchers(EachKey|EachValue $matcher): string + { + $rules = $matcher->getRules(); + if (count($rules) === 0 || count($rules) > 1) { + throw new MatchingExpressionException(sprintf("Matcher '%s' only support 1 rule, %d provided", $matcher->getType(), count($rules))); + } + $rule = reset($rules); + + return sprintf('%s(%s)', $matcher->getType(), $this->format($rule)); + } + + private function normalize(mixed $value): string + { + return match (gettype($value)) { + 'string' => sprintf("'%s'", str_replace("'", "\\'", $value)), + 'boolean' => $value ? 'true' : 'false', + 'integer' => (string) $value, + 'double' => (string) $value, + 'NULL' => 'null', + default => throw new MatchingExpressionException(sprintf("Plugin formatter doesn't support value of type %s", gettype($value))), + }; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Formatters/ValueOptionalFormatter.php b/src/PhpPact/Consumer/Matcher/Formatters/ValueOptionalFormatter.php new file mode 100644 index 00000000..61ce2a0f --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Formatters/ValueOptionalFormatter.php @@ -0,0 +1,36 @@ + + */ + public function format(MatcherInterface $matcher): array + { + $data = [ + 'pact:matcher:type' => $matcher->getType(), + ]; + $attributes = $matcher->getAttributes(); + $generator = $matcher instanceof GeneratorAwareInterface ? $matcher->getGenerator() : null; + + if ($generator) { + return [ + ...$data, + 'pact:generator:type' => $generator->getType(), + ...$attributes->merge($generator->getAttributes())->getData(), + ]; + } + + return [ + ...$data, + ...$attributes->getData(), + 'value' => $matcher->getValue(), + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Formatters/ValueRequiredFormatter.php b/src/PhpPact/Consumer/Matcher/Formatters/ValueRequiredFormatter.php new file mode 100644 index 00000000..7e367b05 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Formatters/ValueRequiredFormatter.php @@ -0,0 +1,18 @@ + + */ + public function format(MatcherInterface $matcher): array + { + return [ + ...parent::format($matcher), + 'value' => $matcher->getValue()]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Formatters/XmlContentFormatter.php b/src/PhpPact/Consumer/Matcher/Formatters/XmlContentFormatter.php new file mode 100644 index 00000000..a554b964 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Formatters/XmlContentFormatter.php @@ -0,0 +1,31 @@ + + */ + public function format(MatcherInterface $matcher): array + { + $generator = $matcher instanceof GeneratorAwareInterface ? $matcher->getGenerator() : null; + $data = [ + 'content' => $matcher->getValue(), + 'matcher' => [ + 'pact:matcher:type' => $matcher->getType(), + ...$matcher->getAttributes()->merge($generator ? $generator->getAttributes() : new Attributes($matcher))->getData(), + ], + ]; + + if ($generator) { + $data['pact:generator:type'] = $generator->getType(); + } + + return $data; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Formatters/XmlElementFormatter.php b/src/PhpPact/Consumer/Matcher/Formatters/XmlElementFormatter.php new file mode 100644 index 00000000..3520b61a --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Formatters/XmlElementFormatter.php @@ -0,0 +1,31 @@ + + */ + public function format(MatcherInterface $matcher): array + { + $value = $matcher->getValue(); + if (!$value instanceof XmlElement) { + throw new InvalidValueException('Value must be xml element'); + } + + $result = parent::format($matcher); + $examples = $value->getExamples(); + + if (null !== $examples) { + $result['examples'] = $examples; + $value->setExamples(null); + } + + return $result; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/AbstractDateTime.php b/src/PhpPact/Consumer/Matcher/Generators/AbstractDateTime.php new file mode 100644 index 00000000..45d8e43b --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/AbstractDateTime.php @@ -0,0 +1,27 @@ + + */ + protected function getAttributesData(): array + { + $data = []; + if ($this->format !== null) { + $data['format'] = $this->format; + } + + if ($this->expression !== null) { + $data['expression'] = $this->expression; + } + + return $data; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/AbstractGenerator.php b/src/PhpPact/Consumer/Matcher/Generators/AbstractGenerator.php new file mode 100644 index 00000000..df1a956c --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/AbstractGenerator.php @@ -0,0 +1,19 @@ +getAttributesData()); + } + + /** + * @return array + */ + abstract protected function getAttributesData(): array; +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/Date.php b/src/PhpPact/Consumer/Matcher/Generators/Date.php new file mode 100644 index 00000000..942f3c76 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/Date.php @@ -0,0 +1,23 @@ + + */ + protected function getAttributesData(): array + { + return [ + 'regex' => $this->regex, + 'example' => $this->example, + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/ProviderState.php b/src/PhpPact/Consumer/Matcher/Generators/ProviderState.php new file mode 100644 index 00000000..0c364421 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/ProviderState.php @@ -0,0 +1,30 @@ + + */ + protected function getAttributesData(): array + { + return [ + 'expression' => $this->expression, + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/RandomBoolean.php b/src/PhpPact/Consumer/Matcher/Generators/RandomBoolean.php new file mode 100644 index 00000000..7bbe480a --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/RandomBoolean.php @@ -0,0 +1,22 @@ + + */ + protected function getAttributesData(): array + { + return []; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/RandomDecimal.php b/src/PhpPact/Consumer/Matcher/Generators/RandomDecimal.php new file mode 100644 index 00000000..0965578e --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/RandomDecimal.php @@ -0,0 +1,28 @@ + + */ + protected function getAttributesData(): array + { + return [ + 'digits' => $this->digits, + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/RandomHexadecimal.php b/src/PhpPact/Consumer/Matcher/Generators/RandomHexadecimal.php new file mode 100644 index 00000000..8a4a990f --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/RandomHexadecimal.php @@ -0,0 +1,28 @@ + + */ + protected function getAttributesData(): array + { + return [ + 'digits' => $this->digits, + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/RandomInt.php b/src/PhpPact/Consumer/Matcher/Generators/RandomInt.php new file mode 100644 index 00000000..2f4ea5e8 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/RandomInt.php @@ -0,0 +1,29 @@ + + */ + protected function getAttributesData(): array + { + return [ + 'min' => $this->min, + 'max' => $this->max, + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/RandomString.php b/src/PhpPact/Consumer/Matcher/Generators/RandomString.php new file mode 100644 index 00000000..911f7363 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/RandomString.php @@ -0,0 +1,28 @@ + + */ + protected function getAttributesData(): array + { + return [ + 'size' => $this->size, + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/Regex.php b/src/PhpPact/Consumer/Matcher/Generators/Regex.php new file mode 100644 index 00000000..c80c3b18 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/Regex.php @@ -0,0 +1,28 @@ + + */ + protected function getAttributesData(): array + { + return [ + 'regex' => $this->regex, + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Generators/Time.php b/src/PhpPact/Consumer/Matcher/Generators/Time.php new file mode 100644 index 00000000..2ce4e359 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Generators/Time.php @@ -0,0 +1,23 @@ + + */ + protected function getAttributesData(): array + { + return $this->format !== null ? [ + 'format' => $this->format, + ] : []; + } +} diff --git a/src/PhpPact/Consumer/Matcher/HttpStatus.php b/src/PhpPact/Consumer/Matcher/HttpStatus.php new file mode 100644 index 00000000..527bd13e --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/HttpStatus.php @@ -0,0 +1,30 @@ + + */ + public static function all(): array + { + return [ + self::INFORMATION, + self::SUCCESS, + self::REDIRECT, + self::CLIENT_ERROR, + self::SERVER_ERROR, + self::NON_ERROR, + self::ERROR, + ]; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matcher.php b/src/PhpPact/Consumer/Matcher/Matcher.php index 6d27e1d1..fd3ae6ae 100644 --- a/src/PhpPact/Consumer/Matcher/Matcher.php +++ b/src/PhpPact/Consumer/Matcher/Matcher.php @@ -2,13 +2,45 @@ namespace PhpPact\Consumer\Matcher; -use Exception; - -use function preg_last_error; -use function preg_match; +use PhpPact\Consumer\Matcher\Exception\MatcherException; +use PhpPact\Consumer\Matcher\Exception\MatcherNotSupportedException; +use PhpPact\Consumer\Matcher\Generators\MockServerURL; +use PhpPact\Consumer\Matcher\Generators\ProviderState; +use PhpPact\Consumer\Matcher\Generators\RandomHexadecimal; +use PhpPact\Consumer\Matcher\Generators\Uuid; +use PhpPact\Consumer\Matcher\Matchers\ArrayContains; +use PhpPact\Consumer\Matcher\Matchers\Boolean; +use PhpPact\Consumer\Matcher\Matchers\ContentType; +use PhpPact\Consumer\Matcher\Matchers\Date; +use PhpPact\Consumer\Matcher\Matchers\DateTime; +use PhpPact\Consumer\Matcher\Matchers\Decimal; +use PhpPact\Consumer\Matcher\Matchers\EachKey; +use PhpPact\Consumer\Matcher\Matchers\EachValue; +use PhpPact\Consumer\Matcher\Matchers\Equality; +use PhpPact\Consumer\Matcher\Matchers\Includes; +use PhpPact\Consumer\Matcher\Matchers\Integer; +use PhpPact\Consumer\Matcher\Matchers\MatchingField; +use PhpPact\Consumer\Matcher\Matchers\MaxType; +use PhpPact\Consumer\Matcher\Matchers\MinMaxType; +use PhpPact\Consumer\Matcher\Matchers\MinType; +use PhpPact\Consumer\Matcher\Matchers\NotEmpty; +use PhpPact\Consumer\Matcher\Matchers\NullValue; +use PhpPact\Consumer\Matcher\Matchers\Number; +use PhpPact\Consumer\Matcher\Matchers\Regex; +use PhpPact\Consumer\Matcher\Matchers\Semver; +use PhpPact\Consumer\Matcher\Matchers\StatusCode; +use PhpPact\Consumer\Matcher\Matchers\StringValue; +use PhpPact\Consumer\Matcher\Matchers\Time; +use PhpPact\Consumer\Matcher\Matchers\Type; +use PhpPact\Consumer\Matcher\Matchers\Values; +use PhpPact\Consumer\Matcher\Model\FormatterAwareInterface; +use PhpPact\Consumer\Matcher\Model\FormatterInterface; +use PhpPact\Consumer\Matcher\Model\GeneratorAwareInterface; +use PhpPact\Consumer\Matcher\Model\MatcherInterface; /** - * Matcher implementation. Builds the Ruby Mock Server specification json for interaction publishing. + * Matcher implementation. Builds the Pact FFI specification json for interaction publishing. + * @see https://docs.pact.io/implementation_guides/rust/pact_ffi/integrationjson */ class Matcher { @@ -23,100 +55,98 @@ class Matcher public const IPV6_FORMAT = '^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$'; public const HEX_FORMAT = '^[0-9a-fA-F]+$'; + public function __construct(private ?FormatterInterface $formatter = null) + { + } + /** * Alias for the `like()` function. - * - * @throws Exception - * - * @return array */ - public function somethingLike(mixed $value): array + public function somethingLike(mixed $value): MatcherInterface { return $this->like($value); } /** - * @param mixed $value example of what the expected data would be - * - * @throws Exception - * - * @return array + * This executes a type based match against the values, that is, they are equal if they are the same type. */ - public function like(mixed $value): array + public function like(mixed $value): MatcherInterface { - if ($value === null) { - throw new \Exception('Value must not be null.'); - } - - return [ - 'contents' => $value, - 'json_class' => 'Pact::SomethingLike', - ]; + return $this->withFormatter(new Type($value)); } /** * Expect an array of similar data as the value passed in. - * + */ + public function eachLike(mixed $value): MatcherInterface + { + return $this->atLeastLike($value, 1); + } + + /** * @param mixed $value example of what the expected data would be * @param int $min minimum number of objects to verify against - * - * @return array */ - public function eachLike(mixed $value, int $min = 1): array + public function atLeastLike(mixed $value, int $min): MatcherInterface { - $result = [ - 'contents' => $value, - 'json_class' => 'Pact::ArrayLike', - ]; - - $result['min'] = $min; + return $this->withFormatter(new MinType(array_fill(0, $min, $value), $min)); + } - return $result; + public function atMostLike(mixed $value, int $max): MatcherInterface + { + return $this->withFormatter(new MaxType([$value], $max)); } /** - * Validate that a value will match a regex pattern. - * - * @param mixed $value example of what the expected data would be - * @param string $pattern valid Ruby regex pattern - * - * @return array + * @param mixed $value example of what the expected data would be + * @param int $min minimum number of objects to verify against + * @param int $max maximum number of objects to verify against + * @param int|null $count number of examples to generate, defaults to one * - * @throws Exception + * @throws MatcherException */ - public function term(mixed $value, string $pattern): array + public function constrainedArrayLike(mixed $value, int $min, int $max, ?int $count = null): MatcherInterface { - $result = preg_match("/$pattern/", $value); - - if ($result === false || $result === 0) { - $errorCode = preg_last_error(); - - throw new Exception("The pattern {$pattern} is not valid for value {$value}. Failed with error code {$errorCode}."); + $elements = $count ?? $min; + if ($count !== null) { + if ($count < $min) { + throw new MatcherException( + "constrainedArrayLike has a minimum of {$min} but {$count} elements where requested." . + ' Make sure the count is greater than or equal to the min.' + ); + } elseif ($count > $max) { + throw new MatcherException( + "constrainedArrayLike has a maximum of {$max} but {$count} elements where requested." . + ' Make sure the count is less than or equal to the max.' + ); + } } - return [ - 'data' => [ - 'generate' => $value, - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => $pattern, - ], - ], - 'json_class' => 'Pact::Term', - ]; + return $this->withFormatter(new MinMaxType(array_fill(0, $elements, $value), $min, $max)); + } + + /** + * Validate that values will match a regex pattern. + * + * @param string|string[]|null $values + * + * @throws MatcherException + */ + public function term(string|array|null $values, string $pattern): MatcherInterface + { + return $this->withFormatter(new Regex($pattern, $values)); } /** * Alias for the term matcher. * - * @return array + * @param string|string[]|null $values * - * @throws Exception + * @throws MatcherException */ - public function regex(mixed $value, string $pattern): array + public function regex(string|array|null $values, string $pattern): MatcherInterface { - return $this->term($value, $pattern); + return $this->term($values, $pattern); } /** @@ -124,11 +154,9 @@ public function regex(mixed $value, string $pattern): array * * @param string $value valid ISO8601 date, example: 2010-01-01 * - * @throws Exception - * - * @return array + * @throws MatcherException */ - public function dateISO8601(string $value = '2013-02-01'): array + public function dateISO8601(string $value = '2013-02-01'): MatcherInterface { return $this->term($value, self::ISO8601_DATE_FORMAT); } @@ -138,11 +166,9 @@ public function dateISO8601(string $value = '2013-02-01'): array * * @param string $value * - * @return array - * - * @throws Exception + * @throws MatcherException */ - public function timeISO8601(string $value = 'T22:44:30.652Z'): array + public function timeISO8601(string $value = 'T22:44:30.652Z'): MatcherInterface { return $this->term($value, self::ISO8601_TIME_FORMAT); } @@ -152,11 +178,9 @@ public function timeISO8601(string $value = 'T22:44:30.652Z'): array * * @param string $value * - * @return array - * - * @throws Exception + * @throws MatcherException */ - public function dateTimeISO8601(string $value = '2015-08-06T16:53:10+01:00'): array + public function dateTimeISO8601(string $value = '2015-08-06T16:53:10+01:00'): MatcherInterface { return $this->term($value, self::ISO8601_DATETIME_FORMAT); } @@ -166,11 +190,9 @@ public function dateTimeISO8601(string $value = '2015-08-06T16:53:10+01:00'): ar * * @param string $value * - * @return array - * - * @throws Exception + * @throws MatcherException */ - public function dateTimeWithMillisISO8601(string $value = '2015-08-06T16:53:10.123+01:00'): array + public function dateTimeWithMillisISO8601(string $value = '2015-08-06T16:53:10.123+01:00'): MatcherInterface { return $this->term($value, self::ISO8601_DATETIME_WITH_MILLIS_FORMAT); } @@ -180,92 +202,283 @@ public function dateTimeWithMillisISO8601(string $value = '2015-08-06T16:53:10.1 * * @param string $value * - * @return array - * - * @throws Exception + * @throws MatcherException */ - public function timestampRFC3339(string $value = 'Mon, 31 Oct 2016 15:21:41 -0400'): array + public function timestampRFC3339(string $value = 'Mon, 31 Oct 2016 15:21:41 -0400'): MatcherInterface { return $this->term($value, self::RFC3339_TIMESTAMP_FORMAT); } + public function boolean(): MatcherInterface + { + return $this->like(true); + } + + public function integer(int $int = 13): MatcherInterface + { + return $this->like($int); + } + + public function decimal(float $float = 13.01): MatcherInterface + { + return $this->like($float); + } + + public function booleanV3(?bool $value = null): MatcherInterface + { + return $this->withFormatter(new Boolean($value)); + } + + public function integerV3(?int $value = null): MatcherInterface + { + return $this->withFormatter(new Integer($value)); + } + + public function decimalV3(?float $value = null): MatcherInterface + { + return $this->withFormatter(new Decimal($value)); + } + + /** + * @throws MatcherException + */ + public function hexadecimal(?string $value = null): MatcherInterface + { + $matcher = new Regex(self::HEX_FORMAT, $value); + + if (null === $value) { + $matcher->setGenerator(new RandomHexadecimal()); + } + + return $this->withFormatter($matcher); + } + + /** + * @throws MatcherException + */ + public function uuid(?string $value = null): MatcherInterface + { + $matcher = new Regex(self::UUID_V4_FORMAT, $value); + + if (null === $value) { + $matcher->setGenerator(new Uuid()); + } + + return $this->withFormatter($matcher); + } + + public function ipv4Address(?string $ip = '127.0.0.13'): MatcherInterface + { + return $this->term($ip, self::IPV4_FORMAT); + } + + public function ipv6Address(?string $ip = '::ffff:192.0.2.128'): MatcherInterface + { + return $this->term($ip, self::IPV6_FORMAT); + } + + public function email(?string $email = 'hello@pact.io'): MatcherInterface + { + return $this->term($email, self::EMAIL_FORMAT); + } + + /** + * Value that must be null. This will only match the JSON Null value. For other content types, it will + * match if the attribute is missing. + */ + public function nullValue(): MatcherInterface + { + return $this->withFormatter(new NullValue()); + } + /** - * @return array + * Matches the string representation of a value against the date format. * - * @throws Exception + * NOTE: Java's datetime format is used, not PHP's datetime format + * For Java one, see https://www.digitalocean.com/community/tutorials/java-simpledateformat-java-date-format#patterns + * For PHP one, see https://www.php.net/manual/en/datetime.format.php#refsect1-datetime.format-parameters */ - public function boolean(): array + public function date(string $format = 'yyyy-MM-dd', ?string $value = null): MatcherInterface { - return $this->like(true); + return $this->withFormatter(new Date($format, $value)); } /** - * @return array + * Matches the string representation of a value against the time format. * - * @throws Exception + * NOTE: Java's datetime format is used, not PHP's datetime format + * For Java one, see https://www.digitalocean.com/community/tutorials/java-simpledateformat-java-date-format#patterns + * For PHP one, see https://www.php.net/manual/en/datetime.format.php#refsect1-datetime.format-parameters */ - public function integer(int $int = 13): array + public function time(string $format = 'HH:mm:ss', ?string $value = null): MatcherInterface { - return $this->like($int); + return $this->withFormatter(new Time($format, $value)); } /** - * @return array + * Matches the string representation of a value against the datetime format. * - * @throws Exception + * NOTE: Java's datetime format is used, not PHP's datetime format + * For Java one, see https://www.digitalocean.com/community/tutorials/java-simpledateformat-java-date-format#patterns + * For PHP one, see https://www.php.net/manual/en/datetime.format.php#refsect1-datetime.format-parameters */ - public function decimal(float $float = 13.01): array + public function datetime(string $format = "yyyy-MM-dd'T'HH:mm:ss", ?string $value = null): MatcherInterface { - return $this->like($float); + return $this->withFormatter(new DateTime($format, $value)); + } + + public function string(?string $value = null): MatcherInterface + { + return $this->withFormatter(new StringValue($value)); } /** - * @return array + * Generates a value that is looked up from the provider state context using the given expression * - * @throws Exception + * @throws MatcherNotSupportedException + */ + public function fromProviderState(MatcherInterface $matcher, string $expression): MatcherInterface + { + if (!$matcher instanceof GeneratorAwareInterface) { + throw new MatcherNotSupportedException(sprintf("Matcher '%s' must be generator aware", $matcher->getType())); + } + + $matcher->setGenerator(new ProviderState($expression)); + + return $matcher; + } + + /** + * Value that must be equal to the example. This is mainly used to reset the matching rules which cascade. + */ + public function equal(mixed $value): MatcherInterface + { + return $this->withFormatter(new Equality($value)); + } + + /** + * Value that must include the example value as a substring. */ - public function hexadecimal(string $hex = '3F'): array + public function includes(string $value): MatcherInterface { - return $this->term($hex, self::HEX_FORMAT); + return $this->withFormatter(new Includes($value)); } /** - * @return array + * Value must be a number * - * @throws Exception + * @param int|float|null $value Example value. If omitted a random integer value will be generated. */ - public function uuid(string $uuid = 'ce118b6e-d8e1-11e7-9296-cec278b6b50a'): array + public function number(int|float|null $value = null): MatcherInterface { - return $this->term($uuid, self::UUID_V4_FORMAT); + return $this->withFormatter(new Number($value)); } /** - * @return array + * Matches the items in an array against a number of variants. Matching is successful if each variant + * occurs once in the array. Variants may be objects containing matching rules. * - * @throws Exception + * @param array $variants */ - public function ipv4Address(string $ip = '127.0.0.13'): array + public function arrayContaining(array $variants): MatcherInterface { - return $this->term($ip, self::IPV4_FORMAT); + return $this->withFormatter(new ArrayContains($variants)); + } + + /** + * Value must be present and not empty (not null or the empty string or empty array or empty object) + */ + public function notEmpty(mixed $value): MatcherInterface + { + return $this->withFormatter(new NotEmpty($value)); } /** - * @return array + * Value must be valid based on the semver specification + */ + public function semver(string $value): MatcherInterface + { + return $this->withFormatter(new Semver($value)); + } + + /** + * Matches the response status code. + */ + public function statusCode(string $status, ?int $value = null): MatcherInterface + { + return $this->withFormatter(new StatusCode($status, $value)); + } + + /** + * Match the values in a map, ignoring the keys + * + * @deprecated use eachKey or eachValue * - * @throws Exception + * @param array $values */ - public function ipv6Address(string $ip = '::ffff:192.0.2.128'): array + public function values(array $values): MatcherInterface { - return $this->term($ip, self::IPV6_FORMAT); + return $this->withFormatter(new Values($values)); + } + + /** + * Match binary data by its content type (magic file check) + */ + public function contentType(string $contentType): MatcherInterface + { + return $this->withFormatter(new ContentType($contentType)); } /** - * @return array + * Allows defining matching rules to apply to the keys in a map * - * @throws \Exception + * @param array $values + * @param array $rules */ - public function email(string $email = 'hello@pact.io'): array + public function eachKey(array $values, array $rules): MatcherInterface { - return $this->term($email, self::EMAIL_FORMAT); + return $this->withFormatter(new EachKey($values, $rules)); + } + + /** + * Allows defining matching rules to apply to the values in a collection. For maps, delgates to the Values matcher. + * + * @param array $values + * @param array $rules + */ + public function eachValue(array $values, array $rules): MatcherInterface + { + return $this->withFormatter(new EachValue($values, $rules)); + } + + /** + * @throws MatcherException + */ + public function url(string $url, string $regex, bool $useMockServerBasePath = true): MatcherInterface + { + $matcher = new Regex($regex, $useMockServerBasePath ? null : $url); + + if ($useMockServerBasePath) { + $matcher->setGenerator(new MockServerURL($regex, $url)); + } + + return $this->withFormatter($matcher); + } + + /** + * Generates a value that is looked up from the provider state context using the given expression + */ + public function matchingField(string $fieldName): MatcherInterface + { + return $this->withFormatter(new MatchingField($fieldName)); + } + + private function withFormatter(MatcherInterface $matcher): MatcherInterface + { + if ($matcher instanceof FormatterAwareInterface && $this->formatter) { + $matcher->setFormatter($this->formatter); + } + + return $matcher; } } diff --git a/src/PhpPact/Consumer/Matcher/Matchers/AbstractDateTime.php b/src/PhpPact/Consumer/Matcher/Matchers/AbstractDateTime.php new file mode 100644 index 00000000..65ea9f50 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/AbstractDateTime.php @@ -0,0 +1,29 @@ +format; + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return ['format' => $this->format]; + } + + public function getValue(): ?string + { + return $this->value; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/AbstractMatcher.php b/src/PhpPact/Consumer/Matcher/Matchers/AbstractMatcher.php new file mode 100644 index 00000000..5326bc33 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/AbstractMatcher.php @@ -0,0 +1,46 @@ +formatter = new ValueOptionalFormatter(); + } + + public function setFormatter(FormatterInterface $formatter): void + { + $this->formatter = $formatter; + } + + public function getFormatter(): FormatterInterface + { + return $this->formatter; + } + + /** + * @return string|array + */ + public function jsonSerialize(): string|array + { + return $this->getFormatter()->format($this); + } + + public function getAttributes(): Attributes + { + return new Attributes($this, $this->getAttributesData()); + } + + /** + * @return array + */ + abstract protected function getAttributesData(): array; +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/ArrayContains.php b/src/PhpPact/Consumer/Matcher/Matchers/ArrayContains.php new file mode 100644 index 00000000..418d4005 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/ArrayContains.php @@ -0,0 +1,40 @@ + $variants + */ + public function __construct(private array $variants) + { + $this->setFormatter(new MinimalFormatter()); + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return ['variants' => array_values($this->variants)]; + } + + /** + * @todo Change return type to `null` + */ + public function getValue(): mixed + { + return null; + } + + public function getType(): string + { + return 'arrayContains'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Boolean.php b/src/PhpPact/Consumer/Matcher/Matchers/Boolean.php new file mode 100644 index 00000000..96f2a59b --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Boolean.php @@ -0,0 +1,34 @@ +setGenerator(new RandomBoolean()); + } + parent::__construct(); + } + + public function getType(): string + { + return 'boolean'; + } + + protected function getAttributesData(): array + { + return []; + } + + public function getValue(): ?bool + { + return $this->value; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/ContentType.php b/src/PhpPact/Consumer/Matcher/Matchers/ContentType.php new file mode 100644 index 00000000..340a6875 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/ContentType.php @@ -0,0 +1,29 @@ +contentType; + } + + public function getType(): string + { + return 'contentType'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Date.php b/src/PhpPact/Consumer/Matcher/Matchers/Date.php new file mode 100644 index 00000000..67ca1e82 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Date.php @@ -0,0 +1,28 @@ +setGenerator(new DateGenerator($format)); + } + parent::__construct($format, $value); + } + + public function getType(): string + { + return 'date'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/DateTime.php b/src/PhpPact/Consumer/Matcher/Matchers/DateTime.php new file mode 100644 index 00000000..5d2f8184 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/DateTime.php @@ -0,0 +1,28 @@ +setGenerator(new DateTimeGenerator($format)); + } + parent::__construct($format, $value); + } + + public function getType(): string + { + return 'datetime'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Decimal.php b/src/PhpPact/Consumer/Matcher/Matchers/Decimal.php new file mode 100644 index 00000000..f1f00f82 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Decimal.php @@ -0,0 +1,34 @@ +setGenerator(new RandomDecimal()); + } + parent::__construct(); + } + + public function getType(): string + { + return 'decimal'; + } + + protected function getAttributesData(): array + { + return []; + } + + public function getValue(): ?float + { + return $this->value; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/EachKey.php b/src/PhpPact/Consumer/Matcher/Matchers/EachKey.php new file mode 100644 index 00000000..028e1e8a --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/EachKey.php @@ -0,0 +1,49 @@ +|object $value + * @param MatcherInterface[] $rules + */ + public function __construct(private object|array $value, private array $rules) + { + parent::__construct(); + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return ['rules' => array_map(fn (MatcherInterface $rule) => $rule, $this->rules)]; + } + + /** + * @return array|object + */ + public function getValue(): object|array + { + return $this->value; + } + + public function getType(): string + { + return 'eachKey'; + } + + /** + * @return MatcherInterface[] + */ + public function getRules(): array + { + return $this->rules; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/EachValue.php b/src/PhpPact/Consumer/Matcher/Matchers/EachValue.php new file mode 100644 index 00000000..08aecb8a --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/EachValue.php @@ -0,0 +1,49 @@ +|object $value + * @param MatcherInterface[] $rules + */ + public function __construct(private object|array $value, private array $rules) + { + parent::__construct(); + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return ['rules' => array_map(fn (MatcherInterface $rule) => $rule, $this->rules)]; + } + + /** + * @return array|object + */ + public function getValue(): object|array + { + return $this->value; + } + + public function getType(): string + { + return 'eachValue'; + } + + /** + * @return MatcherInterface[] + */ + public function getRules(): array + { + return $this->rules; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Equality.php b/src/PhpPact/Consumer/Matcher/Matchers/Equality.php new file mode 100644 index 00000000..f0e1cf8b --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Equality.php @@ -0,0 +1,35 @@ +|string|float|int|bool|null $value + */ + public function __construct(private object|array|string|float|int|bool|null $value) + { + parent::__construct(); + } + + protected function getAttributesData(): array + { + return []; + } + + /** + * @return object|array|string|float|int|bool|null + */ + public function getValue(): object|array|string|float|int|bool|null + { + return $this->value; + } + + public function getType(): string + { + return 'equality'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/GeneratorAwareMatcher.php b/src/PhpPact/Consumer/Matcher/Matchers/GeneratorAwareMatcher.php new file mode 100644 index 00000000..41301f30 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/GeneratorAwareMatcher.php @@ -0,0 +1,39 @@ +generator = $generator; + } + + public function getGenerator(): ?GeneratorInterface + { + return $this->generator; + } + + /** + * @return string|array + */ + public function jsonSerialize(): string|array + { + if (null === $this->getValue()) { + if (!$this->generator) { + throw new GeneratorRequiredException(sprintf("Generator is required for matcher '%s' when example value is not set", $this->getType())); + } + } elseif ($this->generator) { + throw new GeneratorNotRequiredException(sprintf("Generator '%s' is not required for matcher '%s' when example value is set", $this->generator->getType(), $this->getType())); + } + + return $this->getFormatter()->format($this); + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Includes.php b/src/PhpPact/Consumer/Matcher/Matchers/Includes.php new file mode 100644 index 00000000..803d2682 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Includes.php @@ -0,0 +1,29 @@ +value; + } + + public function getType(): string + { + return 'include'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Integer.php b/src/PhpPact/Consumer/Matcher/Matchers/Integer.php new file mode 100644 index 00000000..9fe9a8ff --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Integer.php @@ -0,0 +1,34 @@ +setGenerator(new RandomInt()); + } + parent::__construct(); + } + + public function getType(): string + { + return 'integer'; + } + + protected function getAttributesData(): array + { + return []; + } + + public function getValue(): ?int + { + return $this->value; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/MatchingField.php b/src/PhpPact/Consumer/Matcher/Matchers/MatchingField.php new file mode 100644 index 00000000..e2c11833 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/MatchingField.php @@ -0,0 +1,44 @@ +fieldName; + } + + public function getValue(): mixed + { + throw new MatcherNotSupportedException(self::MATCHER_NOT_SUPPORTED_EXCEPTION_MESSAGE); + } + + public function getType(): string + { + throw new MatcherNotSupportedException(self::MATCHER_NOT_SUPPORTED_EXCEPTION_MESSAGE); + } + + protected function getAttributesData(): array + { + throw new MatcherNotSupportedException(self::MATCHER_NOT_SUPPORTED_EXCEPTION_MESSAGE); + } + + public function jsonSerialize(): string + { + $result = parent::jsonSerialize(); + if (is_array($result)) { + throw new MatcherNotSupportedException(self::MATCHER_NOT_SUPPORTED_EXCEPTION_MESSAGE); + } + + return $result; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/MaxType.php b/src/PhpPact/Consumer/Matcher/Matchers/MaxType.php new file mode 100644 index 00000000..dede0193 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/MaxType.php @@ -0,0 +1,41 @@ + $values + */ + public function __construct( + private array $values, + private int $max, + ) { + parent::__construct(); + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return ['max' => $this->max]; + } + + /** + * @return array + */ + public function getValue(): array + { + return array_values($this->values); + } + + public function getType(): string + { + return 'type'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/MinMaxType.php b/src/PhpPact/Consumer/Matcher/Matchers/MinMaxType.php new file mode 100644 index 00000000..10a29238 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/MinMaxType.php @@ -0,0 +1,45 @@ + $values + */ + public function __construct( + private array $values, + private int $min, + private int $max, + ) { + parent::__construct(); + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return [ + 'min' => $this->min, + 'max' => $this->max, + ]; + } + + /** + * @return array + */ + public function getValue(): array + { + return array_values($this->values); + } + + public function getType(): string + { + return 'type'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/MinType.php b/src/PhpPact/Consumer/Matcher/Matchers/MinType.php new file mode 100644 index 00000000..4f709ad6 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/MinType.php @@ -0,0 +1,41 @@ + $values + */ + public function __construct( + private array $values, + private int $min, + ) { + parent::__construct(); + } + + public function getType(): string + { + return 'type'; + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return ['min' => $this->min]; + } + + /** + * @return array + */ + public function getValue(): array + { + return array_values($this->values); + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/NotEmpty.php b/src/PhpPact/Consumer/Matcher/Matchers/NotEmpty.php new file mode 100644 index 00000000..f044a5a7 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/NotEmpty.php @@ -0,0 +1,35 @@ +|string|float|int|bool $value + */ + public function __construct(private object|array|string|float|int|bool $value) + { + parent::__construct(); + } + + protected function getAttributesData(): array + { + return []; + } + + /** + * @return object|array|string|float|int|bool + */ + public function getValue(): object|array|string|float|int|bool + { + return $this->value; + } + + public function getType(): string + { + return 'notEmpty'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/NullValue.php b/src/PhpPact/Consumer/Matcher/Matchers/NullValue.php new file mode 100644 index 00000000..82715007 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/NullValue.php @@ -0,0 +1,34 @@ +setFormatter(new MinimalFormatter()); + } + + public function getType(): string + { + return 'null'; + } + + protected function getAttributesData(): array + { + return []; + } + + /** + * @todo Change return type to `null` + */ + public function getValue(): mixed + { + return null; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Number.php b/src/PhpPact/Consumer/Matcher/Matchers/Number.php new file mode 100644 index 00000000..83cac53b --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Number.php @@ -0,0 +1,34 @@ +setGenerator(new RandomInt()); + } + parent::__construct(); + } + + public function getType(): string + { + return 'number'; + } + + protected function getAttributesData(): array + { + return []; + } + + public function getValue(): int|float|null + { + return $this->value; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Regex.php b/src/PhpPact/Consumer/Matcher/Matchers/Regex.php new file mode 100644 index 00000000..d7822e9a --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Regex.php @@ -0,0 +1,76 @@ +setGenerator(new RegexGenerator($this->regex)); + } + parent::__construct(); + } + + /** + * @return string|array + */ + public function jsonSerialize(): string|array + { + if (null !== $this->values) { + $this->validateRegex(); + } + + return parent::jsonSerialize(); + } + + private function validateRegex(): void + { + foreach ((array) $this->values as $value) { + $result = preg_match("/$this->regex/", $value); + + if ($result === false || $result === 0) { + $errorCode = preg_last_error(); + + throw new InvalidRegexException("The pattern '{$this->regex}' is not valid for value '{$value}'. Failed with error code {$errorCode}."); + } + } + } + + public function getType(): string + { + return 'regex'; + } + + /** + * @return string|string[]|null + */ + public function getValue(): string|array|null + { + return $this->values; + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return ['regex' => $this->regex]; + } + + public function getRegex(): string + { + return $this->regex; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Semver.php b/src/PhpPact/Consumer/Matcher/Matchers/Semver.php new file mode 100644 index 00000000..03521ce2 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Semver.php @@ -0,0 +1,34 @@ +setGenerator(new Regex('\d+\.\d+\.\d+')); + } + parent::__construct(); + } + + public function getType(): string + { + return 'semver'; + } + + protected function getAttributesData(): array + { + return []; + } + + public function getValue(): ?string + { + return $this->value; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/StatusCode.php b/src/PhpPact/Consumer/Matcher/Matchers/StatusCode.php new file mode 100644 index 00000000..15db5716 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/StatusCode.php @@ -0,0 +1,54 @@ + [100, 199], + HttpStatus::SUCCESS => [200, 299], + HttpStatus::REDIRECT => [300, 399], + HttpStatus::CLIENT_ERROR => [400, 499], + HttpStatus::SERVER_ERROR => [500, 599], + HttpStatus::NON_ERROR => [100, 399], + HttpStatus::ERROR => [400, 599], + default => [100, 199], // Can't happen, just to make PHPStan happy + }; + + $this->setGenerator(new RandomInt($min, $max)); + } + parent::__construct(); + } + + public function getType(): string + { + return 'statusCode'; + } + + /** + * @return array + */ + protected function getAttributesData(): array + { + return ['status' => $this->status]; + } + + public function getValue(): ?int + { + return $this->value; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/StringValue.php b/src/PhpPact/Consumer/Matcher/Matchers/StringValue.php new file mode 100644 index 00000000..f0267bcf --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/StringValue.php @@ -0,0 +1,45 @@ +setGenerator(new RandomString()); + } + $this->setFormatter(new ValueRequiredFormatter()); + } + + public function getType(): string + { + return 'type'; + } + + /** + * @return string|array + */ + public function jsonSerialize(): string|array + { + return $this->getFormatter()->format($this); + } + + protected function getAttributesData(): array + { + return []; + } + + public function getValue(): string + { + return $this->value ?? self::DEFAULT_VALUE; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Time.php b/src/PhpPact/Consumer/Matcher/Matchers/Time.php new file mode 100644 index 00000000..ac3f9fad --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Time.php @@ -0,0 +1,28 @@ +setGenerator(new TimeGenerator($format)); + } + parent::__construct($format, $value); + } + + public function getType(): string + { + return 'time'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Type.php b/src/PhpPact/Consumer/Matcher/Matchers/Type.php new file mode 100644 index 00000000..46b9b51c --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Type.php @@ -0,0 +1,35 @@ +|string|float|int|bool|null $value + */ + public function __construct(private object|array|string|float|int|bool|null $value) + { + parent::__construct(); + } + + protected function getAttributesData(): array + { + return []; + } + + /** + * @return object|array|string|float|int|bool|null + */ + public function getValue(): object|array|string|float|int|bool|null + { + return $this->value; + } + + public function getType(): string + { + return 'type'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Matchers/Values.php b/src/PhpPact/Consumer/Matcher/Matchers/Values.php new file mode 100644 index 00000000..7e68cce4 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Matchers/Values.php @@ -0,0 +1,37 @@ + $values + */ + public function __construct(private array $values) + { + parent::__construct(); + } + + protected function getAttributesData(): array + { + return []; + } + + /** + * @return array + */ + public function getValue(): array + { + return $this->values; + } + + public function getType(): string + { + return 'values'; + } +} diff --git a/src/PhpPact/Consumer/Matcher/Model/Attributes.php b/src/PhpPact/Consumer/Matcher/Model/Attributes.php new file mode 100644 index 00000000..8da2fc7a --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Model/Attributes.php @@ -0,0 +1,59 @@ + $data + */ + public function __construct(private GeneratorInterface|MatcherInterface $parent, private array $data = []) + { + } + + public function getParent(): GeneratorInterface|MatcherInterface + { + return $this->parent; + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } + + public function has(string $key): bool + { + return isset($this->data[$key]); + } + + public function get(string $key): mixed + { + return $this->data[$key] ?? null; + } + + public function merge(self $attributes): self + { + foreach ($this->data as $key => $value) { + if ($attributes->has($key) && $value !== $attributes->get($key)) { + throw new AttributeConflictException(sprintf("Attribute '%s' of %s '%s' and %s '%s' are conflict", $key, $this->getParentType(), $this->getParentName(), $attributes->getParentType(), $attributes->getParentName())); + } + } + + return new self($this->parent, $this->data + $attributes->getData()); + } + + private function getParentType(): string + { + return $this->parent instanceof GeneratorInterface ? 'generator' : 'matcher'; + } + + private function getParentName(): string + { + return $this->parent->getType(); + } +} diff --git a/src/PhpPact/Consumer/Matcher/Model/FormatterAwareInterface.php b/src/PhpPact/Consumer/Matcher/Model/FormatterAwareInterface.php new file mode 100644 index 00000000..d757cb21 --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Model/FormatterAwareInterface.php @@ -0,0 +1,10 @@ + + */ + public function format(MatcherInterface $matcher): string|array; +} diff --git a/src/PhpPact/Consumer/Matcher/Model/GeneratorAwareInterface.php b/src/PhpPact/Consumer/Matcher/Model/GeneratorAwareInterface.php new file mode 100644 index 00000000..5eff073b --- /dev/null +++ b/src/PhpPact/Consumer/Matcher/Model/GeneratorAwareInterface.php @@ -0,0 +1,12 @@ + */ - protected array $callback; - - private Message $message; + protected array $callback = []; - public function __construct(PactConfigInterface $config) + public function __construct(PactConfigInterface $config, ?MessageDriverFactoryInterface $driverFactory = null) { - $this->config = $config; - $this->message = new Message(); - $this->pactMessage = new PactMessage(); + parent::__construct(); + $this->driver = ($driverFactory ?? new MessageDriverFactory())->create($config); } /** @@ -45,83 +42,40 @@ public function setCallback(callable $callback, ?string $description = null): se return $this; } - /** - * @param string $name what is given to the request - * @param array $params for that request - * @param bool $overwrite clear pass states completely and start this array - */ - public function given(string $name, array $params = [], bool $overwrite = false): self - { - $this->message->setProviderState($name, $params, $overwrite); - - return $this; - } - - /** - * @param string $description what is received when the request is made - */ - public function expectsToReceive(string $description): self - { - $this->message->setDescription($description); - - return $this; - } - - /** - * @param array $metadata what is the additional metadata of the message - */ - public function withMetadata(array $metadata): self - { - $this->message->setMetadata($metadata); - - return $this; - } - - /** - * Make the http request to the Mock Service to register the message. Content is required. - * - * @param mixed $contents required to be in the message - */ - public function withContent($contents): self - { - $this->message->setContents($contents); - - return $this; - } - /** * Run reify to create an example pact from the message (i.e. create messages from matchers) */ public function reify(): string { - return $this->pactMessage->reify($this->message); + $this->driver->registerMessage($this->message); + + return $this->driver->reify($this->message); } /** * Wrapper around verify() * * @param null|string $description description of the pact and thus callback - * @throws \Exception + * + * @throws MissingCallbackException */ public function verifyMessage(callable $callback, ?string $description = null): bool { $this->setCallback($callback, $description); - return $this->verify($description); + return $this->verify(); } /** * Verify the use of the pact by calling the callback * It also calls finalize to write the pact * - * @param null|string $description description of the pact and thus callback - * - * @throws \Exception if callback is not set + * @throws MissingCallbackException if callback is not set */ - public function verify(?string $description = null): bool + public function verify(): bool { if (\count($this->callback) < 1) { - throw new \Exception('Callbacks need to exist to run verify.'); + throw new MissingCallbackException('Callbacks need to exist to run verify.'); } $pactJson = $this->reify(); @@ -133,21 +87,11 @@ public function verify(?string $description = null): bool \call_user_func($callback, $pactJson); } - return $this->writePact(); + $this->driver->writePactAndCleanUp(); } catch (\Exception $e) { return false; } - } - - /** - * Write the Pact without deleting the interactions. - * @throws \JsonException - */ - public function writePact(): bool - { - // you do not want to save the reified json - $pactJson = \json_encode($this->message, JSON_THROW_ON_ERROR); - return $this->pactMessage->update($pactJson, $this->config->getConsumer(), $this->config->getProvider(), $this->config->getPactDir()); + return true; } } diff --git a/src/PhpPact/Consumer/Model/Body/Binary.php b/src/PhpPact/Consumer/Model/Body/Binary.php new file mode 100644 index 00000000..98a0164f --- /dev/null +++ b/src/PhpPact/Consumer/Model/Body/Binary.php @@ -0,0 +1,58 @@ +setContentType($contentType); + } + + public function getPath(): string + { + return $this->path; + } + + public function setPath(string $path): self + { + $this->path = $path; + + return $this; + } + + public function getData(): BinaryData + { + if (!$this->data) { + $this->data = $this->createBinaryData(); + } + + return $this->data; + } + + private function createBinaryData(): BinaryData + { + if (!file_exists($this->getPath())) { + throw new BinaryFileNotExistException(sprintf('File %s does not exist', $this->getPath())); + } + $contents = file_get_contents($this->getPath()); + if (false === $contents) { + throw new BinaryFileReadException(sprintf('File %s can not be read', $this->getPath())); + } + + return BinaryData::createFrom($contents); + } + + public function __destruct() + { + $this->data = null; + } +} diff --git a/src/PhpPact/Consumer/Model/Body/ContentTypeTrait.php b/src/PhpPact/Consumer/Model/Body/ContentTypeTrait.php new file mode 100644 index 00000000..41acc2cf --- /dev/null +++ b/src/PhpPact/Consumer/Model/Body/ContentTypeTrait.php @@ -0,0 +1,20 @@ +contentType; + } + + public function setContentType(string $contentType): self + { + $this->contentType = $contentType; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Body/Multipart.php b/src/PhpPact/Consumer/Model/Body/Multipart.php new file mode 100644 index 00000000..fde72186 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Body/Multipart.php @@ -0,0 +1,47 @@ + $parts + */ + public function __construct(private array $parts, private string $boundary) + { + $this->setParts($parts); + } + + /** + * @return array + */ + public function getParts(): array + { + return $this->parts; + } + + /** + * @param array $parts + */ + public function setParts(array $parts): self + { + $this->parts = []; + foreach ($parts as $part) { + $this->addPart($part); + } + + return $this; + } + + public function addPart(Part $part): self + { + $this->parts[] = $part; + + return $this; + } + + public function getBoundary(): string + { + return $this->boundary; + } +} diff --git a/src/PhpPact/Consumer/Model/Body/Part.php b/src/PhpPact/Consumer/Model/Body/Part.php new file mode 100644 index 00000000..9400c2b0 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Body/Part.php @@ -0,0 +1,37 @@ +setContentType($contentType); + } + + public function getPath(): string + { + return $this->path; + } + + public function setPath(string $path): self + { + $this->path = $path; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Body/Text.php b/src/PhpPact/Consumer/Model/Body/Text.php new file mode 100644 index 00000000..961671a3 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Body/Text.php @@ -0,0 +1,25 @@ +setContentType($contentType); + } + + public function getContents(): string + { + return $this->contents; + } + + public function setContents(string $contents): self + { + $this->contents = $contents; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/ConsumerRequest.php b/src/PhpPact/Consumer/Model/ConsumerRequest.php index cc5d797a..1c0b4c08 100644 --- a/src/PhpPact/Consumer/Model/ConsumerRequest.php +++ b/src/PhpPact/Consumer/Model/ConsumerRequest.php @@ -2,142 +2,20 @@ namespace PhpPact\Consumer\Model; +use PhpPact\Consumer\Model\Interaction\BodyTrait; +use PhpPact\Consumer\Model\Interaction\HeadersTrait; +use PhpPact\Consumer\Model\Interaction\MethodTrait; +use PhpPact\Consumer\Model\Interaction\PathTrait; +use PhpPact\Consumer\Model\Interaction\QueryTrait; + /** * Request initiated by the consumer. */ -class ConsumerRequest implements \JsonSerializable +class ConsumerRequest { - private string $method; - - /** - * @var string|array - */ - private string|array $path; - - /** - * @var array - */ - private array $headers = []; - - private mixed $body = null; - - private ?string $query = null; - - public function getMethod(): string - { - return $this->method; - } - - public function setMethod(string $method): self - { - $this->method = $method; - - return $this; - } - - /** - * @return string|array - */ - public function getPath(): string|array - { - return $this->path; - } - - /** - * @param string|array $path - */ - public function setPath(string|array $path): self - { - $this->path = $path; - - return $this; - } - - /** - * @return array - */ - public function getHeaders(): array - { - return $this->headers; - } - - /** - * @param string[] $headers - */ - public function setHeaders(array $headers): self - { - $this->headers = $headers; - - return $this; - } - - public function addHeader(string $header, string $value): self - { - $this->headers[$header] = $value; - - return $this; - } - - public function getBody(): mixed - { - return $this->body; - } - - public function setBody(mixed $body): self - { - $this->body = $body; - - return $this; - } - - public function getQuery(): ?string - { - return $this->query; - } - - public function setQuery(string $query): self - { - $this->query = $query; - - return $this; - } - - public function addQueryParameter(string $key, string $value): self - { - if ($this->query === null) { - $this->query = "{$key}={$value}"; - } else { - $this->query = "{$this->query}&{$key}={$value}"; - } - - return $this; - } - - /** - * @return array - */ - public function jsonSerialize(): array - { - $results = []; - - $results['method'] = $this->getMethod(); - - if (count($this->getHeaders()) > 0) { - $results['headers'] = $this->getHeaders(); - } - - if ($this->getPath() !== null) { - $results['path'] = $this->getPath(); - } - - if ($this->getBody() !== null) { - $results['body'] = $this->getBody(); - } - - if ($this->getQuery() !== null) { - $results['query'] = $this->getQuery(); - } - - return $results; - } + use HeadersTrait; + use BodyTrait; + use MethodTrait; + use PathTrait; + use QueryTrait; } diff --git a/src/PhpPact/Consumer/Model/Interaction.php b/src/PhpPact/Consumer/Model/Interaction.php index 44feaf67..ae2617de 100644 --- a/src/PhpPact/Consumer/Model/Interaction.php +++ b/src/PhpPact/Consumer/Model/Interaction.php @@ -2,43 +2,32 @@ namespace PhpPact\Consumer\Model; +use PhpPact\Consumer\Driver\Enum\InteractionPart; +use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; +use PhpPact\Consumer\Model\Body\Text; +use PhpPact\Consumer\Model\Interaction\CommentsTrait; +use PhpPact\Consumer\Model\Interaction\DescriptionTrait; +use PhpPact\Consumer\Model\Interaction\HandleTrait; +use PhpPact\Consumer\Model\Interaction\KeyTrait; +use PhpPact\Consumer\Model\Interaction\PendingTrait; + /** - * Request/Response Pair to be posted to the Ruby Standalone Mock Server for PACT tests. + * Request/Response Pair to be posted to the Mock Server for PACT tests. */ -class Interaction implements \JsonSerializable +class Interaction { - private string $description; - - private ?string $providerState = null; + use ProviderStates; + use DescriptionTrait; + use HandleTrait; + use KeyTrait; + use PendingTrait; + use CommentsTrait; private ConsumerRequest $request; private ProviderResponse $response; - public function getDescription(): string - { - return $this->description; - } - - public function setDescription(string $description): self - { - $this->description = $description; - - return $this; - } - - public function getProviderState(): ?string - { - return $this->providerState; - } - - public function setProviderState(string $providerState): self - { - $this->providerState = $providerState; - - return $this; - } - public function getRequest(): ConsumerRequest { return $this->request; @@ -63,24 +52,22 @@ public function setResponse(ProviderResponse $response): self return $this; } + public function getBody(InteractionPart $part): Text|Binary|Multipart|null + { + return match ($part) { + InteractionPart::REQUEST => $this->getRequest()->getBody(), + InteractionPart::RESPONSE => $this->getResponse()->getBody(), + }; + } + /** - * @return array + * @return array */ - public function jsonSerialize(): array + public function getHeaders(InteractionPart $part): array { - if ($this->getProviderState()) { - return [ - 'description' => $this->getDescription(), - 'providerState' => $this->getProviderState(), - 'request' => $this->getRequest(), - 'response' => $this->getResponse(), - ]; - } - - return [ - 'description' => $this->getDescription(), - 'request' => $this->getRequest(), - 'response' => $this->getResponse(), - ]; + return match ($part) { + InteractionPart::REQUEST => $this->getRequest()->getHeaders(), + InteractionPart::RESPONSE => $this->getResponse()->getHeaders(), + }; } } diff --git a/src/PhpPact/Consumer/Model/Interaction/BodyTrait.php b/src/PhpPact/Consumer/Model/Interaction/BodyTrait.php new file mode 100644 index 00000000..1481c921 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/BodyTrait.php @@ -0,0 +1,34 @@ +body; + } + + /** + * @throws JsonException + */ + public function setBody(mixed $body): self + { + if (\is_string($body)) { + $this->body = new Text($body, 'text/plain'); + } elseif (\is_null($body) || $body instanceof Text || $body instanceof Binary || $body instanceof Multipart) { + $this->body = $body; + } else { + $this->body = new Text(\json_encode($body, JSON_THROW_ON_ERROR), 'application/json'); + } + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/CommentsTrait.php b/src/PhpPact/Consumer/Model/Interaction/CommentsTrait.php new file mode 100644 index 00000000..fa0e3ed2 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/CommentsTrait.php @@ -0,0 +1,32 @@ + + */ + private array $comments = []; + + /** + * @return array + */ + public function getComments(): array + { + return $this->comments; + } + + /** + * @param array $comments + */ + public function setComments(array $comments): self + { + $this->comments = []; + foreach ($comments as $key => $value) { + $this->comments[$key] = $value; + } + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/DescriptionTrait.php b/src/PhpPact/Consumer/Model/Interaction/DescriptionTrait.php new file mode 100644 index 00000000..dabd9a80 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/DescriptionTrait.php @@ -0,0 +1,20 @@ +description; + } + + public function setDescription(string $description): self + { + $this->description = $description; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/HandleTrait.php b/src/PhpPact/Consumer/Model/Interaction/HandleTrait.php new file mode 100644 index 00000000..9807ae40 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/HandleTrait.php @@ -0,0 +1,20 @@ +handle; + } + + public function setHandle(int $handle): self + { + $this->handle = $handle; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/HeadersTrait.php b/src/PhpPact/Consumer/Model/Interaction/HeadersTrait.php new file mode 100644 index 00000000..9cc6f3ae --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/HeadersTrait.php @@ -0,0 +1,60 @@ + + */ + private array $headers = []; + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * @param array $headers + */ + public function setHeaders(array $headers): self + { + $this->headers = []; + foreach ($headers as $header => $value) { + $this->addHeader($header, $value); + } + + return $this; + } + + /** + * @param MatcherInterface|MatcherInterface[]|string[]|string $value + * + * @throws JsonException + */ + public function addHeader(string $header, array|string|MatcherInterface $value): self + { + $this->headers[$header] = []; + if (is_array($value)) { + array_walk($value, fn (string|MatcherInterface $value) => $this->addHeaderValue($header, $value)); + } else { + $this->addHeaderValue($header, $value); + } + + return $this; + } + + /** + * @throws JsonException + */ + private function addHeaderValue(string $header, string|MatcherInterface $value): void + { + $this->headers[$header][] = is_string($value) ? $value : json_encode($value, JSON_THROW_ON_ERROR); + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/KeyTrait.php b/src/PhpPact/Consumer/Model/Interaction/KeyTrait.php new file mode 100644 index 00000000..2757bc0d --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/KeyTrait.php @@ -0,0 +1,20 @@ +key; + } + + public function setKey(?string $key): self + { + $this->key = $key; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/MethodTrait.php b/src/PhpPact/Consumer/Model/Interaction/MethodTrait.php new file mode 100644 index 00000000..84ab36cc --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/MethodTrait.php @@ -0,0 +1,20 @@ +method; + } + + public function setMethod(string $method): self + { + $this->method = $method; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/PathTrait.php b/src/PhpPact/Consumer/Model/Interaction/PathTrait.php new file mode 100644 index 00000000..52126078 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/PathTrait.php @@ -0,0 +1,26 @@ +path; + } + + /** + * @throws JsonException + */ + public function setPath(MatcherInterface|string $path): self + { + $this->path = is_string($path) ? $path : json_encode($path, JSON_THROW_ON_ERROR); + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/PendingTrait.php b/src/PhpPact/Consumer/Model/Interaction/PendingTrait.php new file mode 100644 index 00000000..1123c7cf --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/PendingTrait.php @@ -0,0 +1,20 @@ +pending; + } + + public function setPending(?bool $pending): self + { + $this->pending = $pending; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/QueryTrait.php b/src/PhpPact/Consumer/Model/Interaction/QueryTrait.php new file mode 100644 index 00000000..a1184855 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/QueryTrait.php @@ -0,0 +1,60 @@ + + */ + private array $query = []; + + /** + * @return array + */ + public function getQuery(): array + { + return $this->query; + } + + /** + * @param array $query + */ + public function setQuery(array $query): self + { + $this->query = []; + foreach ($query as $key => $value) { + $this->addQueryParameter($key, $value); + } + + return $this; + } + + /** + * @param MatcherInterface|MatcherInterface[]|string|string[] $value + * + * @throws JsonException + */ + public function addQueryParameter(string $key, array|string|MatcherInterface $value): self + { + $this->query[$key] = []; + if (is_array($value)) { + array_walk($value, fn (string|MatcherInterface $value) => $this->addQueryParameterValue($key, $value)); + } else { + $this->addQueryParameterValue($key, $value); + } + + return $this; + } + + /** + * @throws JsonException + */ + private function addQueryParameterValue(string $key, string|MatcherInterface $value): void + { + $this->query[$key][] = is_string($value) ? $value : json_encode($value, JSON_THROW_ON_ERROR); + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/StatusTrait.php b/src/PhpPact/Consumer/Model/Interaction/StatusTrait.php new file mode 100644 index 00000000..53a95674 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Interaction/StatusTrait.php @@ -0,0 +1,26 @@ +status; + } + + /** + * @throws JsonException + */ + public function setStatus(int|MatcherInterface $status): self + { + $this->status = is_int($status) ? (string) $status : json_encode($status, JSON_THROW_ON_ERROR); + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Message.php b/src/PhpPact/Consumer/Model/Message.php index 8025b20d..dd4b8e0e 100644 --- a/src/PhpPact/Consumer/Model/Message.php +++ b/src/PhpPact/Consumer/Model/Message.php @@ -2,76 +2,36 @@ namespace PhpPact\Consumer\Model; +use JsonException; +use PhpPact\Consumer\Exception\BodyNotSupportedException; +use PhpPact\Consumer\Matcher\Model\MatcherInterface; +use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; +use PhpPact\Consumer\Model\Body\Text; +use PhpPact\Consumer\Model\Interaction\CommentsTrait; +use PhpPact\Consumer\Model\Interaction\DescriptionTrait; +use PhpPact\Consumer\Model\Interaction\HandleTrait; +use PhpPact\Consumer\Model\Interaction\KeyTrait; +use PhpPact\Consumer\Model\Interaction\PendingTrait; + /** - * Request/Response Pair to be posted to the Ruby Standalone Mock Server for PACT tests. + * Message metadata and contents to be posted to the Mock Server for PACT tests. */ -class Message implements \JsonSerializable +class Message { - private string $description; - - /** - * @var array - */ - private array $providerStates = []; + use ProviderStates; + use DescriptionTrait; + use HandleTrait; + use KeyTrait; + use PendingTrait; + use CommentsTrait; /** * @var array */ - private array $metadata; - - private mixed $contents; - - public function getDescription(): string - { - return $this->description; - } - - public function setDescription(string $description): self - { - $this->description = $description; - - return $this; - } - - /** - * @return array - */ - public function getProviderStates(): array - { - return $this->providerStates; - } - - /** - * @param array $params - * - * @return array - */ - public function setProviderState(string $name, array $params = [], bool $overwrite = true): array - { - $this->addProviderState($name, $params, $overwrite); - - return $this->providerStates; - } - - /** - * @param string $name - * @param array $params - * @param bool $overwrite - if true reset the entire state - */ - public function addProviderState(string $name, array $params, bool $overwrite = false): self - { - $providerState = new \stdClass(); - $providerState->name = $name; - $providerState->params = $params; - - if ($overwrite === true) { - $this->providerStates = []; - } + private array $metadata = []; - $this->providerStates[] = $providerState; - - return $this; - } + private Text|Binary|null $contents = null; /** * @return array @@ -82,49 +42,46 @@ public function getMetadata(): array } /** - * @param array $metadata + * @param array $metadata */ public function setMetadata(array $metadata): self { - $this->metadata = $metadata; + $this->metadata = []; + foreach ($metadata as $key => $value) { + $this->setMetadataValue($key, $value); + } return $this; } - public function getContents(): mixed + /** + * @throws JsonException + */ + private function setMetadataValue(string $key, string|MatcherInterface $value): void { - return $this->contents; + $this->metadata[$key] = is_string($value) ? $value : json_encode($value, JSON_THROW_ON_ERROR); } - public function setContents(mixed $contents): self + public function getContents(): Text|Binary|null { - $this->contents = $contents; - - return $this; + return $this->contents; } /** - * {@inheritdoc} - * - * @return array + * @throws JsonException */ - public function jsonSerialize(): array + public function setContents(mixed $contents): self { - $out = []; - $out['description'] = $this->getDescription(); - - if (\count($this->providerStates) > 0) { - $out['providerStates'] = $this->getProviderStates(); - } - - if ($this->metadata) { - $out['metadata'] = $this->getMetadata(); + if (\is_string($contents)) { + $this->contents = new Text($contents, 'text/plain'); + } elseif (\is_null($contents) || $contents instanceof Text || $contents instanceof Binary) { + $this->contents = $contents; + } elseif ($contents instanceof Multipart) { + throw new BodyNotSupportedException('Message does not support multipart'); + } else { + $this->contents = new Text(\json_encode($contents, JSON_THROW_ON_ERROR), 'application/json'); } - if ($this->contents) { - $out['contents'] = $this->getContents(); - } - - return $out; + return $this; } } diff --git a/src/PhpPact/Consumer/Model/Pact/Pact.php b/src/PhpPact/Consumer/Model/Pact/Pact.php new file mode 100644 index 00000000..dfe3ec75 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Pact/Pact.php @@ -0,0 +1,10 @@ + - */ - private array $headers = []; - - /** - * @var ?array - */ - private ?array $body = null; - - public function getStatus(): int - { - return $this->status; - } - - public function setStatus(int $status): self - { - $this->status = $status; - - return $this; - } - - /** - * @return array - */ - public function getHeaders(): array - { - return $this->headers; - } - - /** - * @param array $headers - */ - public function setHeaders(array $headers): self - { - $this->headers = $headers; - - return $this; - } - - public function addHeader(string $header, string $value): self - { - $this->headers[$header] = $value; - - return $this; - } - - /** - * @return ?array - */ - public function getBody(): ?array - { - return $this->body; - } - - /** - * @param array $body - */ - public function setBody(array $body): self - { - $this->body = $body; - - return $this; - } - - /** - * @return array - */ - public function jsonSerialize(): array - { - $results = [ - 'status' => $this->getStatus(), - ]; - - if (count($this->getHeaders()) > 0) { - $results['headers'] = $this->getHeaders(); - } - - if ($this->getBody() !== null) { - $results['body'] = $this->getBody(); - } - - return $results; - } + use HeadersTrait; + use BodyTrait; + use StatusTrait; } diff --git a/src/PhpPact/Consumer/Model/ProviderState.php b/src/PhpPact/Consumer/Model/ProviderState.php new file mode 100644 index 00000000..acfa571d --- /dev/null +++ b/src/PhpPact/Consumer/Model/ProviderState.php @@ -0,0 +1,46 @@ + + */ + private array $params = []; + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * @return array + */ + public function getParams(): array + { + return $this->params; + } + + /** + * @param array $params + */ + public function setParams(array $params = []): void + { + foreach ($params as $key => $value) { + $this->addParam($key, $value); + } + } + + public function addParam(string $key, string $value): void + { + $this->params[$key] = $value; + } +} diff --git a/src/PhpPact/Consumer/Model/ProviderStates.php b/src/PhpPact/Consumer/Model/ProviderStates.php new file mode 100644 index 00000000..09c09ece --- /dev/null +++ b/src/PhpPact/Consumer/Model/ProviderStates.php @@ -0,0 +1,55 @@ + + */ + private array $providerStates = []; + + /** + * @return array + */ + public function getProviderStates(): array + { + return $this->providerStates; + } + + /** + * @param string $name + * @param array $params + * @param bool $overwrite + * + * @return array + */ + public function setProviderState(string $name, array $params = [], bool $overwrite = true): array + { + $this->addProviderState($name, $params, $overwrite); + + return $this->providerStates; + } + + /** + * @param string $name + * @param array $params + * @param bool $overwrite - if true reset the entire state + * + * @return $this + */ + public function addProviderState(string $name, array $params, bool $overwrite = false): self + { + $providerState = new ProviderState(); + $providerState->setName($name); + $providerState->setParams($params); + + if ($overwrite === true) { + $this->providerStates = []; + } + + $this->providerStates[] = $providerState; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Service/MockServer.php b/src/PhpPact/Consumer/Service/MockServer.php new file mode 100644 index 00000000..b34dd9fa --- /dev/null +++ b/src/PhpPact/Consumer/Service/MockServer.php @@ -0,0 +1,97 @@ +client->call( + 'pactffi_create_mock_server_for_transport', + $this->pactDriver->getPact()->handle, + $this->config->getHost(), + $this->config->getPort(), + $this->getTransport(), + $this->getTransportConfig() + ); + + if ($port < 0) { + throw new MockServerNotStartedException($port); + } + $this->config->setPort($port); + } + + public function verify(): VerifyResult + { + try { + $matched = $this->isMatched(); + + if ($matched) { + $this->writePact(); + } else { + $mismatches = $this->getMismatches(); + } + + return new VerifyResult($matched, $mismatches ?? ''); + } finally { + $this->cleanUp(); + } + } + + protected function getTransport(): string + { + return $this->config->isSecure() ? 'https' : 'http'; + } + + protected function getTransportConfig(): ?string + { + return null; + } + + public function writePact(): void + { + $error = $this->client->call( + 'pactffi_write_pact_file', + $this->config->getPort(), + $this->config->getPactDir(), + $this->config->getPactFileWriteMode() === PactConfigInterface::MODE_OVERWRITE + ); + if ($error) { + throw new MockServerPactFileNotWrittenException($error); + } + } + + public function cleanUp(): void + { + $this->client->call('pactffi_cleanup_mock_server', $this->config->getPort()); + $this->pactDriver->cleanUp(); + } + + private function isMatched(): bool + { + return $this->client->call('pactffi_mock_server_matched', $this->config->getPort()); + } + + private function getMismatches(): string + { + $cData = $this->client->call('pactffi_mock_server_mismatches', $this->config->getPort()); + + return FFI::string($cData); + } +} diff --git a/src/PhpPact/Consumer/Service/MockServerInterface.php b/src/PhpPact/Consumer/Service/MockServerInterface.php new file mode 100644 index 00000000..0096610c --- /dev/null +++ b/src/PhpPact/Consumer/Service/MockServerInterface.php @@ -0,0 +1,16 @@ +ffi = FFI::cdef($code, Scripts::getLibrary()); + } + + public function call(string $name, ...$arguments): mixed + { + return $this->ffi->{$name}(...$arguments); + } + + public function get(string $name): mixed + { + return $this->ffi->{$name}; + } +} diff --git a/src/PhpPact/FFI/ClientInterface.php b/src/PhpPact/FFI/ClientInterface.php new file mode 100644 index 00000000..c0e767ff --- /dev/null +++ b/src/PhpPact/FFI/ClientInterface.php @@ -0,0 +1,13 @@ + $arguments + */ + public function call(string $name, ...$arguments): mixed; + + public function get(string $name): mixed; +} diff --git a/src/PhpPact/FFI/Exception/CDataNotCreatedException.php b/src/PhpPact/FFI/Exception/CDataNotCreatedException.php new file mode 100644 index 00000000..aa882a06 --- /dev/null +++ b/src/PhpPact/FFI/Exception/CDataNotCreatedException.php @@ -0,0 +1,7 @@ +items; + } + + public function getSize(): int + { + return $this->size; + } + + /** + * @param array $values + */ + public static function createFrom(array $values): ?self + { + $size = count($values); + if ($size === 0) { + return null; + } + + $items = FFI::new("char*[{$size}]"); + if ($items === null) { + throw new CDataNotCreatedException(); + } + foreach ($values as $index => $value) { + $length = \strlen($value); + $itemSize = $length + 1; + $item = FFI::new("char[{$itemSize}]", false); + if ($item === null) { + throw new CDataNotCreatedException(); + } + FFI::memcpy($item, $value, $length); + $items[$index] = $item; // @phpstan-ignore-line + } + + return new self($items, $size); + } + + public function __destruct() + { + for ($i = 0; $i < $this->size; $i++) { + FFI::free($this->items[$i]); // @phpstan-ignore-line + } + } +} diff --git a/src/PhpPact/FFI/Model/BinaryData.php b/src/PhpPact/FFI/Model/BinaryData.php new file mode 100644 index 00000000..5172b550 --- /dev/null +++ b/src/PhpPact/FFI/Model/BinaryData.php @@ -0,0 +1,53 @@ +value; + } + + public function getSize(): int + { + return $this->size; + } + + public static function createFrom(string $contents): self + { + if (empty($contents)) { + throw new EmptyBinaryFileNotSupportedException(); + } + + $length = \strlen($contents); + $cData = FFI::new("uint8_t[{$length}]"); + if ($cData === null) { + throw new CDataNotCreatedException(); + } + FFI::memcpy($cData, $contents, $length); + + return new self($cData, $length); + } + + public function __toString(): string + { + $result = ''; + for ($index = 0; $index < $this->size; $index++) { + $result .= chr($this->value[$index]); // @phpstan-ignore-line + } + + return $result; + } +} diff --git a/src/PhpPact/Http/ClientInterface.php b/src/PhpPact/Http/ClientInterface.php deleted file mode 100644 index fc687e11..00000000 --- a/src/PhpPact/Http/ClientInterface.php +++ /dev/null @@ -1,37 +0,0 @@ - $options - */ - public function get(UriInterface $uri, array $options = []): ResponseInterface; - - /** - * Put Request. - * - * @param array $options - */ - public function put(UriInterface $uri, array $options = []): ResponseInterface; - - /** - * Post Request. - * - * @param array $options - */ - public function post(UriInterface $uri, array $options = []): ResponseInterface; - - /** - * Delete Request. - * - * @param array $options - */ - public function delete(UriInterface $uri, array $options = []): ResponseInterface; -} diff --git a/src/PhpPact/Http/GuzzleClient.php b/src/PhpPact/Http/GuzzleClient.php deleted file mode 100644 index e3d3b776..00000000 --- a/src/PhpPact/Http/GuzzleClient.php +++ /dev/null @@ -1,60 +0,0 @@ - $config - */ - public function __construct(array $config = []) - { - $this->client = new Client($config); - } - - /** - * @param array $options - * @throws GuzzleException - */ - public function get(UriInterface $uri, array $options = []): ResponseInterface - { - return $this->client->get($uri, $options); - } - - /** - * @param array $options - * @throws GuzzleException - */ - public function put(UriInterface $uri, array $options = []): ResponseInterface - { - return $this->client->put($uri, $options); - } - - /** - * @param array $options - * @throws GuzzleException - */ - public function delete(UriInterface $uri, array $options = []): ResponseInterface - { - return $this->client->delete($uri, $options); - } - - /** - * @param array $options - * @throws GuzzleException - */ - public function post(UriInterface $uri, array $options = []): ResponseInterface - { - return $this->client->post($uri, $options); - } -} diff --git a/src/PhpPact/Plugin/Driver/Body/PluginBodyDriver.php b/src/PhpPact/Plugin/Driver/Body/PluginBodyDriver.php new file mode 100644 index 00000000..6e039030 --- /dev/null +++ b/src/PhpPact/Plugin/Driver/Body/PluginBodyDriver.php @@ -0,0 +1,50 @@ +getContents() : $interaction->getBody($interactionPart); + $partId = $interaction instanceof Message ? $this->client->get('InteractionPart_Request') : match ($interactionPart) { + InteractionPart::REQUEST => $this->client->get('InteractionPart_Request'), + InteractionPart::RESPONSE => $this->client->get('InteractionPart_Response'), + }; + switch (true) { + case $body instanceof Binary: + throw new BodyNotSupportedException('Plugin does not support binary body'); + + case $body instanceof Text: + json_decode($body->getContents()); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new BodyNotSupportedException('Plugin only support json body contents'); + } + $error = $this->client->call('pactffi_interaction_contents', $interaction->getHandle(), $partId, $body->getContentType(), $body->getContents()); + if ($error) { + throw new PluginBodyNotAddedException($error); + } + break; + + case $body instanceof Multipart: + throw new BodyNotSupportedException('Plugin does not support multipart body'); + + default: + break; + }; + } +} diff --git a/src/PhpPact/Plugin/Driver/Body/PluginBodyDriverInterface.php b/src/PhpPact/Plugin/Driver/Body/PluginBodyDriverInterface.php new file mode 100644 index 00000000..5ca75854 --- /dev/null +++ b/src/PhpPact/Plugin/Driver/Body/PluginBodyDriverInterface.php @@ -0,0 +1,12 @@ +validatePact(); + $this->client->call('pactffi_cleanup_plugins', $this->pact->handle); + parent::cleanUp(); + } + + public function setUp(): void + { + parent::setUp(); + $this->usingPlugin(); + } + + abstract protected function getPluginName(): string; + + protected function getPluginVersion(): ?string + { + return null; + } + + private function usingPlugin(): self + { + if ($this->getSpecification() < $this->client->get('PactSpecification_V4')) { + throw new PluginNotSupportedBySpecificationException($this->config->getPactSpecificationVersion()); + } + + $this->client->call('pactffi_using_plugin', $this->pact->handle, $this->getPluginName(), $this->getPluginVersion()); + + return $this; + } +} diff --git a/src/PhpPact/Plugin/Exception/PluginBodyNotAddedException.php b/src/PhpPact/Plugin/Exception/PluginBodyNotAddedException.php new file mode 100644 index 00000000..a9b75498 --- /dev/null +++ b/src/PhpPact/Plugin/Exception/PluginBodyNotAddedException.php @@ -0,0 +1,20 @@ + 'A general panic was caught.', + 2 => 'The mock server has already been started.', + 3 => 'The interaction handle is invalid.', + 4 => 'The content type is not valid.', + 5 => 'The contents JSON is not valid JSON.', + 6 => 'The plugin returned an error.', + default => 'Unknown error', + }; + parent::__construct($message, $code); + } +} diff --git a/src/PhpPact/Plugin/Exception/PluginException.php b/src/PhpPact/Plugin/Exception/PluginException.php new file mode 100644 index 00000000..6c9b529d --- /dev/null +++ b/src/PhpPact/Plugin/Exception/PluginException.php @@ -0,0 +1,9 @@ +pluginBodyDriver->registerBody($interaction, $part); + } +} diff --git a/src/PhpPact/Plugins/Csv/Driver/Pact/CsvPactDriver.php b/src/PhpPact/Plugins/Csv/Driver/Pact/CsvPactDriver.php new file mode 100644 index 00000000..045fe996 --- /dev/null +++ b/src/PhpPact/Plugins/Csv/Driver/Pact/CsvPactDriver.php @@ -0,0 +1,13 @@ +pluginParts = $pluginParts; + } + + public function create(MockServerConfigInterface $config): InteractionDriverInterface + { + $client = new Client(); + $pactDriver = new CsvPactDriver($client, $config); + $mockServer = new MockServer($client, $pactDriver, $config); + $csvBodyDriver = new CsvBodyDriver(new PluginBodyDriver($client)); + $requestDriver = in_array(InteractionPart::REQUEST, $this->pluginParts) ? new RequestDriver($client, $csvBodyDriver) : null; + $responseDriver = in_array(InteractionPart::RESPONSE, $this->pluginParts) ? new ResponseDriver($client, $csvBodyDriver) : null; + + return new InteractionDriver($client, $mockServer, $pactDriver, $requestDriver, $responseDriver); + } +} diff --git a/src/PhpPact/Plugins/Protobuf/Driver/Body/ProtobufMessageBodyDriver.php b/src/PhpPact/Plugins/Protobuf/Driver/Body/ProtobufMessageBodyDriver.php new file mode 100644 index 00000000..65b1c446 --- /dev/null +++ b/src/PhpPact/Plugins/Protobuf/Driver/Body/ProtobufMessageBodyDriver.php @@ -0,0 +1,20 @@ +pluginBodyDriver->registerBody($message, InteractionPart::REQUEST); + } +} diff --git a/src/PhpPact/Plugins/Protobuf/Driver/Pact/ProtobufPactDriver.php b/src/PhpPact/Plugins/Protobuf/Driver/Pact/ProtobufPactDriver.php new file mode 100644 index 00000000..176486a9 --- /dev/null +++ b/src/PhpPact/Plugins/Protobuf/Driver/Pact/ProtobufPactDriver.php @@ -0,0 +1,13 @@ + */ - protected array $callbacks = []; - - /** - * Default host name for the proxy server - */ - protected string $defaultProxyHost = 'localhost'; - - /** - * Default port for the proxy server to listen on - */ - protected int $defaultProxyPort = 7201; - - /** - * floor(provider-verification timeout / this value) = default verificationDelaySec - */ - protected int $defaultDelayFactor = 3; - - /** - * Set the number of seconds to delay the verification test to allow the proxy server to be stood up - * - * By default, it is a third of the provider-verification timeout - */ - protected float $verificationDelaySec; - - private ?LoggerInterface $logger = null; - - public function __construct(VerifierConfigInterface $config) - { - parent::__construct($config); - - $this->callbacks = []; - - $baseUrl = $this->config->getProviderBaseUrl(); - if ($baseUrl === null) { - $config->setProviderBaseUrl(new Uri("http://{$this->defaultProxyHost}:{$this->defaultProxyPort}")); - } - - // default verification delay - $this->setVerificationDelaySec(\floor($config->getProcessIdleTimeout() / $this->defaultDelayFactor)); - } - - /** - * @param array $callbacks - */ - public function setCallbacks(array $callbacks): self - { - $this->callbacks = $callbacks; - - return $this; - } - - /** - * Add an individual call back - * - * @throws \Exception - */ - public function addCallback(string $key, callable $callback): self - { - if (isset($this->callbacks[$key])) { - throw new \Exception("Callback with key ($key) already exists"); - } - - $this->callbacks[$key] = $callback; - - return $this; - } - - public function setVerificationDelaySec(float $verificationDelaySec): self - { - $this->verificationDelaySec = $verificationDelaySec; - - return $this; - } - - public function setLogger(LoggerInterface $logger): self - { - $this->logger = $logger; - - return $this; - } - - /** - * @throws \Exception - */ - protected function verifyAction(array $arguments): void - { - if (\count($this->callbacks) < 1) { - throw new \Exception('Callback needs to bet set when using message pacts'); - } - - $callbacks = $this->callbacks; - $uri = $this->config->getProviderBaseUrl(); - - $arguments = \array_merge([Scripts::getProviderVerifier()], $arguments); - - /** - * @throws \Amp\Socket\SocketException - * @throws \Error - * @throws \TypeError - * - * @return \Generator - */ - $lambdaLoop = function () use ($callbacks, $arguments, $uri) { - // spin up a server - $url = "{$uri->getHost()}:{$uri->getPort()}"; - $servers = [ - Socket\Server::listen($url) - ]; - - $logger = $this->getLogger(); - - $server = new Server($servers, new CallableRequestHandler(function (Request $request) use ($callbacks) { - if (\count($callbacks) === 1) { - $callback = \array_pop($callbacks); - } else { - $payload = new Payload($request->getBody()); - $requestBody = yield $payload->buffer(); - $requestBody = \json_decode($requestBody); - $description = $requestBody->description; - - $callback = false; - - if (isset($this->callbacks[$description])) { - $callback = $this->callbacks[$description]; - } - - if ($callback === false) { - throw new \Exception("Pacts with multiple states need to have callbacks key'ed by the description"); - } - } - - //@todo pass $providerStates to the call back - $out = \call_user_func($callback); - - // return response should only happen if the \call_user_fun() - return new Response(Status::OK, [ - 'content-type' => 'application/json;', - ], $out); - }), $logger); - - yield $server->start(); - - // delay long enough for the server to be stood up - $delay = (int) ($this->verificationDelaySec * 1000); - - // call the provider-verification cmd - Loop::delay($delay, function () use ($arguments) { - $cmd = \implode(' ', $arguments); - $process = new Process($cmd); - yield $process->start(); - - $payload = new Payload($process->getStdout()); - print yield $payload->buffer(); - - $code = yield $process->join(); - - // if the provider verification cmd returns a non-zero number, the test failed - if ($code !== 0) { - $this->getLogger()->warning(yield $process->getStderr()->read()); - - throw new \Exception("Pact failed to validate. Exit code: {$code}"); - } - - Loop::stop(); - }); - }; - - Loop::run($lambdaLoop); - } - - private function getLogger(): LoggerInterface - { - if (null === $this->logger) { - $logHandler = new StreamHandler(new ResourceOutputStream(\STDOUT)); - $logHandler->setFormatter(new ConsoleFormatter(null, null, true)); - $this->logger = new Logger('server'); - $this->logger->pushHandler($logHandler); - } - - return $this->logger; - } -} diff --git a/src/PhpPact/Service/LoggerInterface.php b/src/PhpPact/Service/LoggerInterface.php new file mode 100644 index 00000000..9e2e9511 --- /dev/null +++ b/src/PhpPact/Service/LoggerInterface.php @@ -0,0 +1,8 @@ +config = $config; - $this->command = Scripts::getBroker(); - } - - /** - * @throws \Exception - */ - public function canIDeploy(): mixed - { - $runner = new ProcessRunner( - $this->command, - \array_merge( - [ - 'can-i-deploy', - '--pacticipant=\'' . $this->config->getPacticipant().'\'', - '--version=' . $this->config->getVersion(), - '--output=json', - ], - $this->getArguments() - ) - ); - $runner->runBlocking(); - - return \json_decode($runner->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - /** - * @return array parameters to be passed into the process - */ - public function getArguments(): array - { - $parameters = []; - - if ($this->config->getBrokerUri() !== null) { - $parameters[] = "--broker-base-url={$this->config->getBrokerUri()}"; - } - - if ($this->config->getBrokerToken() !== null) { - $parameters[] = "--broker-token={$this->config->getBrokerToken()}"; - } - - if ($this->config->getBrokerUsername() !== null) { - $parameters[] = "--broker-username={$this->config->getBrokerUsername()}"; - } - - if ($this->config->getBrokerPassword() !== null) { - $parameters[] = "--broker-password={$this->config->getBrokerPassword()}"; - } - - return $parameters; - } - - /** - * @throws \Exception - */ - public function createOrUpdatePacticipant(): mixed - { - $runner = new ProcessRunner( - $this->command, - \array_merge( - [ - 'create-or-update-pacticipant', - '--name=' . $this->config->getName(), - '--repository-url=' . $this->config->getRepositoryUrl(), - ], - $this->getArguments() - ) - ); - $runner->runBlocking(); - - return \json_decode($runner->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - /** - * @throws \Exception - */ - public function createOrUpdateWebhook(): mixed - { - $runner = new ProcessRunner( - $this->command, - \array_merge( - [ - 'create-or-update-webhook', - $this->config->getUrl(), - '--request=' . $this->config->getRequest(), - '--header=' . $this->config->getHeader(), - '--data=' . $this->config->getData(), - '--user=' . $this->config->getUser(), - '--consumer=' . $this->config->getConsumer(), - '--provider=' . $this->config->getProvider(), - '--description=' . $this->config->getDescription(), - '--uuid=' . $this->config->getUuid(), - ], - $this->getArguments() - ) - ); - $runner->runBlocking(); - - return \json_decode($runner->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - /** - * @throws \Exception - */ - public function createVersionTag(): mixed - { - $runner = new ProcessRunner( - $this->command, - \array_merge( - [ - 'create-version-tag', - '--pacticipant=\'' . $this->config->getPacticipant().'\'', - '--version=' . $this->config->getVersion(), - '--tag=' . $this->config->getTag(), - ], - $this->getArguments() - ) - ); - $runner->runBlocking(); - - return \json_decode($runner->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - /** - * @throws \Exception - */ - public function createWebhook(): mixed - { - $runner = new ProcessRunner( - $this->command, - \array_merge( - [ - 'create-webhook', - $this->config->getUrl(), - '--request=' . $this->config->getRequest(), - '--header=' . $this->config->getHeader(), - '--data=' . $this->config->getData(), - '--user=' . $this->config->getUser(), - '--consumer=' . $this->config->getConsumer(), - '--provider=' . $this->config->getProvider(), - '--description=' . $this->config->getDescription(), - ], - $this->getArguments() - ) - ); - $runner->runBlocking(); - - return \json_decode($runner->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - /** - * @throws \Exception - */ - public function describeVersion(): mixed - { - $runner = new ProcessRunner( - $this->command, - \array_merge( - [ - 'describe-version', - '--pacticipant=\'' . $this->config->getPacticipant().'\'', - '--output=json', - ], - $this->getArguments() - ) - ); - $runner->runBlocking(); - - return \json_decode($runner->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - /** - * @throws \Exception - */ - public function listLatestPactVersions(): mixed - { - $runner = new ProcessRunner( - $this->command, - \array_merge( - [ - 'list-latest-pact-versions', - '--output=json', - ], - $this->getArguments() - ) - ); - $runner->runBlocking(); - - return \json_decode($runner->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - public function publish(): void - { - $options = [ - 'publish', - $this->config->getPactLocations(), - '--consumer-app-version=' . $this->config->getConsumerVersion(), - ]; - - if (null !== $this->config->getBranch()) { - $options[] = '--branch=' . $this->config->getBranch(); - } - - if (null !== $this->config->getTag()) { - $options[] = '--tag=' . $this->config->getTag(); - } - - $runner = new ProcessRunner( - $this->command, - \array_merge( - $options, - $this->getArguments() - ) - ); - - $runner->runBlocking(); - } - - /** - * @throws \Exception - */ - public function testWebhook(): mixed - { - $runner = new ProcessRunner( - $this->command, - \array_merge( - [ - 'test-webhook', - '--uuid=' . $this->config->getUuid(), - ], - $this->getArguments() - ) - ); - $runner->runBlocking(); - - return \json_decode($runner->getOutput(), true, 512, JSON_THROW_ON_ERROR); - } - - public function generateUuid(): string - { - $runner = new ProcessRunner($this->command, ['generate-uuid']); - $runner->runBlocking(); - - return \rtrim($runner->getOutput()); - } -} diff --git a/src/PhpPact/Standalone/Broker/BrokerConfig.php b/src/PhpPact/Standalone/Broker/BrokerConfig.php deleted file mode 100644 index 1ab80b26..00000000 --- a/src/PhpPact/Standalone/Broker/BrokerConfig.php +++ /dev/null @@ -1,309 +0,0 @@ -repositoryUrl; - } - - public function setRepositoryUrl(?string $repositoryUrl): self - { - $this->repositoryUrl = $repositoryUrl; - - return $this; - } - - public function getUrl(): ?string - { - return $this->url; - } - - public function setUrl(?string $url): self - { - $this->url = $url; - - return $this; - } - - public function getVersion(): ?string - { - return $this->version; - } - - public function setVersion(?string $version): self - { - $this->version = $version; - - return $this; - } - - public function getBranch(): ?string - { - return $this->branch; - } - - public function setBranch(?string $branch): self - { - $this->branch = $branch; - - return $this; - } - - public function getTag(): ?string - { - return $this->tag; - } - - public function setTag(?string $tag): self - { - $this->tag = $tag; - - return $this; - } - - public function getName(): ?string - { - return $this->name; - } - - public function setName(?string $name): self - { - $this->name = $name; - - return $this; - } - - public function getRequest(): ?string - { - return $this->request; - } - - public function setRequest(?string $request): self - { - $this->request = $request; - - return $this; - } - - public function getHeader(): ?string - { - return $this->header; - } - - public function setHeader(?string $header): self - { - $this->header = $header; - - return $this; - } - - public function getData(): ?string - { - return $this->data; - } - - public function setData(?string $data): self - { - $this->data = $data; - - return $this; - } - - public function getUser(): ?string - { - return $this->user; - } - - public function setUser(?string $user): self - { - $this->user = $user; - - return $this; - } - - public function getConsumer(): ?string - { - return $this->consumer; - } - - public function setConsumer(?string $consumer): self - { - $this->consumer = $consumer; - - return $this; - } - - public function getProvider(): ?string - { - return $this->provider; - } - - public function setProvider(?string $provider): self - { - $this->provider = $provider; - - return $this; - } - - public function getDescription(): ?string - { - return $this->description; - } - - public function setDescription(?string $description): self - { - $this->description = $description; - - return $this; - } - - public function getUuid(): ?string - { - return $this->uuid; - } - - public function setUuid(?string $uuid): self - { - $this->uuid = $uuid; - - return $this; - } - - public function isVerbose(): bool - { - return $this->verbose; - } - - public function getBrokerUri(): ?UriInterface - { - return $this->brokerUri; - } - - public function setBrokerUri(?UriInterface $brokerUri): self - { - $this->brokerUri = $brokerUri; - - return $this; - } - - public function getBrokerToken(): ?string - { - return $this->brokerToken; - } - - public function setBrokerToken(?string $brokerToken): self - { - $this->brokerToken = $brokerToken; - - return $this; - } - - public function getBrokerUsername(): ?string - { - return $this->brokerUsername; - } - - public function setBrokerUsername(?string $brokerUsername): self - { - $this->brokerUsername = $brokerUsername; - - return $this; - } - - public function getBrokerPassword(): ?string - { - return $this->brokerPassword; - } - - public function setBrokerPassword(?string $brokerPassword): self - { - $this->brokerPassword = $brokerPassword; - - return $this; - } - - public function getPacticipant(): string - { - return $this->pacticipant; - } - - public function setPacticipant(?string $pacticipant): self - { - $this->pacticipant = $pacticipant; - - return $this; - } - - public function getConsumerVersion(): ?string - { - return $this->consumerVersion; - } - - public function setConsumerVersion(?string $consumerVersion): self - { - $this->consumerVersion = $consumerVersion; - - return $this; - } - - public function getPactLocations(): ?string - { - return $this->pactLocations; - } - - public function setPactLocations(string $locations): self - { - $this->pactLocations = $locations; - - return $this; - } -} diff --git a/src/PhpPact/Standalone/Exception/HealthCheckFailedException.php b/src/PhpPact/Standalone/Exception/HealthCheckFailedException.php deleted file mode 100644 index a2e98a23..00000000 --- a/src/PhpPact/Standalone/Exception/HealthCheckFailedException.php +++ /dev/null @@ -1,16 +0,0 @@ -config = $config; - $this->httpService = $httpService ?: new MockServerHttpService(new GuzzleClient(), $this->config); - } - - /** - * Start the Mock Server. Verify that it is running. - * - * @throws Exception - * - * @return int process ID of the started Mock Server - */ - public function start(): int - { - $this->processRunner = new ProcessRunner(Scripts::getMockService(), $this->getArguments()); - - $processId = $this->processRunner->run(); - - $result = $this->verifyHealthCheck(); - - return $processId; - } - - /** - * Stop the Mock Server process. - * - * @return bool Was stopping successful? - * @throws ProcessException - */ - public function stop(): bool - { - return $this->processRunner->stop(); - } - - /** - * Build an array of command arguments. - * - * @return array - */ - private function getArguments(): array - { - $results = []; - - $logLevel = $this->config->getLogLevel(); - $consumer = \escapeshellarg($this->config->getConsumer()); - $provider = \escapeshellarg($this->config->getProvider()); - $pactDir = \escapeshellarg($this->config->getPactDir()); - - $results[] = 'service'; - $results[] = "--consumer={$consumer}"; - $results[] = "--provider={$provider}"; - $results[] = "--pact-dir={$pactDir}"; - $results[] = "--pact-file-write-mode={$this->config->getPactFileWriteMode()}"; - $results[] = "--host={$this->config->getHost()}"; - $results[] = "--port={$this->config->getPort()}"; - - if ($logLevel !== null) { - $results[] = \sprintf('--log-level=%s', \escapeshellarg($logLevel)); - } - - if ($this->config->hasCors()) { - $results[] = '--cors=true'; - } - - if ($this->config->getPactSpecificationVersion() !== null) { - $results[] = "--pact-specification-version={$this->config->getPactSpecificationVersion()}"; - } - - if ($this->config->getLog() !== null) { - $log = \escapeshellarg($this->config->getLog()); - $results[] = \sprintf('--log=%s', $log); - } - - return $results; - } - - /** - * Make sure the server starts as expected. - * - * @throws Exception - */ - private function verifyHealthCheck(): bool - { - $service = $this->httpService; - - // Verify that the service is up. - $tries = 0; - $maxTries = $this->config->getHealthCheckTimeout(); - $retrySec = $this->config->getHealthCheckRetrySec(); - do { - ++$tries; - - try { - return $service->healthCheck(); - } catch (ConnectionException $e) { - \usleep(intval(round($retrySec * 1000000))); - } - } while ($tries <= $maxTries); - - throw new HealthCheckFailedException("Failed to make connection to Mock Server in {$maxTries} attempts."); - } -} diff --git a/src/PhpPact/Standalone/MockService/MockServerConfig.php b/src/PhpPact/Standalone/MockService/MockServerConfig.php index f736f104..fb062e6f 100644 --- a/src/PhpPact/Standalone/MockService/MockServerConfig.php +++ b/src/PhpPact/Standalone/MockService/MockServerConfig.php @@ -2,15 +2,14 @@ namespace PhpPact\Standalone\MockService; -use Composer\Semver\VersionParser; use GuzzleHttp\Psr7\Uri; -use PhpPact\Standalone\PactConfigInterface; +use PhpPact\Config\PactConfig; use Psr\Http\Message\UriInterface; /** * Configuration defining the default PhpPact Ruby Standalone server. */ -class MockServerConfig implements MockServerConfigInterface, PactConfigInterface +class MockServerConfig extends PactConfig implements MockServerConfigInterface { /** * Host on which to bind the service. @@ -18,58 +17,14 @@ class MockServerConfig implements MockServerConfigInterface, PactConfigInterface private string $host = 'localhost'; /** - * Port on which to run the service. + * Port on which to run the service. A value of zero will result in the operating system allocating an available port. */ - private int $port = 7200; - - private bool $secure = false; - - /** - * Consumer name. - */ - private string $consumer; - - /** - * Provider name. - */ - private string $provider; - - /** - * Directory to which the pacts will be written. - */ - private ?string $pactDir = null; - - /** - * `overwrite` or `merge`. Use `merge` when running multiple mock service - * instances in parallel for the same consumer/provider pair. Ensure the - * pact file is deleted before running tests when using this option so that - * interactions deleted from the code are not maintained in the file. - */ - private string $pactFileWriteMode = 'overwrite'; - - /** - * The pact specification version to use when writing the pact. Note that only versions 1 and 2 are currently supported. - */ - private string $pactSpecificationVersion; - - /** - * File to which to log output. - */ - private ?string $log = null; - - private bool $cors = false; - - /** - * The max allowed attempts the mock server has to be available in. Otherwise it is considered as sick. - */ - private int $healthCheckTimeout = 100; + private int $port = 0; /** - * The seconds between health checks of mock server + * @var bool */ - private float $healthCheckRetrySec = 0.1; - - private ?string $logLevel = null; + private bool $secure = false; /** * {@inheritdoc} @@ -134,196 +89,4 @@ public function getBaseUri(): UriInterface return new Uri("{$protocol}://{$this->getHost()}:{$this->getPort()}"); } - - /** - * {@inheritdoc} - */ - public function getConsumer(): string - { - return $this->consumer; - } - - /** - * {@inheritdoc} - */ - public function setConsumer(string $consumer): self - { - $this->consumer = $consumer; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getProvider(): string - { - return $this->provider; - } - - /** - * {@inheritdoc} - */ - public function setProvider(string $provider): self - { - $this->provider = $provider; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getPactDir(): string - { - if ($this->pactDir === null) { - return \sys_get_temp_dir(); - } - - return $this->pactDir; - } - - /** - * {@inheritdoc} - */ - public function setPactDir(?string $pactDir): self - { - if ($pactDir === null) { - return $this; - } - - if ('\\' !== \DIRECTORY_SEPARATOR) { - $pactDir = \str_replace('\\', \DIRECTORY_SEPARATOR, $pactDir); - } - - $this->pactDir = $pactDir; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getPactFileWriteMode(): string - { - return $this->pactFileWriteMode; - } - - /** - * {@inheritdoc} - */ - public function setPactFileWriteMode(string $pactFileWriteMode): self - { - $options = ['overwrite', 'merge']; - - if (!\in_array($pactFileWriteMode, $options)) { - $implodedOptions = \implode(', ', $options); - - throw new \InvalidArgumentException("Invalid PhpPact File Write Mode, value must be one of the following: {$implodedOptions}."); - } - - $this->pactFileWriteMode = $pactFileWriteMode; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getPactSpecificationVersion(): string - { - return $this->pactSpecificationVersion; - } - - /** - * {@inheritdoc} - */ - public function setPactSpecificationVersion(string $pactSpecificationVersion): self - { - /* - * Parse the version but do not assign it. If it is an invalid version, an exception is thrown - */ - $parser = new VersionParser(); - $parser->normalize($pactSpecificationVersion); - - $this->pactSpecificationVersion = $pactSpecificationVersion; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getLog(): ?string - { - return $this->log; - } - - /** - * {@inheritdoc} - */ - public function setLog(string $log): self - { - $this->log = $log; - - return $this; - } - - public function getLogLevel(): ?string - { - return $this->logLevel; - } - - public function setLogLevel(string $logLevel): self - { - $logLevel = \strtoupper($logLevel); - if (!\in_array($logLevel, ['DEBUG', 'INFO', 'WARN', 'ERROR'])) { - throw new \InvalidArgumentException('LogLevel ' . $logLevel . ' not supported.'); - } - $this->logLevel = $logLevel; - - return $this; - } - - public function hasCors(): bool - { - return $this->cors; - } - - public function setCors(mixed $flag): self - { - if ($flag === 'true') { - $this->cors = true; - } elseif ($flag === 'false') { - $this->cors = false; - } else { - $this->cors = (bool) $flag; - } - - return $this; - } - - public function setHealthCheckTimeout(int $timeout): self - { - $this->healthCheckTimeout = $timeout; - - return $this; - } - - public function getHealthCheckTimeout(): int - { - return $this->healthCheckTimeout; - } - - public function setHealthCheckRetrySec(float $seconds): self - { - $this->healthCheckRetrySec = $seconds; - - return $this; - } - - public function getHealthCheckRetrySec(): float - { - return $this->healthCheckRetrySec; - } } diff --git a/src/PhpPact/Standalone/MockService/MockServerConfigInterface.php b/src/PhpPact/Standalone/MockService/MockServerConfigInterface.php index dbce087e..3b25bfe4 100644 --- a/src/PhpPact/Standalone/MockService/MockServerConfigInterface.php +++ b/src/PhpPact/Standalone/MockService/MockServerConfigInterface.php @@ -2,12 +2,13 @@ namespace PhpPact\Standalone\MockService; +use PhpPact\Config\PactConfigInterface; use Psr\Http\Message\UriInterface; /** * Mock Server configuration interface to allow for simple overrides that are reusable. */ -interface MockServerConfigInterface +interface MockServerConfigInterface extends PactConfigInterface { /** * @return string the host of the mock service @@ -40,26 +41,4 @@ public function isSecure(): bool; public function setSecure(bool $secure): self; public function getBaseUri(): UriInterface; - - /** - * @return string 'merge' or 'overwrite' merge means that interactions are added and overwrite means that the entire file is overwritten - */ - public function getPactFileWriteMode(): string; - - /** - * @param string $pactFileWriteMode 'merge' or 'overwrite' merge means that interactions are added and overwrite means that the entire file is overwritten - */ - public function setPactFileWriteMode(string $pactFileWriteMode): self; - - public function hasCors(): bool; - - public function setCors(mixed $flag): self; - - public function setHealthCheckTimeout(int $timeout): self; - - public function getHealthCheckTimeout(): int; - - public function setHealthCheckRetrySec(float $seconds): self; - - public function getHealthCheckRetrySec(): float; } diff --git a/src/PhpPact/Standalone/MockService/MockServerEnvConfig.php b/src/PhpPact/Standalone/MockService/MockServerEnvConfig.php index 5407e64e..a8183b2a 100644 --- a/src/PhpPact/Standalone/MockService/MockServerEnvConfig.php +++ b/src/PhpPact/Standalone/MockService/MockServerEnvConfig.php @@ -9,19 +9,22 @@ */ class MockServerEnvConfig extends MockServerConfig { - public const DEFAULT_SPECIFICATION_VERSION = '2.0.0'; - /** * @throws MissingEnvVariableException */ public function __construct() { - $this->setHost($this->parseEnv('PACT_MOCK_SERVER_HOST')); - $this->setPort((int) $this->parseEnv('PACT_MOCK_SERVER_PORT')); + if ($host = $this->parseEnv('PACT_MOCK_SERVER_HOST', false)) { + $this->setHost($host); + } + + if ($port = $this->parseEnv('PACT_MOCK_SERVER_PORT', false)) { + $this->setPort((int) $port); + } + $this->setConsumer($this->parseEnv('PACT_CONSUMER_NAME')); $this->setProvider($this->parseEnv('PACT_PROVIDER_NAME')); $this->setPactDir($this->parseEnv('PACT_OUTPUT_DIR', false)); - $this->setCors($this->parseEnv('PACT_CORS', false)); if ($logDir = $this->parseEnv('PACT_LOG', false)) { $this->setLog($logDir); @@ -31,18 +34,6 @@ public function __construct() $this->setLogLevel($logLevel); } - $timeout = $this->parseEnv('PACT_MOCK_SERVER_HEALTH_CHECK_TIMEOUT', false); - if (!$timeout) { - $timeout = 100; - } - $this->setHealthCheckTimeout($timeout); - - $seconds = $this->parseEnv('PACT_MOCK_SERVER_HEALTH_CHECK_RETRY_SEC', false); - if (!$seconds) { - $seconds = 0.1; - } - $this->setHealthCheckRetrySec($seconds); - $version = $this->parseEnv('PACT_SPECIFICATION_VERSION', false); if (!$version) { $version = static::DEFAULT_SPECIFICATION_VERSION; @@ -56,17 +47,12 @@ public function __construct() * * @throws MissingEnvVariableException */ - private function parseEnv(string $variableName, bool $required = true): mixed + private function parseEnv(string $variableName, bool $required = true): ?string { - $result = null; + $result = \getenv($variableName); - if (\getenv($variableName) === 'false') { - $result = false; - } elseif (\getenv($variableName) === 'true') { - $result = true; - } - if (\getenv($variableName) !== false) { - $result = \getenv($variableName); + if (is_bool($result)) { + $result = null; } if ($required === true && $result === null) { diff --git a/src/PhpPact/Standalone/MockService/Model/VerifyResult.php b/src/PhpPact/Standalone/MockService/Model/VerifyResult.php new file mode 100644 index 00000000..7cffd732 --- /dev/null +++ b/src/PhpPact/Standalone/MockService/Model/VerifyResult.php @@ -0,0 +1,10 @@ +client = $client; - $this->config = $config; - } - - /** - * {@inheritdoc} - */ - public function healthCheck(): bool - { - $uri = $this->config->getBaseUri()->withPath('/'); - - try { - $response = $this->client->get($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Pact-Mock-Service' => true, - ], - ]); - - $body = $response->getBody()->getContents(); - - if ($response->getStatusCode() !== 200 - || $body !== "Mock service running\n") { - throw new ConnectionException('Failed to receive a successful response from the Mock Server.'); - } - } catch (RequestException $e) { - throw new ConnectionException('Failed to receive a successful response from the Mock Server.', $e); - } catch (GuzzleConnectionException $e) { - throw new ConnectionException('Failed to receive a successful response from the Mock Server.', $e); - } - - return true; - } - - /** - * {@inheritdoc} - */ - public function deleteAllInteractions(): bool - { - $uri = $this->config->getBaseUri()->withPath('/interactions'); - - $response = $this->client->delete($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Pact-Mock-Service' => true, - ], - ]); - - if ($response->getStatusCode() !== 200) { - return false; - } - - return true; - } - - /** - * {@inheritdoc} - */ - public function registerInteraction(Interaction $interaction): bool - { - $uri = $this->config->getBaseUri()->withPath('/interactions'); - - $body = \json_encode($interaction->jsonSerialize(), JSON_THROW_ON_ERROR); - - $this->client->post($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Pact-Mock-Service' => true, - ], - 'body' => $body, - ]); - - return true; - } - - /** - * Separate function for messages, instead of interactions, as I am unsure what to do with the Ruby Standalone at the moment - */ - public function registerMessage(Message $message): bool - { - $uri = $this->config->getBaseUri()->withPath('/interactions'); - - $body = \json_encode($message->jsonSerialize(), JSON_THROW_ON_ERROR); - - $this->client->post($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Pact-Mock-Service' => true, - ], - 'body' => $body, - ]); - - return true; - } - - /** - * {@inheritdoc} - */ - public function verifyInteractions(): bool - { - $uri = $this->config->getBaseUri()->withPath('/interactions/verification'); - - $this->client->get($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Pact-Mock-Service' => true, - ], - ]); - - return true; - } - - /** - * {@inheritdoc} - * @throws \JsonException - */ - public function getPactJson(): string - { - $uri = $this->config->getBaseUri()->withPath('/pact'); - $response = $this->client->post($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Pact-Mock-Service' => true, - ], - ]); - - return \json_encode(\json_decode($response->getBody()->getContents()), JSON_THROW_ON_ERROR); - } - - /** - * Wrapper for getPactJson to force the Ruby server to write the pact file to disk - * - * If the Pact-PHP does not gracefully kill the Ruby Server, it will not write the - * file to disk. This enables a work around. - * @throws \JsonException - */ - public function writePact(): string - { - return $this->getPactJson(); - } -} diff --git a/src/PhpPact/Standalone/MockService/Service/MockServerHttpServiceInterface.php b/src/PhpPact/Standalone/MockService/Service/MockServerHttpServiceInterface.php deleted file mode 100644 index caf095f5..00000000 --- a/src/PhpPact/Standalone/MockService/Service/MockServerHttpServiceInterface.php +++ /dev/null @@ -1,36 +0,0 @@ -runBlocking(); - - $output = $process->getOutput(); - \preg_replace("/\r|\n/", '', $output); - - return $output; - } - - /** - * Update a pact with the given message, or create the pact if it does not exist. The MESSAGE_JSON may be in the legacy Ruby JSON format or the v2+ format. - */ - public function update(string $pactJson, string $consumer, string $provider, string $pactDir): bool - { - $arguments = []; - $arguments[] = 'update'; - $arguments[] = "--consumer={$consumer}"; - $arguments[] = "--provider={$provider}"; - $arguments[] = "--pact-dir={$pactDir}"; - $arguments[] = "'" . $pactJson . "'"; - - $process = new ProcessRunner(Scripts::getPactMessage(), $arguments); - $process->runBlocking(); - - return true; - } -} diff --git a/src/PhpPact/Standalone/PactMessage/PactMessageConfig.php b/src/PhpPact/Standalone/PactMessage/PactMessageConfig.php index d386fe88..56314f6c 100644 --- a/src/PhpPact/Standalone/PactMessage/PactMessageConfig.php +++ b/src/PhpPact/Standalone/PactMessage/PactMessageConfig.php @@ -2,157 +2,11 @@ namespace PhpPact\Standalone\PactMessage; -use Composer\Semver\VersionParser; -use PhpPact\Standalone\PactConfigInterface; +use PhpPact\Config\PactConfig; /** * Configuration defining the default PhpPact Ruby Standalone server. - * Class MockServerConfig. */ -class PactMessageConfig implements PactConfigInterface +class PactMessageConfig extends PactConfig { - /** - * Consumer name. - */ - private string $consumer; - - /** - * Provider name. - */ - private string $provider; - - /** - * Directory to which the pacts will be written. - */ - private ?string $pactDir = null; - - /** - * The pact specification version to use when writing the pact. Note that only versions 1 and 2 are currently supported. - */ - private string $pactSpecificationVersion; - - /** - * File to which to log output. - */ - private string $log; - - private string $logLevel; - - /** - * {@inheritdoc} - */ - public function getConsumer(): string - { - return $this->consumer; - } - - /** - * {@inheritdoc} - */ - public function setConsumer(string $consumer): PactConfigInterface - { - $this->consumer = $consumer; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getProvider(): string - { - return $this->provider; - } - - /** - * {@inheritdoc} - */ - public function setProvider(string $provider): PactConfigInterface - { - $this->provider = $provider; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getPactDir(): string - { - if ($this->pactDir === null) { - return \sys_get_temp_dir(); - } - - return $this->pactDir; - } - - /** - * {@inheritdoc} - */ - public function setPactDir($pactDir): PactConfigInterface - { - $this->pactDir = $pactDir; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getPactSpecificationVersion(): string - { - return $this->pactSpecificationVersion; - } - - /** - * {@inheritdoc} - * - * @throws \UnexpectedValueException - */ - public function setPactSpecificationVersion($pactSpecificationVersion): PactConfigInterface - { - /* - * Parse the version but do not assign it. If it is an invalid version, an exception is thrown - */ - $parser = new VersionParser(); - $parser->normalize($pactSpecificationVersion); - - $this->pactSpecificationVersion = $pactSpecificationVersion; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getLog(): string - { - return $this->log; - } - - /** - * {@inheritdoc} - */ - public function setLog(string $log): PactConfigInterface - { - $this->log = $log; - - return $this; - } - - public function getLogLevel(): string - { - return $this->logLevel; - } - - public function setLogLevel(string $logLevel): PactConfigInterface - { - $logLevel = \strtoupper($logLevel); - if (!\in_array($logLevel, ['DEBUG', 'INFO', 'WARN', 'ERROR'])) { - throw new \InvalidArgumentException('LogLevel ' . $logLevel . ' not supported.'); - } - $this->logLevel = $logLevel; - - return $this; - } } diff --git a/src/PhpPact/Standalone/ProviderVerifier/Exception/InvalidSelectorValueException.php b/src/PhpPact/Standalone/ProviderVerifier/Exception/InvalidSelectorValueException.php new file mode 100644 index 00000000..f1c4357a --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Exception/InvalidSelectorValueException.php @@ -0,0 +1,7 @@ +name; + } + + public function setName(?string $name): CallingAppInterface + { + $this->name = $name; + + return $this; + } + + public function getVersion(): ?string + { + return $this->version; + } + + public function setVersion(?string $version): CallingAppInterface + { + $this->version = $version; + + return $this; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/CallingAppInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/CallingAppInterface.php new file mode 100644 index 00000000..bae0ec05 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/CallingAppInterface.php @@ -0,0 +1,14 @@ + + */ + private array $filterConsumerNames = []; + + public function setFilterConsumerNames(array $filterConsumerNames): self + { + $this->filterConsumerNames = []; + foreach ($filterConsumerNames as $filterConsumerName) { + $this->addFilterConsumerName($filterConsumerName); + } + + return $this; + } + + public function addFilterConsumerName(string $filterConsumerName): self + { + $this->filterConsumerNames[] = $filterConsumerName; + + return $this; + } + + public function getFilterConsumerNames(): array + { + return $this->filterConsumerNames; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ConsumerFiltersInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ConsumerFiltersInterface.php new file mode 100644 index 00000000..91402e7b --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ConsumerFiltersInterface.php @@ -0,0 +1,18 @@ + $filterConsumerNames + */ + public function setFilterConsumerNames(array $filterConsumerNames): self; + + public function addFilterConsumerName(string $filterConsumerName): self; + + /** + * @return array + */ + public function getFilterConsumerNames(): array; +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/FilterInfo.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/FilterInfo.php new file mode 100644 index 00000000..1f5cdce6 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/FilterInfo.php @@ -0,0 +1,46 @@ +filterDescription; + } + + public function setFilterDescription(?string $filterDescription): self + { + $this->filterDescription = $filterDescription; + + return $this; + } + + public function getFilterNoState(): bool + { + return $this->filterNoState; + } + + public function setFilterNoState(bool $filterNoState): self + { + $this->filterNoState = $filterNoState; + + return $this; + } + + public function getFilterState(): ?string + { + return $this->filterState; + } + + public function setFilterState(?string $filterState): self + { + $this->filterState = $filterState; + + return $this; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/FilterInfoInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/FilterInfoInterface.php new file mode 100644 index 00000000..51420ae8 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/FilterInfoInterface.php @@ -0,0 +1,18 @@ +name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getHost(): ?string + { + return $this->host; + } + + public function setHost(string $host): self + { + $this->host = $host; + + return $this; + } + + public function getScheme(): ?string + { + return $this->scheme; + } + + public function setScheme(?string $scheme): self + { + $this->scheme = $scheme; + + return $this; + } + + public function getPort(): ?int + { + return $this->port; + } + + public function setPort(?int $port): self + { + $this->port = $port; + + return $this; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function setPath(?string $path): self + { + $this->path = $path; + + return $this; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderInfoInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderInfoInterface.php new file mode 100644 index 00000000..d76d297a --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderInfoInterface.php @@ -0,0 +1,26 @@ +stateChangeUrl; + } + + public function setStateChangeUrl(?UriInterface $stateChangeUrl): self + { + $this->stateChangeUrl = $stateChangeUrl; + + return $this; + } + + public function setStateChangeAsBody(bool $stateChangeAsBody): self + { + $this->stateChangeAsBody = $stateChangeAsBody; + + return $this; + } + + public function isStateChangeAsBody(): bool + { + return $this->stateChangeAsBody; + } + + public function setStateChangeTeardown(bool $stateChangeTeardown): self + { + $this->stateChangeTeardown = $stateChangeTeardown; + + return $this; + } + + public function isStateChangeTeardown(): bool + { + return $this->stateChangeTeardown; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderStateInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderStateInterface.php new file mode 100644 index 00000000..a51f8619 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderStateInterface.php @@ -0,0 +1,20 @@ +protocol; + } + + public function setProtocol(?string $protocol): self + { + $this->protocol = $protocol; + + return $this; + } + + public function getScheme(): ?string + { + return $this->scheme; + } + + public function setScheme(?string $scheme): self + { + $this->scheme = $scheme; + + return $this; + } + + public function getPort(): ?int + { + return $this->port; + } + + public function setPort(?int $port): self + { + $this->port = $port; + + return $this; + } + + public function getPath(): ?string + { + return $this->path; + } + + public function setPath(?string $path): self + { + $this->path = $path; + + return $this; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderTransportInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderTransportInterface.php new file mode 100644 index 00000000..ad269b16 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/ProviderTransportInterface.php @@ -0,0 +1,28 @@ + + */ + private array $providerTags = []; + private string $providerVersion; + private ?UriInterface $buildUrl = null; + private ?string $providerBranch = null; + + public function getProviderTags(): array + { + return $this->providerTags; + } + + public function setProviderTags(array $providerTags): self + { + $this->providerTags = []; + foreach ($providerTags as $providerTag) { + $this->addProviderTag($providerTag); + } + + return $this; + } + + public function addProviderTag(string $providerTag): self + { + $this->providerTags[] = $providerTag; + + return $this; + } + + public function getProviderVersion(): string + { + return $this->providerVersion; + } + + public function setProviderVersion(string $providerVersion): self + { + $this->providerVersion = $providerVersion; + + return $this; + } + + public function getBuildUrl(): ?UriInterface + { + return $this->buildUrl; + } + + public function setBuildUrl(?UriInterface $buildUrl): self + { + $this->buildUrl = $buildUrl; + + return $this; + } + + public function getProviderBranch(): ?string + { + return $this->providerBranch; + } + + public function setProviderBranch(?string $providerBranch): self + { + $this->providerBranch = $providerBranch; + + return $this; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/PublishOptionsInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/PublishOptionsInterface.php new file mode 100644 index 00000000..00429e89 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/PublishOptionsInterface.php @@ -0,0 +1,32 @@ + + */ + public function getProviderTags(): array; + + /** + * @param array $providerTags + */ + public function setProviderTags(array $providerTags): self; + + public function addProviderTag(string $providerTag): self; + + public function getProviderVersion(): string; + + public function setProviderVersion(string $providerVersion): self; + + public function getBuildUrl(): ?UriInterface; + + public function setBuildUrl(UriInterface $buildUrl): self; + + public function getProviderBranch(): ?string; + + public function setProviderBranch(?string $providerBranch): self; +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/VerificationOptions.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/VerificationOptions.php new file mode 100644 index 00000000..5e6c25a8 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/VerificationOptions.php @@ -0,0 +1,33 @@ +disableSslVerification; + } + + public function setDisableSslVerification(bool $disableSslVerification): self + { + $this->disableSslVerification = $disableSslVerification; + + return $this; + } + + public function setRequestTimeout(int $requestTimeout): self + { + $this->requestTimeout = $requestTimeout; + + return $this; + } + + public function getRequestTimeout(): int + { + return $this->requestTimeout; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Config/VerificationOptionsInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/VerificationOptionsInterface.php new file mode 100644 index 00000000..093fbbf7 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Config/VerificationOptionsInterface.php @@ -0,0 +1,14 @@ + @@ -12,55 +14,65 @@ class ConsumerVersionSelectors implements Iterator, Countable { private int $position = 0; - /** @var array> */ - private array $selectors; + /** @var array */ + private array $selectors = []; /** - * @param array $selectors + * @param array $selectors */ public function __construct(array $selectors = []) { - $this->selectors = $selectors; + $this->setSelectors($selectors); } - public function addSelector(string $selector): self + /** + * @param array $selectors + */ + public function setSelectors(array $selectors): self + { + $this->selectors = []; + foreach ($selectors as $selector) { + $this->addSelector($selector); + } + + return $this; + } + + /** + * @throws JsonException + */ + public function addSelector(string|SelectorInterface $selector): self { - $this->selectors[] = $selector; + $this->selectors[] = $selector instanceof SelectorInterface ? json_encode($selector, JSON_THROW_ON_ERROR) : $selector; return $this; } - #[\ReturnTypeWillChange] - public function current() + public function current(): string { return $this->selectors[$this->position]; } - #[\ReturnTypeWillChange] - public function next() + public function next(): void { ++$this->position; } - #[\ReturnTypeWillChange] public function key(): int { return $this->position; } - #[\ReturnTypeWillChange] public function valid(): bool { return isset($this->selectors[$this->position]); } - #[\ReturnTypeWillChange] - public function rewind() + public function rewind(): void { $this->position = 0; } - #[\ReturnTypeWillChange] public function count(): int { return \count($this->selectors); diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Selector/Selector.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Selector/Selector.php new file mode 100644 index 00000000..31910832 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Selector/Selector.php @@ -0,0 +1,37 @@ + $value) { + if (false === $value && 'latest' !== $key) { + throw new InvalidSelectorValueException(sprintf("Value 'false' is not allowed for selector %s", $key)); + } + } + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return array_filter(get_object_vars($this), fn (null|string|bool $value) => null !== $value); + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Selector/SelectorInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Selector/SelectorInterface.php new file mode 100644 index 00000000..ec3dfdd8 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Selector/SelectorInterface.php @@ -0,0 +1,9 @@ + + */ + private array $providerTags = []; + protected ?string $providerBranch = null; + protected ConsumerVersionSelectors $consumerVersionSelectors; + /** + * @var array + */ + private array $consumerVersionTags = []; + + public function __construct() + { + $this->consumerVersionSelectors = new ConsumerVersionSelectors(); + } + + public function isEnablePending(): bool + { + return $this->enablePending; + } + + public function setEnablePending(bool $enablePending): self + { + $this->enablePending = $enablePending; + + return $this; + } + + public function setIncludeWipPactSince(?string $date): self + { + $this->wipPactSince = $date; + + return $this; + } + + public function getIncludeWipPactSince(): ?string + { + return $this->wipPactSince; + } + + public function getProviderTags(): array + { + return $this->providerTags; + } + + public function setProviderTags(array $providerTags): self + { + $this->providerTags = []; + foreach ($providerTags as $providerTag) { + $this->addProviderTag($providerTag); + } + + return $this; + } + + public function addProviderTag(string $providerTag): self + { + $this->providerTags[] = $providerTag; + + return $this; + } + + public function getProviderBranch(): ?string + { + return $this->providerBranch; + } + + public function setProviderBranch(?string $providerBranch): self + { + $this->providerBranch = $providerBranch; + + return $this; + } + + public function getConsumerVersionSelectors(): ConsumerVersionSelectors + { + return $this->consumerVersionSelectors; + } + + public function setConsumerVersionSelectors(ConsumerVersionSelectors $selectors): self + { + $this->consumerVersionSelectors = $selectors; + + return $this; + } + + public function getConsumerVersionTags(): array + { + return $this->consumerVersionTags; + } + + public function setConsumerVersionTags(array $consumerVersionTags): self + { + $this->consumerVersionTags = []; + foreach ($consumerVersionTags as $consumerVersionTag) { + $this->addConsumerVersionTag($consumerVersionTag); + } + + return $this; + } + + public function addConsumerVersionTag(string $consumerVersionTag): self + { + $this->consumerVersionTags[] = $consumerVersionTag; + + return $this; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Source/BrokerInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Source/BrokerInterface.php new file mode 100644 index 00000000..45419b23 --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Source/BrokerInterface.php @@ -0,0 +1,52 @@ + + */ + public function getProviderTags(): array; + + /** + * @param array $providerTags + */ + public function setProviderTags(array $providerTags): self; + + public function addProviderTag(string $providerTag): self; + + public function getProviderBranch(): ?string; + + public function setProviderBranch(?string $providerBranch): self; + + public function getConsumerVersionSelectors(): ConsumerVersionSelectors; + + public function setConsumerVersionSelectors(ConsumerVersionSelectors $selectors): self; + + /** + * @return array + */ + public function getConsumerVersionTags(): array; + + /** + * @param array $consumerVersionTags + */ + public function setConsumerVersionTags(array $consumerVersionTags): self; + + public function addConsumerVersionTag(string $consumerVersionTag): self; +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Source/Url.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Source/Url.php new file mode 100644 index 00000000..eb3dd1fb --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Source/Url.php @@ -0,0 +1,61 @@ +url; + } + + public function setUrl(UriInterface $url): self + { + $this->url = $url; + + return $this; + } + + public function getToken(): ?string + { + return $this->token; + } + + public function setToken(?string $token): self + { + $this->token = $token; + + return $this; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(string $username): self + { + $this->username = $username; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } +} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/Source/UrlInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/Source/UrlInterface.php new file mode 100644 index 00000000..cfc8effa --- /dev/null +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/Source/UrlInterface.php @@ -0,0 +1,24 @@ + - */ - private array $providerVersionTag = []; - - private bool $publishResults = false; - - private ?UriInterface $brokerUri = null; - - private ?string $brokerToken = null; - - private ?string $brokerUsername = null; - - private ?string $brokerPassword = null; + private CallingAppInterface $callingApp; + private ProviderInfoInterface $providerInfo; /** - * @var array + * @var array */ - private array $customProviderHeaders = []; - - private bool $verbose = false; - - private ?string $logDirectory = null; - - private ?string $format = null; - - private int $processTimeout = 60; - - private int $processIdleTimeout = 10; + private array $providerTransports = []; - private bool $enablePending = false; - - private ?string $wipPactSince = null; - - /** - * @var array - */ - private array $consumerVersionTag = []; - - private ConsumerVersionSelectors $consumerVersionSelectors; - - /** @var null|callable */ - private $requestFilter = null; + private FilterInfoInterface $filterInfo; + private ProviderStateInterface $providerState; + private VerificationOptionsInterface $verificationOptions; + private ?PublishOptionsInterface $publishOptions = null; + private ConsumerFiltersInterface $consumerFilters; public function __construct() { - $this->consumerVersionSelectors = new ConsumerVersionSelectors(); - } - - /** - * {@inheritdoc} - */ - public function getProviderBaseUrl(): ?UriInterface - { - return $this->providerBaseUrl; - } - - /** - * {@inheritdoc} - */ - public function setProviderBaseUrl(UriInterface $providerBaseUrl): VerifierConfigInterface - { - $this->providerBaseUrl = $providerBaseUrl; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getProviderStatesSetupUrl(): ?string - { - return $this->providerStatesSetupUrl; - } - - /** - * {@inheritdoc} - */ - public function setProviderStatesSetupUrl(string $providerStatesSetupUrl): VerifierConfigInterface - { - $this->providerStatesSetupUrl = $providerStatesSetupUrl; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getProviderName(): ?string - { - return $this->providerName; - } - - /** - * {@inheritdoc} - */ - public function setProviderName(string $providerName): VerifierConfigInterface - { - $this->providerName = $providerName; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getProviderVersion(): ?string - { - return $this->providerVersion; - } - - /** - * {@inheritdoc} - */ - public function setProviderVersion(string $providerVersion): VerifierConfigInterface - { - $this->providerVersion = $providerVersion; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getProviderVersionTag(): array - { - return $this->providerVersionTag; - } - - /** - * {@inheritdoc} - */ - public function setProviderVersionTag(string $providerVersionTag): VerifierConfigInterface - { - return $this->addProviderVersionTag($providerVersionTag); - } - - /** - * {@inheritdoc} - */ - public function getConsumerVersionTag(): array - { - return $this->consumerVersionTag; - } - - /** - * {@inheritdoc} - */ - public function addConsumerVersionTag(string $consumerVersionTag): VerifierConfigInterface - { - $this->consumerVersionTag[] = $consumerVersionTag; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function addProviderVersionTag(string $providerVersionTag): VerifierConfigInterface - { - $this->providerVersionTag[] = $providerVersionTag; - - return $this; - } - - public function setConsumerVersionTag(string $consumerVersionTag): VerifierConfigInterface - { - return $this->addConsumerVersionTag($consumerVersionTag); - } - - public function getConsumerVersionSelectors(): ConsumerVersionSelectors - { - return $this->consumerVersionSelectors; - } - - public function setConsumerVersionSelectors(ConsumerVersionSelectors $selectors): VerifierConfigInterface - { - $this->consumerVersionSelectors = $selectors; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function isPublishResults(): bool - { - return $this->publishResults; + $this->callingApp = new CallingApp(); + $this->providerInfo = new ProviderInfo(); + $this->filterInfo = new FilterInfo(); + $this->providerState = new ProviderState(); + $this->verificationOptions = new VerificationOptions(); + $this->consumerFilters = new ConsumerFilters(); } - /** - * {@inheritdoc} - */ - public function setPublishResults(bool $publishResults): VerifierConfigInterface + public function setCallingApp(CallingAppInterface $callingApp): self { - $this->publishResults = $publishResults; + $this->callingApp = $callingApp; return $this; } - /** - * {@inheritdoc} - */ - public function getBrokerUri(): ?UriInterface + public function getCallingApp(): CallingAppInterface { - return $this->brokerUri; + return $this->callingApp; } - /** - * {@inheritdoc} - */ - public function setBrokerUri(UriInterface $brokerUri): VerifierConfigInterface + public function setProviderInfo(ProviderInfoInterface $providerInfo): self { - $this->brokerUri = $brokerUri; + $this->providerInfo = $providerInfo; return $this; } - /** - * {@inheritdoc}} - */ - public function getBrokerToken(): ?string + public function getProviderInfo(): ProviderInfoInterface { - return $this->brokerToken; - } - - /** - * {@inheritdoc } - */ - public function setBrokerToken(?string $brokerToken): VerifierConfigInterface - { - $this->brokerToken = $brokerToken; - - return $this; + return $this->providerInfo; } /** * {@inheritdoc} */ - public function getBrokerUsername(): ?string + public function setProviderTransports(array $providerTransports): self { - return $this->brokerUsername; - } - - /** - * {@inheritdoc} - */ - public function setBrokerUsername(string $brokerUsername): VerifierConfigInterface - { - $this->brokerUsername = $brokerUsername; + $this->providerTransports = []; + foreach ($providerTransports as $providerTransport) { + $this->addProviderTransport($providerTransport); + } return $this; } - /** - * {@inheritdoc} - */ - public function getBrokerPassword(): ?string + public function addProviderTransport(ProviderTransportInterface $providerTransport): self { - return $this->brokerPassword; - } - - /** - * {@inheritdoc} - */ - public function setBrokerPassword(string $brokerPassword): self - { - $this->brokerPassword = $brokerPassword; + $this->providerTransports[] = $providerTransport; return $this; } @@ -294,169 +93,73 @@ public function setBrokerPassword(string $brokerPassword): self /** * {@inheritdoc} */ - public function getCustomProviderHeaders(): array - { - return $this->customProviderHeaders; - } - - /** - * {@inheritdoc} - */ - public function setCustomProviderHeaders(array $customProviderHeaders): VerifierConfigInterface + public function getProviderTransports(): array { - $this->customProviderHeaders = $customProviderHeaders; - - return $this; + return $this->providerTransports; } - public function addCustomProviderHeader(string $name, string $value): VerifierConfigInterface + public function setFilterInfo(FilterInfoInterface $filterInfo): self { - $this->customProviderHeaders[] = "$name: $value"; + $this->filterInfo = $filterInfo; return $this; } - /** - * {@inheritdoc} - */ - public function isVerbose(): bool + public function getFilterInfo(): FilterInfoInterface { - return $this->verbose; + return $this->filterInfo; } - /** - * {@inheritdoc} - */ - public function setVerbose(bool $verbose): VerifierConfigInterface + public function setProviderState(ProviderStateInterface $providerState): self { - $this->verbose = $verbose; + $this->providerState = $providerState; return $this; } - /** - * {@inheritdoc} - */ - public function getLogDirectory(): ?string + public function getProviderState(): ProviderStateInterface { - return $this->logDirectory; + return $this->providerState; } - /** - * {@inheritdoc} - */ - public function setLogDirectory(string $log): VerifierConfigInterface - { - $this->logDirectory = $log; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getFormat(): ?string - { - return $this->format; - } - - /** - * {@inheritdoc} - */ - public function setFormat(string $format): VerifierConfigInterface + public function setPublishOptions(?PublishOptionsInterface $publishOptions): self { - $this->format = $format; + $this->publishOptions = $publishOptions; return $this; } - public function setProcessTimeout(int $timeout): VerifierConfigInterface - { - $this->processTimeout = $timeout; - - return $this; - } - - public function setProcessIdleTimeout(int $timeout): VerifierConfigInterface - { - $this->processIdleTimeout = $timeout; - - return $this; - } - - public function getProcessTimeout(): int - { - return $this->processTimeout; - } - - public function getProcessIdleTimeout(): int - { - return $this->processIdleTimeout; - } - - /** - * {@inheritdoc} - */ - public function isEnablePending(): bool + public function getPublishOptions(): ?PublishOptionsInterface { - return $this->enablePending; + return $this->publishOptions; } - /** - * {@inheritdoc} - */ - public function setEnablePending(bool $pending): VerifierConfigInterface + public function isPublishResults(): bool { - $this->enablePending = $pending; - - return $this; + return $this->publishOptions !== null; } - /** - * {@inheritdoc} - */ - public function setIncludeWipPactSince(string $date): VerifierConfigInterface + public function setConsumerFilters(ConsumerFiltersInterface $consumerFilters): self { - $this->wipPactSince = $date; + $this->consumerFilters = $consumerFilters; return $this; } - /** - * {@inheritdoc} - */ - public function getIncludeWipPactSince(): ?string - { - return $this->wipPactSince; - } - - public function getRequestFilter(): ?callable + public function getConsumerFilters(): ConsumerFiltersInterface { - return $this->requestFilter; + return $this->consumerFilters; } - public function setRequestFilter(callable $requestFilter): VerifierConfigInterface + public function setVerificationOptions(VerificationOptionsInterface $verificationOptions): self { - $this->requestFilter = $requestFilter; + $this->verificationOptions = $verificationOptions; return $this; } - /** - * {@inheritdoc} - */ - public function setProviderBranch(string $providerBranch): VerifierConfigInterface - { - $this->providerBranch = $providerBranch; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getProviderBranch(): ?string + public function getVerificationOptions(): VerificationOptionsInterface { - return $this->providerBranch; + return $this->verificationOptions; } } diff --git a/src/PhpPact/Standalone/ProviderVerifier/Model/VerifierConfigInterface.php b/src/PhpPact/Standalone/ProviderVerifier/Model/VerifierConfigInterface.php index 9b748f9e..bad14ed8 100644 --- a/src/PhpPact/Standalone/ProviderVerifier/Model/VerifierConfigInterface.php +++ b/src/PhpPact/Standalone/ProviderVerifier/Model/VerifierConfigInterface.php @@ -2,223 +2,60 @@ namespace PhpPact\Standalone\ProviderVerifier\Model; -use Psr\Http\Message\UriInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\CallingAppInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\ConsumerFiltersInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\FilterInfoInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\ProviderInfoInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\ProviderStateInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\ProviderTransportInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\PublishOptionsInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\VerificationOptionsInterface; -/** - * Configuration to use with the verifier server. - */ interface VerifierConfigInterface { - /** - * @return null|UriInterface providers base url - */ - public function getProviderBaseUrl(): ?UriInterface; + public function setCallingApp(CallingAppInterface $callingApp): self; - /** - * @param UriInterface $providerBaseUrl providers base url - */ - public function setProviderBaseUrl(UriInterface $providerBaseUrl): self; + public function getCallingApp(): CallingAppInterface; - /** - * @return null|string Base URL to setup the provider states at - */ - public function getProviderStatesSetupUrl(): ?string; + public function setProviderInfo(ProviderInfoInterface $providerInfo): self; - /** - * @param string $providerStatesSetupUrl Base URL to setup the provider states at - */ - public function setProviderStatesSetupUrl(string $providerStatesSetupUrl): self; - - /** - * @return null|string name of the provider - */ - public function getProviderName(): ?string; + public function getProviderInfo(): ProviderInfoInterface; /** - * @param string $providerName Name of the provider + * @param array $providerTransports */ - public function setProviderName(string $providerName): self; + public function setProviderTransports(array $providerTransports): self; - /** - * @return null|string providers version - */ - public function getProviderVersion(): ?string; + public function addProviderTransport(ProviderTransportInterface $providerTransport): self; /** - * @param string $providerVersion providers version + * @return array */ - public function setProviderVersion(string $providerVersion): self; + public function getProviderTransports(): array; - /** - * @param string $providerBranch providers branch name - */ - public function setProviderBranch(string $providerBranch): self; + public function setFilterInfo(FilterInfoInterface $filterInfo): self; - /** - * @return array providers version tag - */ - public function getProviderVersionTag(): array; + public function getFilterInfo(): FilterInfoInterface; - /** - * @return null|string providers branch name - */ - public function getProviderBranch(): ?string; + public function setProviderState(ProviderStateInterface $providerState): self; - /** - * @param string $providerVersionTag providers version tag - */ - public function setProviderVersionTag(string $providerVersionTag): self; + public function getProviderState(): ProviderStateInterface; - /** - * @return array consumers version tag - */ - public function getConsumerVersionTag(): array; + public function setPublishOptions(?PublishOptionsInterface $publishOptions): self; - /** - * @param string $consumerVersionTag consumers version tag - */ - public function addConsumerVersionTag(string $consumerVersionTag): self; + public function getPublishOptions(): ?PublishOptionsInterface; - /** - * @param string $providerVersionTag provider version tag - */ - public function addProviderVersionTag(string $providerVersionTag): self; - - public function getConsumerVersionSelectors(): ConsumerVersionSelectors; - - /** - * @param ConsumerVersionSelectors $selectors Consumer version selectors - */ - public function setConsumerVersionSelectors(ConsumerVersionSelectors $selectors): self; - - /** - * @return bool are results going to be published - */ public function isPublishResults(): bool; - /** - * @param bool $publishResults flag to publish results - */ - public function setPublishResults(bool $publishResults): self; - - /** - * @return null|UriInterface url to the pact broker - */ - public function getBrokerUri(): ?UriInterface; - - /** - * @param UriInterface $brokerUri uri to the pact broker - */ - public function setBrokerUri(UriInterface $brokerUri): self; - - /** - * @return null|string token for the pact broker - */ - public function getBrokerToken(): ?string; - - /** - * @param null|string $brokerToken token for the pact broker - */ - public function setBrokerToken(?string $brokerToken): self; - - /** - * @return null|string username for the pact broker if secured - */ - public function getBrokerUsername(): ?string; - - /** - * @param string $brokerUsername username for the pact broker if secured - */ - public function setBrokerUsername(string $brokerUsername): self; - - /** - * @return null|string password for the pact broker if secured - */ - public function getBrokerPassword(): ?string; - - /** - * @param string $brokerPassword password for the pact broker if secured - */ - public function setBrokerPassword(string $brokerPassword): self; - - /** - * @return array custom headers for the request to the provider such as authorization - */ - public function getCustomProviderHeaders(): array; - - /** - * @param array $customProviderHeaders custom headers for the requests to the provider such as authorization - */ - public function setCustomProviderHeaders(array $customProviderHeaders): self; + public function setConsumerFilters(ConsumerFiltersInterface $consumerFilters): self; - public function addCustomProviderHeader(string $name, string $value): self; + public function getConsumerFilters(): ConsumerFiltersInterface; - /** - * @return bool is verbosity level increased - */ - public function isVerbose(): bool; + public function setVerificationOptions(VerificationOptionsInterface $verificationOptions): self; - /** - * @param bool $verbose increase verbosity level - */ - public function setVerbose(bool $verbose): self; + public function getVerificationOptions(): VerificationOptionsInterface; - /** - * @return null|string set the directory for the pact.log file - */ - public function getLogDirectory(): ?string; - - /** - * @param string $log set the directory for the pact.log file - */ - public function setLogDirectory(string $log): self; + public function getLogLevel(): ?string; - /** - * @return null|string RSpec formatter. Defaults to custom Pact formatter. json and RspecJunitFormatter may also be used - */ - public function getFormat(): ?string; - - /** - * @param string $format RSpec formatter. Defaults to custom Pact formatter. json and RspecJunitFormatter may also be used - */ - public function setFormat(string $format): self; - - public function setProcessTimeout(int $timeout): self; - - public function setProcessIdleTimeout(int $timeout): self; - - public function getProcessTimeout(): int; - - public function getProcessIdleTimeout(): int; - - /** - * @param bool $pending allow pacts which are in pending state to be verified without causing the overall task to fail - */ - public function setEnablePending(bool $pending): self; - - /** - * @return bool is enabled pending pacts - */ - public function isEnablePending(): bool; - - /** - * @param string $date Includes pact marked as WIP since this date. - * Accepted formats: Y-m-d (2020-01-30) or c (ISO 8601 date 2004-02-12T15:19:21+00:00) - */ - public function setIncludeWipPactSince(string $date): self; - - /** - * @return null|string get start date of included WIP Pacts - */ - public function getIncludeWipPactSince(); - - /** - * @return null|callable - */ - public function getRequestFilter(): ?callable; - - /** - * @param callable $requestFilter - */ - public function setRequestFilter(callable $requestFilter): self; + public function setLogLevel(string $logLevel): self; } diff --git a/src/PhpPact/Standalone/ProviderVerifier/ProcessRunnerFactory.php b/src/PhpPact/Standalone/ProviderVerifier/ProcessRunnerFactory.php deleted file mode 100644 index 257e44a6..00000000 --- a/src/PhpPact/Standalone/ProviderVerifier/ProcessRunnerFactory.php +++ /dev/null @@ -1,30 +0,0 @@ -providerVerifier = $providerVerifier ?: Scripts::getProviderVerifier(); - } - - /** - * @param array $arguments - */ - public function createRunner(array $arguments, LoggerInterface $logger = null): ProcessRunner - { - $processRunner = new ProcessRunner($this->providerVerifier, $arguments); - if ($logger) { - $processRunner->setLogger($logger); - } - - return $processRunner; - } -} diff --git a/src/PhpPact/Standalone/ProviderVerifier/Verifier.php b/src/PhpPact/Standalone/ProviderVerifier/Verifier.php index 2bc678b3..5d6918a8 100644 --- a/src/PhpPact/Standalone/ProviderVerifier/Verifier.php +++ b/src/PhpPact/Standalone/ProviderVerifier/Verifier.php @@ -2,264 +2,198 @@ namespace PhpPact\Standalone\ProviderVerifier; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; -use PhpPact\Broker\Service\BrokerHttpClient; -use PhpPact\Broker\Service\BrokerHttpClientInterface; -use PhpPact\Http\GuzzleClient; +use FFI\CData; +use PhpPact\FFI\Client; +use PhpPact\FFI\ClientInterface; +use PhpPact\FFI\Model\ArrayData; +use PhpPact\Service\LoggerInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Source\BrokerInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Source\UrlInterface; use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfigInterface; -/** - * Wrapper for the Ruby Standalone Verifier service. - */ class Verifier { - protected int $processTimeout = 60; + protected ClientInterface $client; + protected CData $handle; - protected int $processIdleTimeout = 10; - - protected VerifierConfigInterface $config; - - protected ?BrokerHttpClientInterface $brokerHttpClient = null; - - protected ?VerifierProcess $verifierProcess = null; - - public function __construct( - VerifierConfigInterface $config, - VerifierProcess $verifierProcess = null, - BrokerHttpClient $brokerHttpClient = null - ) { - $this->config = $config; - $this->verifierProcess = $verifierProcess ?: new VerifierProcess(); - $this->processTimeout = $config->getProcessTimeout(); - $this->processIdleTimeout = $config->getProcessIdleTimeout(); - - if ($brokerHttpClient) { - $this->brokerHttpClient = $brokerHttpClient; - } + public function __construct(VerifierConfigInterface $config, private ?LoggerInterface $logger = null, ?ClientInterface $client = null) + { + $this->client = $client ?? new Client(); + $this->newHandle($config); + $this->setProviderInfo($config); + $this->setProviderTransports($config); + $this->setFilterInfo($config); + $this->setProviderState($config); + $this->setVerificationOptions($config); + $this->setPublishOptions($config); + $this->setConsumerFilters($config); + $this->setLogLevel($config); } - /** - * @throws \Exception - * - * @return array parameters to be passed into the process - */ - public function getArguments(): array + private function newHandle(VerifierConfigInterface $config): void { - $parameters = []; - - if ($this->config->getProviderName() !== null) { - $parameters[] = "--provider='{$this->config->getProviderName()}'"; - } - - if ($this->config->getProviderBaseUrl() !== null) { - $parameters[] = "--provider-base-url={$this->config->getProviderBaseUrl()}"; - } - - if ($this->config->getProviderVersion() !== null) { - $parameters[] = "--provider-app-version={$this->config->getProviderVersion()}"; - } - - if ($this->config->getProviderBranch() !== null) { - $parameters[] = "--provider-version-branch={$this->config->getProviderBranch()}"; - } - - if (\count($this->config->getConsumerVersionTag()) > 0) { - foreach ($this->config->getConsumerVersionTag() as $tag) { - $parameters[] = "--consumer-version-tag={$tag}"; - } - } - - if (\count($this->config->getConsumerVersionSelectors()) > 0) { - foreach ($this->config->getConsumerVersionSelectors() as $selector) { - $parameters[] = "--consumer-version-selector='{$selector}'"; - } - } - - if (\count($this->config->getProviderVersionTag()) > 0) { - foreach ($this->config->getProviderVersionTag() as $tag) { - $parameters[] = "--provider-version-tag={$tag}"; - } - } - - if ($this->config->getProviderStatesSetupUrl() !== null) { - $parameters[] = "--provider-states-setup-url={$this->config->getProviderStatesSetupUrl()}"; - } - - if ($this->config->isPublishResults() === true) { - $parameters[] = '--publish-verification-results'; - } - - if ($this->config->getBrokerToken() !== null) { - $parameters[] = "--broker-token={$this->config->getBrokerToken()}"; - } - - if ($this->config->getBrokerUsername() !== null) { - $parameters[] = "--broker-username={$this->config->getBrokerUsername()}"; - } - - if ($this->config->getBrokerPassword() !== null) { - $parameters[] = "--broker-password={$this->config->getBrokerPassword()}"; - } - - if (count($this->config->getCustomProviderHeaders()) > 0) { - foreach ($this->config->getCustomProviderHeaders() as $customProviderHeader) { - $parameters[] = "--custom-provider-header=\"{$customProviderHeader}\""; - } - } + $this->handle = $this->client->call( + 'pactffi_verifier_new_for_application', + $config->getCallingApp()->getName(), + $config->getCallingApp()->getVersion() + ); + } - if ($this->config->isVerbose() === true) { - $parameters[] = '--verbose=VERBOSE'; - } + private function setProviderInfo(VerifierConfigInterface $config): void + { + $this->client->call( + 'pactffi_verifier_set_provider_info', + $this->handle, + $config->getProviderInfo()->getName(), + $config->getProviderInfo()->getScheme(), + $config->getProviderInfo()->getHost(), + $config->getProviderInfo()->getPort(), + $config->getProviderInfo()->getPath() + ); + } - if ($this->config->getLogDirectory() !== null) { - $parameters[] = "--log-dir={$this->config->getLogDirectory()}"; + private function setProviderTransports(VerifierConfigInterface $config): void + { + foreach ($config->getProviderTransports() as $transport) { + $this->client->call( + 'pactffi_verifier_add_provider_transport', + $this->handle, + $transport->getProtocol(), + $transport->getPort(), + $transport->getPath(), + $transport->getScheme() + ); } + } - if ($this->config->getFormat() !== null) { - $parameters[] = "--format={$this->config->getFormat()}"; - } + private function setFilterInfo(VerifierConfigInterface $config): void + { + $this->client->call( + 'pactffi_verifier_set_filter_info', + $this->handle, + $config->getFilterInfo()->getFilterDescription(), + $config->getFilterInfo()->getFilterState(), + $config->getFilterInfo()->getFilterNoState() + ); + } - if ($this->config->isEnablePending() === true) { - $parameters[] = '--enable-pending'; - } + private function setProviderState(VerifierConfigInterface $config): void + { + $this->client->call( + 'pactffi_verifier_set_provider_state', + $this->handle, + $config->getProviderState()->getStateChangeUrl() ? (string) $config->getProviderState()->getStateChangeUrl() : null, + $config->getProviderState()->isStateChangeTeardown(), + $config->getProviderState()->isStateChangeAsBody() + ); + } - if ($this->config->getIncludeWipPactSince() !== null) { - $parameters[] = "--include-wip-pacts-since={$this->config->getIncludeWipPactSince()}"; - } + private function setVerificationOptions(VerifierConfigInterface $config): void + { + $this->client->call( + 'pactffi_verifier_set_verification_options', + $this->handle, + $config->getVerificationOptions()->isDisableSslVerification(), + $config->getVerificationOptions()->getRequestTimeout() + ); + } - if ($this->config->getBrokerUri() !== null) { - $parameters[] = "--pact-broker-base-url={$this->config->getBrokerUri()->__toString()}"; + private function setPublishOptions(VerifierConfigInterface $config): void + { + if ($config->isPublishResults()) { + $providerTags = ArrayData::createFrom($config->getPublishOptions()->getProviderTags()); + $this->client->call( + 'pactffi_verifier_set_publish_options', + $this->handle, + $config->getPublishOptions()->getProviderVersion(), + $config->getPublishOptions()->getBuildUrl(), + $providerTags?->getItems(), + $providerTags?->getSize(), + $config->getPublishOptions()->getProviderBranch() + ); } - - return $parameters; } - /** - * Make the request to the PACT Verifier Service to run a Pact file tests from the Pact Broker. - * - * @param string $consumerName name of the consumer to be compared against - * @param null|string $tag optional tag of the consumer such as a branch name - * @param null|string $consumerVersion optional specific version of the consumer; this is overridden by tag - * @throws \Exception - */ - public function verify(string $consumerName, string $tag = null, string $consumerVersion = null): self + private function setConsumerFilters(VerifierConfigInterface $config): void { - $path = "/pacts/provider/{$this->config->getProviderName()}/consumer/{$consumerName}/"; + $filterConsumerNames = ArrayData::createFrom($config->getConsumerFilters()->getFilterConsumerNames()); + $this->client->call( + 'pactffi_verifier_set_consumer_filters', + $this->handle, + $filterConsumerNames?->getItems(), + $filterConsumerNames?->getSize() + ); + } - if ($tag) { - $path .= "latest/{$tag}/"; - } elseif ($consumerVersion) { - $path .= "version/{$consumerVersion}/"; - } else { - $path .= 'latest/'; + private function setLogLevel(VerifierConfigInterface $config): void + { + if ($logLevel = $config->getLogLevel()) { + $this->client->call('pactffi_init_with_log_level', $logLevel); } - - $uri = $this->config->getBrokerUri()->withPath($path); - - $arguments = \array_merge([$uri->__toString()], $this->getArguments()); - - $this->verifyAction($arguments); - - return $this; } - /** - * Provides a way to validate local Pact JSON files. - * - * @param array $files paths to pact json files - * @throws \Exception - */ - public function verifyFiles(array $files): self + public function addFile(string $file): self { - $arguments = \array_merge($files, $this->getArguments()); - - $this->verifyAction($arguments); + $this->client->call('pactffi_verifier_add_file_source', $this->handle, $file); return $this; } - /** - * Verify all Pacts from the Pact Broker are valid for the Provider. - * @throws \Exception - */ - public function verifyAll(): void + public function addDirectory(string $directory): self { - $arguments = $this->getBrokerHttpClient()->getAllConsumerUrls($this->config->getProviderName()); + $this->client->call('pactffi_verifier_add_directory_source', $this->handle, $directory); - $arguments = \array_merge($arguments, $this->getArguments()); - - $this->verifyAction($arguments); + return $this; } - /** - * Verify all PACTs for a given tag. - * @throws \Exception - */ - public function verifyAllForTag(string $tag): void + public function addUrl(UrlInterface $url): self { - $arguments = $this->getBrokerHttpClient()->getAllConsumerUrlsForTag($this->config->getProviderName(), $tag); + $this->client->call( + 'pactffi_verifier_url_source', + $this->handle, + (string) $url->getUrl(), + $url->getUsername(), + $url->getPassword(), + $url->getToken() + ); - $arguments = \array_merge($arguments, $this->getArguments()); - - $this->verifyAction($arguments); - } - - /** - * Verify all PACTs that match the VerifierConfig - * @throws \Exception - */ - public function verifyFromConfig(): void - { - $this->verifyAction($this->getArguments()); + return $this; } - /** - * @return array - */ - public function getTimeoutValues(): array + public function addBroker(BrokerInterface $broker): self { - return ['process_timeout' => $this->processTimeout, 'process_idle_timeout' => $this->processIdleTimeout]; - } + $providerTags = ArrayData::createFrom($broker->getProviderTags()); + $consumerVersionSelectors = ArrayData::createFrom(iterator_to_array($broker->getConsumerVersionSelectors())); + $consumerVersionTags = ArrayData::createFrom($broker->getConsumerVersionTags()); + $this->client->call( + 'pactffi_verifier_broker_source_with_selectors', + $this->handle, + (string) $broker->getUrl(), + $broker->getUsername(), + $broker->getPassword(), + $broker->getToken(), + $broker->isEnablePending(), + $broker->getIncludeWipPactSince(), + $providerTags?->getItems(), + $providerTags?->getSize(), + $broker->getProviderBranch(), + $consumerVersionSelectors?->getItems(), + $consumerVersionSelectors?->getSize(), + $consumerVersionTags?->getItems(), + $consumerVersionTags?->getSize() + ); - /** - * Trigger execution of the Pact Verifier Service. - * - * @param array $arguments - * @throws \Exception - */ - protected function verifyAction(array $arguments): void - { - $this->verifierProcess->run($arguments, $this->processTimeout, $this->processIdleTimeout); + return $this; } - protected function getBrokerHttpClient(): BrokerHttpClientInterface + public function verify(): bool { - if (!$this->brokerHttpClient) { - $user = $this->config->getBrokerUsername(); - $password = $this->config->getBrokerPassword(); - $token = $this->config->getBrokerToken(); - $reqFilter = $this->config->getRequestFilter(); - - $config = []; - if (\strlen($token) > 0) { - $config = ['headers' => ['Authorization' => 'Bearer ' . $token]]; - } elseif ($user && $password) { - $config = ['auth' => [$user, $password]]; - } - if (\is_callable($reqFilter)) { - $stack = HandlerStack::create(); - $stack->push(Middleware::mapRequest($reqFilter), 'requestFilter'); - $config['handler'] = $stack; - } - if (($sslVerify = \getenv('PACT_BROKER_SSL_VERIFY'))) { - $client['verify'] = $sslVerify !== 'no'; - } - $client = new GuzzleClient($config); - - $this->brokerHttpClient = new BrokerHttpClient($client, $this->config->getBrokerUri()); + $error = $this->client->call('pactffi_verifier_execute', $this->handle); + if ($this->logger) { + $this->logger->log($this->client->call('pactffi_verifier_json', $this->handle)); } + $this->client->call('pactffi_verifier_shutdown', $this->handle); - return $this->brokerHttpClient; + return !$error; } } diff --git a/src/PhpPact/Standalone/ProviderVerifier/VerifierProcess.php b/src/PhpPact/Standalone/ProviderVerifier/VerifierProcess.php deleted file mode 100644 index 6201c53f..00000000 --- a/src/PhpPact/Standalone/ProviderVerifier/VerifierProcess.php +++ /dev/null @@ -1,57 +0,0 @@ -processRunnerFactory = $processRunnerFactory ?: new ProcessRunnerFactory(); - } - - public function setLogger(LoggerInterface $logger): self - { - $this->logger = $logger; - - return $this; - } - - /** - * @param array $arguments - * @throws \Exception - */ - public function run(array $arguments, ?int $processTimeout = null, ?int $processIdleTimeout = null): void - { - $logger = $this->getLogger(); - $processRunner = $this->processRunnerFactory->createRunner( - $arguments, - $logger - ); - - $logger->info("Verifying PACT with script:\n{$processRunner->getCommand()}\n\n"); - - $processRunner->runBlocking(); - } - - private function getLogger(): LoggerInterface - { - if (null === $this->logger) { - $logHandler = new StreamHandler(new ResourceOutputStream(\STDOUT)); - $logHandler->setFormatter(new ConsoleFormatter(null, null, true)); - $this->logger = new Logger('console'); - $this->logger->pushHandler($logHandler); - } - - return $this->logger; - } -} diff --git a/src/PhpPact/Standalone/Runner/ProcessRunner.php b/src/PhpPact/Standalone/Runner/ProcessRunner.php deleted file mode 100644 index 2da9eb15..00000000 --- a/src/PhpPact/Standalone/Runner/ProcessRunner.php +++ /dev/null @@ -1,193 +0,0 @@ - $arguments - */ - public function __construct(string $command, array $arguments) - { - $this->exitCode = -1; - $this->process = new Process($command . ' ' . \implode(' ', $arguments)); - } - - public function setLogger(LoggerInterface $logger): self - { - $this->logger = $logger; - - return $this; - } - - public function getOutput(): string - { - return $this->output; - } - - public function setOutput(string $output): void - { - $this->output = $output; - } - - public function getExitCode(): int - { - return $this->exitCode; - } - - public function setExitCode(int $exitCode): void - { - $this->exitCode = $exitCode; - } - - public function getCommand(): string - { - return $this->process->getCommand(); - } - - public function getStderr(): string - { - return $this->stderr; - } - - public function setStderr(string $stderr): void - { - $this->stderr = $stderr; - } - - /** - * Run a blocking, synchronous process - */ - public function runBlocking(): int - { - $logger = $this->getLogger(); - $pid = null; - $lambdaLoop = function () use ($logger, &$pid) { - $logger->debug("Process command: {$this->process->getCommand()}"); - - $pid = yield $this->process->start(); - - $this->output .= yield ByteStream\buffer($this->process->getStdout()); - $this->stderr .= yield ByteStream\buffer($this->process->getStderr()); - - $exitCode = yield $this->process->join(); - $this->setExitCode($exitCode); - $logger->debug("Exit code: {$this->getExitCode()}"); - - if ($this->getExitCode() !== 0) { - $this->logger->info('out > ' . $this->getOutput()); - $this->logger->error('err > ' . $this->getStderr()); - throw new \Exception("PactPHP Process returned non-zero exit code: {$this->getExitCode()}", $this->getExitCode()); - } - - Loop::stop(); - }; - - Loop::run($lambdaLoop); - - return $pid; - } - - /** - * Run a blocking, synchronous process - */ - public function runNonBlocking(): int - { - $logger = $this->getLogger(); - - $pid = null; - - $lambdaLoop = function () use ($logger, &$pid) { - $logger->debug("start background command: {$this->process->getCommand()}"); - - $pid = yield $this->process->start(); - - $this->process->getStdout()->read()->onResolve(function (\Throwable $reason = null, $value) { - $this->output .= $value; - }); - $this->process->getStderr()->read()->onResolve(function (\Throwable $reason = null, $value) { - $this->output .= $value; - }); - - Loop::stop(); - }; - - Loop::run($lambdaLoop); - - $logger->debug("started process pid=$pid"); - - return $pid; - } - - /** - * Run the process and set output - * - * @return int Process Id - */ - public function run(bool $blocking = false): int - { - return $blocking - ? $this->runBlocking() - : $this->runNonBlocking(); - } - - /** - * Stop the running process - * - * @throws ProcessException - */ - public function stop(): bool - { - $pid = $this->process->getPid(); - - print "\nStopping Process Id: {$pid}\n"; - - if ('\\' === \DIRECTORY_SEPARATOR) { - \exec(\sprintf('taskkill /F /T /PID %d 2>&1', $pid), $output, $exitCode); - } - - $this->process->kill(); - - if ($this->process->isRunning()) { - throw new ProcessException(\sprintf('Error while killing process "%s".', $pid)); - } - - return true; - } - - private function getLogger(): LoggerInterface - { - if (null === $this->logger) { - $logHandler = new StreamHandler(new ResourceOutputStream(\STDOUT)); - $logHandler->setFormatter(new ConsoleFormatter(null, null, true)); - $this->logger = new Logger('server'); - $this->logger->pushHandler($logHandler); - } - - return $this->logger; - } -} diff --git a/src/PhpPact/Standalone/StubService/Exception/LogLevelNotSupportedException.php b/src/PhpPact/Standalone/StubService/Exception/LogLevelNotSupportedException.php new file mode 100644 index 00000000..c55675cc --- /dev/null +++ b/src/PhpPact/Standalone/StubService/Exception/LogLevelNotSupportedException.php @@ -0,0 +1,7 @@ +client = $client; - $this->config = $config; - } - - /** - * {@inheritdoc} - */ - public function healthCheck(): bool - { - $uri = $this->config->getBaseUri()->withPath('/'); - - $response = $this->client->get($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'X-Pact-Mock-Service' => true, - ], - ]); - - $body = $response->getBody()->getContents(); - - if ($response->getStatusCode() !== 200 - || $body !== "Mock service running\n") { - throw new ConnectionException('Failed to receive a successful response from the Stub Server.'); - } - - return true; - } - - /** - * {@inheritdoc} - * @throws \JsonException - */ - public function getJson(): string - { - $uri = $this->config->getBaseUri()->withPath('/' . $this->config->getEndpoint()); - $response = $this->client->get($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - ], - ]); - - return \json_encode(\json_decode($response->getBody()->getContents(), null, 512, JSON_THROW_ON_ERROR), JSON_THROW_ON_ERROR); - } -} diff --git a/src/PhpPact/Standalone/StubService/Service/StubServerHttpServiceInterface.php b/src/PhpPact/Standalone/StubService/Service/StubServerHttpServiceInterface.php deleted file mode 100644 index 307a9448..00000000 --- a/src/PhpPact/Standalone/StubService/Service/StubServerHttpServiceInterface.php +++ /dev/null @@ -1,20 +0,0 @@ -config = $config; + $this->process = $process ?? new Process([Scripts::getStubService(), ...$this->getArguments()], null, ['PACT_BROKER_BASE_URL' => false]); } /** * Start the Stub Server. Verify that it is running. * - * @param int $wait seconds to delay for the server to come up - * * @throws Exception * - * @return int process ID of the started Stub Server + * @return int|null process ID of the started Stub Server if running, null otherwise */ - public function start(int $wait = 1): int + public function start(): ?int { - $this->processRunner = new ProcessRunner(Scripts::getStubService(), $this->getArguments()); + $logLevel = $this->config->getLogLevel(); + if (is_null($logLevel) || \strtoupper($logLevel) !== 'NONE') { + $callback = function (string $type, string $buffer): void { + echo "\n$type > $buffer"; + }; + } + $this->process->start($callback ?? null); + if (is_null($logLevel) || in_array(\strtoupper($logLevel), ['INFO', 'DEBUG', 'TRACE'])) { + $this->process->waitUntil(function (string $type, string $output) { + $result = preg_match('/Server started on port (\d+)/', $output, $matches); + if ($result === 1 && $this->config->getPort() === 0) { + $this->config->setPort((int)$matches[1]); + } - $processId = $this->processRunner->run(); - \sleep($wait); // wait for server to start + return $result; + }); + } else { + if ($this->config->getPort() === 0) { + throw new LogLevelNotSupportedException(sprintf("Setting random port for stub server required log level 'info', 'debug' or 'trace'. '%s' given.", $logLevel)); + } + } - return $processId; + return $this->process->getPid(); } /** * Stop the Stub Server process. * * @return bool Was stopping successful? - * @throws ProcessException */ public function stop(): bool { - return $this->processRunner->stop(); + $this->process->stop(); + + return true; } /** @@ -60,12 +74,64 @@ private function getArguments(): array { $results = []; - $results[] = $this->config->getPactLocation(); - $results[] = "--host={$this->config->getHost()}"; - $results[] = "--port={$this->config->getPort()}"; + if ($this->config->getBrokerUrl() !== null) { + $results[] = "--broker-url={$this->config->getBrokerUrl()}"; + } + + foreach ($this->config->getDirs() as $dir) { + $results[] = "--dir={$dir}"; + } + + if ($this->config->getExtension() !== null) { + $results[] = "--extension={$this->config->getExtension()}"; + } + + foreach ($this->config->getFiles() as $file) { + $results[] = "--file={$file}"; + } + + if ($this->config->getLogLevel() !== null) { + $results[] = "--loglevel={$this->config->getLogLevel()}"; + } + + if ($this->config->getPort() !== null) { + $results[] = "--port={$this->config->getPort()}"; + } + + if ($this->config->getProviderState() !== null) { + $results[] = "--provider-state={$this->config->getProviderState()}"; + } + + if ($this->config->getProviderStateHeaderName() !== null) { + $results[] = "--provider-state-header-name={$this->config->getProviderStateHeaderName()}"; + } + + if ($this->config->getToken() !== null) { + $results[] = "--token={$this->config->getToken()}"; + } + + foreach ($this->config->getUrls() as $url) { + $results[] = "--url={$url}"; + } + + if ($this->config->getUser() !== null) { + $results[] = "--user={$this->config->getUser()}"; + } + + if ($this->config->isCors()) { + $results[] = '--cors'; + } + + if ($this->config->isCorsReferer()) { + $results[] = '--cors-referer'; + } + + if ($this->config->isEmptyProviderState()) { + $results[] = '--empty-provider-state'; + } - if ($this->config->getLog() !== null) { - $results[] = "--log={$this->config->getLog()}"; + if ($this->config->isInsecureTls()) { + $results[] = '--insecure-tls'; } return $results; diff --git a/src/PhpPact/Standalone/StubService/StubServerConfig.php b/src/PhpPact/Standalone/StubService/StubServerConfig.php index e12779a6..91eedb41 100644 --- a/src/PhpPact/Standalone/StubService/StubServerConfig.php +++ b/src/PhpPact/Standalone/StubService/StubServerConfig.php @@ -10,55 +10,107 @@ */ class StubServerConfig implements StubServerConfigInterface { + private ?UriInterface $brokerUrl = null; + private int $port = 0; + + private ?string $extension = null; + private ?string $logLevel = null; + private ?string $providerState = null; + private ?string $providerStateHeaderName = null; + private ?string $token = null; + private ?string $user = null; + /** - * Host on which to bind the service. + * @var array */ - private string $host = 'localhost'; - + private array $dirs = []; /** - * Port on which to run the service. + * @var array */ - private int $port = 7201; - - private bool $secure = false; - + private array $files = []; /** - * File to which to log output. + * @var array */ - private ?string $log = null; - - private string $pactLocation; - private string $endpoint; - + private array $urls = []; /** - * {@inheritdoc} + * @var array */ - public function getHost(): string + private array $consumerNames = []; + /** + * @var array + */ + private array $providerNames = []; + + private bool $cors = false; + private bool $corsReferer = false; + private bool $emptyProviderState = false; + private bool $insecureTls = false; + + public function getBrokerUrl(): ?UriInterface { - return $this->host; + return $this->brokerUrl; } - /** - * {@inheritdoc} - */ - public function setHost(string $host): StubServerConfigInterface + public function setBrokerUrl(UriInterface $brokerUrl): StubServerConfigInterface { - $this->host = $host; + $this->brokerUrl = $brokerUrl; return $this; } - /** - * {@inheritdoc} - */ + public function setDirs(array $dirs): StubServerConfigInterface + { + $this->dirs = array_map(fn (string $dir) => $dir, $dirs); + + return $this; + } + + public function getDirs(): array + { + return $this->dirs; + } + + public function getExtension(): ?string + { + return $this->extension; + } + + public function setExtension(string $extension): StubServerConfigInterface + { + $this->extension = $extension; + + return $this; + } + + public function setFiles(array $files): StubServerConfigInterface + { + $this->files = array_map(fn (string $file) => $file, $files); + + return $this; + } + + public function getFiles(): array + { + return $this->files; + } + + public function setLogLevel(?string $logLevel): StubServerConfigInterface + { + $this->logLevel = $logLevel; + + return $this; + } + + public function getLogLevel(): ?string + { + return $this->logLevel; + } + public function getPort(): int { return $this->port; } - /** - * {@inheritdoc} - */ public function setPort(int $port): StubServerConfigInterface { $this->port = $port; @@ -66,73 +118,140 @@ public function setPort(int $port): StubServerConfigInterface return $this; } - /** - * {@inheritdoc} - */ - public function isSecure(): bool + public function getProviderState(): ?string { - return $this->secure; + return $this->providerState; } - /** - * {@inheritdoc} - */ - public function setSecure(bool $secure): StubServerConfigInterface + public function setProviderState(string $providerState): StubServerConfigInterface { - $this->secure = $secure; + $this->providerState = $providerState; return $this; } - /** - * {@inheritdoc} - */ - public function getBaseUri(): UriInterface + public function getProviderStateHeaderName(): ?string + { + return $this->providerStateHeaderName; + } + + public function setProviderStateHeaderName(string $providerStateHeaderName): StubServerConfigInterface { - $protocol = $this->secure ? 'https' : 'http'; + $this->providerStateHeaderName = $providerStateHeaderName; - return new Uri("{$protocol}://{$this->getHost()}:{$this->getPort()}"); + return $this; } - /** - * {@inheritdoc} - */ - public function getLog(): ?string + public function getToken(): ?string { - return $this->log; + return $this->token; } - /** - * {@inheritdoc} - */ - public function setLog(string $log): StubServerConfigInterface + public function setToken(?string $token): StubServerConfigInterface + { + $this->token = $token; + + return $this; + } + + public function setUrls(array $urls): StubServerConfigInterface + { + $this->urls = array_map(fn (string $url) => $url, $urls); + + return $this; + } + + public function getUrls(): array + { + return $this->urls; + } + + public function getUser(): ?string + { + return $this->user; + } + + public function setUser(string $user): StubServerConfigInterface + { + $this->user = $user; + + return $this; + } + + public function isCors(): bool + { + return $this->cors; + } + + public function setCors(bool $cors): StubServerConfigInterface { - $this->log = $log; + $this->cors = $cors; return $this; } - public function getPactLocation(): string + public function isCorsReferer(): bool { - return $this->pactLocation; + return $this->corsReferer; } - public function setPactLocation(string $location): self + public function setCorsReferer(bool $corsReferer): StubServerConfigInterface { - $this->pactLocation = $location; + $this->corsReferer = $corsReferer; return $this; } - public function getEndpoint(): string + public function isEmptyProviderState(): bool { - return $this->endpoint; + return $this->emptyProviderState; } - public function setEndpoint(string $endpoint): self + public function setEmptyProviderState(bool $emptyProviderState): StubServerConfigInterface { - $this->endpoint = $endpoint; + $this->emptyProviderState = $emptyProviderState; return $this; } + + public function isInsecureTls(): bool + { + return $this->insecureTls; + } + + public function setInsecureTls(bool $insecureTls): StubServerConfigInterface + { + $this->insecureTls = $insecureTls; + + return $this; + } + + public function setConsumerNames(array $consumerNames): StubServerConfigInterface + { + $this->consumerNames = array_map(fn (string $consumerName) => $consumerName, $consumerNames); + + return $this; + } + + public function getConsumerNames(): array + { + return $this->consumerNames; + } + + public function setProviderNames(array $providerNames): StubServerConfigInterface + { + $this->providerNames = array_map(fn (string $providerName) => $providerName, $providerNames); + + return $this; + } + + public function getProviderNames(): array + { + return $this->providerNames; + } + + public function getBaseUri(): UriInterface + { + return new Uri("http://localhost:{$this->getPort()}"); + } } diff --git a/src/PhpPact/Standalone/StubService/StubServerConfigInterface.php b/src/PhpPact/Standalone/StubService/StubServerConfigInterface.php index 7f232923..a1f8c641 100644 --- a/src/PhpPact/Standalone/StubService/StubServerConfigInterface.php +++ b/src/PhpPact/Standalone/StubService/StubServerConfigInterface.php @@ -10,14 +10,48 @@ interface StubServerConfigInterface { /** - * @return string the host of the stub service + * @return null|UriInterface url to the pact broker */ - public function getHost(): string; + public function getBrokerUrl(): ?UriInterface; /** - * @param string $host The host of the stub service + * @param UriInterface $brokerUrl URL of the pact broker to fetch pacts from */ - public function setHost(string $host): self; + public function setBrokerUrl(UriInterface $brokerUrl): self; + + /** + * @param array $dirs Directory of pact files to load + */ + public function setDirs(array $dirs): self; + + /** + * @return array + */ + public function getDirs(): array; + + public function getExtension(): ?string; + + /** + * @param string $extension File extension to use when loading from a directory (default is json) + */ + public function setExtension(string $extension): self; + + /** + * @param array $files Pact file to load + */ + public function setFiles(array $files): self; + + /** + * @return array + */ + public function getFiles(): array; + + public function getLogLevel(): ?string; + + /** + * @param string $logLevel Log level (defaults to info) [possible values: error, warn, info, debug, trace, none] + */ + public function setLogLevel(string $logLevel): self; /** * @return int the port of the stub service @@ -25,40 +59,95 @@ public function setHost(string $host): self; public function getPort(): int; /** - * @param int $port the port of the stub service + * @param int $port Port to run on (defaults to random port assigned by the OS) */ public function setPort(int $port): self; /** - * @return bool true if https + * @return null|string state of the provider */ - public function isSecure(): bool; + public function getProviderState(): ?string; /** - * @param bool $secure set to true for https + * @param string $providerState Provider state regular expression to filter the responses by */ - public function setSecure(bool $secure): self; + public function setProviderState(string $providerState): self; /** - * @return UriInterface + * @return null|string name of the header */ - public function getBaseUri(): UriInterface; + public function getProviderStateHeaderName(): ?string; + + /** + * @param string $providerStateHeaderName Name of the header parameter containing the provider state to be used in case multiple matching interactions are found + */ + public function setProviderStateHeaderName(string $providerStateHeaderName): self; + + /** + * @return null|string token for the pact broker + */ + public function getToken(): ?string; /** - * @return ?string directory for log output + * @param null|string $token Bearer token to use when fetching pacts from URLS or Pact Broker */ - public function getLog(): ?string; + public function setToken(?string $token): self; /** - * @param string $log directory for log output + * @param array $urls URL of pact file to fetch */ - public function setLog(string $log): self; + public function setUrls(array $urls): self; - public function getPactLocation(): string; + /** + * @return array + */ + public function getUrls(): array; + + /** + * @return null|string user and password + */ + public function getUser(): ?string; + + /** + * @param string $user User and password to use when fetching pacts from URLS or Pact Broker in user:password form + */ + public function setUser(string $user): self; + + public function isCors(): bool; + + public function setCors(bool $cors): self; + + public function isCorsReferer(): bool; + + public function setCorsReferer(bool $corsReferer): self; + + public function isEmptyProviderState(): bool; + + public function setEmptyProviderState(bool $emptyProviderState): self; - public function setPactLocation(string $location): self; + public function isInsecureTls(): bool; - public function getEndpoint(): string; + public function setInsecureTls(bool $insecureTls): self; - public function setEndpoint(string $endpoint): self; + /** + * @param array $consumerNames Consumer name to use to filter the Pacts fetched from the Pact broker + */ + public function setConsumerNames(array $consumerNames): self; + + /** + * @return array + */ + public function getConsumerNames(): array; + + /** + * @param array $providerNames Provider name to use to filter the Pacts fetched from the Pact broker + */ + public function setProviderNames(array $providerNames): self; + + /** + * @return array + */ + public function getProviderNames(): array; + + public function getBaseUri(): UriInterface; } diff --git a/src/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriver.php b/src/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriver.php new file mode 100644 index 00000000..f76a4044 --- /dev/null +++ b/src/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriver.php @@ -0,0 +1,57 @@ +mockServer->verify(); + } + + public function registerMessage(Message $message): void + { + parent::registerMessage($message); + + $this->mockServer->start(); + } + + public function writePactAndCleanUp(): void + { + $this->mockServer->writePact(); + $this->mockServer->cleanUp(); + } + + protected function newInteraction(Message $message): void + { + $handle = $this->client->call('pactffi_new_sync_message_interaction', $this->pactDriver->getPact()->handle, $message->getDescription()); + $message->setHandle($handle); + } + + protected function given(Message $message): void + { + foreach ($message->getProviderStates() as $providerState) { + $this->client->call('pactffi_given', $message->getHandle(), $providerState->getName()); + foreach ($providerState->getParams() as $key => $value) { + $this->client->call('pactffi_given_with_param', $message->getHandle(), $providerState->getName(), (string) $key, (string) $value); + } + } + } +} diff --git a/src/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriverInterface.php b/src/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriverInterface.php new file mode 100644 index 00000000..84893fb8 --- /dev/null +++ b/src/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriverInterface.php @@ -0,0 +1,14 @@ +driver = ($driverFactory ?? new SyncMessageDriverFactory())->create($config); + } + + public function registerMessage(): void + { + $this->driver->registerMessage($this->message); + } + + public function verify(): bool + { + return $this->driver->verifyMessage()->matched; + } +} diff --git a/src/PhpPact/Xml/Exception/InvalidXmlElementException.php b/src/PhpPact/Xml/Exception/InvalidXmlElementException.php new file mode 100644 index 00000000..d62ad2e7 --- /dev/null +++ b/src/PhpPact/Xml/Exception/InvalidXmlElementException.php @@ -0,0 +1,7 @@ +root = new XmlElement(...$options); + } + + public function examples(int $examples): callable + { + return fn (XmlElement $element) => $element->setExamples($examples); + } + + public function add(callable ...$options): callable + { + return fn (XmlElement $element) => $element->addChild(new XmlElement(...$options)); + } + + public function name(string $name): callable + { + return fn (XmlElement $element) => $element->setName($name); + } + + public function attribute(string $name, string|float|int|bool|MatcherInterface $value): callable + { + return fn (XmlElement $element) => $element->addAttribute($name, $value); + } + + public function eachLike(callable ...$options): callable + { + return function (XmlElement $element) use ($options): void { + $child = new XmlElement(...$options); + $matcher = new Type($child); + $matcher->setFormatter(new XmlElementFormatter()); + $element->addChild($matcher); + }; + } +} diff --git a/src/PhpPact/Xml/Model/Builder/TextTrait.php b/src/PhpPact/Xml/Model/Builder/TextTrait.php new file mode 100644 index 00000000..4fe8a7f2 --- /dev/null +++ b/src/PhpPact/Xml/Model/Builder/TextTrait.php @@ -0,0 +1,27 @@ + $element->setText(new XmlText($content)); + } + + public function contentLike(string|float|int|bool|null $content): callable + { + return function (XmlElement $element) use ($content): void { + $matcher = new Type($content); + $matcher->setFormatter(new XmlContentFormatter()); + $text = new XmlText($matcher); + $element->setText($text); + }; + } +} diff --git a/src/PhpPact/Xml/XmlBuilder.php b/src/PhpPact/Xml/XmlBuilder.php new file mode 100644 index 00000000..65653d2f --- /dev/null +++ b/src/PhpPact/Xml/XmlBuilder.php @@ -0,0 +1,29 @@ + + */ + public function jsonSerialize(): array + { + return [ + 'version' => $this->version, + 'charset' => $this->charset, + 'root' => $this->root, + ]; + } +} diff --git a/src/PhpPact/Xml/XmlElement.php b/src/PhpPact/Xml/XmlElement.php new file mode 100644 index 00000000..9f3d10d1 --- /dev/null +++ b/src/PhpPact/Xml/XmlElement.php @@ -0,0 +1,96 @@ + + */ + private array $children = []; + + /** + * @var array + */ + private array $attributes = []; + + private ?XmlText $text = null; + + private ?int $examples = null; + + public function __construct(callable ...$options) + { + array_walk($options, fn (callable $option) => $option($this)); + if (!isset($this->name)) { + throw new InvalidXmlElementException("Xml element's name is required"); + } + } + + public function setName(string $name): self + { + $this->name = preg_replace('/(^[0-9]+|[^a-zA-Z0-9\-\_\:]+)/', '', $name); + + return $this; + } + + public function addChild(self|MatcherInterface $child): self + { + $this->children[] = $child; + + return $this; + } + + public function setText(?XmlText $text): self + { + $this->text = $text; + + return $this; + } + + public function addAttribute(string $name, string|float|int|bool|MatcherInterface $value): self + { + $this->attributes[$name] = $value; + + return $this; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $result = [ + 'name' => $this->name, + 'children' => $this->children, + 'attributes' => $this->attributes, + ]; + + if (null !== $this->examples) { + $result['examples'] = $this->examples; + } + + if ($this->text) { + $result['children'][] = $this->text; + } + + return $result; + } + + public function setExamples(?int $examples): self + { + $this->examples = $examples; + + return $this; + } + + public function getExamples(): ?int + { + return $this->examples; + } +} diff --git a/src/PhpPact/Xml/XmlText.php b/src/PhpPact/Xml/XmlText.php new file mode 100644 index 00000000..395afbfa --- /dev/null +++ b/src/PhpPact/Xml/XmlText.php @@ -0,0 +1,27 @@ + + */ + public function jsonSerialize(): array + { + if ($this->content instanceof MatcherInterface) { + return $this->content->jsonSerialize(); + } + + return [ + 'content' => $this->content, + ]; + } +} diff --git a/tests/PhpPact/Broker/Service/BrokerHttpClientTest.php b/tests/PhpPact/Broker/Service/BrokerHttpClientTest.php deleted file mode 100644 index 7c510e79..00000000 --- a/tests/PhpPact/Broker/Service/BrokerHttpClientTest.php +++ /dev/null @@ -1,53 +0,0 @@ - [ - 'pacts' => [ - ['href' => 'pact-url-1'], - ['href' => 'pact-url-2'], - ], - ], - ] - ); - - $streamMock = $this->createMock(StreamInterface::class); - $streamMock->expects($this->once()) - ->method('getContents') - ->will($this->returnValue($expectedContents)); - - $responseMock = $this->createMock(ResponseInterface::class); - $responseMock->expects($this->once()) - ->method('getBody') - ->will($this->returnValue($streamMock)); - - $httpClientMock = $this->createMock(ClientInterface::class); - $httpClientMock->expects($this->once()) - ->method('get') - ->will($this->returnValue($responseMock)); - - $uriMock = $this->createMock(UriInterface::class); - $uriMock->expects($this->once()) - ->method('withPath') - ->with($this->equalTo($expectedPath)) - ->will($this->returnValue($uriMock)); - - $broker = new BrokerHttpClient($httpClientMock, $uriMock); - $broker->getAllConsumerUrls($provider); - } -} diff --git a/tests/PhpPact/Config/PactConfigTest.php b/tests/PhpPact/Config/PactConfigTest.php new file mode 100644 index 00000000..ceb7da85 --- /dev/null +++ b/tests/PhpPact/Config/PactConfigTest.php @@ -0,0 +1,78 @@ +config = new PactConfig(); + } + + public function testSetters(): void + { + $provider = 'test-provider'; + $consumer = 'test-consumer'; + $pactDir = 'test-pact-dir/'; + $pactSpecificationVersion = '3.0.0'; + $log = 'test-log-dir/'; + $logLevel = 'ERROR'; + $pactFileWriteMode = 'merge'; + + $this->config + ->setProvider($provider) + ->setConsumer($consumer) + ->setPactDir($pactDir) + ->setPactSpecificationVersion($pactSpecificationVersion) + ->setLog($log) + ->setLogLevel($logLevel) + ->setPactFileWriteMode($pactFileWriteMode); + + static::assertSame($provider, $this->config->getProvider()); + static::assertSame($consumer, $this->config->getConsumer()); + static::assertSame($pactDir, $this->config->getPactDir()); + static::assertSame($pactSpecificationVersion, $this->config->getPactSpecificationVersion()); + static::assertSame($log, $this->config->getLog()); + static::assertSame($logLevel, $this->config->getLogLevel()); + static::assertSame($pactFileWriteMode, $this->config->getPactFileWriteMode()); + } + + public function testInvalidPactSpecificationVersion(): void + { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Invalid version string "invalid"'); + $this->config->setPactSpecificationVersion('invalid'); + } + + #[TestWith(['trace', 'TRACE'])] + #[TestWith(['debug', 'DEBUG'])] + #[TestWith(['info', 'INFO'])] + #[TestWith(['warn', 'WARN'])] + #[TestWith(['error', 'ERROR'])] + #[TestWith(['off', 'OFF'])] + #[TestWith(['none', 'NONE'])] + #[TestWith(['verbose', null])] + public function testLogLevel(string $logLevel, ?string $result): void + { + if (!$result) { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('LogLevel VERBOSE not supported.'); + } + $this->config->setLogLevel($logLevel); + $this->assertSame($result, $this->config->getLogLevel()); + } + + public function testInvalidPactFileWriteMode(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid PhpPact File Write Mode, value must be one of the following: overwrite, merge."); + $this->config->setPactFileWriteMode('APPEND'); + } +} diff --git a/tests/PhpPact/Consumer/Driver/Body/InteractionBodyDriverTest.php b/tests/PhpPact/Consumer/Driver/Body/InteractionBodyDriverTest.php new file mode 100644 index 00000000..fa0e5f9a --- /dev/null +++ b/tests/PhpPact/Consumer/Driver/Body/InteractionBodyDriverTest.php @@ -0,0 +1,188 @@ +client = $this->createMock(ClientInterface::class); + $this->client + ->expects($this->once()) + ->method('get') + ->willReturnMap([ + ['InteractionPart_Request', $this->requestPartId], + ['InteractionPart_Response', $this->responsePartId], + ]); + $this->driver = new InteractionBodyDriver($this->client); + $this->interaction = new Interaction(); + $this->interaction->setHandle($this->interactionHandle); + $this->interaction->setRequest(new ConsumerRequest()); + $this->interaction->setResponse(new ProviderResponse()); + $this->binary = new Binary(__DIR__ . '/../../../../_resources/image.jpg', 'image/jpeg'); + $this->text = new Text('example', 'text/plain'); + $this->parts = [ + new Part('/path/to/id.txt', 'id', 'text/plain'), + new Part('/path/to//address.json', 'address', 'application/json'), + new Part('/path/to//image.png', 'profileImage', 'image/png'), + ]; + $this->multipart = new Multipart($this->parts, $this->boundary); + $this->failed = FFI::new('char[5]'); + FFI::memcpy($this->failed, $this->message, 5); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testRequestBinaryBody(bool $success): void + { + $data = $this->binary->getData(); + $this->interaction->getRequest()->setBody($this->binary); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_with_binary_file', $this->interactionHandle, $this->requestPartId, $this->binary->getContentType(), $data->getValue(), $data->getSize()) + ->willReturn($success); + if (!$success) { + $this->expectException(InteractionBodyNotAddedException::class); + } + $this->driver->registerBody($this->interaction, InteractionPart::REQUEST); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testResponseBinaryBody(bool $success): void + { + $data = $this->binary->getData(); + $this->interaction->getResponse()->setBody($this->binary); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_with_binary_file', $this->interactionHandle, $this->responsePartId, $this->binary->getContentType(), $data->getValue(), $data->getSize()) + ->willReturn($success); + if (!$success) { + $this->expectException(InteractionBodyNotAddedException::class); + } + $this->driver->registerBody($this->interaction, InteractionPart::RESPONSE); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testRequestTextBody(bool $success): void + { + $this->interaction->getRequest()->setBody($this->text); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_with_body', $this->interactionHandle, $this->requestPartId, $this->text->getContentType(), $this->text->getContents()) + ->willReturn($success); + if (!$success) { + $this->expectException(InteractionBodyNotAddedException::class); + } + $this->driver->registerBody($this->interaction, InteractionPart::REQUEST); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testResponseTextBody(bool $success): void + { + $this->interaction->getResponse()->setBody($this->text); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_with_body', $this->interactionHandle, $this->responsePartId, $this->text->getContentType(), $this->text->getContents()) + ->willReturn($success); + if (!$success) { + $this->expectException(InteractionBodyNotAddedException::class); + } + $this->driver->registerBody($this->interaction, InteractionPart::RESPONSE); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testRequestMultipartBody(bool $success): void + { + $this->interaction->getRequest()->setBody($this->multipart); + $this->client + ->expects($this->exactly(count($this->parts))) + ->method('call') + ->willReturnCallback( + fn (string $method, int $interactionId, int $partId, string $contentType, string $path, string $name, string $boundary) => + match([$method, $interactionId, $partId, $contentType, $path, $name, $boundary]) { + ['pactffi_with_multipart_file_v2', $this->interactionHandle, $this->requestPartId, $this->parts[0]->getContentType(), $this->parts[0]->getPath(), $this->parts[0]->getName(), $this->boundary] => (object) ['failed' => null], + ['pactffi_with_multipart_file_v2', $this->interactionHandle, $this->requestPartId, $this->parts[1]->getContentType(), $this->parts[1]->getPath(), $this->parts[1]->getName(), $this->boundary] => (object) ['failed' => null], + ['pactffi_with_multipart_file_v2', $this->interactionHandle, $this->requestPartId, $this->parts[2]->getContentType(), $this->parts[2]->getPath(), $this->parts[2]->getName(), $this->boundary] => (object) (['failed' => $success ? null : $this->failed]), + } + ); + if (!$success) { + $this->expectException(PartNotAddedException::class); + $this->expectExceptionMessage("Can not add part '{$this->parts[2]->getName()}': {$this->message}"); + } + $this->driver->registerBody($this->interaction, InteractionPart::REQUEST); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testResponseMultipartBody(bool $success): void + { + $this->interaction->getResponse()->setBody($this->multipart); + $this->client + ->expects($this->exactly(count($this->parts))) + ->method('call') + ->willReturnCallback( + fn (string $method, int $interactionId, int $partId, string $contentType, string $path, string $name, string $boundary) => + match([$method, $interactionId, $partId, $contentType, $path, $name, $boundary]) { + ['pactffi_with_multipart_file_v2', $this->interactionHandle, $this->responsePartId, $this->parts[0]->getContentType(), $this->parts[0]->getPath(), $this->parts[0]->getName(), $this->boundary] => (object) ['failed' => null], + ['pactffi_with_multipart_file_v2', $this->interactionHandle, $this->responsePartId, $this->parts[1]->getContentType(), $this->parts[1]->getPath(), $this->parts[1]->getName(), $this->boundary] => (object) ['failed' => null], + ['pactffi_with_multipart_file_v2', $this->interactionHandle, $this->responsePartId, $this->parts[2]->getContentType(), $this->parts[2]->getPath(), $this->parts[2]->getName(), $this->boundary] => (object) (['failed' => $success ? null : $this->failed]), + } + ); + if (!$success) { + $this->expectException(PartNotAddedException::class); + $this->expectExceptionMessage("Can not add part '{$this->parts[2]->getName()}': {$this->message}"); + } + $this->driver->registerBody($this->interaction, InteractionPart::RESPONSE); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testEmptyBody(InteractionPart $part): void + { + $this->client + ->expects($this->never()) + ->method('call'); + $this->driver->registerBody($this->interaction, $part); + } +} diff --git a/tests/PhpPact/Consumer/Driver/Body/MessageBodyDriverTest.php b/tests/PhpPact/Consumer/Driver/Body/MessageBodyDriverTest.php new file mode 100644 index 00000000..9c832d52 --- /dev/null +++ b/tests/PhpPact/Consumer/Driver/Body/MessageBodyDriverTest.php @@ -0,0 +1,81 @@ +client = $this->createMock(ClientInterface::class); + $this->driver = new MessageBodyDriver($this->client); + $this->client + ->expects($this->once()) + ->method('get') + ->with('InteractionPart_Request') + ->willReturn($this->requestPartId); + $this->message = new Message(); + $this->message->setHandle($this->messageId); + $this->binary = new Binary(__DIR__ . '/../../../../_resources/image.jpg', 'image/jpeg'); + $this->text = new Text('example', 'text/plain'); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testMessageBinaryBody(bool $success): void + { + $data = $this->binary->getData(); + $this->message->setContents($this->binary); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_with_binary_file', $this->messageId, $this->requestPartId, $this->binary->getContentType(), $data->getValue(), $data->getSize()) + ->willReturn($success); + if (!$success) { + $this->expectException(MessageContentsNotAddedException::class); + } + $this->driver->registerBody($this->message); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testMessageTextBody(bool $success): void + { + $this->message->setContents($this->text); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_with_body', $this->messageId, $this->requestPartId, $this->text->getContentType(), $this->text->getContents()) + ->willReturn($success); + if (!$success) { + $this->expectException(MessageContentsNotAddedException::class); + } + $this->driver->registerBody($this->message); + } + + public function testEmptyBody(): void + { + $this->client + ->expects($this->never()) + ->method('call'); + $this->driver->registerBody($this->message); + } +} diff --git a/tests/PhpPact/Consumer/Driver/Interaction/InteractionDriverTest.php b/tests/PhpPact/Consumer/Driver/Interaction/InteractionDriverTest.php new file mode 100644 index 00000000..49513a52 --- /dev/null +++ b/tests/PhpPact/Consumer/Driver/Interaction/InteractionDriverTest.php @@ -0,0 +1,197 @@ + [ + 'id' => 12, + 'name' => 'abc', + ] + ]; + + public function setUp(): void + { + $this->client = $this->createMock(ClientInterface::class); + $this->mockServer = $this->createMock(MockServerInterface::class); + $this->pactDriver = $this->createMock(PactDriverInterface::class); + $this->requestDriver = $this->createMock(RequestDriverInterface::class); + $this->responseDriver = $this->createMock(ResponseDriverInterface::class); + $this->driver = new InteractionDriver($this->client, $this->mockServer, $this->pactDriver, $this->requestDriver, $this->responseDriver); + $this->interaction = new Interaction(); + $this->interaction->setDescription($this->description); + foreach ($this->providerStates as $name => $params) { + $this->interaction->addProviderState($name, $params); + } + } + + public function testVerifyInteractions(): void + { + $result = new VerifyResult(true, ''); + $this->mockServer + ->expects($this->once()) + ->method('verify') + ->willReturn($result); + $this->assertSame($result, $this->driver->verifyInteractions()); + } + + public function testWritePactAndCleanUp(): void + { + $this->mockServer + ->expects($this->once()) + ->method('writePact'); + $this->mockServer + ->expects($this->once()) + ->method('cleanUp'); + $this->driver->writePactAndCleanUp(); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testRegisterInteraction(bool $startMockServer): void + { + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $this->requestDriver + ->expects($this->once()) + ->method('registerRequest') + ->with($this->interaction); + $this->responseDriver + ->expects($this->once()) + ->method('registerResponse') + ->with($this->interaction); + $calls = [ + ['pactffi_new_interaction', $this->pactHandle, $this->description, $this->interactionHandle], + ['pactffi_given', $this->interactionHandle, 'item exist', null], + ['pactffi_given_with_param', $this->interactionHandle, 'item exist', 'id', '12', null], + ['pactffi_given_with_param', $this->interactionHandle, 'item exist', 'name', 'abc', null], + ['pactffi_upon_receiving', $this->interactionHandle, $this->description, null], + ]; + $this->assertClientCalls($calls); + $this->mockServer + ->expects($this->exactly($startMockServer)) + ->method('start'); + $this->assertTrue($this->driver->registerInteraction($this->interaction, $startMockServer)); + $this->assertSame($this->interactionHandle, $this->interaction->getHandle()); + } + + #[TestWith([null, true])] + #[TestWith([null, true])] + #[TestWith(['123ABC', false])] + #[TestWith(['123ABC', true])] + public function testSetKey(?string $key, $success): void + { + $this->interaction->setKey($key); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_interaction', $this->pactHandle, $this->description, $this->interactionHandle], + ['pactffi_given', $this->interactionHandle, 'item exist', null], + ['pactffi_given_with_param', $this->interactionHandle, 'item exist', 'id', '12', null], + ['pactffi_given_with_param', $this->interactionHandle, 'item exist', 'name', 'abc', null], + ['pactffi_upon_receiving', $this->interactionHandle, $this->description, null], + ]; + if (is_string($key)) { + $calls[] = ['pactffi_set_key', $this->interactionHandle, $key, $success]; + } + if (!$success) { + $this->expectException(InteractionKeyNotSetException::class); + $this->expectExceptionMessage("Can not set the key '$key' for the interaction '{$this->description}'"); + } + $this->assertClientCalls($calls); + $this->driver->registerInteraction($this->interaction, false); + } + + #[TestWith([null, true])] + #[TestWith([null, true])] + #[TestWith([true, false])] + #[TestWith([true, true])] + #[TestWith([false, false])] + #[TestWith([false, true])] + public function testSetPending(?bool $pending, $success): void + { + $this->interaction->setPending($pending); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_interaction', $this->pactHandle, $this->description, $this->interactionHandle], + ['pactffi_given', $this->interactionHandle, 'item exist', null], + ['pactffi_given_with_param', $this->interactionHandle, 'item exist', 'id', '12', null], + ['pactffi_given_with_param', $this->interactionHandle, 'item exist', 'name', 'abc', null], + ['pactffi_upon_receiving', $this->interactionHandle, $this->description, null], + ]; + if (is_bool($pending)) { + $calls[] = ['pactffi_set_pending', $this->interactionHandle, $pending, $success]; + } + if (!$success) { + $this->expectException(InteractionPendingNotSetException::class); + $this->expectExceptionMessage("Can not mark interaction '{$this->description}' as pending"); + } + $this->assertClientCalls($calls); + $this->driver->registerInteraction($this->interaction, false); + } + + #[TestWith([[], true])] + #[TestWith([['key1' => 'value1'], false])] + #[TestWith([['key2' => 'value2', 'key3' => 'value3'], true])] + public function testSetComments(array $comments, $success): void + { + $this->interaction->setComments($comments); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_interaction', $this->pactHandle, $this->description, $this->interactionHandle], + ['pactffi_given', $this->interactionHandle, 'item exist', null], + ['pactffi_given_with_param', $this->interactionHandle, 'item exist', 'id', '12', null], + ['pactffi_given_with_param', $this->interactionHandle, 'item exist', 'name', 'abc', null], + ['pactffi_upon_receiving', $this->interactionHandle, $this->description, null], + ]; + foreach ($comments as $key => $value) { + $calls[] = ['pactffi_set_comment', $this->interactionHandle, $key, $value, $success]; + } + if (!$success) { + $this->expectException(InteractionCommentNotSetException::class); + $this->expectExceptionMessage("Can add comment '$key' to the interaction '{$this->description}'"); + } + $this->assertClientCalls($calls); + $this->driver->registerInteraction($this->interaction, false); + } +} diff --git a/tests/PhpPact/Consumer/Driver/Interaction/MessageDriverTest.php b/tests/PhpPact/Consumer/Driver/Interaction/MessageDriverTest.php new file mode 100644 index 00000000..ebde5c30 --- /dev/null +++ b/tests/PhpPact/Consumer/Driver/Interaction/MessageDriverTest.php @@ -0,0 +1,196 @@ + [ + 'id' => 12, + 'name' => 'abc', + ] + ]; + private array $metadata = [ + 'key1' => 'value1', + 'key2' => 'value2', + ]; + + public function setUp(): void + { + $this->client = $this->createMock(ClientInterface::class); + $this->pactDriver = $this->createMock(PactDriverInterface::class); + $this->messageBodyDriver = $this->createMock(MessageBodyDriverInterface::class); + $this->driver = new MessageDriver($this->client, $this->pactDriver, $this->messageBodyDriver); + $this->message = new Message(); + $this->message->setDescription($this->description); + foreach ($this->providerStates as $name => $params) { + $this->message->addProviderState($name, $params); + } + $this->message->setMetadata($this->metadata); + } + + public function testReify(): void + { + $this->message->setHandle($this->messageHandle); + $result = 'message'; + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_message_reify', $this->messageHandle) + ->willReturn($result); + $this->assertSame($result, $this->driver->reify($this->message)); + } + + public function testWritePactAndCleanUp(): void + { + $this->pactDriver + ->expects($this->once()) + ->method('writePact'); + $this->pactDriver + ->expects($this->once()) + ->method('cleanUp'); + $this->driver->writePactAndCleanUp(); + } + + public function testRegisterInteraction(): void + { + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $this->messageBodyDriver + ->expects($this->once()) + ->method('registerBody') + ->with($this->message); + $calls = [ + ['pactffi_new_message_interaction', $this->pactHandle, $this->description, $this->messageHandle], + ['pactffi_message_given', $this->messageHandle, 'item exist', null], + ['pactffi_message_given_with_param', $this->messageHandle, 'item exist', 'id', '12', null], + ['pactffi_message_given_with_param', $this->messageHandle, 'item exist', 'name', 'abc', null], + ['pactffi_message_expects_to_receive', $this->messageHandle, $this->description, null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key1', 'value1', null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key2', 'value2', null], + ]; + $this->assertClientCalls($calls); + $this->driver->registerMessage($this->message); + $this->assertSame($this->messageHandle, $this->message->getHandle()); + } + + #[TestWith([null, true])] + #[TestWith([null, true])] + #[TestWith(['123ABC', false])] + #[TestWith(['123ABC', true])] + public function testSetKey(?string $key, $success): void + { + $this->message->setKey($key); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_message_interaction', $this->pactHandle, $this->description, $this->messageHandle], + ['pactffi_message_given', $this->messageHandle, 'item exist', null], + ['pactffi_message_given_with_param', $this->messageHandle, 'item exist', 'id', '12', null], + ['pactffi_message_given_with_param', $this->messageHandle, 'item exist', 'name', 'abc', null], + ['pactffi_message_expects_to_receive', $this->messageHandle, $this->description, null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key1', 'value1', null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key2', 'value2', null], + ]; + if (is_string($key)) { + $calls[] = ['pactffi_set_key', $this->messageHandle, $key, $success]; + } + if (!$success) { + $this->expectException(InteractionKeyNotSetException::class); + $this->expectExceptionMessage("Can not set the key '$key' for the interaction '{$this->description}'"); + } + $this->assertClientCalls($calls); + $this->driver->registerMessage($this->message); + } + + #[TestWith([null, true])] + #[TestWith([null, true])] + #[TestWith([true, false])] + #[TestWith([true, true])] + #[TestWith([false, false])] + #[TestWith([false, true])] + public function testSetPending(?bool $pending, $success): void + { + $this->message->setPending($pending); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_message_interaction', $this->pactHandle, $this->description, $this->messageHandle], + ['pactffi_message_given', $this->messageHandle, 'item exist', null], + ['pactffi_message_given_with_param', $this->messageHandle, 'item exist', 'id', '12', null], + ['pactffi_message_given_with_param', $this->messageHandle, 'item exist', 'name', 'abc', null], + ['pactffi_message_expects_to_receive', $this->messageHandle, $this->description, null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key1', 'value1', null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key2', 'value2', null], + ]; + if (is_bool($pending)) { + $calls[] = ['pactffi_set_pending', $this->messageHandle, $pending, $success]; + } + if (!$success) { + $this->expectException(InteractionPendingNotSetException::class); + $this->expectExceptionMessage("Can not mark interaction '{$this->description}' as pending"); + } + $this->assertClientCalls($calls); + $this->driver->registerMessage($this->message); + } + + #[TestWith([[], true])] + #[TestWith([['key1' => 'value1'], false])] + #[TestWith([['key2' => 'value2', 'key3' => 'value3'], true])] + public function testSetComments(array $comments, $success): void + { + $this->message->setComments($comments); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_message_interaction', $this->pactHandle, $this->description, $this->messageHandle], + ['pactffi_message_given', $this->messageHandle, 'item exist', null], + ['pactffi_message_given_with_param', $this->messageHandle, 'item exist', 'id', '12', null], + ['pactffi_message_given_with_param', $this->messageHandle, 'item exist', 'name', 'abc', null], + ['pactffi_message_expects_to_receive', $this->messageHandle, $this->description, null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key1', 'value1', null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key2', 'value2', null], + ]; + foreach ($comments as $key => $value) { + $calls[] = ['pactffi_set_comment', $this->messageHandle, $key, $value, $success]; + } + if (!$success) { + $this->expectException(InteractionCommentNotSetException::class); + $this->expectExceptionMessage("Can add comment '$key' to the interaction '{$this->description}'"); + } + $this->assertClientCalls($calls); + $this->driver->registerMessage($this->message); + } +} diff --git a/tests/PhpPact/Consumer/Driver/InteractionPart/RequestDriverTest.php b/tests/PhpPact/Consumer/Driver/InteractionPart/RequestDriverTest.php new file mode 100644 index 00000000..cdf0ea25 --- /dev/null +++ b/tests/PhpPact/Consumer/Driver/InteractionPart/RequestDriverTest.php @@ -0,0 +1,76 @@ + ['query-value-1', 'query-value-2'], + 'query2' => ['query-value-3'], + ]; + private array $headers = [ + 'header1' => ['header-value-1'], + 'header2' => ['header-value-2', 'header-value-3'], + ]; + + public function setUp(): void + { + $this->client = $this->createMock(ClientInterface::class); + $this->bodyDriver = $this->createMock(InteractionBodyDriverInterface::class); + $this->driver = new RequestDriver($this->client, $this->bodyDriver); + $this->interaction = new Interaction(); + $this->interaction->setHandle($this->interactionHandle); + $request = new ConsumerRequest(); + $request->setMethod($this->method); + $request->setPath($this->path); + $request->setQuery($this->query); + $request->setHeaders($this->headers); + $this->interaction->setRequest($request); + } + + public function testRegisterRequest(): void + { + $this->client + ->expects($this->once()) + ->method('get') + ->with('InteractionPart_Request') + ->willReturn($this->requestPartId); + $this->client + ->expects($this->exactly(7)) + ->method('call') + ->willReturnCallback( + fn (...$args) => match($args) { + ['pactffi_with_request', $this->interactionHandle, $this->method, $this->path] => null, + ['pactffi_with_query_parameter_v2', $this->interactionHandle, 'query1', 0, 'query-value-1'] => null, + ['pactffi_with_query_parameter_v2', $this->interactionHandle, 'query1', 1, 'query-value-2'] => null, + ['pactffi_with_query_parameter_v2', $this->interactionHandle, 'query2', 0, 'query-value-3'] => null, + ['pactffi_with_header_v2', $this->interactionHandle, $this->requestPartId, 'header1', 0, 'header-value-1'] => null, + ['pactffi_with_header_v2', $this->interactionHandle, $this->requestPartId, 'header2', 0, 'header-value-2'] => null, + ['pactffi_with_header_v2', $this->interactionHandle, $this->requestPartId, 'header2', 1, 'header-value-3'] => null, + } + ); + $this->bodyDriver + ->expects($this->once()) + ->method('registerBody') + ->with($this->interaction, InteractionPart::REQUEST); + $this->driver->registerRequest($this->interaction); + } +} diff --git a/tests/PhpPact/Consumer/Driver/InteractionPart/ResponseDriverTest.php b/tests/PhpPact/Consumer/Driver/InteractionPart/ResponseDriverTest.php new file mode 100644 index 00000000..fb2a1575 --- /dev/null +++ b/tests/PhpPact/Consumer/Driver/InteractionPart/ResponseDriverTest.php @@ -0,0 +1,66 @@ + ['header-value-1'], + 'header2' => ['header-value-2', 'header-value-3'], + ]; + + public function setUp(): void + { + $this->client = $this->createMock(ClientInterface::class); + $this->bodyDriver = $this->createMock(InteractionBodyDriverInterface::class); + $this->driver = new ResponseDriver($this->client, $this->bodyDriver); + $this->interaction = new Interaction(); + $this->interaction->setHandle($this->interactionHandle); + $response = new ProviderResponse(); + $response->setStatus($this->status); + $response->setHeaders($this->headers); + $this->interaction->setResponse($response); + } + + public function testRegisterResponse(): void + { + $this->client + ->expects($this->once()) + ->method('get') + ->with('InteractionPart_Response') + ->willReturn($this->responsePartId); + $this->client + ->expects($this->exactly(4)) + ->method('call') + ->willReturnCallback( + fn (...$args) => match($args) { + ['pactffi_response_status_v2', $this->interactionHandle, $this->status] => null, + ['pactffi_with_header_v2', $this->interactionHandle, $this->responsePartId, 'header1', 0, 'header-value-1'] => null, + ['pactffi_with_header_v2', $this->interactionHandle, $this->responsePartId, 'header2', 0, 'header-value-2'] => null, + ['pactffi_with_header_v2', $this->interactionHandle, $this->responsePartId, 'header2', 1, 'header-value-3'] => null, + } + ); + $this->bodyDriver + ->expects($this->once()) + ->method('registerBody') + ->with($this->interaction, InteractionPart::RESPONSE); + $this->driver->registerResponse($this->interaction); + } +} diff --git a/tests/PhpPact/Consumer/Driver/Pact/PactDriverTest.php b/tests/PhpPact/Consumer/Driver/Pact/PactDriverTest.php new file mode 100644 index 00000000..93401d4e --- /dev/null +++ b/tests/PhpPact/Consumer/Driver/Pact/PactDriverTest.php @@ -0,0 +1,193 @@ +client = $this->createMock(ClientInterface::class); + $this->config = $this->createMock(PactConfigInterface::class); + $this->client + ->expects($this->any()) + ->method('get') + ->willReturnMap([ + ['PactSpecification_Unknown', self::SPEC_UNKNOWN], + ['PactSpecification_V1', self::SPEC_V1], + ['PactSpecification_V1_1', self::SPEC_V1_1], + ['PactSpecification_V2', self::SPEC_V2], + ['PactSpecification_V3', self::SPEC_V3], + ['PactSpecification_V4', self::SPEC_V4], + ]); + $this->driver = new PactDriver($this->client, $this->config); + } + + #[TestWith([null , '1.0.0', self::SPEC_V1])] + #[TestWith(['trace', '1.1.0', self::SPEC_V1_1])] + #[TestWith(['debug', '2.0.0', self::SPEC_V2])] + #[TestWith(['info' , '3.0.0', self::SPEC_V3])] + #[TestWith(['warn' , '4.0.0', self::SPEC_V4])] + #[TestWith(['error', '1.0.0', self::SPEC_V1])] + #[TestWith(['off' , '1.1.0', self::SPEC_V1_1])] + #[TestWith(['none' , '2.0.0', self::SPEC_V2])] + #[TestWith([null , '0.1.2', self::SPEC_UNKNOWN])] + #[TestWith([null , 'x.y.z', self::SPEC_UNKNOWN])] + public function testSetUp(?string $logLevel, string $version, int $specificationHandle): void + { + $this->assertConfig($logLevel, $version); + $calls = $logLevel ? [ + ['pactffi_init_with_log_level', $logLevel, null], + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, $specificationHandle, null], + ] : [ + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, $specificationHandle, null], + ]; + $this->assertClientCalls($calls); + $this->driver->setUp(); + $this->assertSame($this->pactHandle, $this->driver->getPact()->handle); + } + + public function testSetUpMultipleTimes(): void + { + $this->assertConfig(null, '1.0.0'); + $calls = [ + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, self::SPEC_V1, null], + ]; + $this->assertClientCalls($calls); + $this->driver->setUp(); + $this->driver->setUp(); + $this->driver->setUp(); + } + + public function testCleanUp(): void + { + $this->assertConfig(null, '1.0.0'); + $calls = [ + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, self::SPEC_V1, null], + ['pactffi_free_pact_handle', $this->pactHandle, null], + ]; + $this->assertClientCalls($calls); + $this->driver->setUp(); + $this->driver->cleanUp(); + } + + public function testCleanUpWithoutPact(): void + { + $this->expectException(MissingPactException::class); + $this->driver->cleanUp(); + } + + public function testGetPact(): void + { + $this->assertConfig(null, '1.0.0'); + $calls = [ + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, self::SPEC_V1, null], + ]; + $this->assertClientCalls($calls); + $this->driver->setUp(); + $pact = $this->driver->getPact(); + $this->assertSame($this->pactHandle, $pact->handle); + } + + public function testGetPactWithoutPact(): void + { + $this->expectException(MissingPactException::class); + $this->driver->getPact(); + } + + #[TestWith([0, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([1, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([2, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([3, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([4, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([0, PactConfigInterface::MODE_MERGE])] + #[TestWith([1, PactConfigInterface::MODE_MERGE])] + #[TestWith([2, PactConfigInterface::MODE_MERGE])] + #[TestWith([3, PactConfigInterface::MODE_MERGE])] + #[TestWith([4, PactConfigInterface::MODE_MERGE])] + public function testWritePact(int $error, string $writeMode): void + { + $this->assertConfig(null, '1.0.0'); + $this->config + ->expects($this->once()) + ->method('getPactDir') + ->willReturn($this->pactDir); + $this->config + ->expects($this->once()) + ->method('getPactFileWriteMode') + ->willReturn($writeMode); + $calls = [ + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, self::SPEC_V1, null], + ['pactffi_pact_handle_write_file', $this->pactHandle, $this->pactDir, $writeMode === PactConfigInterface::MODE_OVERWRITE, $error], + ]; + $this->assertClientCalls($calls); + $this->driver->setUp(); + if ($error) { + $this->expectException(PactFileNotWrittenException::class); + $this->expectExceptionMessage(match ($error) { + 1 => 'The function panicked.', + 2 => 'The pact file was not able to be written.', + 3 => 'The pact for the given handle was not found.', + default => 'Unknown error', + }); + } + $this->driver->writePact(); + } + + public function testWritePactWithoutPact(): void + { + $this->expectException(MissingPactException::class); + $this->driver->writePact(); + } + + protected function assertConfig(?string $logLevel, string $version): void + { + $this->config + ->expects($this->once()) + ->method('getLogLevel') + ->willReturn($logLevel); + $this->config + ->expects($this->any()) + ->method('getPactSpecificationVersion') + ->willReturn($version); + $this->config + ->expects($this->once()) + ->method('getConsumer') + ->willReturn($this->consumer); + $this->config + ->expects($this->once()) + ->method('getProvider') + ->willReturn($this->provider); + } +} diff --git a/tests/PhpPact/Consumer/Factory/InteractionDriverFactoryTest.php b/tests/PhpPact/Consumer/Factory/InteractionDriverFactoryTest.php new file mode 100644 index 00000000..04b3e707 --- /dev/null +++ b/tests/PhpPact/Consumer/Factory/InteractionDriverFactoryTest.php @@ -0,0 +1,41 @@ +config = $this->createMock(MockServerConfigInterface::class); + $this->config + ->expects($this->any()) + ->method('getPactSpecificationVersion') + ->willReturn('3.0.0'); + } + + public function testCreate(): void + { + $this->factory = new InteractionDriverFactory(); + $driver = $this->factory->create($this->config); + $this->assertPropertiesInstanceOf($driver, null, [ + 'client' => Client::class, + 'mockServer' => MockServer::class, + 'pactDriver' => PactDriver::class, + ]); + } +} diff --git a/tests/PhpPact/Consumer/Factory/MessageDriverFactoryTest.php b/tests/PhpPact/Consumer/Factory/MessageDriverFactoryTest.php new file mode 100644 index 00000000..9701637e --- /dev/null +++ b/tests/PhpPact/Consumer/Factory/MessageDriverFactoryTest.php @@ -0,0 +1,39 @@ +config = $this->createMock(MockServerConfigInterface::class); + $this->config + ->expects($this->any()) + ->method('getPactSpecificationVersion') + ->willReturn('3.0.0'); + } + + public function testCreate(): void + { + $this->factory = new MessageDriverFactory(); + $driver = $this->factory->create($this->config); + $this->assertPropertiesInstanceOf($driver, null, [ + 'client' => Client::class, + 'pactDriver' => PactDriver::class, + ]); + } +} diff --git a/tests/PhpPact/Consumer/InteractionBuilderTest.php b/tests/PhpPact/Consumer/InteractionBuilderTest.php index b9bf75b3..a067cea2 100644 --- a/tests/PhpPact/Consumer/InteractionBuilderTest.php +++ b/tests/PhpPact/Consumer/InteractionBuilderTest.php @@ -2,143 +2,136 @@ namespace PhpPactTest\Consumer; +use PhpPact\Consumer\Driver\Interaction\InteractionDriverInterface; +use PhpPact\Consumer\Factory\InteractionDriverFactoryInterface; use PhpPact\Consumer\InteractionBuilder; -use PhpPact\Consumer\Matcher\Matcher; use PhpPact\Consumer\Model\ConsumerRequest; +use PhpPact\Consumer\Model\Interaction; use PhpPact\Consumer\Model\ProviderResponse; -use PhpPact\Http\GuzzleClient; -use PhpPact\Standalone\Exception\MissingEnvVariableException; -use PhpPact\Standalone\MockService\MockServer; -use PhpPact\Standalone\MockService\MockServerEnvConfig; -use PhpPact\Standalone\MockService\Service\MockServerHttpService; -use PhpPact\Standalone\MockService\Service\MockServerHttpServiceInterface; +use PhpPact\Consumer\Model\ProviderState; +use PhpPact\Standalone\MockService\MockServerConfigInterface; +use PhpPact\Standalone\MockService\Model\VerifyResult; +use PHPUnit\Framework\Attributes\TestWith; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use ReflectionProperty; class InteractionBuilderTest extends TestCase { - private MockServerHttpServiceInterface $service; + private InteractionBuilder $builder; + private InteractionDriverInterface|MockObject $driver; + private MockServerConfigInterface|MockObject $config; + private InteractionDriverFactoryInterface|MockObject $driverFactory; - private MockServer $mockServer; - - /** - * @throws MissingEnvVariableException - * @throws \Exception - */ - protected function setUp(): void + public function setUp(): void { - $config = new MockServerEnvConfig(); - $this->mockServer = new MockServer($config); - $this->mockServer->start(); - $this->service = new MockServerHttpService(new GuzzleClient(), $config); + $this->driver = $this->createMock(InteractionDriverInterface::class); + $this->config = $this->createMock(MockServerConfigInterface::class); + $this->driverFactory = $this->createMock(InteractionDriverFactoryInterface::class); + $this->driverFactory + ->expects($this->once()) + ->method('create') + ->with($this->config) + ->willReturn($this->driver); + $this->builder = new InteractionBuilder($this->config, $this->driverFactory); } - protected function tearDown(): void + public function testNewInteraction(): void { - $this->mockServer->stop(); + $oldInteraction = $this->getInteraction(); + $this->builder->newInteraction(); + $newInteraction = $this->getInteraction(); + $this->assertNotSame($oldInteraction, $newInteraction); } - /** - * @throws MissingEnvVariableException - * @throws \Exception - */ - public function testSimpleGet() + public function testGiven(): void { - $matcher = new Matcher(); - - $request = new ConsumerRequest(); - $request - ->setPath('/something') - ->setMethod('GET') - ->addHeader('Content-Type', 'application/json'); - - $response = new ProviderResponse(); - $response - ->setStatus(200) - ->setBody([ - 'message' => 'Hello, world!', - 'age' => $matcher->like(73), - ]) - ->addHeader('Content-Type', 'application/json'); - - $builder = new InteractionBuilder(new MockServerEnvConfig()); - $result = $builder - ->given('A test request.') - ->uponReceiving('A test response.') - ->with($request) - ->willRespondWith($response); + $this->assertSame($this->builder, $this->builder->given('test', ['key' => 'value'])); + $interaction = $this->getInteraction(); + $providerStates = $interaction->getProviderStates(); + $this->assertCount(1, $providerStates); + $providerState = $providerStates[0]; + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('test', $providerState->getName()); + $this->assertSame(['key' => 'value'], $providerState->getParams()); + } - $this->assertTrue($result); + public function testUponReceiving(): void + { + $description = 'interaction description'; + $this->assertSame($this->builder, $this->builder->uponReceiving($description)); + $interaction = $this->getInteraction(); + $this->assertSame($description, $interaction->getDescription()); } - /** - * @throws MissingEnvVariableException - */ - public function testPostWithBody() + public function testWithRequest(): void { $request = new ConsumerRequest(); - $request - ->setPath('/something') - ->setMethod('POST') - ->addHeader('Content-Type', 'application/json') - ->setBody([ - 'someStuff' => 'someOtherStuff', - 'someNumber' => 12, - 'anArray' => [ - 12, - 'words here', - 493.5, - ], - ]); + $this->assertSame($this->builder, $this->builder->with($request)); + $interaction = $this->getInteraction(); + $this->assertSame($request, $interaction->getRequest()); + } + #[TestWith([false, true])] + #[TestWith([true, true])] + #[TestWith([false, false])] + #[TestWith([true, false])] + public function testWillRespondWith(bool $startMockServer, bool $result): void + { $response = new ProviderResponse(); - $response - ->setStatus(200) - ->addHeader('Content-Type', 'application/json') - ->setBody([ - 'message' => 'Hello, world!', - ]); - - $builder = new InteractionBuilder(new MockServerEnvConfig()); - $result = $builder - ->given('A test request.') - ->uponReceiving('A test response.') - ->with($request) - ->willRespondWith($response); + $interaction = $this->getInteraction(); + $this->driver + ->expects($this->once()) + ->method('registerInteraction') + ->with($interaction, $startMockServer) + ->willReturn($result); + $this->assertSame($result, $this->builder->willRespondWith($response, $startMockServer)); + $this->assertSame($response, $interaction->getResponse()); + } - $this->assertTrue($result); + #[TestWith([false])] + #[TestWith([true])] + public function testVerify(bool $matched): void + { + $this->driver + ->expects($this->once()) + ->method('verifyInteractions') + ->willReturn(new VerifyResult($matched, '')); + $this->assertSame($matched, $this->builder->verify()); } - /** - * @throws MissingEnvVariableException - */ - public function testBuildWithEachLikeMatcher() + #[TestWith([null])] + #[TestWith(['key'])] + public function testSetKey(?string $key): void { - $matcher = new Matcher(); + $this->assertSame($this->builder, $this->builder->key($key)); + $interaction = $this->getInteraction(); + $this->assertSame($key, $interaction->getKey()); + } - $request = new ConsumerRequest(); - $request - ->setPath('/something') - ->setMethod('GET') - ->addHeader('Content-Type', 'application/json'); + #[TestWith([null])] + #[TestWith([false])] + #[TestWith([true])] + public function testSetPending(?bool $pending): void + { + $this->assertSame($this->builder, $this->builder->pending($pending)); + $interaction = $this->getInteraction(); + $this->assertSame($pending, $interaction->getPending()); + } - $response = new ProviderResponse(); - $response - ->setStatus(200) - ->addHeader('Content-Type', 'application/json') - ->setBody([ - 'list' => $matcher->eachLike([ - 'test' => 1, - 'another' => 2, - ]), - ]); + #[TestWith([[]])] + #[TestWith([['key' => 'value']])] + public function testSetComments(array $comments): void + { + $this->assertSame($this->builder, $this->builder->comments($comments)); + $interaction = $this->getInteraction(); + $this->assertSame($comments, $interaction->getComments()); + } - $builder = new InteractionBuilder(new MockServerEnvConfig()); - $result = $builder - ->given('A test request.') - ->uponReceiving('A test response.') - ->with($request) - ->willRespondWith($response); + private function getInteraction(): Interaction + { + $reflection = new ReflectionProperty($this->builder, 'interaction'); - $this->assertTrue($result); + return $reflection->getValue($this->builder); } } diff --git a/tests/PhpPact/Consumer/Matcher/Formatters/MinimalFormatterTest.php b/tests/PhpPact/Consumer/Matcher/Formatters/MinimalFormatterTest.php new file mode 100644 index 00000000..3cbcc2c9 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Formatters/MinimalFormatterTest.php @@ -0,0 +1,28 @@ +assertSame([ + 'pact:matcher:type' => 'date', + 'format' => 'yyyy-MM-dd', + ], $formatter->format($matcher, $generator, $value)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Formatters/PluginFormatterTest.php b/tests/PhpPact/Consumer/Matcher/Formatters/PluginFormatterTest.php new file mode 100644 index 00000000..b8afee3d --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Formatters/PluginFormatterTest.php @@ -0,0 +1,148 @@ +formatter = new PluginFormatter(); + } + + public function testFormatWithGenerator(): void + { + $this->expectException(GeneratorNotRequiredException::class); + $this->expectExceptionMessage('Generator is not support in plugin'); + $matcher = new StringValue('example value'); + $matcher->setGenerator(new RandomString()); + $this->formatter->format($matcher); + } + + /** + * @dataProvider invalidRulesProvider + */ + public function testInvalidRules(EachKey|EachValue $matcher): void + { + $this->expectException(MatchingExpressionException::class); + $this->expectExceptionMessage(sprintf("Matcher '%s' only support 1 rule, %d provided", $matcher->getType(), count($matcher->getRules()))); + $this->formatter->format($matcher); + } + + public static function invalidRulesProvider(): array + { + return [ + [new EachKey(["doesn't matter"], [])], + [new EachValue(["doesn't matter"], [])], + [new EachKey(["doesn't matter"], [new Type(1), new Type(2)])], + [new EachValue(["doesn't matter"], [new Type(1), new Type(2), new Type(3)])], + ]; + } + + /** + * @dataProvider invalidValueProvider + */ + public function testInvalidValue(MatcherInterface $matcher, string $type): void + { + $this->expectException(MatchingExpressionException::class); + $this->expectExceptionMessage(sprintf("Plugin formatter doesn't support value of type %s", $type)); + $this->formatter->format($matcher); + } + + public static function invalidValueProvider(): array + { + return [ + [new Type((object)['key' => 'value']), 'object'], + [new Type(['key' => 'value']), 'array'], + [new MinType(['Example value'], 1), 'array'], + [new MaxType(['Example value'], 2), 'array'], + [new MinMaxType(['Example value'], 1, 2), 'array'], + ]; + } + + /** + * @dataProvider notSupportedMatcherProvider + */ + public function testNotSupportedMatcher(MatcherInterface $matcher): void + { + $this->expectException(MatcherNotSupportedException::class); + $this->expectExceptionMessage(sprintf("Matcher '%s' is not supported by plugin", $matcher->getType())); + $this->formatter->format($matcher); + } + + public static function notSupportedMatcherProvider(): array + { + return [ + [new Values([1, 2, 3])], + [new ArrayContains([new Equality(1)])], + [new StatusCode('clientError', 405)], + ]; + } + + /** + * @dataProvider matcherProvider + */ + public function testFormat(MatcherInterface $matcher, string $json): void + { + $this->assertSame($json, json_encode($this->formatter->format($matcher))); + } + + public static function matcherProvider(): array + { + return [ + [new MatchingField('product'), '"matching($\'product\')"'], + [new NotEmpty('test'), '"notEmpty(\'test\')"'], + [new EachKey(["doesn't matter"], [new Regex('\$(\.\w+)+', '$.test.one')]), '"eachKey(matching(regex, \'\\\\$(\\\\.\\\\w+)+\', \'$.test.one\'))"'], + [new EachValue(["doesn't matter"], [new Type(100)]), '"eachValue(matching(type, 100))"'], + [new Equality('Example value'), '"matching(equalTo, \'Example value\')"'], + [new Type('Example value'), '"matching(type, \'Example value\')"'], + [new Number(100.09), '"matching(number, 100.09)"'], + [new Integer(100), '"matching(integer, 100)"'], + [new Decimal(100.01), '"matching(decimal, 100.01)"'], + [new Includes('testing'), '"matching(include, \'testing\')"'], + [new Boolean(true), '"matching(boolean, true)"'], + [new Semver('1.0.0'), '"matching(semver, \'1.0.0\')"'], + [new DateTime('yyyy-MM-dd HH:mm:ssZZZZZ', '2020-05-21 16:44:32+10:00'), '"matching(datetime, \'yyyy-MM-dd HH:mm:ssZZZZZ\', \'2020-05-21 16:44:32+10:00\')"'], + [new Date('yyyy-MM-dd', '2012-04-12'), '"matching(date, \'yyyy-MM-dd\', \'2012-04-12\')"'], + [new Time('HH:mm', '22:04'), '"matching(time, \'HH:mm\', \'22:04\')"'], + [new Regex('\\w{3}\\d+', 'abc123'), '"matching(regex, \'\\\\w{3}\\\\d+\', \'abc123\')"'], + [new ContentType('application/xml'), '"matching(contentType, \'application\/xml\', \'application\/xml\')"'], + [new NullValue(), '"matching(type, null)"'], + ]; + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Formatters/ValueOptionalFormatterTest.php b/tests/PhpPact/Consumer/Matcher/Formatters/ValueOptionalFormatterTest.php new file mode 100644 index 00000000..dd3d6861 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Formatters/ValueOptionalFormatterTest.php @@ -0,0 +1,26 @@ +setGenerator($generator); + $formatter = new ValueOptionalFormatter(); + $this->assertSame($result, $formatter->format($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Formatters/ValueRequiredFormatterTest.php b/tests/PhpPact/Consumer/Matcher/Formatters/ValueRequiredFormatterTest.php new file mode 100644 index 00000000..3e092bc3 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Formatters/ValueRequiredFormatterTest.php @@ -0,0 +1,26 @@ +setGenerator($generator); + $formatter = new ValueRequiredFormatter(); + $this->assertSame($result, $formatter->format($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Formatters/XmlContentFormatterTest.php b/tests/PhpPact/Consumer/Matcher/Formatters/XmlContentFormatterTest.php new file mode 100644 index 00000000..e2a10f67 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Formatters/XmlContentFormatterTest.php @@ -0,0 +1,24 @@ +setGenerator($generator); + $formatter = new XmlContentFormatter(); + $this->assertSame($result, $formatter->format($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Formatters/XmlElementFormatterTest.php b/tests/PhpPact/Consumer/Matcher/Formatters/XmlElementFormatterTest.php new file mode 100644 index 00000000..543862fb --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Formatters/XmlElementFormatterTest.php @@ -0,0 +1,39 @@ +expectException(InvalidValueException::class); + $this->expectExceptionMessage('Value must be xml element'); + $matcher = new StringValue('example value'); + $matcher->setGenerator(new RandomString()); + $formatter = new XmlElementFormatter(); + $formatter->format($matcher); + } + + /** + * @testWith [null, {"pact:matcher:type": "type", "value": {"name": "test", "children": [], "attributes": []}}] + * [123, {"pact:matcher:type": "type", "value": {"name": "test", "children": [], "attributes": []}, "examples": 123}] + */ + public function testFormat(?int $examples, array $result): void + { + $value = new XmlElement( + fn (XmlElement $element) => $element->setName('test'), + fn (XmlElement $element) => $element->setExamples($examples), + ); + $matcher = new Type($value); + $formatter = new XmlElementFormatter(); + $this->assertSame(json_encode($result), json_encode($formatter->format($matcher))); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/DateTest.php b/tests/PhpPact/Consumer/Matcher/Generators/DateTest.php new file mode 100644 index 00000000..1b0b2dfa --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/DateTest.php @@ -0,0 +1,29 @@ +assertSame('Date', $generator->getType()); + } + + /** + * @testWith [null, null, []] + * ["yyyy-MM-dd", null, {"format":"yyyy-MM-dd"}] + * [null, "+1 day", {"expression":"+1 day"}] + * ["yyyy-MM-dd", "+1 day", {"format":"yyyy-MM-dd","expression":"+1 day"}] + */ + public function testAttributes(?string $format, ?string $expression, array $data): void + { + $generator = new Date($format, $expression); + $attributes = $generator->getAttributes(); + $this->assertSame($generator, $attributes->getParent()); + $this->assertSame($data, $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/DateTimeTest.php b/tests/PhpPact/Consumer/Matcher/Generators/DateTimeTest.php new file mode 100644 index 00000000..57add126 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/DateTimeTest.php @@ -0,0 +1,29 @@ +assertSame('DateTime', $generator->getType()); + } + + /** + * @testWith [null, null, []] + * ["yyyy-MM-dd'T'HH:mm:ss", null, {"format":"yyyy-MM-dd'T'HH:mm:ss"}] + * [null, "+1 day", {"expression":"+1 day"}] + * ["yyyy-MM-dd'T'HH:mm:ss", "+1 day", {"format":"yyyy-MM-dd'T'HH:mm:ss","expression":"+1 day"}] + */ + public function testAttributes(?string $format, ?string $expression, array $data): void + { + $generator = new DateTime($format, $expression); + $attributes = $generator->getAttributes(); + $this->assertSame($generator, $attributes->getParent()); + $this->assertSame($data, $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/MockServerURLTest.php b/tests/PhpPact/Consumer/Matcher/Generators/MockServerURLTest.php new file mode 100644 index 00000000..de6dbfde --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/MockServerURLTest.php @@ -0,0 +1,29 @@ +generator = new MockServerURL('.*(/path)$', 'http://localhost:1234/path'); + } + + public function testType(): void + { + $this->assertSame('MockServerURL', $this->generator->getType()); + } + + public function testAttributes(): void + { + $attributes = $this->generator->getAttributes(); + $this->assertSame($this->generator, $attributes->getParent()); + $this->assertSame(['regex' => '.*(/path)$', 'example' => 'http://localhost:1234/path'], $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/ProviderStateTest.php b/tests/PhpPact/Consumer/Matcher/Generators/ProviderStateTest.php new file mode 100644 index 00000000..eaec9144 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/ProviderStateTest.php @@ -0,0 +1,29 @@ +generator = new ProviderState('/products/${id}'); + } + + public function testType(): void + { + $this->assertSame('ProviderState', $this->generator->getType()); + } + + public function testAttributes(): void + { + $attributes = $this->generator->getAttributes(); + $this->assertSame($this->generator, $attributes->getParent()); + $this->assertSame(['expression' => '/products/${id}'], $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/RandomBooleanTest.php b/tests/PhpPact/Consumer/Matcher/Generators/RandomBooleanTest.php new file mode 100644 index 00000000..5bd8e790 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/RandomBooleanTest.php @@ -0,0 +1,29 @@ +generator = new RandomBoolean(); + } + + public function testType(): void + { + $this->assertSame('RandomBoolean', $this->generator->getType()); + } + + public function testAttributes(): void + { + $attributes = $this->generator->getAttributes(); + $this->assertSame($this->generator, $attributes->getParent()); + $this->assertSame([], $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/RandomDecimalTest.php b/tests/PhpPact/Consumer/Matcher/Generators/RandomDecimalTest.php new file mode 100644 index 00000000..3b0b354e --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/RandomDecimalTest.php @@ -0,0 +1,29 @@ +generator = new RandomDecimal(12); + } + + public function testType(): void + { + $this->assertSame('RandomDecimal', $this->generator->getType()); + } + + public function testAttributes(): void + { + $attributes = $this->generator->getAttributes(); + $this->assertSame($this->generator, $attributes->getParent()); + $this->assertSame(['digits' => 12], $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/RandomHexadecimalTest.php b/tests/PhpPact/Consumer/Matcher/Generators/RandomHexadecimalTest.php new file mode 100644 index 00000000..dd95abc9 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/RandomHexadecimalTest.php @@ -0,0 +1,29 @@ +generator = new RandomHexadecimal(8); + } + + public function testType(): void + { + $this->assertSame('RandomHexadecimal', $this->generator->getType()); + } + + public function testAttributes(): void + { + $attributes = $this->generator->getAttributes(); + $this->assertSame($this->generator, $attributes->getParent()); + $this->assertSame(['digits' => 8], $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/RandomIntTest.php b/tests/PhpPact/Consumer/Matcher/Generators/RandomIntTest.php new file mode 100644 index 00000000..ef8b8c6e --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/RandomIntTest.php @@ -0,0 +1,29 @@ +generator = new RandomInt(5, 15); + } + + public function testType(): void + { + $this->assertSame('RandomInt', $this->generator->getType()); + } + + public function testAttributes(): void + { + $attributes = $this->generator->getAttributes(); + $this->assertSame($this->generator, $attributes->getParent()); + $this->assertSame(['min' => 5, 'max' => 15], $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/RandomStringTest.php b/tests/PhpPact/Consumer/Matcher/Generators/RandomStringTest.php new file mode 100644 index 00000000..5afdb898 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/RandomStringTest.php @@ -0,0 +1,29 @@ +generator = new RandomString(11); + } + + public function testType(): void + { + $this->assertSame('RandomString', $this->generator->getType()); + } + + public function testAttributes(): void + { + $attributes = $this->generator->getAttributes(); + $this->assertSame($this->generator, $attributes->getParent()); + $this->assertSame(['size' => 11], $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/RegexTest.php b/tests/PhpPact/Consumer/Matcher/Generators/RegexTest.php new file mode 100644 index 00000000..35a1c158 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/RegexTest.php @@ -0,0 +1,29 @@ +generator = new Regex('[\w\d]+'); + } + + public function testType(): void + { + $this->assertSame('Regex', $this->generator->getType()); + } + + public function testAttributes(): void + { + $attributes = $this->generator->getAttributes(); + $this->assertSame($this->generator, $attributes->getParent()); + $this->assertSame(['regex' => '[\w\d]+'], $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/TimeTest.php b/tests/PhpPact/Consumer/Matcher/Generators/TimeTest.php new file mode 100644 index 00000000..f0a887d1 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/TimeTest.php @@ -0,0 +1,29 @@ +assertSame('Time', $generator->getType()); + } + + /** + * @testWith [null, null, []] + * ["HH:mm:ss", null, {"format":"HH:mm:ss"}] + * [null, "+1 hour", {"expression":"+1 hour"}] + * ["HH:mm:ss", "+1 hour", {"format":"HH:mm:ss","expression":"+1 hour"}] + */ + public function testAttributes(?string $format, ?string $expression, array $data): void + { + $generator = new Time($format, $expression); + $attributes = $generator->getAttributes(); + $this->assertSame($generator, $attributes->getParent()); + $this->assertSame($data, $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Generators/UuidTest.php b/tests/PhpPact/Consumer/Matcher/Generators/UuidTest.php new file mode 100644 index 00000000..313d0a55 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Generators/UuidTest.php @@ -0,0 +1,36 @@ +assertSame('Uuid', $generator->getType()); + } + + /** + * @testWith [null, []] + * ["simple", {"format":"simple"}] + * ["lower-case-hyphenated", {"format":"lower-case-hyphenated"}] + * ["upper-case-hyphenated", {"format":"upper-case-hyphenated"}] + * ["URN", {"format":"URN"}] + * ["invalid", null] + */ + public function testAttributes(?string $format, ?array $data): void + { + if (null === $data) { + $this->expectException(InvalidUuidFormatException::class); + $this->expectExceptionMessage('Format invalid is not supported. Supported formats are: simple, lower-case-hyphenated, upper-case-hyphenated, URN'); + } + $generator = new Uuid($format); + $attributes = $generator->getAttributes(); + $this->assertSame($generator, $attributes->getParent()); + $this->assertSame($data, $attributes->getData()); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/MatcherTest.php b/tests/PhpPact/Consumer/Matcher/MatcherTest.php index 8b72a7ac..5f681635 100644 --- a/tests/PhpPact/Consumer/Matcher/MatcherTest.php +++ b/tests/PhpPact/Consumer/Matcher/MatcherTest.php @@ -2,8 +2,41 @@ namespace PhpPactTest\Consumer\Matcher; -use Exception; +use PhpPact\Consumer\Matcher\Exception\MatcherException; +use PhpPact\Consumer\Matcher\Exception\MatcherNotSupportedException; +use PhpPact\Consumer\Matcher\Formatters\MinimalFormatter; +use PhpPact\Consumer\Matcher\Formatters\ValueOptionalFormatter; +use PhpPact\Consumer\Matcher\Generators\MockServerURL; +use PhpPact\Consumer\Matcher\Generators\ProviderState; +use PhpPact\Consumer\Matcher\Generators\RandomHexadecimal; +use PhpPact\Consumer\Matcher\Generators\Uuid; +use PhpPact\Consumer\Matcher\HttpStatus; use PhpPact\Consumer\Matcher\Matcher; +use PhpPact\Consumer\Matcher\Matchers\ArrayContains; +use PhpPact\Consumer\Matcher\Matchers\Boolean; +use PhpPact\Consumer\Matcher\Matchers\ContentType; +use PhpPact\Consumer\Matcher\Matchers\Date; +use PhpPact\Consumer\Matcher\Matchers\DateTime; +use PhpPact\Consumer\Matcher\Matchers\Decimal; +use PhpPact\Consumer\Matcher\Matchers\EachKey; +use PhpPact\Consumer\Matcher\Matchers\EachValue; +use PhpPact\Consumer\Matcher\Matchers\Equality; +use PhpPact\Consumer\Matcher\Matchers\Includes; +use PhpPact\Consumer\Matcher\Matchers\Integer; +use PhpPact\Consumer\Matcher\Matchers\MatchingField; +use PhpPact\Consumer\Matcher\Matchers\MaxType; +use PhpPact\Consumer\Matcher\Matchers\MinMaxType; +use PhpPact\Consumer\Matcher\Matchers\MinType; +use PhpPact\Consumer\Matcher\Matchers\NotEmpty; +use PhpPact\Consumer\Matcher\Matchers\NullValue; +use PhpPact\Consumer\Matcher\Matchers\Number; +use PhpPact\Consumer\Matcher\Matchers\Regex; +use PhpPact\Consumer\Matcher\Matchers\Semver; +use PhpPact\Consumer\Matcher\Matchers\StatusCode; +use PhpPact\Consumer\Matcher\Matchers\StringValue; +use PhpPact\Consumer\Matcher\Matchers\Time; +use PhpPact\Consumer\Matcher\Matchers\Type; +use PhpPact\Consumer\Matcher\Matchers\Values; use PHPUnit\Framework\TestCase; class MatcherTest extends TestCase @@ -15,156 +48,79 @@ protected function setUp(): void $this->matcher = new Matcher(); } - /** - * @throws Exception - */ - public function testLikeNoValue() + public function testSomethingLike(): void { - $this->expectException(Exception::class); - $this->matcher->like(null); + $this->assertInstanceOf(Type::class, $this->matcher->somethingLike(123)); } - /** - * @throws Exception - */ - public function testLike() + public function testLike(): void { - $json = \json_encode($this->matcher->like(12)); - - $this->assertEquals('{"contents":12,"json_class":"Pact::SomethingLike"}', $json); + $this->assertInstanceOf(Type::class, $this->matcher->like('abc')); } - /** - * @throws Exception - */ - public function testEachLikeStdClass() + public function testEachLike(): void { - $object = new \stdClass(); - $object->value1 = $this->matcher->like(1); - $object->value2 = 2; - - $expected = \json_encode([ - 'contents' => [ - 'value1' => [ - 'contents' => 1, - 'json_class' => 'Pact::SomethingLike', - ], - 'value2' => 2, - ], - 'json_class' => 'Pact::ArrayLike', - 'min' => 1, - ]); - - $actual = \json_encode($this->matcher->eachLike($object, 1)); - - $this->assertEquals($expected, $actual); + $this->assertInstanceOf(MinType::class, $this->matcher->eachLike('test')); } - /** - * @throws Exception - */ - public function testEachLikeArray() + public function testAtLeastLike(): void { - $object = [ - 'value1' => $this->matcher->like(1), - 'value2' => 2, - ]; - - $expected = \json_encode([ - 'contents' => [ - 'value1' => [ - 'contents' => 1, - 'json_class' => 'Pact::SomethingLike', - ], - 'value2' => 2, - ], - 'json_class' => 'Pact::ArrayLike', - 'min' => 1, - ]); - - $actual = \json_encode($this->matcher->eachLike($object, 1)); - - $this->assertEquals($expected, $actual); + $this->assertInstanceOf(MinType::class, $this->matcher->atLeastLike('test', 2)); } - /** - * @throws Exception - */ - public function testRegexNoMatch() + public function testAtMostLike(): void { - $this->expectException(Exception::class); - $this->matcher->regex('SomeWord', 'BadPattern'); + $this->assertInstanceOf(MaxType::class, $this->matcher->atMostLike('test', 2)); } - /** - * @throws Exception - */ - public function testRegex() - { - $expected = [ - 'data' => [ - 'generate' => 'Games', - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => 'Games|Other', - ], - ], - 'json_class' => 'Pact::Term', - ]; + public function testConstrainedArrayLikeCountLessThanMin(): void + { + $this->expectException(MatcherException::class); + $this->expectExceptionMessage('constrainedArrayLike has a minimum of 2 but 1 elements where requested.' . + ' Make sure the count is greater than or equal to the min.'); + $this->matcher->constrainedArrayLike('text', 2, 4, 1); + } - $actual = $this->matcher->regex('Games', 'Games|Other'); + public function testConstrainedArrayLikeCountLargerThanMax(): void + { + $this->expectException(MatcherException::class); + $this->expectExceptionMessage('constrainedArrayLike has a maximum of 5 but 7 elements where requested.' . + ' Make sure the count is less than or equal to the max.'); + $this->matcher->constrainedArrayLike('text', 3, 5, 7); + } - $this->assertEquals($expected, $actual); + public function testConstrainedArrayLike(): void + { + $this->assertInstanceOf(MinMaxType::class, $this->matcher->constrainedArrayLike('test', 2, 4, 3)); } - /** - * @throws Exception - */ - public function testDate() - { - $expected = [ - 'data' => [ - 'generate' => '2010-01-17', - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^([\\+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))?)$', - ], - ], - 'json_class' => 'Pact::Term', - ]; + public function testTerm(): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->term('123', '\d+')); + } - $actual = $this->matcher->dateISO8601('2010-01-17'); + public function testRegex(): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->regex('Games', 'Games|Other')); + } - $this->assertEquals($expected, $actual); + public function testDateISO8601(): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->dateISO8601('2010-01-17')); } /** * @dataProvider dataProviderForTimeTest - * - * @throws Exception */ - public function testTime($time) - { - $expected = [ - 'data' => [ - 'generate' => $time, - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^(T\\d\\d:\\d\\d(:\\d\\d)?(\\.\\d+)?([+-][0-2]\\d(?:|:?[0-5]\\d)|Z)?)$', - ], - ], - 'json_class' => 'Pact::Term', - ]; - - $actual = $this->matcher->timeISO8601($time); - - $this->assertEquals($expected, $actual); + public function testTimeISO8601(string $time): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->timeISO8601($time)); } - public function dataProviderForTimeTest() + /** + * @return string[] + */ + public static function dataProviderForTimeTest(): array { return [ ['T22:44:30.652Z'], @@ -182,29 +138,16 @@ public function dataProviderForTimeTest() /** * @dataProvider dataProviderForDateTimeTest - * - * @throws Exception */ - public function testDateTime($dateTime) - { - $expected = [ - 'data' => [ - 'generate' => $dateTime, - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d([+-][0-2]\\d(?:|:?[0-5]\\d)|Z)?$', - ], - ], - 'json_class' => 'Pact::Term', - ]; - - $actual = $this->matcher->dateTimeISO8601($dateTime); - - $this->assertEquals($expected, $actual); + public function testDateTimeISO8601(string $dateTime): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->dateTimeISO8601($dateTime)); } - public function dataProviderForDateTimeTest() + /** + * @return string[] + */ + public static function dataProviderForDateTimeTest(): array { return [ ['2015-08-06T16:53:10+01:00'], @@ -220,29 +163,16 @@ public function dataProviderForDateTimeTest() /** * @dataProvider dataProviderForDateTimeWithMillisTest - * - * @throws Exception */ - public function testDateTimeWithMillis($dateTime) - { - $expected = [ - 'data' => [ - 'generate' => $dateTime, - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d\\.\\d{3}([+-][0-2]\\d(?:|:?[0-5]\\d)|Z)?$', - ], - ], - 'json_class' => 'Pact::Term', - ]; - - $actual = $this->matcher->dateTimeWithMillisISO8601($dateTime); - - $this->assertEquals($expected, $actual); + public function testDateTimeWithMillisISO8601(string $dateTime): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->dateTimeWithMillisISO8601($dateTime)); } - public function dataProviderForDateTimeWithMillisTest() + /** + * @return string[] + */ + public static function dataProviderForDateTimeWithMillisTest(): array { return [ ['2015-08-06T16:53:10.123+01:00'], @@ -256,156 +186,229 @@ public function dataProviderForDateTimeWithMillisTest() ]; } - /** - * @throws Exception - */ - public function testTimestampRFC3339() - { - $expected = [ - 'data' => [ - 'generate' => 'Mon, 31 Oct 2016 15:21:41 -0400', - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\\s\\d{2}\\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s\\d{4}\\s\\d{2}:\\d{2}:\\d{2}\\s(\\+|-)\\d{4}$', - ], - ], - 'json_class' => 'Pact::Term', - ]; + public function testTimestampRFC3339(): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->timestampRFC3339('Mon, 31 Oct 2016 15:21:41 -0400')); + } - $actual = $this->matcher->timestampRFC3339('Mon, 31 Oct 2016 15:21:41 -0400'); + public function testInteger(): void + { + $this->assertInstanceOf(Type::class, $this->matcher->integer()); + } - $this->assertEquals($expected, $actual); + public function testBoolean(): void + { + $this->assertInstanceOf(Type::class, $this->matcher->boolean()); } - /** - * @throws Exception - */ - public function testInteger() + public function testDecimal(): void { - $json = \json_encode($this->matcher->integer()); + $this->assertInstanceOf(Type::class, $this->matcher->decimal()); + } - $this->assertEquals('{"contents":13,"json_class":"Pact::SomethingLike"}', $json); + public function testIntegerV3(): void + { + $this->assertInstanceOf(Integer::class, $this->matcher->integerV3(13)); } - /** - * @throws Exception - */ - public function testBoolean() + public function testBooleanV3(): void { - $json = \json_encode($this->matcher->boolean()); + $this->assertInstanceOf(Boolean::class, $this->matcher->booleanV3(true)); + } - $this->assertEquals('{"contents":true,"json_class":"Pact::SomethingLike"}', $json); + public function testDecimalV3(): void + { + $this->assertInstanceOf(Decimal::class, $this->matcher->decimalV3(13.01)); } /** - * @throws Exception + * @testWith [null, true] + * ["3F", false] */ - public function testDecimal() + public function testHexadecimal(?string $value, bool $hasGenerator): void { - $json = \json_encode($this->matcher->decimal()); - - $this->assertEquals('{"contents":13.01,"json_class":"Pact::SomethingLike"}', $json); + $hexadecimal = $this->matcher->hexadecimal($value); + $this->assertInstanceOf(Regex::class, $hexadecimal); + if ($hasGenerator) { + $this->assertSame(RandomHexadecimal::class, get_class($hexadecimal->getGenerator())); + } else { + $this->assertNull($hexadecimal->getGenerator()); + } } /** - * @throws Exception + * @testWith [null, true] + * ["ce118b6e-d8e1-11e7-9296-cec278b6b50a", false] */ - public function testHexadecimal() - { - $expected = [ - 'data' => [ - 'generate' => '3F', - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^[0-9a-fA-F]+$', - ], - ], - 'json_class' => 'Pact::Term', - ]; + public function testUuid(?string $value, bool $hasGenerator): void + { + $uuid = $this->matcher->uuid($value); + $this->assertInstanceOf(Regex::class, $uuid); + if ($hasGenerator) { + $this->assertSame(Uuid::class, get_class($uuid->getGenerator())); + } else { + $this->assertNull($uuid->getGenerator()); + } + } - $this->assertEquals($expected, $this->matcher->hexadecimal()); + public function testIpv4Address(): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->ipv4Address()); } - /** - * @throws Exception - */ - public function testUuid() - { - $expected = [ - 'data' => [ - 'generate' => 'ce118b6e-d8e1-11e7-9296-cec278b6b50a', - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$', - ], - ], - 'json_class' => 'Pact::Term', - ]; + public function testIpv6Address(): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->ipv6Address()); + } - $this->assertEquals($expected, $this->matcher->uuid()); + public function testEmail(): void + { + $this->assertInstanceOf(Regex::class, $this->matcher->email()); } - /** - * @throws Exception - */ - public function testIpv4Address() - { - $expected = [ - 'data' => [ - 'generate' => '127.0.0.13', - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^(\\d{1,3}\\.)+\\d{1,3}$', - ], - ], - 'json_class' => 'Pact::Term', - ]; + public function testNullValue(): void + { + $this->assertInstanceOf(NullValue::class, $this->matcher->nullValue()); + } - $this->assertEquals($expected, $this->matcher->ipv4Address()); + public function testDate(): void + { + $this->assertInstanceOf(Date::class, $this->matcher->date('yyyy-MM-dd', '2022-11-21')); } - /** - * @throws Exception - */ - public function testIpv6Address() - { - $expected = [ - 'data' => [ - 'generate' => '::ffff:192.0.2.128', - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$', - ], - ], - 'json_class' => 'Pact::Term', - ]; + public function testTime(): void + { + $this->assertInstanceOf(Time::class, $this->matcher->time('HH:mm:ss', '21:45::31')); + } + + public function testDateTime(): void + { + $this->assertInstanceOf(DateTime::class, $this->matcher->datetime("yyyy-MM-dd'T'HH:mm:ss", '2015-08-06T16:53:10')); + } + + public function testString(): void + { + $this->assertInstanceOf(StringValue::class, $this->matcher->string('test string')); + } + + public function testFromProviderStateMatcherNotSupport(): void + { + $this->expectException(MatcherNotSupportedException::class); + $this->expectExceptionMessage("Matcher 'type' must be generator aware"); + $this->matcher->fromProviderState(new Type('text'), '${text}'); + } + + public function testFromProviderState(): void + { + $uuid = $this->matcher->uuid(); + $this->assertInstanceOf(Regex::class, $uuid); + $this->assertSame(Uuid::class, get_class($uuid->getGenerator())); + $this->assertSame($uuid, $this->matcher->fromProviderState($uuid, '${id}')); + $this->assertSame(ProviderState::class, get_class($uuid->getGenerator())); + } + + public function testEqual(): void + { + $this->assertInstanceOf(Equality::class, $this->matcher->equal('test string')); + } + + public function testIncludes(): void + { + $this->assertInstanceOf(Includes::class, $this->matcher->includes('test string')); + } - $this->assertEquals($expected, $this->matcher->ipv6Address()); + public function testNumber(): void + { + $this->assertInstanceOf(Number::class, $this->matcher->number(13.01)); + } + + public function testArrayContaining(): void + { + $this->assertInstanceOf(ArrayContains::class, $this->matcher->arrayContaining([ + 'item 1', + 'item 2' + ])); + } + + public function testNotEmpty(): void + { + $this->assertInstanceOf(NotEmpty::class, $this->matcher->notEmpty('not empty string')); + } + + public function testSemver(): void + { + $this->assertInstanceOf(Semver::class, $this->matcher->semver('1.2.3')); + } + + public function testValidStatusCode(): void + { + $this->assertInstanceOf(StatusCode::class, $this->matcher->statusCode(HttpStatus::SUCCESS)); + } + + public function testValues(): void + { + $this->assertInstanceOf(Values::class, $this->matcher->values([ + 'item 1', + 'item 2' + ])); + } + + public function testContentType(): void + { + $this->assertInstanceOf(ContentType::class, $this->matcher->contentType('image/jpeg')); } + public function testEachKey(): void + { + $values = [ + 'page 1' => 'Hello', + 'page 2' => 'World', + ]; + $rules = [ + $this->matcher->regex('page 3', '^page \d+$'), + ]; + $this->assertInstanceOf(EachKey::class, $this->matcher->eachKey($values, $rules)); + } + + public function testEachValue(): void + { + $values = [ + 'vehicle 1' => 'car', + 'vehicle 2' => 'bike', + 'vehicle 3' => 'motorbike' + ]; + $rules = [ + $this->matcher->regex('car', 'car|bike|motorbike'), + ]; + $this->assertInstanceOf(EachValue::class, $this->matcher->eachValue($values, $rules)); + } /** - * @throws Exception + * @testWith [true, true] + * [false, false] */ - public function testEmail() - { - - $expected = [ - 'data' => [ - 'generate' => 'hello@pact.io', - 'matcher' => [ - 'json_class' => 'Regexp', - 'o' => 0, - 's' => '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$', - ], - ], - 'json_class' => 'Pact::Term', - ]; - $this->assertEquals($expected, $this->matcher->email()); + public function testUrl(bool $useMockServerBasePath, bool $hasGenerator): void + { + $url = $this->matcher->url('http://localhost:1234/path', '.*(/path)$', $useMockServerBasePath); + $this->assertInstanceOf(Regex::class, $url); + if ($hasGenerator) { + $this->assertSame(MockServerURL::class, get_class($url->getGenerator())); + } else { + $this->assertNull($url->getGenerator()); + } + } + + public function testMatchingField(): void + { + $this->assertInstanceOf(MatchingField::class, $this->matcher->matchingField('address')); + } + + public function testWithFormatter(): void + { + $uuid = $this->matcher->uuid(); + $this->assertInstanceOf(ValueOptionalFormatter::class, $uuid->getFormatter()); + $matcher = new Matcher($formatter = new MinimalFormatter()); + $uuid = $matcher->uuid(); + $this->assertSame($formatter, $uuid->getFormatter()); } } diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/ArrayContainsTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/ArrayContainsTest.php new file mode 100644 index 00000000..d611b65b --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/ArrayContainsTest.php @@ -0,0 +1,24 @@ +assertSame( + '{"pact:matcher:type":"arrayContains","variants":[{"pact:matcher:type":"type","value":"string"},{"pact:matcher:type":"integer","pact:generator:type":"RandomInt","min":0,"max":10}]}', + json_encode($array) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/BooleanTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/BooleanTest.php new file mode 100644 index 00000000..edca058e --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/BooleanTest.php @@ -0,0 +1,30 @@ +assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/ContentTypeTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/ContentTypeTest.php new file mode 100644 index 00000000..4670af8c --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/ContentTypeTest.php @@ -0,0 +1,18 @@ +assertJsonStringEqualsJsonString( + '{"value":"text\/csv","pact:matcher:type":"contentType"}', + json_encode($contentType) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/DateTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/DateTest.php new file mode 100644 index 00000000..ea03cebc --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/DateTest.php @@ -0,0 +1,30 @@ +assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/DateTimeTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/DateTimeTest.php new file mode 100644 index 00000000..5c7f4f3e --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/DateTimeTest.php @@ -0,0 +1,30 @@ +assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/DecimalTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/DecimalTest.php new file mode 100644 index 00000000..ce6330e4 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/DecimalTest.php @@ -0,0 +1,29 @@ +assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/EachKeyTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/EachKeyTest.php new file mode 100644 index 00000000..d068e4ab --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/EachKeyTest.php @@ -0,0 +1,31 @@ + 123, + 'def' => 111, + 'ghi' => [ + 'test' => 'value', + ], + ]; + $rules = [ + new Type('string'), + new Regex('\w{3}'), + ]; + $eachKey = new EachKey($value, $rules); + $this->assertJsonStringEqualsJsonString( + '{"pact:matcher:type":"eachKey","value":{"abc":123,"def":111,"ghi":{"test":"value"}},"rules":[{"pact:matcher:type":"type","value":"string"},{"pact:matcher:type":"regex","pact:generator:type":"Regex","regex":"\\\\w{3}"}]}', + json_encode($eachKey) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/EachValueTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/EachValueTest.php new file mode 100644 index 00000000..95dc7fb0 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/EachValueTest.php @@ -0,0 +1,29 @@ +assertJsonStringEqualsJsonString( + '{"pact:matcher:type":"eachValue","value":["ab1","cd2","ef9"],"rules":[{"pact:matcher:type":"type","value":"string"},{"pact:matcher:type":"regex","pact:generator:type":"Regex","regex":"\\\\w{2}\\\\d"}]}', + json_encode($eachValue) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/EqualityTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/EqualityTest.php new file mode 100644 index 00000000..1cffba46 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/EqualityTest.php @@ -0,0 +1,18 @@ +assertSame( + '{"pact:matcher:type":"equality","value":"exact this string"}', + json_encode($string) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/GeneratorAwareMatcherTestCase.php b/tests/PhpPact/Consumer/Matcher/Matchers/GeneratorAwareMatcherTestCase.php new file mode 100644 index 00000000..d309162f --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/GeneratorAwareMatcherTestCase.php @@ -0,0 +1,70 @@ +getMatcherWithoutExampleValue(); + $this->expectException(GeneratorRequiredException::class); + $this->expectExceptionMessage(sprintf("Generator is required for matcher '%s' when example value is not set", $matcher->getType())); + $matcher->setGenerator(null); + json_encode($matcher); + } + + /** + * @dataProvider generatorProvider + */ + public function testGeneratorNotRequired(GeneratorInterface $generator): void + { + $matcher = $this->getMatcherWithExampleValue(); + $this->expectException(GeneratorNotRequiredException::class); + $this->expectExceptionMessage(sprintf("Generator '%s' is not required for matcher '%s' when example value is set", $generator->getType(), $matcher->getType())); + $matcher->setGenerator($generator); + json_encode($matcher); + } + + /** + * @return GeneratorInterface[] + */ + public static function generatorProvider(): array + { + return [ + [new Date()], + [new DateTime()], + [new MockServerURL('.*(/\d+)$', 'http://example.com/123')], + [new ProviderState('${key}')], + [new RandomBoolean()], + [new RandomDecimal()], + [new RandomHexadecimal()], + [new RandomInt()], + [new RandomString()], + [new Regex('\w')], + [new Time()], + [new Uuid()], + ]; + } + + abstract protected function getMatcherWithExampleValue(): GeneratorAwareMatcher; + + abstract protected function getMatcherWithoutExampleValue(): GeneratorAwareMatcher; +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/IncludesTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/IncludesTest.php new file mode 100644 index 00000000..6310f3fe --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/IncludesTest.php @@ -0,0 +1,18 @@ +assertSame( + '{"pact:matcher:type":"include","value":"contains this string"}', + json_encode($string) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/IntegerTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/IntegerTest.php new file mode 100644 index 00000000..b36c74ba --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/IntegerTest.php @@ -0,0 +1,29 @@ +assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/MatchingFieldTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/MatchingFieldTest.php new file mode 100644 index 00000000..d43fd132 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/MatchingFieldTest.php @@ -0,0 +1,53 @@ +setFormatter(new PluginFormatter()); + $this->assertSame( + $json, + json_encode($matcher) + ); + } + + /** + * @dataProvider formatterProvider + */ + public function testNotSupportedFormatter(string $formatterClassName): void + { + $this->expectException(MatcherNotSupportedException::class); + $this->expectExceptionMessage('MatchingField matcher only work with plugin'); + $matcher = new MatchingField('person'); + $matcher->setFormatter(new $formatterClassName()); + json_encode($matcher); + } + + public static function formatterProvider(): array + { + return [ + [MinimalFormatter::class], + [ValueOptionalFormatter::class], + [ValueRequiredFormatter::class], + [XmlContentFormatter::class], + [XmlElementFormatter::class], + ]; + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/MaxTypeTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/MaxTypeTest.php new file mode 100644 index 00000000..2c034c90 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/MaxTypeTest.php @@ -0,0 +1,21 @@ +assertSame( + '{"pact:matcher:type":"type","max":3,"value":["string value"]}', + json_encode($array) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/MinMaxTypeTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/MinMaxTypeTest.php new file mode 100644 index 00000000..49271a75 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/MinMaxTypeTest.php @@ -0,0 +1,22 @@ +assertSame( + '{"pact:matcher:type":"type","min":2,"max":5,"value":[1.23,2.34]}', + json_encode($array) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/MinTypeTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/MinTypeTest.php new file mode 100644 index 00000000..dba58a43 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/MinTypeTest.php @@ -0,0 +1,23 @@ +assertSame( + '{"pact:matcher:type":"type","min":3,"value":[123,34,5]}', + json_encode($array) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/NotEmptyTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/NotEmptyTest.php new file mode 100644 index 00000000..fd5d89f5 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/NotEmptyTest.php @@ -0,0 +1,18 @@ +assertSame( + '{"pact:matcher:type":"notEmpty","value":["some text"]}', + json_encode($array) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/NullValueTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/NullValueTest.php new file mode 100644 index 00000000..4e7906d0 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/NullValueTest.php @@ -0,0 +1,18 @@ +assertSame( + '{"pact:matcher:type":"null"}', + json_encode($null) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/NumberTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/NumberTest.php new file mode 100644 index 00000000..0505623e --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/NumberTest.php @@ -0,0 +1,30 @@ +assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/RegexTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/RegexTest.php new file mode 100644 index 00000000..e3eea53c --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/RegexTest.php @@ -0,0 +1,40 @@ +regex); + } + + protected function getMatcherWithExampleValue(): GeneratorAwareMatcher + { + return new Regex($this->regex, ['1', '23']); + } + + /** + * @testWith [null, "{\"pact:matcher:type\":\"regex\",\"pact:generator:type\":\"Regex\",\"regex\":\"\\\\d+\"}"] + * ["number", null] + * [["integer"], null] + * ["12+", "{\"pact:matcher:type\":\"regex\",\"regex\":\"\\\\d+\",\"value\":\"12+\"}"] + * [["12.3", "456"], "{\"pact:matcher:type\":\"regex\",\"regex\":\"\\\\d+\",\"value\":[\"12.3\",\"456\"]}"] + */ + public function testSerialize(string|array|null $values, ?string $json): void + { + if (!$json && $values) { + $this->expectException(InvalidRegexException::class); + $value = is_array($values) ? $values[0] : $values; + $this->expectExceptionMessage("The pattern '{$this->regex}' is not valid for value '{$value}'. Failed with error code 0."); + } + $matcher = new Regex($this->regex, $values); + $this->assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/SemverTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/SemverTest.php new file mode 100644 index 00000000..4178634d --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/SemverTest.php @@ -0,0 +1,29 @@ +assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/StatusCodeTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/StatusCodeTest.php new file mode 100644 index 00000000..74d6ef97 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/StatusCodeTest.php @@ -0,0 +1,41 @@ +expectException(InvalidHttpStatusException::class); + $this->expectExceptionMessage("Status 'invalid' is not supported. Supported status are: info, success, redirect, clientError, serverError, nonError, error"); + } + $matcher = new StatusCode($status, $value); + $this->assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/StringValueTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/StringValueTest.php new file mode 100644 index 00000000..b9ca2805 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/StringValueTest.php @@ -0,0 +1,26 @@ +matcher = new StringValue(); + } + + /** + * @testWith [null, "{\"pact:matcher:type\":\"type\",\"value\":\"some string\",\"pact:generator:type\":\"RandomString\",\"size\":10}"] + * ["test", "{\"pact:matcher:type\":\"type\",\"value\":\"test\"}"] + */ + public function testSerialize(?string $value, string $json): void + { + $this->matcher = new StringValue($value); + $this->assertJsonStringEqualsJsonString($json, json_encode($this->matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/TimeTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/TimeTest.php new file mode 100644 index 00000000..3a6c7933 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/TimeTest.php @@ -0,0 +1,30 @@ +assertSame($json, json_encode($matcher)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/TypeTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/TypeTest.php new file mode 100644 index 00000000..3501c291 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/TypeTest.php @@ -0,0 +1,19 @@ + 'value']; + $object = new Type($value); + $this->assertSame( + '{"pact:matcher:type":"type","value":{"key":"value"}}', + json_encode($object) + ); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Matchers/ValuesTest.php b/tests/PhpPact/Consumer/Matcher/Matchers/ValuesTest.php new file mode 100644 index 00000000..7b5885c0 --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Matchers/ValuesTest.php @@ -0,0 +1,19 @@ +assertSame($json, json_encode($array)); + } +} diff --git a/tests/PhpPact/Consumer/Matcher/Model/AttributesTest.php b/tests/PhpPact/Consumer/Matcher/Model/AttributesTest.php new file mode 100644 index 00000000..1b8ce95c --- /dev/null +++ b/tests/PhpPact/Consumer/Matcher/Model/AttributesTest.php @@ -0,0 +1,49 @@ +assertSame($generator, $subject->getParent()); + + //$subject = new Attributes($matcher = new NullValue()); + //$this->assertSame($matcher, $subject->getParent()); + } + + public function testData(): void + { + $subject = new Attributes(new RandomBoolean(), $data = ['key' => 'value']); + $this->assertSame($data, $subject->getData()); + $this->assertFalse($subject->has('new key')); + $this->assertNull($subject->get('new key')); + $this->assertTrue($subject->has('key')); + $this->assertSame('value', $subject->get('key')); + } + + public function testMergeConflict(): void + { + $attributes = new Attributes(new RandomBoolean(), ['key' => 'value 1']); + $this->expectException(AttributeConflictException::class); + //$this->expectExceptionMessage("Attribute 'key' of generator 'RandomBoolean' and matcher 'null' are conflict"); + $this->expectExceptionMessage("Attribute 'key' of generator 'RandomBoolean' and generator 'RandomBoolean' are conflict"); + $attributes->merge(new Attributes(new RandomBoolean(), ['key' => 'value 2'])); + } + + public function testMerge(): void + { + $parent = new RandomBoolean(); + $attributes = new Attributes($parent, ['key' => 'value', 'key 2' => 123]); + $merged = $attributes->merge(new Attributes(new RandomBoolean(), ['key' => 'value', 'key 3' => ['value 1', 'value 2']])); + $this->assertSame($parent, $merged->getParent()); + $this->assertSame(['key' => 'value', 'key 2' => 123, 'key 3' => ['value 1', 'value 2']], $merged->getData()); + } +} diff --git a/tests/PhpPact/Consumer/MessageBuilderTest.php b/tests/PhpPact/Consumer/MessageBuilderTest.php new file mode 100644 index 00000000..3cacfd66 --- /dev/null +++ b/tests/PhpPact/Consumer/MessageBuilderTest.php @@ -0,0 +1,228 @@ +driver = $this->createMock(MessageDriverInterface::class); + $this->config = $this->createMock(PactConfigInterface::class); + $this->driverFactory = $this->createMock(MessageDriverFactoryInterface::class); + $this->driverFactory + ->expects($this->once()) + ->method('create') + ->with($this->config) + ->willReturn($this->driver); + $this->builder = new MessageBuilder($this->config, $this->driverFactory); + } + + public function testGiven(): void + { + $this->assertSame($this->builder, $this->builder->given('test', ['key' => 'value'])); + $message = $this->getMessage(); + $providerStates = $message->getProviderStates(); + $this->assertCount(1, $providerStates); + $providerState = $providerStates[0]; + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('test', $providerState->getName()); + $this->assertSame(['key' => 'value'], $providerState->getParams()); + } + + public function testExpectsToReceive(): void + { + $description = 'message description'; + $this->assertSame($this->builder, $this->builder->expectsToReceive($description)); + $message = $this->getMessage(); + $this->assertSame($description, $message->getDescription()); + } + + public function testWithMetadata(): void + { + $metadata = ['key' => 'value']; + $this->assertSame($this->builder, $this->builder->withMetadata($metadata)); + $message = $this->getMessage(); + $this->assertSame($metadata, $message->getMetadata()); + } + + #[TestWith([null , null])] + #[TestWith([new Text('example', 'text/plain') , null])] + #[TestWith([new Binary('/path/to/image.jpg', 'image/jpeg'), null])] + #[TestWith(['example text' , Text::class])] + #[TestWith([['key' => 'value'] , Text::class])] + public function testWithContent(mixed $content, ?string $contentClass): void + { + $this->assertSame($this->builder, $this->builder->withContent($content)); + $message = $this->getMessage(); + if ($contentClass) { + $this->assertInstanceOf($contentClass, $message->getContents()); + } else { + $this->assertSame($content, $message->getContents()); + } + } + + #[TestWith([null])] + #[TestWith(['key'])] + public function testSetKey(?string $key): void + { + $this->assertSame($this->builder, $this->builder->key($key)); + $message = $this->getMessage(); + $this->assertSame($key, $message->getKey()); + } + + #[TestWith([null])] + #[TestWith([false])] + #[TestWith([true])] + public function testSetPending(?bool $pending): void + { + $this->assertSame($this->builder, $this->builder->pending($pending)); + $message = $this->getMessage(); + $this->assertSame($pending, $message->getPending()); + } + + #[TestWith([[]])] + #[TestWith([['key' => 'value']])] + public function testSetComments(array $comments): void + { + $this->assertSame($this->builder, $this->builder->comments($comments)); + $message = $this->getMessage(); + $this->assertSame($comments, $message->getComments()); + } + + public function testSetSingleCallback(): void + { + $callbacks = [ + fn () => 'first', + fn () => 'second', + fn () => 'third', + fn () => 'fourth', + ]; + foreach ($callbacks as $callback) { + $this->assertSame($this->builder, $this->builder->setCallback($callback)); + } + $builderCallbacks = $this->getCallbacks(); + $this->assertSame([end($callbacks)], $builderCallbacks); + } + + public function testSetMultipleCallbacks(): void + { + $callbacks = [ + 'first callback' => fn () => 'first', + 'second callback' => fn () => 'second', + 'third callback' => fn () => 'third', + 'fourth callback' => fn () => 'fourth', + ]; + foreach ($callbacks as $description => $callback) { + $this->assertSame($this->builder, $this->builder->setCallback($callback, $description)); + } + $builderCallbacks = $this->getCallbacks(); + $this->assertSame($callbacks, $builderCallbacks); + } + + public function testReify(): void + { + $jsonMessage = '{"key": "value"}'; + $message = $this->getMessage(); + $this->driver + ->expects($this->once()) + ->method('registerMessage') + ->with($message); + $this->driver + ->expects($this->once()) + ->method('reify') + ->with($message) + ->willReturn($jsonMessage); + $this->assertSame($jsonMessage, $this->builder->reify()); + } + + public function testVerifyWithoutCallback(): void + { + $this->expectException(MissingCallbackException::class); + $this->expectExceptionMessage('Callbacks need to exist to run verify.'); + $this->builder->verify(); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testVerifyMessage(bool $callbackThrowException): void + { + $jsonMessage = '{"key": "value"}'; + $callback = $this->getMockBuilder(stdClass::class) + ->addMethods(['__invoke']) + ->getMock(); + $mocker = $callback + ->expects($this->once()) + ->method('__invoke'); + if ($callbackThrowException) { + $mocker->willThrowException(new Exception('something wrong')); + } + $this->driver + ->expects($this->once()) + ->method('reify') + ->willReturn($jsonMessage); + $this->driver + ->expects($this->exactly(!$callbackThrowException)) + ->method('writePactAndCleanUp'); + $this->assertSame(!$callbackThrowException, $this->builder->verifyMessage($callback, 'a callback')); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testVerify(bool $callbackThrowException): void + { + $jsonMessage = '{"key": "value"}'; + $callback = $this->getMockBuilder(stdClass::class) + ->addMethods(['__invoke']) + ->getMock(); + $mocker = $callback + ->expects($this->once()) + ->method('__invoke'); + if ($callbackThrowException) { + $mocker->willThrowException(new Exception('something wrong')); + } + $this->driver + ->expects($this->once()) + ->method('reify') + ->willReturn($jsonMessage); + $this->driver + ->expects($this->exactly(!$callbackThrowException)) + ->method('writePactAndCleanUp'); + $this->builder->setCallback($callback); + $this->assertSame(!$callbackThrowException, $this->builder->verify()); + } + + private function getMessage(): Message + { + $reflection = new ReflectionProperty($this->builder, 'message'); + + return $reflection->getValue($this->builder); + } + + private function getCallbacks(): array + { + $reflection = new ReflectionProperty($this->builder, 'callback'); + + return $reflection->getValue($this->builder); + } +} diff --git a/tests/PhpPact/Consumer/Model/Body/BinaryTest.php b/tests/PhpPact/Consumer/Model/Body/BinaryTest.php new file mode 100644 index 00000000..9812b01f --- /dev/null +++ b/tests/PhpPact/Consumer/Model/Body/BinaryTest.php @@ -0,0 +1,44 @@ +assertSame('/path/to/file1.jpg', $body->getPath()); + $body->setPath('/other/path/to/file2.jpg'); + $this->assertSame('/other/path/to/file2.jpg', $body->getPath()); + } + + public function testGetContentType(): void + { + $body = new Binary('/path/to/text.txt', 'plain/text'); + $this->assertSame('plain/text', $body->getContentType()); + $body->setContentType('text/csv'); + $this->assertSame('text/csv', $body->getContentType()); + } + + public function testGetDataFromInvalidFilePath(): void + { + $path = __DIR__ . '/../../../../_resources/invalid.jpg'; + $body = new Binary($path, 'image/jpeg'); + $this->expectException(BinaryFileNotExistException::class); + $this->expectExceptionMessage("File $path does not exist"); + $body->getData(); + } + + public function testGetData(): void + { + $path = __DIR__ . '/../../../../_resources/image.jpg'; + $body = new Binary($path, 'image/jpeg'); + $data = $body->getData(); + + $this->assertEquals(file_get_contents($path), (string) $data); + } +} diff --git a/tests/PhpPact/Consumer/Model/Body/MultipartTest.php b/tests/PhpPact/Consumer/Model/Body/MultipartTest.php new file mode 100644 index 00000000..fde002b6 --- /dev/null +++ b/tests/PhpPact/Consumer/Model/Body/MultipartTest.php @@ -0,0 +1,32 @@ +multipart = new Multipart([], '2a8ae6ad'); + } + + public function testParts(): void + { + $this->assertSame([], $this->multipart->getParts()); + $this->multipart->setParts($parts = [ + new Part('/path/to/file1.txt', 'file1', 'text/plain'), + new Part('/path/to/file2.csv', 'file2', 'text/csv'), + ]); + $this->assertSame($parts, $this->multipart->getParts()); + } + + public function testBoundary(): void + { + $this->assertSame('2a8ae6ad', $this->multipart->getBoundary()); + } +} diff --git a/tests/PhpPact/Consumer/Model/Body/PartTest.php b/tests/PhpPact/Consumer/Model/Body/PartTest.php new file mode 100644 index 00000000..9ecdde80 --- /dev/null +++ b/tests/PhpPact/Consumer/Model/Body/PartTest.php @@ -0,0 +1,37 @@ +part = new Part('/path/to/file.txt', 'id', 'text/plain'); + } + + public function testPart(): void + { + $this->assertSame('/path/to/file.txt', $this->part->getPath()); + $this->part->setPath('/other/path/to/file.jpg'); + $this->assertSame('/other/path/to/file.jpg', $this->part->getPath()); + } + + public function testName(): void + { + $this->assertSame('id', $this->part->getName()); + $this->part->setName('profilePicture'); + $this->assertSame('profilePicture', $this->part->getName()); + } + + public function testContentType(): void + { + $this->assertSame('text/plain', $this->part->getContentType()); + $this->part->setContentType('application/json'); + $this->assertSame('application/json', $this->part->getContentType()); + } +} diff --git a/tests/PhpPact/Consumer/Model/Body/TextTest.php b/tests/PhpPact/Consumer/Model/Body/TextTest.php new file mode 100644 index 00000000..c2546a99 --- /dev/null +++ b/tests/PhpPact/Consumer/Model/Body/TextTest.php @@ -0,0 +1,30 @@ +text = new Text('example', 'text/plain'); + } + + public function testContents(): void + { + $this->assertSame('example', $this->text->getContents()); + $this->text->setContents($csv = 'column1,column2,column3'); + $this->assertSame($csv, $this->text->getContents()); + } + + public function testContentType(): void + { + $this->assertSame('text/plain', $this->text->getContentType()); + $this->text->setContentType('application/json'); + $this->assertSame('application/json', $this->text->getContentType()); + } +} diff --git a/tests/PhpPact/Consumer/Model/ConsumerRequestTest.php b/tests/PhpPact/Consumer/Model/ConsumerRequestTest.php index 4e760bbe..b22bff7d 100644 --- a/tests/PhpPact/Consumer/Model/ConsumerRequestTest.php +++ b/tests/PhpPact/Consumer/Model/ConsumerRequestTest.php @@ -3,11 +3,22 @@ namespace PhpPactTest\Consumer\Model; use PhpPact\Consumer\Matcher\Matcher; +use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; +use PhpPact\Consumer\Model\Body\Text; use PhpPact\Consumer\Model\ConsumerRequest; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; class ConsumerRequestTest extends TestCase { + private ConsumerRequest $request; + + public function setUp(): void + { + $this->request = new ConsumerRequest(); + } + public function testSerializing() { $model = new ConsumerRequest(); @@ -15,16 +26,20 @@ public function testSerializing() ->setMethod('PUT') ->setPath('/somepath') ->addHeader('Content-Type', 'application/json') + ->addQueryParameter('fruit', ['apple', 'banana']) ->setBody([ 'currentCity' => 'Austin', ]); - $data = \json_decode(\json_encode($model->jsonSerialize()), true); + $this->assertEquals('PUT', $model->getMethod()); + $this->assertEquals(['Content-Type' => ['application/json']], $model->getHeaders()); + $this->assertEquals(['fruit' => ['apple', 'banana']], $model->getQuery()); + $this->assertEquals('/somepath', $model->getPath()); - $this->assertEquals('PUT', $data['method']); - $this->assertEquals('application/json', $data['headers']['Content-Type']); - $this->assertEquals('/somepath', $data['path']); - $this->assertEquals('Austin', $data['body']['currentCity']); + $body = $model->getBody(); + $this->assertInstanceOf(Text::class, $body); + $this->assertEquals('{"currentCity":"Austin"}', $body->getContents()); + $this->assertEquals('application/json', $body->getContentType()); } public function testSerializingWhenPathUsingMatcher() @@ -36,17 +51,49 @@ public function testSerializingWhenPathUsingMatcher() ->setMethod('PATCH') ->setPath($matcher->regex("/somepath/$pathVariable/status", '\/somepath\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}\/status')) ->addHeader('Content-Type', 'application/json') + ->addQueryParameter('food', 'milk') ->setBody([ 'status' => 'finished', ]); - $data = \json_decode(\json_encode($model->jsonSerialize()), true); + $this->assertEquals('PATCH', $model->getMethod()); + $this->assertEquals(['Content-Type' => ['application/json']], $model->getHeaders()); + $this->assertEquals(['food' => ['milk']], $model->getQuery()); + $this->assertJsonStringEqualsJsonString('{"value":"\/somepath\/474d610b-c6e3-45bd-9f70-529e7ad21df0\/status","regex":"\\\\\\/somepath\\\\\\/[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}\\\\\\/status","pact:matcher:type":"regex"}', $model->getPath()); - $this->assertEquals('PATCH', $data['method']); - $this->assertEquals('application/json', $data['headers']['Content-Type']); - $this->assertIsArray($data['path']); - $this->assertArrayHasKey('data', $data['path']); - $this->assertArrayHasKey('json_class', $data['path']); - $this->assertEquals('finished', $data['body']['status']); + $body = $model->getBody(); + $this->assertInstanceOf(Text::class, $body); + $this->assertEquals('{"status":"finished"}', $body->getContents()); + $this->assertEquals('application/json', $body->getContentType()); + } + + #[TestWith([null])] + #[TestWith([new Text('column1,column2,column3', 'text/csv')])] + #[TestWith([new Binary('/path/to/image.png', 'image/png')])] + #[TestWith([new Multipart([], 'abc123')])] + public function testBody(mixed $body): void + { + $this->assertSame($this->request, $this->request->setBody($body)); + $this->assertSame($body, $this->request->getBody()); + } + + public function testTextBody(): void + { + $text = 'example text'; + $this->assertSame($this->request, $this->request->setBody($text)); + $body = $this->request->getBody(); + $this->assertInstanceOf(Text::class, $body); + $this->assertSame($text, $body->getContents()); + $this->assertSame('text/plain', $body->getContentType()); + } + + public function testJsonBody(): void + { + $array = ['key' => 'value']; + $this->assertSame($this->request, $this->request->setBody($array)); + $body = $this->request->getBody(); + $this->assertInstanceOf(Text::class, $body); + $this->assertSame('{"key":"value"}', $body->getContents()); + $this->assertSame('application/json', $body->getContentType()); } } diff --git a/tests/PhpPact/Consumer/Model/InteractionTest.php b/tests/PhpPact/Consumer/Model/InteractionTest.php new file mode 100644 index 00000000..1458a760 --- /dev/null +++ b/tests/PhpPact/Consumer/Model/InteractionTest.php @@ -0,0 +1,127 @@ +request = new ConsumerRequest(); + $this->response = new ProviderResponse(); + $this->interaction = new Interaction(); + $this->interaction->setRequest($this->request); + $this->interaction->setResponse($this->response); + } + + public function testSetters(): void + { + $handle = 123; + $description = 'a message'; + $providerStateName = 'a provider state'; + $providerStateParams = ['foo' => 'bar']; + + $this->interaction + ->setHandle($handle) + ->setDescription($description) + ->addProviderState($providerStateName, $providerStateParams); + + static::assertSame($handle, $this->interaction->getHandle()); + static::assertSame($description, $this->interaction->getDescription()); + $providerStates = $this->interaction->getProviderStates(); + static::assertCount(1, $providerStates); + static::assertContainsOnlyInstancesOf(ProviderState::class, $providerStates); + static::assertEquals($providerStateName, $providerStates[0]->getName()); + static::assertEquals($providerStateParams, $providerStates[0]->getParams()); + static::assertSame($this->request, $this->interaction->getRequest()); + static::assertSame($this->response, $this->interaction->getResponse()); + } + + public function testGetBody(): void + { + $requestBody = new Multipart([], 'abc123'); + $this->request->setBody($requestBody); + + $responseBody = new Text('example', 'text/plain'); + $this->response->setBody($responseBody); + + $this->assertSame($requestBody, $this->interaction->getBody(InteractionPart::REQUEST)); + $this->assertSame($responseBody, $this->interaction->getBody(InteractionPart::RESPONSE)); + } + + public function testGetHeaders(): void + { + $requestHeaders = ['key1' => ['value1']]; + $this->request->setHeaders($requestHeaders); + + $responseHeaders = ['key2' => ['value1', 'value2']]; + $this->response->setHeaders($responseHeaders); + + $this->assertSame($requestHeaders, $this->interaction->getHeaders(InteractionPart::REQUEST)); + $this->assertSame($responseHeaders, $this->interaction->getHeaders(InteractionPart::RESPONSE)); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testSetProviderState(bool $overwrite): void + { + $this->interaction->setProviderState('provider state 1', ['key 1' => 'value 1'], true); + $providerStates = $this->interaction->setProviderState('provider state 2', ['key 2' => 'value 2'], $overwrite); + if ($overwrite) { + $this->assertCount(1, $providerStates); + $providerState = reset($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 2', $providerState->getName()); + $this->assertSame(['key 2' => 'value 2'], $providerState->getParams()); + } else { + $this->assertCount(2, $providerStates); + $providerState = reset($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 1', $providerState->getName()); + $this->assertSame(['key 1' => 'value 1'], $providerState->getParams()); + $providerState = end($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 2', $providerState->getName()); + $this->assertSame(['key 2' => 'value 2'], $providerState->getParams()); + } + } + + #[TestWith([false])] + #[TestWith([true])] + public function testAddProviderState(bool $overwrite): void + { + $this->assertSame($this->interaction, $this->interaction->addProviderState('provider state 1', ['key 1' => 'value 1'], true)); + $this->assertSame($this->interaction, $this->interaction->addProviderState('provider state 2', ['key 2' => 'value 2'], $overwrite)); + $providerStates = $this->interaction->getProviderStates(); + if ($overwrite) { + $this->assertCount(1, $providerStates); + $providerState = reset($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 2', $providerState->getName()); + $this->assertSame(['key 2' => 'value 2'], $providerState->getParams()); + } else { + $this->assertCount(2, $providerStates); + $providerState = reset($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 1', $providerState->getName()); + $this->assertSame(['key 1' => 'value 1'], $providerState->getParams()); + $providerState = end($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 2', $providerState->getName()); + $this->assertSame(['key 2' => 'value 2'], $providerState->getParams()); + } + } +} diff --git a/tests/PhpPact/Consumer/Model/MessageTest.php b/tests/PhpPact/Consumer/Model/MessageTest.php new file mode 100644 index 00000000..b426a3a7 --- /dev/null +++ b/tests/PhpPact/Consumer/Model/MessageTest.php @@ -0,0 +1,141 @@ +message = new Message(); + } + + public function testSetters() + { + $handle = 123; + $description = 'a message'; + $providerStateName = 'a provider state'; + $providerStateParams = ['foo' => 'bar']; + $metadata = ['queue' => 'foo', 'routing_key' => 'bar']; + $contents = 'test'; + + $subject = $this->message + ->setHandle($handle) + ->setDescription($description) + ->addProviderState($providerStateName, $providerStateParams) + ->setMetadata($metadata) + ->setContents($contents); + + static::assertSame($handle, $subject->getHandle()); + static::assertSame($description, $subject->getDescription()); + $providerStates = $subject->getProviderStates(); + static::assertCount(1, $providerStates); + static::assertContainsOnlyInstancesOf(ProviderState::class, $providerStates); + static::assertEquals($providerStateName, $providerStates[0]->getName()); + static::assertEquals($providerStateParams, $providerStates[0]->getParams()); + static::assertSame($metadata, $subject->getMetadata()); + + $messageContents = $subject->getContents(); + $this->assertInstanceOf(Text::class, $messageContents); + $this->assertEquals($contents, $messageContents->getContents()); + $this->assertEquals('text/plain', $messageContents->getContentType()); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testSetProviderState(bool $overwrite): void + { + $this->message->setProviderState('provider state 1', ['key 1' => 'value 1'], true); + $providerStates = $this->message->setProviderState('provider state 2', ['key 2' => 'value 2'], $overwrite); + if ($overwrite) { + $this->assertCount(1, $providerStates); + $providerState = reset($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 2', $providerState->getName()); + $this->assertSame(['key 2' => 'value 2'], $providerState->getParams()); + } else { + $this->assertCount(2, $providerStates); + $providerState = reset($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 1', $providerState->getName()); + $this->assertSame(['key 1' => 'value 1'], $providerState->getParams()); + $providerState = end($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 2', $providerState->getName()); + $this->assertSame(['key 2' => 'value 2'], $providerState->getParams()); + } + } + + #[TestWith([false])] + #[TestWith([true])] + public function testAddProviderState(bool $overwrite): void + { + $this->assertSame($this->message, $this->message->addProviderState('provider state 1', ['key 1' => 'value 1'], true)); + $this->assertSame($this->message, $this->message->addProviderState('provider state 2', ['key 2' => 'value 2'], $overwrite)); + $providerStates = $this->message->getProviderStates(); + if ($overwrite) { + $this->assertCount(1, $providerStates); + $providerState = reset($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 2', $providerState->getName()); + $this->assertSame(['key 2' => 'value 2'], $providerState->getParams()); + } else { + $this->assertCount(2, $providerStates); + $providerState = reset($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 1', $providerState->getName()); + $this->assertSame(['key 1' => 'value 1'], $providerState->getParams()); + $providerState = end($providerStates); + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('provider state 2', $providerState->getName()); + $this->assertSame(['key 2' => 'value 2'], $providerState->getParams()); + } + } + + #[TestWith([null])] + #[TestWith([new Text('column1,column2,column3', 'text/csv')])] + #[TestWith([new Binary('/path/to/image.png', 'image/png')])] + public function testContents(mixed $contents): void + { + $this->assertSame($this->message, $this->message->setContents($contents)); + $this->assertSame($contents, $this->message->getContents()); + } + + public function testTextContents(): void + { + $text = 'example text'; + $this->assertSame($this->message, $this->message->setContents($text)); + $contents = $this->message->getContents(); + $this->assertInstanceOf(Text::class, $contents); + $this->assertSame($text, $contents->getContents()); + $this->assertSame('text/plain', $contents->getContentType()); + } + + public function testJsonContents(): void + { + $array = ['key' => 'value']; + $this->assertSame($this->message, $this->message->setContents($array)); + $contents = $this->message->getContents(); + $this->assertInstanceOf(Text::class, $contents); + $this->assertSame('{"key":"value"}', $contents->getContents()); + $this->assertSame('application/json', $contents->getContentType()); + } + + public function testMultipartContents(): void + { + $this->expectException(BodyNotSupportedException::class); + $this->expectExceptionMessage('Message does not support multipart'); + $multipart = new Multipart([], 'abc123'); + $this->message->setContents($multipart); + } +} diff --git a/tests/PhpPact/Consumer/Model/ProviderResponseTest.php b/tests/PhpPact/Consumer/Model/ProviderResponseTest.php index 49e2c7f1..d7517ad8 100644 --- a/tests/PhpPact/Consumer/Model/ProviderResponseTest.php +++ b/tests/PhpPact/Consumer/Model/ProviderResponseTest.php @@ -2,11 +2,22 @@ namespace PhpPactTest\Consumer\Model; +use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; +use PhpPact\Consumer\Model\Body\Text; use PhpPact\Consumer\Model\ProviderResponse; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; class ProviderResponseTest extends TestCase { + private ProviderResponse $response; + + public function setUp(): void + { + $this->response = new ProviderResponse(); + } + public function testSerializing() { $model = new ProviderResponse(); @@ -17,10 +28,42 @@ public function testSerializing() 'currentCity' => 'Austin', ]); - $data = \json_decode(\json_encode($model->jsonSerialize()), true); + $this->assertEquals(200, $model->getStatus()); + $this->assertEquals(['Content-Type' => ['application/json']], $model->getHeaders()); - $this->assertEquals(200, $data['status']); - $this->assertEquals('application/json', $data['headers']['Content-Type']); - $this->assertEquals('Austin', $data['body']['currentCity']); + $body = $model->getBody(); + $this->assertInstanceOf(Text::class, $body); + $this->assertEquals('{"currentCity":"Austin"}', $body->getContents()); + $this->assertEquals('application/json', $body->getContentType()); + } + + #[TestWith([null])] + #[TestWith([new Text('column1,column2,column3', 'text/csv')])] + #[TestWith([new Binary('/path/to/image.png', 'image/png')])] + #[TestWith([new Multipart([], 'abc123')])] + public function testBody(mixed $body): void + { + $this->assertSame($this->response, $this->response->setBody($body)); + $this->assertSame($body, $this->response->getBody()); + } + + public function testTextBody(): void + { + $text = 'example text'; + $this->assertSame($this->response, $this->response->setBody($text)); + $body = $this->response->getBody(); + $this->assertInstanceOf(Text::class, $body); + $this->assertSame($text, $body->getContents()); + $this->assertSame('text/plain', $body->getContentType()); + } + + public function testJsonBody(): void + { + $array = ['key' => 'value']; + $this->assertSame($this->response, $this->response->setBody($array)); + $body = $this->response->getBody(); + $this->assertInstanceOf(Text::class, $body); + $this->assertSame('{"key":"value"}', $body->getContents()); + $this->assertSame('application/json', $body->getContentType()); } } diff --git a/tests/PhpPact/Consumer/Service/MockServerTest.php b/tests/PhpPact/Consumer/Service/MockServerTest.php new file mode 100644 index 00000000..7e668199 --- /dev/null +++ b/tests/PhpPact/Consumer/Service/MockServerTest.php @@ -0,0 +1,150 @@ +client = $this->createMock(ClientInterface::class); + $this->pactDriver = $this->createMock(PactDriverInterface::class); + $this->config = new MockServerConfig(); + $this->mockServer = new MockServer($this->client, $this->pactDriver, $this->config); + } + + #[TestWith([234, true])] + #[TestWith([234, false])] + #[TestWith([0, true])] + #[TestWith([-1, true])] + #[TestWith([-2, true])] + #[TestWith([-3, true])] + #[TestWith([-4, true])] + #[TestWith([-5, true])] + #[TestWith([-6, true])] + public function testStart(int $returnedPort, bool $secure): void + { + $this->config->setHost($this->host); + $this->config->setPort($this->port); + $this->config->setSecure($secure); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_create_mock_server_for_transport', $this->pactHandle, $this->host, $this->port, $this->getTransport($secure), null, $returnedPort], + ]; + $this->assertClientCalls($calls); + if ($returnedPort < 0) { + $this->expectException(MockServerNotStartedException::class); + $this->expectExceptionMessage(match ($returnedPort) { + -1 => 'An invalid handle was received. Handles should be created with `pactffi_new_pact`', + -2 => 'Transport_config is not valid JSON', + -3 => 'The mock server could not be started', + -4 => 'The method panicked', + -5 => 'The address is not valid', + default => 'Unknown error', + }); + } + $this->mockServer->start(); + $this->assertSame($returnedPort, $this->config->getPort()); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testVerify(bool $matched): void + { + $this->config->setPort($this->port); + $this->config->setPactDir($this->pactDir); + $calls = $matched ? [ + ['pactffi_mock_server_matched', $this->port, $matched], + ['pactffi_write_pact_file', $this->port, $this->pactDir, false, 0], + ['pactffi_cleanup_mock_server', $this->port, null], + ] : [ + ['pactffi_mock_server_matched', $this->port, $matched], + ['pactffi_mock_server_mismatches', $this->port, FFI::new('char[1]')], + ['pactffi_cleanup_mock_server', $this->port, null], + ]; + $this->assertClientCalls($calls); + $this->pactDriver + ->expects($this->once()) + ->method('cleanUp'); + $result = $this->mockServer->verify(); + $this->assertSame($matched, $result->matched); + $this->assertSame('', $result->mismatches); + } + + #[TestWith([0, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([1, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([2, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([3, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([4, PactConfigInterface::MODE_OVERWRITE])] + #[TestWith([0, PactConfigInterface::MODE_MERGE])] + #[TestWith([1, PactConfigInterface::MODE_MERGE])] + #[TestWith([2, PactConfigInterface::MODE_MERGE])] + #[TestWith([3, PactConfigInterface::MODE_MERGE])] + #[TestWith([4, PactConfigInterface::MODE_MERGE])] + public function testWritePact(int $error, string $writeMode): void + { + $this->config->setPort($this->port); + $this->config->setPactDir($this->pactDir); + $this->config->setPactFileWriteMode($writeMode); + $calls = [ + ['pactffi_write_pact_file', $this->port, $this->pactDir, $writeMode === PactConfigInterface::MODE_OVERWRITE, $error], + ]; + $this->assertClientCalls($calls); + if ($error) { + $this->expectException(MockServerPactFileNotWrittenException::class); + $this->expectExceptionMessage(match ($error) { + 1 => 'A general panic was caught', + 2 => 'The pact file was not able to be written', + 3 => 'A mock server with the provided port was not found', + default => 'Unknown error', + }); + } + $this->mockServer->writePact(); + } + + public function testCleanUp(): void + { + $this->config->setPort($this->port); + $calls = [ + ['pactffi_cleanup_mock_server', $this->port, null], + ]; + $this->assertClientCalls($calls); + $this->pactDriver + ->expects($this->once()) + ->method('cleanUp'); + $this->mockServer->cleanUp(); + } + + protected function getTransport(bool $secure): string + { + return $secure ? 'https' : 'http'; + } +} diff --git a/tests/PhpPact/FFI/ClientTest.php b/tests/PhpPact/FFI/ClientTest.php new file mode 100644 index 00000000..9aca443c --- /dev/null +++ b/tests/PhpPact/FFI/ClientTest.php @@ -0,0 +1,31 @@ +client = new Client(); + } + + public function testGet(): void + { + $this->assertSame(5, $this->client->get('LevelFilter_Trace')); + } + + #[TestWith(['abc123', true])] + #[TestWith(['testing', false])] + public function testCall(string $example, bool $result): void + { + $this->assertSame($result, $this->client->call('pactffi_check_regex', '\w{3}\d+', $example)); + } +} diff --git a/tests/PhpPact/FFI/Model/ArrayDataTest.php b/tests/PhpPact/FFI/Model/ArrayDataTest.php new file mode 100644 index 00000000..3de17e15 --- /dev/null +++ b/tests/PhpPact/FFI/Model/ArrayDataTest.php @@ -0,0 +1,26 @@ +assertNull(ArrayData::createFrom([])); + } + + public function testCreateFromArray() + { + $branches = ['feature-x', 'master', 'test', 'prod']; + $arrayData = ArrayData::createFrom($branches); + + $this->assertSame(count($branches), $arrayData->getSize()); + foreach ($branches as $index => $branch) { + $this->assertSame($branch, FFI::string($arrayData->getItems()[$index])); + } + } +} diff --git a/tests/PhpPact/FFI/Model/BinaryDataTest.php b/tests/PhpPact/FFI/Model/BinaryDataTest.php new file mode 100644 index 00000000..f86e5559 --- /dev/null +++ b/tests/PhpPact/FFI/Model/BinaryDataTest.php @@ -0,0 +1,31 @@ +expectException(EmptyBinaryFileNotSupportedException::class); + BinaryData::createFrom(''); + } + + public function testCreateBinaryString(): void + { + $path = __DIR__ . '/../../../_resources/image.jpg'; + $contents = file_get_contents($path); + $length = \strlen($contents); + + $binaryData = BinaryData::createFrom($contents); + $cData = $binaryData->getValue(); + + $this->assertSame($length, FFI::sizeof($cData)); + $this->assertSame($length, $binaryData->getSize()); + $this->assertEquals($contents, (string) $binaryData); + } +} diff --git a/tests/PhpPact/Helper/AbstractProcess.php b/tests/PhpPact/Helper/AbstractProcess.php new file mode 100644 index 00000000..3f6a36e6 --- /dev/null +++ b/tests/PhpPact/Helper/AbstractProcess.php @@ -0,0 +1,47 @@ +process = $this->getProcess(); + } + + public function start(): void + { + if ($this->process->isRunning()) { + return; + } + if (($logLevel = \getenv('PACT_LOGLEVEL')) && !in_array(\strtoupper($logLevel), ['OFF', 'NONE'])) { + $callback = function (string $type, string $buffer): void { + echo "\n$type > $buffer"; + }; + } + $this->process->start($callback ?? null); + $this->process->waitUntil(function (): bool { + $fp = @fsockopen('127.0.0.1', $this->getPort()); + $isOpen = \is_resource($fp); + if ($isOpen) { + \fclose($fp); + } + + return $isOpen; + }); + } + + public function stop(): void + { + $this->process->stop(); + } + + abstract public function getPort(): int; + + abstract protected function getProcess(): Process; +} diff --git a/tests/PhpPact/Helper/Exception/HelperException.php b/tests/PhpPact/Helper/Exception/HelperException.php new file mode 100644 index 00000000..36c6d060 --- /dev/null +++ b/tests/PhpPact/Helper/Exception/HelperException.php @@ -0,0 +1,9 @@ +client + ->expects($this->exactly(count($calls))) + ->method('call') + ->willReturnCallback(function (...$args) use (&$calls) { + $call = array_shift($calls); + $return = array_pop($call); + foreach ($args as $key => $arg) { + $this->assertThat($arg, $call[$key] instanceof Constraint ? $call[$key] : new IsIdentical($call[$key])); + } + + return $return; + }); + } +} diff --git a/tests/PhpPact/Helper/FactoryTrait.php b/tests/PhpPact/Helper/FactoryTrait.php new file mode 100644 index 00000000..7b2c1cb0 --- /dev/null +++ b/tests/PhpPact/Helper/FactoryTrait.php @@ -0,0 +1,20 @@ + $properties + */ + private function assertPropertiesInstanceOf(object $object, ?string $class, array $properties): void + { + foreach ($properties as $property => $propertyClass) { + $reflection = new ReflectionProperty($class ?? $object, $property); + $value = $reflection->getValue($object); + $this->assertInstanceOf($propertyClass, $value); + } + } +} diff --git a/tests/PhpPact/Helper/PhpProcess.php b/tests/PhpPact/Helper/PhpProcess.php new file mode 100644 index 00000000..ea7fa27e --- /dev/null +++ b/tests/PhpPact/Helper/PhpProcess.php @@ -0,0 +1,41 @@ +port) { + $this->port = $this->findAvailablePort(); + } + + return $this->port; + } + + protected function getProcess(): Process + { + return new Process(['php', '-S', '127.0.0.1:' . $this->getPort(), '-t', $this->publicPath]); + } + + private function findAvailablePort(): int + { + $socket = \socket_create_listen(0); + \socket_getsockname($socket, $addr, $port); + \socket_close($socket); + + if (!$port) { + throw new NoPortAvailableException(); + } + + return $port; + } +} diff --git a/tests/PhpPact/Plugin/Driver/Body/PluginBodyDriverTest.php b/tests/PhpPact/Plugin/Driver/Body/PluginBodyDriverTest.php new file mode 100644 index 00000000..7bd4322b --- /dev/null +++ b/tests/PhpPact/Plugin/Driver/Body/PluginBodyDriverTest.php @@ -0,0 +1,232 @@ +client = $this->createMock(ClientInterface::class); + $this->client + ->expects($this->once()) + ->method('get') + ->willReturnMap([ + ['InteractionPart_Request', $this->requestPartId], + ['InteractionPart_Response', $this->responsePartId], + ]); + $this->driver = new PluginBodyDriver($this->client); + $this->interaction = new Interaction(); + $this->interaction->setHandle($this->interactionId); + $this->interaction->setRequest(new ConsumerRequest()); + $this->interaction->setResponse(new ProviderResponse()); + $this->message = new Message(); + $this->message->setHandle($this->messageId); + $this->binary = new Binary(__DIR__ . '/../../../../_resources/image.jpg', 'image/jpeg'); + $this->text = new Text('example', 'text/plain'); + $this->json = new Text('{}', 'application/json'); + $this->multipart = new Multipart([], 'abcde12345'); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testInteractionBinaryBody(InteractionPart $part): void + { + if ($part === InteractionPart::REQUEST) { + $this->interaction->getRequest()->setBody($this->binary); + } else { + $this->interaction->getResponse()->setBody($this->binary); + } + $this->client + ->expects($this->never()) + ->method('call'); + $this->expectException(BodyNotSupportedException::class); + $this->expectExceptionMessage('Plugin does not support binary body'); + $this->driver->registerBody($this->interaction, $part); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testMessageBinaryBody(InteractionPart $part): void + { + $this->message->setContents($this->binary); + $this->client + ->expects($this->never()) + ->method('call'); + $this->expectException(BodyNotSupportedException::class); + $this->expectExceptionMessage('Plugin does not support binary body'); + $this->driver->registerBody($this->message, $part); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testInteractionPlainTextBody(InteractionPart $part): void + { + if ($part === InteractionPart::REQUEST) { + $this->interaction->getRequest()->setBody($this->text); + } else { + $this->interaction->getResponse()->setBody($this->text); + } + $this->client + ->expects($this->never()) + ->method('call'); + $this->expectException(BodyNotSupportedException::class); + $this->expectExceptionMessage('Plugin only support json body contents'); + $this->driver->registerBody($this->interaction, $part); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testMessagePlainTextBody(InteractionPart $part): void + { + $this->message->setContents($this->text); + $this->client + ->expects($this->never()) + ->method('call'); + $this->expectException(BodyNotSupportedException::class); + $this->expectExceptionMessage('Plugin only support json body contents'); + $this->driver->registerBody($this->message, $part); + } + + private function getPluginBodyErrorMessage(int $error): string + { + return match ($error) { + 1 => 'A general panic was caught.', + 2 => 'The mock server has already been started.', + 3 => 'The interaction handle is invalid.', + 4 => 'The content type is not valid.', + 5 => 'The contents JSON is not valid JSON.', + 6 => 'The plugin returned an error.', + default => 'Unknown error', + }; + } + + #[DataProvider('errorProvider')] + public function testRequestJsonBody(int $error): void + { + $this->interaction->getRequest()->setBody($this->json); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_interaction_contents', $this->interactionId, $this->requestPartId, $this->json->getContentType(), $this->json->getContents()) + ->willReturn($error); + if ($error) { + $this->expectException(PluginBodyNotAddedException::class); + $this->expectExceptionMessage($this->getPluginBodyErrorMessage($error)); + } + $this->driver->registerBody($this->interaction, InteractionPart::REQUEST); + } + + #[DataProvider('errorProvider')] + public function testResponseJsonBody(int $error): void + { + $this->interaction->getResponse()->setBody($this->json); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_interaction_contents', $this->interactionId, $this->responsePartId, $this->json->getContentType(), $this->json->getContents()) + ->willReturn($error); + if ($error) { + $this->expectException(PluginBodyNotAddedException::class); + $this->expectExceptionMessage($this->getPluginBodyErrorMessage($error)); + } + $this->driver->registerBody($this->interaction, InteractionPart::RESPONSE); + } + + #[DataProvider('errorProvider')] + public function testMessageJsonBody(int $error): void + { + $this->message->setContents($this->json); + $this->client + ->expects($this->once()) + ->method('call') + ->with('pactffi_interaction_contents', $this->messageId, $this->requestPartId, $this->json->getContentType(), $this->json->getContents()) + ->willReturn($error); + if ($error) { + $this->expectException(PluginBodyNotAddedException::class); + $this->expectExceptionMessage($this->getPluginBodyErrorMessage($error)); + } + $this->driver->registerBody($this->message, InteractionPart::REQUEST); + } + + public static function errorProvider(): array + { + return [ + [0], + [1], + [2], + [3], + [4], + [5], + [6], + [7], + ]; + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testInteractionMultipartBody(InteractionPart $part): void + { + if ($part === InteractionPart::REQUEST) { + $this->interaction->getRequest()->setBody($this->multipart); + } else { + $this->interaction->getResponse()->setBody($this->multipart); + } + $this->client + ->expects($this->never()) + ->method('call'); + $this->expectException(BodyNotSupportedException::class); + $this->expectExceptionMessage('Plugin does not support multipart body'); + $this->driver->registerBody($this->interaction, $part); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testEmptyInteractionBody(InteractionPart $part): void + { + $this->client + ->expects($this->never()) + ->method('call'); + $this->driver->registerBody($this->interaction, $part); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testEmptyMessageBody(InteractionPart $part): void + { + $this->client + ->expects($this->never()) + ->method('call'); + $this->driver->registerBody($this->message, $part); + } +} diff --git a/tests/PhpPact/Plugin/Driver/Pact/AbstractPluginPactDriverTestCase.php b/tests/PhpPact/Plugin/Driver/Pact/AbstractPluginPactDriverTestCase.php new file mode 100644 index 00000000..56537349 --- /dev/null +++ b/tests/PhpPact/Plugin/Driver/Pact/AbstractPluginPactDriverTestCase.php @@ -0,0 +1,66 @@ +assertConfig(null, $version); + $calls = $supported ? [ + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, $specificationHandle, null], + ['pactffi_using_plugin', $this->pactHandle, $this->getPluginName(), null, null], + ] : [ + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, $specificationHandle, null], + ]; + $this->assertClientCalls($calls); + if (!$supported) { + $this->expectException(PluginNotSupportedBySpecificationException::class); + $this->expectExceptionMessage(sprintf( + 'Plugin is not supported by specification %s, use 4.0.0 or above', + $version, + )); + } + $this->driver = $this->createPactDriver(); + $this->driver->setUp(); + } + + public function testCleanUpPlugin(): void + { + $this->assertConfig(null, '4.0.0'); + $calls = [ + ['pactffi_new_pact', $this->consumer, $this->provider, $this->pactHandle], + ['pactffi_with_specification', $this->pactHandle, self::SPEC_V4, null], + ['pactffi_using_plugin', $this->pactHandle, $this->getPluginName(), null, null], + ['pactffi_cleanup_plugins', $this->pactHandle, null], + ['pactffi_free_pact_handle', $this->pactHandle, null], + ]; + $this->assertClientCalls($calls); + $this->driver = $this->createPactDriver(); + $this->driver->setUp(); + $this->driver->cleanUp(); + } + + public function testCleanUpPluginWithoutPact(): void + { + $this->expectException(MissingPactException::class); + $this->driver->cleanUp(); + } + + abstract protected function createPactDriver(): AbstractPluginPactDriver; + + abstract protected function getPluginName(): string; +} diff --git a/tests/PhpPact/Plugins/Csv/Driver/Body/CsvBodyDriverTest.php b/tests/PhpPact/Plugins/Csv/Driver/Body/CsvBodyDriverTest.php new file mode 100644 index 00000000..b51ad365 --- /dev/null +++ b/tests/PhpPact/Plugins/Csv/Driver/Body/CsvBodyDriverTest.php @@ -0,0 +1,36 @@ +decorated = $this->createMock(PluginBodyDriverInterface::class); + $this->driver = new CsvBodyDriver($this->decorated); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testRegisterBody(InteractionPart $part): void + { + $interaction = new Interaction(); + $this->decorated + ->expects($this->once()) + ->method('registerBody') + ->with($interaction, $part); + $this->driver->registerBody($interaction, $part); + } +} diff --git a/tests/PhpPact/Plugins/Csv/Driver/Pact/CsvPactDriverTest.php b/tests/PhpPact/Plugins/Csv/Driver/Pact/CsvPactDriverTest.php new file mode 100644 index 00000000..dfa26d9f --- /dev/null +++ b/tests/PhpPact/Plugins/Csv/Driver/Pact/CsvPactDriverTest.php @@ -0,0 +1,19 @@ +client, $this->config); + } + + protected function getPluginName(): string + { + return 'csv'; + } +} diff --git a/tests/PhpPact/Plugins/Csv/Factory/CsvInteractionDriverFactoryTest.php b/tests/PhpPact/Plugins/Csv/Factory/CsvInteractionDriverFactoryTest.php new file mode 100644 index 00000000..ce5e4b63 --- /dev/null +++ b/tests/PhpPact/Plugins/Csv/Factory/CsvInteractionDriverFactoryTest.php @@ -0,0 +1,89 @@ +config = $this->createMock(MockServerConfigInterface::class); + $this->config + ->expects($this->any()) + ->method('getPactSpecificationVersion') + ->willReturn('4.0.0'); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + #[TestWith([InteractionPart::REQUEST, InteractionPart::RESPONSE])] + public function testCreate(InteractionPart ...$pluginParts): void + { + $this->factory = new CsvInteractionDriverFactory(...$pluginParts); + $driver = $this->factory->create($this->config); + $this->assertPropertiesInstanceOf($driver, null, [ + 'client' => Client::class, + 'mockServer' => MockServer::class, + 'pactDriver' => CsvPactDriver::class, + 'requestDriver' => RequestDriver::class, + 'responseDriver' => ResponseDriver::class, + ]); + $requestDriver = $this->getRequestDriver($driver); + $this->assertPropertiesInstanceOf($requestDriver, AbstractInteractionPartDriver::class, [ + 'client' => Client::class, + 'bodyDriver' => in_array(InteractionPart::REQUEST, $pluginParts) ? CsvBodyDriver::class : InteractionBodyDriver::class, + ]); + $responseDriver = $this->getResponseDriver($driver); + $this->assertPropertiesInstanceOf($responseDriver, AbstractInteractionPartDriver::class, [ + 'client' => Client::class, + 'bodyDriver' => in_array(InteractionPart::RESPONSE, $pluginParts) ? CsvBodyDriver::class : InteractionBodyDriver::class, + ]); + } + + public function testMissingPluginPartsException(): void + { + $this->expectException(MissingPluginPartsException::class); + $this->expectExceptionMessage('At least 1 interaction part must be csv'); + $this->factory = new CsvInteractionDriverFactory(); + } + + private function getRequestDriver(InteractionDriverInterface $driver): RequestDriverInterface + { + $reflection = new ReflectionProperty($driver, 'requestDriver'); + + return $reflection->getValue($driver); + } + + private function getResponseDriver(InteractionDriverInterface $driver): ResponseDriverInterface + { + $reflection = new ReflectionProperty($driver, 'responseDriver'); + + return $reflection->getValue($driver); + } +} diff --git a/tests/PhpPact/Plugins/Protobuf/Driver/Body/ProtobufMessageBodyDriverTest.php b/tests/PhpPact/Plugins/Protobuf/Driver/Body/ProtobufMessageBodyDriverTest.php new file mode 100644 index 00000000..d2b4f681 --- /dev/null +++ b/tests/PhpPact/Plugins/Protobuf/Driver/Body/ProtobufMessageBodyDriverTest.php @@ -0,0 +1,36 @@ +decorated = $this->createMock(PluginBodyDriverInterface::class); + $this->driver = new ProtobufMessageBodyDriver($this->decorated); + } + + #[TestWith([InteractionPart::REQUEST])] + #[TestWith([InteractionPart::RESPONSE])] + public function testRegisterBody(InteractionPart $part): void + { + $message = new Message(); + $this->decorated + ->expects($this->once()) + ->method('registerBody') + ->with($message, InteractionPart::REQUEST); + $this->driver->registerBody($message); + } +} diff --git a/tests/PhpPact/Plugins/Protobuf/Driver/Pact/ProtobufPactDriverTest.php b/tests/PhpPact/Plugins/Protobuf/Driver/Pact/ProtobufPactDriverTest.php new file mode 100644 index 00000000..ccb321eb --- /dev/null +++ b/tests/PhpPact/Plugins/Protobuf/Driver/Pact/ProtobufPactDriverTest.php @@ -0,0 +1,19 @@ +client, $this->config); + } + + protected function getPluginName(): string + { + return 'protobuf'; + } +} diff --git a/tests/PhpPact/Plugins/Protobuf/Factory/ProtobufMessageDriverFactoryTest.php b/tests/PhpPact/Plugins/Protobuf/Factory/ProtobufMessageDriverFactoryTest.php new file mode 100644 index 00000000..cc8cb94b --- /dev/null +++ b/tests/PhpPact/Plugins/Protobuf/Factory/ProtobufMessageDriverFactoryTest.php @@ -0,0 +1,42 @@ +config = $this->createMock(MockServerConfigInterface::class); + $this->config + ->expects($this->any()) + ->method('getPactSpecificationVersion') + ->willReturn('4.0.0'); + } + + public function testCreate(): void + { + $this->factory = new ProtobufMessageDriverFactory(); + $driver = $this->factory->create($this->config); + $this->assertPropertiesInstanceOf($driver, AbstractMessageDriver::class, [ + 'client' => Client::class, + 'pactDriver' => PactDriver::class, + 'messageBodyDriver' => ProtobufMessageBodyDriver::class, + ]); + } +} diff --git a/tests/PhpPact/Plugins/Protobuf/Factory/ProtobufSyncMessageDriverFactoryTest.php b/tests/PhpPact/Plugins/Protobuf/Factory/ProtobufSyncMessageDriverFactoryTest.php new file mode 100644 index 00000000..a31e2faa --- /dev/null +++ b/tests/PhpPact/Plugins/Protobuf/Factory/ProtobufSyncMessageDriverFactoryTest.php @@ -0,0 +1,46 @@ +config = $this->createMock(MockServerConfigInterface::class); + $this->config + ->expects($this->any()) + ->method('getPactSpecificationVersion') + ->willReturn('4.0.0'); + } + + public function testCreate(): void + { + $this->factory = new ProtobufSyncMessageDriverFactory(); + $driver = $this->factory->create($this->config); + $this->assertPropertiesInstanceOf($driver, null, [ + 'client' => Client::class, + 'pactDriver' => ProtobufPactDriver::class, + 'mockServer' => GrpcMockServer::class, + ]); + $this->assertPropertiesInstanceOf($driver, AbstractMessageDriver::class, [ + 'messageBodyDriver' => ProtobufMessageBodyDriver::class, + ]); + } +} diff --git a/tests/PhpPact/Plugins/Protobuf/Service/GrpcMockServerTest.php b/tests/PhpPact/Plugins/Protobuf/Service/GrpcMockServerTest.php new file mode 100644 index 00000000..98bdc186 --- /dev/null +++ b/tests/PhpPact/Plugins/Protobuf/Service/GrpcMockServerTest.php @@ -0,0 +1,20 @@ +mockServer = new GrpcMockServer($this->client, $this->pactDriver, $this->config); + } + + protected function getTransport(bool $secure): string + { + return 'grpc'; + } +} diff --git a/tests/PhpPact/Standalone/Broker/BrokerConfigTest.php b/tests/PhpPact/Standalone/Broker/BrokerConfigTest.php deleted file mode 100644 index c5b2bc5c..00000000 --- a/tests/PhpPact/Standalone/Broker/BrokerConfigTest.php +++ /dev/null @@ -1,43 +0,0 @@ -setHost($host) - ->setPort($port) - ->setProvider($provider) - ->setConsumer($consumer) - ->setPactDir($pactDir) - ->setPactFileWriteMode($pactFileWriteMode) - ->setLog($log) - ->setPactSpecificationVersion($pactSpecificationVersion) - ->setCors($cors); - - static::assertSame($host, $subject->getHost()); - static::assertSame($port, $subject->getPort()); - static::assertSame($provider, $subject->getProvider()); - static::assertSame($consumer, $subject->getConsumer()); - static::assertSame($pactDir, $subject->getPactDir()); - static::assertSame($pactFileWriteMode, $subject->getPactFileWriteMode()); - static::assertSame($log, $subject->getLog()); - static::assertSame($pactSpecificationVersion, $subject->getPactSpecificationVersion()); - static::assertSame($cors, $subject->hasCors()); - } -} diff --git a/tests/PhpPact/Standalone/Broker/BrokerTest.php b/tests/PhpPact/Standalone/Broker/BrokerTest.php deleted file mode 100644 index b61a4cf2..00000000 --- a/tests/PhpPact/Standalone/Broker/BrokerTest.php +++ /dev/null @@ -1,106 +0,0 @@ -setBrokerToken('someToken') - ->setBrokerUsername('someusername') - ->setBrokerPassword('somepassword') - ))->getArguments(); - - $this->assertContains('--broker-token=someToken', $arguments); - $this->assertContains('--broker-username=someusername', $arguments); - $this->assertContains('--broker-password=somepassword', $arguments); - } - - /** - * @test - */ - public function getArgumentsEmptyConfig(): void - { - $this->assertEmpty((new Broker(new BrokerConfig()))->getArguments()); - } - - /** - * @test - */ - //public function generateUuid(): void - //{ - // $this->assertContains('-', (new Broker(new BrokerConfig()))->generateUuid()); - //} - - - /** - * @test - * - * @throws \Exception - */ - public function describeVersion(): void - { - - // this test has slain many a developer 🤯 - // if (php_uname('m') != 'arm64' && PHP_OS != 'Linux') { - $config = new BrokerConfig(); - $config->setPacticipant('Animal Profile Service') - ->setBrokerUri(new Uri('https://test.pactflow.io')) - ->setBrokerUsername('dXfltyFMgNOFZAxr8io9wJ37iUpY42M') - ->setBrokerPassword('O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1'); - $broker = new Broker($config); - - $result = $broker->describeVersion(); - - $this->assertArrayHasKey('number', $result); - // }; - } - - - - /** - * @test - * - * @throws \Exception - */ - public function listLatestPactVersions(): void - { - $config = new BrokerConfig(); - $config->setPacticipant("\"Animal Profile Service\"") - ->setBrokerUri(new Uri('https://test.pactflow.io')) - ->setBrokerUsername('dXfltyFMgNOFZAxr8io9wJ37iUpY42M') - ->setBrokerPassword('O5AIZWxelWbLvqMd8PkAVycBJh2Psyg1'); - $broker = new Broker($config); - - $result = $broker->listLatestPactVersions(); - $this->assertArrayHasKey('pacts', $result); - } - - /** - * @test - * - * @throws \Exception - */ - public function publishLogsStdError(): void - { - $config = new BrokerConfig(); - $config->setPactLocations('not a directory'); - $broker = new Broker($config); - try { - $broker->publish(); - } catch(\Exception $e) { - $this->assertEquals(1, $e->getCode()); - $this->assertStringContainsString("PactPHP Process returned non-zero exit code: 1", $e->getMessage()); - } - } -} diff --git a/tests/PhpPact/Standalone/MockServer/MockServerConfigTest.php b/tests/PhpPact/Standalone/MockServer/MockServerConfigTest.php index 79fcd04a..b8f02c7f 100644 --- a/tests/PhpPact/Standalone/MockServer/MockServerConfigTest.php +++ b/tests/PhpPact/Standalone/MockServer/MockServerConfigTest.php @@ -3,11 +3,12 @@ namespace PhpPactTest\Standalone\MockServer; use PhpPact\Standalone\MockService\MockServerConfig; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; class MockServerConfigTest extends TestCase { - public function testSetters() + public function testSetters(): void { $host = 'test-host'; $port = 1234; @@ -17,15 +18,11 @@ public function testSetters() $pactFileWriteMode = 'merge'; $logLevel = 'INFO'; $log = 'test-log-dir/'; - $cors = true; - $pactSpecificationVersion = '2.0'; - $healthCheckTimeout = 20; - $healthCheckRetrySec = 2.0; + $pactSpecificationVersion = '3.0.0'; $secure = false; - $subject = (new MockServerConfig()) - ->setSecure(false) - ->setHost($host) + $subject = new MockServerConfig(); + $subject->setHost($host) ->setPort($port) ->setProvider($provider) ->setConsumer($consumer) @@ -33,10 +30,7 @@ public function testSetters() ->setPactFileWriteMode($pactFileWriteMode) ->setLogLevel($logLevel) ->setLog($log) - ->setPactSpecificationVersion($pactSpecificationVersion) - ->setCors($cors) - ->setHealthCheckTimeout(20) - ->setHealthCheckRetrySec(2); + ->setPactSpecificationVersion($pactSpecificationVersion); static::assertSame($secure, $subject->isSecure()); static::assertSame($host, $subject->getHost()); @@ -48,8 +42,17 @@ public function testSetters() static::assertSame($log, $subject->getLog()); static::assertSame($logLevel, $subject->getLogLevel()); static::assertSame($pactSpecificationVersion, $subject->getPactSpecificationVersion()); - static::assertSame($cors, $subject->hasCors()); - static::assertSame($healthCheckTimeout, $subject->getHealthCheckTimeout()); - static::assertSame($healthCheckRetrySec, $subject->getHealthCheckRetrySec()); + } + + #[TestWith([false, 'http://example.test:123'])] + #[TestWith([true, 'https://example.test:123'])] + public function testGetBaseUri(bool $secure, string $baseUri): void + { + $config = new MockServerConfig(); + $config + ->setHost('example.test') + ->setPort(123) + ->setSecure($secure); + $this->assertEquals($baseUri, $config->getBaseUri()); } } diff --git a/tests/PhpPact/Standalone/MockServer/MockServerEnvConfigTest.php b/tests/PhpPact/Standalone/MockServer/MockServerEnvConfigTest.php new file mode 100644 index 00000000..5a054b80 --- /dev/null +++ b/tests/PhpPact/Standalone/MockServer/MockServerEnvConfigTest.php @@ -0,0 +1,108 @@ +preserveEnv('PACT_MOCK_SERVER_PORT', $assignment, function () use ($host) { + $config = new MockServerEnvConfig(); + $this->assertSame($host, $config->getHost()); + }); + } + + #[TestWith(['PACT_MOCK_SERVER_PORT', 0])] + #[TestWith(['PACT_MOCK_SERVER_PORT=123', 123])] + public function testPort(string $assignment, int $port): void + { + $this->preserveEnv('PACT_MOCK_SERVER_PORT', $assignment, function () use ($port) { + $config = new MockServerEnvConfig(); + $this->assertSame($port, $config->getPort()); + }); + } + + #[TestWith(['PACT_CONSUMER_NAME', null])] + #[TestWith(['PACT_CONSUMER_NAME=consumer', 'consumer'])] + public function testConsumer(string $assignment, ?string $consumer): void + { + $this->preserveEnv('PACT_CONSUMER_NAME', $assignment, function () use ($consumer) { + if (!$consumer) { + $this->expectException(MissingEnvVariableException::class); + $this->expectExceptionMessage('Please provide required environmental variable PACT_CONSUMER_NAME!'); + } + $config = new MockServerEnvConfig(); + $this->assertSame($consumer, $config->getConsumer()); + }); + } + + #[TestWith(['PACT_PROVIDER_NAME', null])] + #[TestWith(['PACT_PROVIDER_NAME=provider', 'provider'])] + public function testProvider(string $assignment, ?string $provider): void + { + $this->preserveEnv('PACT_PROVIDER_NAME', $assignment, function () use ($provider) { + if (!$provider) { + $this->expectException(MissingEnvVariableException::class); + $this->expectExceptionMessage('Please provide required environmental variable PACT_PROVIDER_NAME!'); + } + $config = new MockServerEnvConfig(); + $this->assertSame($provider, $config->getProvider()); + }); + } + + #[TestWith(['PACT_OUTPUT_DIR', null])] + #[TestWith(['PACT_OUTPUT_DIR=/path/to/pact/dir', '/path/to/pact/dir'])] + public function testPactDir(string $assignment, ?string $pactDir): void + { + $this->preserveEnv('PACT_OUTPUT_DIR', $assignment, function () use ($pactDir) { + $pactDir ??= \sys_get_temp_dir(); + $config = new MockServerEnvConfig(); + $this->assertSame($pactDir, $config->getPactDir()); + }); + } + + #[TestWith(['PACT_LOG', null])] + #[TestWith(['PACT_LOG=/path/to/log/dir', '/path/to/log/dir'])] + public function testLog(string $assignment, ?string $logDir): void + { + $this->preserveEnv('PACT_LOG', $assignment, function () use ($logDir) { + $config = new MockServerEnvConfig(); + $this->assertSame($logDir, $config->getLog()); + }); + } + + #[TestWith(['PACT_LOGLEVEL', null])] + #[TestWith(['PACT_LOGLEVEL=trace', 'TRACE'])] + public function testLogLevel(string $assignment, ?string $logLevel): void + { + $this->preserveEnv('PACT_LOGLEVEL', $assignment, function () use ($logLevel) { + $config = new MockServerEnvConfig(); + $this->assertSame($logLevel, $config->getLogLevel()); + }); + } + + #[TestWith(['PACT_SPECIFICATION_VERSION', '3.0.0'])] + #[TestWith(['PACT_SPECIFICATION_VERSION=1.1.0', '1.1.0'])] + public function testPactSpecificationVersion(string $assignment, ?string $specificationVersion): void + { + $this->preserveEnv('PACT_SPECIFICATION_VERSION', $assignment, function () use ($specificationVersion) { + $config = new MockServerEnvConfig(); + $this->assertSame($specificationVersion, $config->getPactSpecificationVersion()); + }); + } + + private function preserveEnv(string $env, string $assignment, callable $callback): void + { + $value = getenv($env); + putenv($assignment); + $callback(); + putenv("$env=$value"); + } +} diff --git a/tests/PhpPact/Standalone/MockServer/MockServerTest.php b/tests/PhpPact/Standalone/MockServer/MockServerTest.php deleted file mode 100644 index 6d356742..00000000 --- a/tests/PhpPact/Standalone/MockServer/MockServerTest.php +++ /dev/null @@ -1,68 +0,0 @@ -start(); - $this->assertTrue(\is_int($pid)); - } finally { - $result = $mockServer->stop(); - $this->assertTrue($result); - } - } - - /** - * @throws MissingEnvVariableException - * @throws \Exception - */ - public function testStartAndStopWithRecognizedTimeout() - { - // the mock server actually takes more than one second to be ready - // we use this fact to test the timeout - $orig = \getenv('PACT_MOCK_SERVER_HEALTH_CHECK_TIMEOUT'); - \putenv('PACT_MOCK_SERVER_HEALTH_CHECK_TIMEOUT=1'); - - $httpService = $this->getMockBuilder(MockServerHttpService::class) - ->disableOriginalConstructor() - ->getMock(); - - $connectionException = $this->getMockBuilder(ConnectionException::class) - ->disableOriginalConstructor() - ->getMock(); - - // take sth lower than the default value - $httpService->expects($this->atMost(5)) - ->method('healthCheck') - ->will($this->returnCallback(function () use ($connectionException) { - throw $connectionException; - })); - - try { - $mockServer = new MockServer(new MockServerEnvConfig(), $httpService); - $mockServer->start(); - $this->fail('MockServer should not pass defined health check.'); - } catch (HealthCheckFailedException $e) { - $this->assertTrue(true); - } finally { - $mockServer->stop(); - \putenv('PACT_MOCK_SERVER_HEALTH_CHECK_TIMEOUT=' . $orig); - } - } -} diff --git a/tests/PhpPact/Standalone/MockServer/Service/MockServerHttpServiceTest.php b/tests/PhpPact/Standalone/MockServer/Service/MockServerHttpServiceTest.php deleted file mode 100644 index c389dad7..00000000 --- a/tests/PhpPact/Standalone/MockServer/Service/MockServerHttpServiceTest.php +++ /dev/null @@ -1,209 +0,0 @@ -config = new MockServerEnvConfig(); - $this->mockServer = new MockServer($this->config); - $this->mockServer->start(); - $this->service = new MockServerHttpService(new GuzzleClient(), $this->config); - } - - protected function tearDown(): void - { - $this->mockServer->stop(); - } - - /** - * @throws ConnectionException - */ - public function testHealthCheck(): void - { - $result = $this->service->healthCheck(); - $this->assertTrue($result); - } - - public function testRegisterInteraction(): void - { - $request = new ConsumerRequest(); - $request - ->setPath('/example') - ->setMethod('GET'); - $response = new ProviderResponse(); - $response->setStatus(200); - - $interaction = new Interaction(); - $interaction - ->setDescription('Fake description') - ->setProviderState('Fake provider state') - ->setRequest($request) - ->setResponse($response); - - $result = $this->service->registerInteraction($interaction); - - $this->assertTrue($result); - } - - public function testDeleteAllInteractions(): void - { - $result = $this->service->deleteAllInteractions(); - $this->assertTrue($result); - } - - public function testVerifyInteractions(): void - { - $result = $this->service->verifyInteractions(); - $this->assertTrue($result); - } - - public function testVerifyInteractionsFailure(): void - { - $request = new ConsumerRequest(); - $request - ->setPath('/example') - ->setMethod('GET'); - - $response = new ProviderResponse(); - $response->setStatus(200); - - $interaction = new Interaction(); - $interaction - ->setDescription('Some description') - ->setProviderState('Some state') - ->setRequest($request) - ->setResponse($response); - $this->service->registerInteraction($interaction); - - $this->expectException(ServerException::class); - $result = $this->service->verifyInteractions(); - $this->assertFalse($result); - } - - public function testGetPactJson(): void - { - $result = $this->service->getPactJson(); - $this->assertEquals('{"consumer":{"name":"someConsumer"},"provider":{"name":"someProvider"},"interactions":[],"metadata":{"pactSpecification":{"version":"2.0.0"}}}', $result); - } - - public function testFullGetInteraction(): void - { - $request = new ConsumerRequest(); - $request - ->setPath('/example') - ->setMethod('GET') - ->setQuery('enabled=true') - ->addQueryParameter('order', 'asc') - ->addQueryParameter('value', '12') - ->addHeader('Content-Type', 'application/json'); - - $expectedResponseBody = [ - 'message' => 'Hello, world!', - ]; - $response = new ProviderResponse(); - $response - ->setStatus(200) - ->setBody($expectedResponseBody) - ->addHeader('Content-Type', 'application/json'); - - $interaction = new Interaction(); - $interaction - ->setDescription('Fake description') - ->setProviderState('Fake provider state') - ->setRequest($request) - ->setResponse($response); - - $result = $this->service->registerInteraction($interaction); - - $this->assertTrue($result); - - $client = new GuzzleClient(); - $uri = $this->config->getBaseUri()->withPath('/example')->withQuery('enabled=true&order=asc&value=12'); - $response = $client->get($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - ], - ]); - - $body = $response->getBody()->getContents(); - $this->assertEquals(\json_encode($expectedResponseBody), $body); - $this->assertEquals($response->getHeaderLine('Access-Control-Allow-Origin'), '*', 'CORS flag not set properly'); - $this->assertEquals(200, $response->getStatusCode()); - } - - /** - * @throws MissingEnvVariableException - * @throws \Exception - */ - public function testMatcherWithMockServer(): void - { - $matcher = new Matcher(); - - $category = new stdClass(); - $category->name = $matcher->term('Games', '[gbBG]'); - - $request = new ConsumerRequest(); - $request - ->setPath('/test') - ->setMethod('GET'); - - $response = new ProviderResponse(); - $response - ->setStatus(200) - ->addHeader('Content-Type', 'application/json') - ->setBody([ - 'results' => $matcher->eachLike($category), - ]); - - $config = new MockServerEnvConfig(); - $interaction = new InteractionBuilder($config); - $interaction - ->given('Something') - ->uponReceiving('Stuff') - ->with($request) - ->willRespondWith($response); - - $client = new GuzzleClient(); - $uri = $this->config->getBaseUri()->withPath('/test'); - $client->get($uri, [ - 'headers' => [ - 'Content-Type' => 'application/json', - ], - ]); - - $httpClient = new MockServerHttpService(new GuzzleClient(), $config); - - $pact = \json_decode($httpClient->getPactJson(), true); - - $this->assertArrayHasKey('$.body.results[*].name', $pact['interactions'][0]['response']['matchingRules']); - } -} diff --git a/tests/PhpPact/Standalone/PactMessage/PactMessageConfigTest.php b/tests/PhpPact/Standalone/PactMessage/PactMessageConfigTest.php new file mode 100644 index 00000000..e03aca21 --- /dev/null +++ b/tests/PhpPact/Standalone/PactMessage/PactMessageConfigTest.php @@ -0,0 +1,14 @@ +config = new PactMessageConfig(); + } +} diff --git a/tests/PhpPact/Standalone/ProviderVerifier/Model/ConsumerVersionSelectorsTest.php b/tests/PhpPact/Standalone/ProviderVerifier/Model/ConsumerVersionSelectorsTest.php new file mode 100644 index 00000000..a4d97408 --- /dev/null +++ b/tests/PhpPact/Standalone/ProviderVerifier/Model/ConsumerVersionSelectorsTest.php @@ -0,0 +1,30 @@ +> + */ + public static function selectorsProvider(): array + { + return [ + [['{ "mainBranch": true }', '{ "deployedOrReleased": true }'], ['{ "mainBranch": true }', '{ "deployedOrReleased": true }']], + [[new Selector(matchingBranch: true), '{ "mainBranch": true }', new Selector(deployedOrReleased: true)], ['{"matchingBranch":true}', '{ "mainBranch": true }', '{"deployedOrReleased":true}']], + [[new Selector(mainBranch: true)], ['{"mainBranch":true}']], + ]; + } +} diff --git a/tests/PhpPact/Standalone/ProviderVerifier/Model/Selector/SelectorTest.php b/tests/PhpPact/Standalone/ProviderVerifier/Model/Selector/SelectorTest.php new file mode 100644 index 00000000..d93cfd79 --- /dev/null +++ b/tests/PhpPact/Standalone/ProviderVerifier/Model/Selector/SelectorTest.php @@ -0,0 +1,37 @@ + false]; + $this->expectException(InvalidSelectorValueException::class); + $this->expectExceptionMessage(sprintf("Value 'false' is not allowed for selector %s", $key)); + new Selector(...$values); + } + + /** + * @testWith [{ "mainBranch": true }, "{\"mainBranch\":true}"] + * [{ "branch": "feat-xxx" }, "{\"branch\":\"feat-xxx\"}"] + * [{ "deployedOrReleased": true }, "{\"deployedOrReleased\":true}"] + * [{ "matchingBranch": true }, "{\"matchingBranch\":true}"] + * [{ "mainBranch": null, "branch": "fix-yyy", "fallbackBranch": null, "matchingBranch": null, "tag": null, "fallbackTag": null, "deployed": null, "released": null, "deployedOrReleased": null, "environment": null, "latest": null, "consumer": "my-consumer" }, "{\"branch\":\"fix-yyy\",\"consumer\":\"my-consumer\"}"] + */ + public function testJsonSerialize(array $values, string $json): void + { + static::assertSame($json, json_encode(new Selector(...$values))); + } +} diff --git a/tests/PhpPact/Standalone/ProviderVerifier/Model/Source/BrokerTest.php b/tests/PhpPact/Standalone/ProviderVerifier/Model/Source/BrokerTest.php new file mode 100644 index 00000000..23106df1 --- /dev/null +++ b/tests/PhpPact/Standalone/ProviderVerifier/Model/Source/BrokerTest.php @@ -0,0 +1,37 @@ +addSelector('{"tag":"foo","latest":true}') + ->addSelector('{"tag":"bar","latest":true}'); + $consumerVersionTags = ['dev']; + + $subject = (new Broker()) + ->setEnablePending($enablePending) + ->setIncludeWipPactSince($wipPactSince) + ->setProviderTags($providerTags) + ->setProviderBranch($providerBranch) + ->setConsumerVersionSelectors($consumerVersionSelectors) + ->setConsumerVersionTags($consumerVersionTags); + + static::assertSame($enablePending, $subject->isEnablePending()); + static::assertSame($wipPactSince, $subject->getIncludeWipPactSince()); + static::assertSame($providerTags, $subject->getProviderTags()); + static::assertSame($providerBranch, $subject->getProviderBranch()); + static::assertSame($consumerVersionSelectors, $subject->getConsumerVersionSelectors()); + static::assertSame($consumerVersionTags, $subject->getConsumerVersionTags()); + } +} diff --git a/tests/PhpPact/Standalone/ProviderVerifier/Model/Source/UrlTest.php b/tests/PhpPact/Standalone/ProviderVerifier/Model/Source/UrlTest.php new file mode 100644 index 00000000..20716b8a --- /dev/null +++ b/tests/PhpPact/Standalone/ProviderVerifier/Model/Source/UrlTest.php @@ -0,0 +1,29 @@ +setUrl($url) + ->setToken($token) + ->setUsername($username) + ->setPassword($password); + + static::assertSame($url, $subject->getUrl()); + static::assertSame($token, $subject->getToken()); + static::assertSame($username, $subject->getUsername()); + static::assertSame($password, $subject->getPassword()); + } +} diff --git a/tests/PhpPact/Standalone/ProviderVerifier/Model/VerifierConfigTest.php b/tests/PhpPact/Standalone/ProviderVerifier/Model/VerifierConfigTest.php new file mode 100644 index 00000000..4ad73247 --- /dev/null +++ b/tests/PhpPact/Standalone/ProviderVerifier/Model/VerifierConfigTest.php @@ -0,0 +1,53 @@ +setCallingApp($callingApp); + $subject->setProviderInfo($providerInfo); + $subject->setFilterInfo($filterInfo); + $subject->setProviderState($providerState); + $subject->setVerificationOptions($verificationOptions); + $subject->setPublishOptions($publishOptions); + $subject->setConsumerFilters($consumerFilters); + $subject->setProviderTransports($providerTransports); + + $this->assertSame($callingApp, $subject->getCallingApp()); + $this->assertSame($providerInfo, $subject->getProviderInfo()); + $this->assertSame($filterInfo, $subject->getFilterInfo()); + $this->assertSame($providerState, $subject->getProviderState()); + $this->assertSame($verificationOptions, $subject->getVerificationOptions()); + $this->assertTrue($subject->isPublishResults()); + $this->assertSame($publishOptions, $subject->getPublishOptions()); + $this->assertSame($consumerFilters, $subject->getConsumerFilters()); + $this->assertSame($providerTransports, $subject->getProviderTransports()); + } +} diff --git a/tests/PhpPact/Standalone/ProviderVerifier/VerifierProcessTest.php b/tests/PhpPact/Standalone/ProviderVerifier/VerifierProcessTest.php deleted file mode 100644 index 72449939..00000000 --- a/tests/PhpPact/Standalone/ProviderVerifier/VerifierProcessTest.php +++ /dev/null @@ -1,77 +0,0 @@ - 'bar']; - - $logger = $this->createMock(LoggerInterface::class); - - $processRunner = $this->createMock(ProcessRunner::class); - - $processRunnerFactory = $this->createMock(ProcessRunnerFactory::class); - $processRunnerFactory->expects($this->once()) - ->method('createRunner') - ->with($this->equalTo($arguments), $this->equalTo($logger)) - ->will($this->returnValue($processRunner)); - - $process = new VerifierProcess($processRunnerFactory); - $process->setLogger($logger); - $process->run($arguments, 42, 23); - } - - public function testRunWithDefaultLogger() - { - $arguments = ['foo' => 'bar']; - - $processRunner = $this->createMock(ProcessRunner::class); - - $processRunnerFactory = $this->createMock(ProcessRunnerFactory::class); - $processRunnerFactory->expects($this->once()) - ->method('createRunner') - ->with($this->equalTo($arguments)) - ->will($this->returnValue($processRunner)); - - $process = new VerifierProcess($processRunnerFactory); - $process->run($arguments, 42, 23); - } - - public function testRunForwardsException() - { - $this->expectExceptionMessage('foo'); - $this->expectException(\RuntimeException::class); - - $arguments = ['foo' => 'bar']; - - $expectedException = new \RuntimeException('foo'); - - $processRunner = $this->createMock(ProcessRunner::class); - $processRunner->expects($this->once()) - ->method('runBlocking') - ->will( - $this->returnCallback( - function () use ($expectedException) { - throw $expectedException; - } - ) - ); - - $processRunnerFactory = $this->createMock(ProcessRunnerFactory::class); - $processRunnerFactory->expects($this->once()) - ->method('createRunner') - ->with($this->equalTo($arguments)) - ->will($this->returnValue($processRunner)); - - $process = new VerifierProcess($processRunnerFactory); - $process->run($arguments, 42, 23); - } -} diff --git a/tests/PhpPact/Standalone/ProviderVerifier/VerifierTest.php b/tests/PhpPact/Standalone/ProviderVerifier/VerifierTest.php index 8a5d896a..aa4d17b8 100644 --- a/tests/PhpPact/Standalone/ProviderVerifier/VerifierTest.php +++ b/tests/PhpPact/Standalone/ProviderVerifier/VerifierTest.php @@ -2,235 +2,223 @@ namespace PhpPactTest\Standalone\ProviderVerifier; +use FFI; +use FFI\CData; use GuzzleHttp\Psr7\Uri; -use Monolog\Handler\TestHandler; -use Monolog\Logger; -use PhpPact\Broker\Service\BrokerHttpClient; -use PhpPact\Broker\Service\BrokerHttpClientInterface; +use PhpPact\FFI\ClientInterface; +use PhpPact\Service\LoggerInterface; +use PhpPact\Standalone\ProviderVerifier\Model\Config\ProviderTransport; +use PhpPact\Standalone\ProviderVerifier\Model\Config\PublishOptions; use PhpPact\Standalone\ProviderVerifier\Model\ConsumerVersionSelectors; +use PhpPact\Standalone\ProviderVerifier\Model\Selector\Selector; +use PhpPact\Standalone\ProviderVerifier\Model\Source\Broker; +use PhpPact\Standalone\ProviderVerifier\Model\Source\Url; use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfig; -use PhpPact\Standalone\ProviderVerifier\ProcessRunnerFactory; +use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfigInterface; use PhpPact\Standalone\ProviderVerifier\Verifier; -use PhpPact\Standalone\ProviderVerifier\VerifierProcess; +use PhpPactTest\Helper\FFI\ClientTrait; +use PHPUnit\Framework\Attributes\TestWith; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Psr\Http\Message\RequestInterface; class VerifierTest extends TestCase { - public function testGetArguments() - { - $consumerVersionSelectors = (new ConsumerVersionSelectors()) - ->addSelector('{"tag":"foo","latest":true}') - ->addSelector('{"tag":"bar","latest":true}'); - - $config = new VerifierConfig(); - $config - ->setProviderName('some provider with whitespace') - ->setProviderVersion('1.0.0') - ->setProviderBranch('main') - ->addProviderVersionTag('prod') - ->addProviderVersionTag('dev') - ->addConsumerVersionTag('dev') - ->setProviderBaseUrl(new Uri('http://myprovider:1234')) - ->setProviderStatesSetupUrl(new Uri('http://someurl:1234')) - ->setPublishResults(true) - ->setBrokerToken('someToken') - ->setBrokerUsername('someusername') - ->setBrokerPassword('somepassword') - ->setBrokerUri(new Uri('https://example.broker/')) - ->addCustomProviderHeader('key1', 'value1') - ->addCustomProviderHeader('key2', 'value2') - ->setVerbose(true) - ->setLogDirectory('my/log/directory') - ->setFormat('someformat') - ->setProcessTimeout(30) - ->setProcessIdleTimeout(5) - ->setEnablePending(true) - ->setIncludeWipPactSince('2020-01-30') - ->setRequestFilter( - function (RequestInterface $r) { - return $r->withHeader('MY_SPECIAL_HEADER', 'my special value'); - } - ) - ->setConsumerVersionSelectors($consumerVersionSelectors); + use ClientTrait; - /** @var BrokerHttpClientInterface $brokerHttpService */ - $server = new Verifier($config); - $arguments = $server->getArguments(); + private Verifier $verifier; + private VerifierConfigInterface $config; + private LoggerInterface|MockObject $logger; + private CData $handle; + private array $calls; - $this->assertContains('--provider-base-url=http://myprovider:1234', $arguments); - $this->assertContains('--provider-states-setup-url=http://someurl:1234', $arguments); - $this->assertContains('--publish-verification-results', $arguments); - $this->assertContains('--broker-token=someToken', $arguments); - $this->assertContains('--broker-username=someusername', $arguments); - $this->assertContains('--broker-password=somepassword', $arguments); - $this->assertContains('--custom-provider-header="key1: value1"', $arguments); - $this->assertContains('--custom-provider-header="key2: value2"', $arguments); - $this->assertContains('--verbose=VERBOSE', $arguments); - $this->assertContains('--log-dir=my/log/directory', $arguments); - $this->assertContains('--format=someformat', $arguments); - $this->assertContains('--provider-version-tag=prod', $arguments); - $this->assertContains('--provider-version-tag=dev', $arguments); - $this->assertContains('--provider-version-branch=main', $arguments); - $this->assertContains('--consumer-version-tag=dev', $arguments); - $this->assertSame(['process_timeout' => 30, 'process_idle_timeout' => 5], $server->getTimeoutValues()); - $this->assertContains('--enable-pending', $arguments); - $this->assertContains('--include-wip-pacts-since=2020-01-30', $arguments); - $this->assertContains('--consumer-version-selector=\'{"tag":"foo","latest":true}\'', $this->stripSpaces($arguments)); - $this->assertContains('--consumer-version-selector=\'{"tag":"bar","latest":true}\'', $this->stripSpaces($arguments)); - $this->assertContains('--provider=\'some provider with whitespace\'', $arguments); - $this->assertContains('--pact-broker-base-url=https://example.broker/', $arguments); + protected function setUp(): void + { + $this->config = new VerifierConfig(); + $this->logger = $this->createMock(LoggerInterface::class); + $this->client = $this->createMock(ClientInterface::class); + $this->handle = FFI::new('int'); } - /** - * Strip spaces for Windows CMD - */ - private function stripSpaces($arr) + private function setUpCalls(bool $hasProviderTags = true, bool $hasFilterConsumerNames = true): void { - $newArr = []; - foreach ($arr as $str) { - $newArr[] = str_ireplace(' ', '', $str); - } - return $newArr; + $this->config->getCallingApp() + ->setName($callingAppName = 'calling app name') + ->setVersion($callingAppVersion = '1.2.3'); + $this->config->getProviderInfo() + ->setName($providerName = 'provider name') + ->setScheme($providerScheme = 'https') + ->setHost($providerHost = 'provider.domain') + ->setPort($providerPort = 123) + ->setPath($providerPath = '/provider/path'); + $transport = new ProviderTransport(); + $transport->setProtocol($transportProtocol = 'message') + ->setPort($transportPort = 234) + ->setPath($transportPath = '/provider-messages') + ->setScheme($transportScheme = 'http'); + $this->config->getProviderState() + ->setStateChangeUrl($stateChangeUrl = new Uri('http://provider.host:432/pact-change-state')) + ->setStateChangeTeardown($stateChangeTearDown = true) + ->setStateChangeAsBody($stateChangeAsBody = true); + $this->config->addProviderTransport($transport); + $this->config->getFilterInfo() + ->setFilterDescription($filterDescription = 'request to /hello') + ->setFilterNoState($filterNoState = true) + ->setFilterState($filterState = 'given state'); + $this->config->getVerificationOptions() + ->setRequestTimeout($requestTimeout = 500) + ->setDisableSslVerification($disableSslVerification = true); + $publishOptions = new PublishOptions(); + $publishOptions + ->setProviderTags($providerTags = $hasProviderTags ? ['feature-x', 'master', 'test', 'prod'] : []) + ->setProviderVersion($providerVersion = '1.2.3') + ->setBuildUrl($buildUrl = new Uri('http://ci/build/1')) + ->setProviderBranch($providerBranch = 'some-branch'); + $this->config->setPublishOptions($publishOptions); + $this->config->getConsumerFilters() + ->setFilterConsumerNames($filterConsumerNames = $hasFilterConsumerNames ? ['http-consumer-1', 'http-consumer-2', 'message-consumer-2'] : []); + $this->config->setLogLevel($logLevel = 'info'); + $this->calls = [ + ['pactffi_verifier_new_for_application', $callingAppName, $callingAppVersion, $this->handle], + ['pactffi_verifier_set_provider_info', $this->handle, $providerName, $providerScheme, $providerHost, $providerPort, $providerPath, null], + ['pactffi_verifier_add_provider_transport', $this->handle, $transportProtocol, $transportPort, $transportPath, $transportScheme, null], + ['pactffi_verifier_set_filter_info', $this->handle, $filterDescription, $filterState, $filterNoState, null], + ['pactffi_verifier_set_provider_state', $this->handle, (string) $stateChangeUrl, $stateChangeTearDown, $stateChangeAsBody, null], + ['pactffi_verifier_set_verification_options', $this->handle, $disableSslVerification, $requestTimeout, null], + [ + 'pactffi_verifier_set_publish_options', + $this->handle, + $providerVersion, + $buildUrl, + $hasProviderTags ? $this->isInstanceOf(CData::class) : null, + $hasProviderTags ? count($providerTags) : null, + $providerBranch, + null + ], + [ + 'pactffi_verifier_set_consumer_filters', + $this->handle, + $hasFilterConsumerNames ? $this->isInstanceOf(CData::class) : null, + $hasFilterConsumerNames ? count($filterConsumerNames) : null, + null + ], + ['pactffi_init_with_log_level', strtoupper($logLevel), null], + ]; } - public function testGetArgumentsEmptyConfig() + #[TestWith([true, true])] + #[TestWith([false, false])] + #[TestWith([true, false])] + #[TestWith([false, true])] + public function testConstruct(bool $hasProviderTags, bool $hasFilterConsumerNames): void { - $this->assertEmpty((new Verifier(new VerifierConfig()))->getArguments()); + $this->setUpCalls($hasProviderTags, $hasFilterConsumerNames); + $this->assertClientCalls($this->calls); + $this->verifier = new Verifier($this->config, $this->logger, $this->client); } - /** - * @dataProvider dataProviderForBrokerPathTest - * - * @param string $consumerName - * @param string $providerName - * @param null|string $tag - * @param null|string $version - * @param string $path - */ - public function testBuildValidPathToPactBroker($consumerName, $providerName, $tag, $version, $path) + public function testAddFile(): void { - $expectedUrltoBroker = 'http://mock/' . $path; - - /** @var Uri $uriMock */ - $uriMock = $this->createMock(Uri::class); - $uriMock->expects($this->once()) - ->method('withPath') - ->with($path) - ->willReturn($uriMock); - - $uriMock->expects($this->any()) - ->method('__toString') - ->willReturn($expectedUrltoBroker); - - $verifierProcessMock = $this->createMock(VerifierProcess::class); - $verifierProcessMock->expects($this->once()) - ->method('run') - ->with( - $this->callback(function ($args) use ($expectedUrltoBroker) { - return \in_array($expectedUrltoBroker, $args); - }) - ); - - $config = new VerifierConfig(); - $config->setProviderName($providerName) - ->setProviderBaseUrl(new Uri('http://myprovider:1234')) - ->setProviderStatesSetupUrl(new Uri('http://someurl:1234')) - ->setBrokerUri($uriMock) - ->setVerbose(true); - - $verifier = new Verifier($config, $verifierProcessMock); - - $verifier->verify($consumerName, $tag, $version); + $this->setUpCalls(); + $file = '/path/to/file.json'; + $this->calls[] = ['pactffi_verifier_add_file_source', $this->handle, $file, null]; + $this->assertClientCalls($this->calls); + $this->verifier = new Verifier($this->config, $this->logger, $this->client); + $this->assertSame($this->verifier, $this->verifier->addFile($file)); } - public function dataProviderForBrokerPathTest() + public function testAddDirectory(): void { - $consumerName = 'someProviderName'; - $providerName = 'someProviderName'; - $tag = '1.0.0'; - $version = '11111'; - - return [ - [$consumerName, $providerName, null, $version, "/pacts/provider/$providerName/consumer/$consumerName/version/$version/"], - [$consumerName, $providerName, $tag, null, "/pacts/provider/$providerName/consumer/$consumerName/latest/$tag/"], - [$consumerName, $providerName, $tag, $version, "/pacts/provider/$providerName/consumer/$consumerName/latest/$tag/"], - [$consumerName, $providerName, null, null, "/pacts/provider/$providerName/consumer/$consumerName/latest/"], - ]; + $this->setUpCalls(); + $directory = '/path/to/directory'; + $this->calls[] = ['pactffi_verifier_add_directory_source', $this->handle, $directory, null]; + $this->assertClientCalls($this->calls); + $this->verifier = new Verifier($this->config, $this->logger, $this->client); + $this->assertSame($this->verifier, $this->verifier->addDirectory($directory)); } - /** - * @dataProvider provideDataForVerifyAll - * - * @param string $providerName - * @param string $providerVersion - * @param bool $forceLatest - * @param mixed $expectedProviderVersion - */ - public function testIfDataForVerifyAllIsConvertedCorrectly($providerName, $providerVersion) + public function testAddUrl(): void { - $expectedUrl1 = 'expectedUrl1'; - $expectedUrl2 = 'expectedUrl2'; - $expectedPactUrls = [$expectedUrl1, $expectedUrl2]; - - $verifierProcessMock = $this->createMock(VerifierProcess::class); - $verifierProcessMock->expects($this->once()) - ->method('run') - ->with( - $this->callback(function ($args) use ($expectedUrl1, $expectedUrl2) { - return \in_array($expectedUrl1, $args) && \in_array($expectedUrl2, $args); - }) - ); - - $brokerHttpClient = $this->createMock(BrokerHttpClient::class); - - $brokerHttpClient->expects($this->once()) - ->method('getAllConsumerUrls') - ->with($this->equalTo($providerName)) - ->willReturn($expectedPactUrls); - - $config = new VerifierConfig(); - $config->setProviderName($providerName); - $config->setProviderVersion($providerVersion); - - $verifier = new Verifier($config, $verifierProcessMock, $brokerHttpClient); - $verifier->verifyAll(); + $this->setUpCalls(); + $source = new Url(); + $source + ->setUrl($url = new Uri('http://example.test/path/to/file.json')) + ->setToken($token = 'secret token') + ->setUsername($username = 'my username') + ->setPassword($password = 'secret password'); + $this->calls[] = ['pactffi_verifier_url_source', $this->handle, (string) $url, $username, $password, $token, null]; + $this->assertClientCalls($this->calls); + $this->verifier = new Verifier($this->config, $this->logger, $this->client); + $this->assertSame($this->verifier, $this->verifier->addUrl($source)); } - public function provideDataForVerifyAll() + #[TestWith([true, true, true])] + #[TestWith([false, false, false])] + #[TestWith([true, false, false])] + #[TestWith([false, true, false])] + #[TestWith([false, false, true])] + public function testAddBroker(bool $hasVersionSelectors, bool $hasProviderTags, bool $hasConsumerVersionTags): void { - return [ - ['someProvider', '1.0.0'], - ['someProvider', '1.2.3'], + $this->setUpCalls(); + $consumerVersionSelectors = (new ConsumerVersionSelectors()); + if ($hasVersionSelectors) { + $consumerVersionSelectors + ->addSelector(new Selector(tag: 'foo', latest: true)) + ->addSelector('{"tag":"bar","latest":true}'); + } + $source = new Broker(); + $source + ->setUrl($url = new Uri('http://example.test/path/to/file.json')) + ->setToken($token = 'secret token') + ->setUsername($username = 'my username') + ->setPassword($password = 'secret password') + ->setEnablePending($enablePending = true) + ->setIncludeWipPactSince($wipPactSince = '2020-01-30') + ->setProviderTags($providerTags = $hasProviderTags ? ['prod', 'staging'] : []) + ->setProviderBranch($providerBranch = 'main') + ->setConsumerVersionSelectors($consumerVersionSelectors) + ->setConsumerVersionTags($consumerVersionTags = $hasConsumerVersionTags ? ['dev'] : []); + $this->calls[] = [ + 'pactffi_verifier_broker_source_with_selectors', + $this->handle, + (string) $url, + $username, + $password, + $token, + $enablePending, + $wipPactSince, + $hasProviderTags ? $this->isInstanceOf(CData::class) : null, + $hasProviderTags ? count($providerTags) : null, + $providerBranch, + $hasVersionSelectors ? $this->isInstanceOf(CData::class) : null, + $hasVersionSelectors ? count($consumerVersionSelectors) : null, + $hasConsumerVersionTags ? $this->isInstanceOf(CData::class) : null, + $hasConsumerVersionTags ? count($consumerVersionTags) : null, + null ]; + $this->assertClientCalls($this->calls); + $this->verifier = new Verifier($this->config, $this->logger, $this->client); + $this->assertSame($this->verifier, $this->verifier->addBroker($source)); } - public function testRunShouldLogOutputIfCmdFails() + #[TestWith([0, true, false])] + #[TestWith([0, true, true])] + #[TestWith([1, false, false])] + #[TestWith([2, false, false])] + public function testVerify(int $error, bool $success, bool $hasLogger): void { - if ('\\' !== \DIRECTORY_SEPARATOR) { - $cmd = __DIR__ . \DIRECTORY_SEPARATOR . 'verifier.sh'; - } else { - $cmd = 'cmd /c' . __DIR__ . \DIRECTORY_SEPARATOR . 'verifier.bat'; + $this->setUpCalls(); + $json = '{"key": "value"}'; + $this->calls[] = ['pactffi_verifier_execute', $this->handle, $error]; + $this->logger + ->expects($this->exactly($hasLogger)) + ->method('log') + ->with($json); + if ($hasLogger) { + $this->calls[] = ['pactffi_verifier_json', $this->handle, $json]; } - - $process = new VerifierProcess(new ProcessRunnerFactory($cmd)); - - $logger = new Logger('console', [$handler = new TestHandler()]); - $process->setLogger($logger); - - try { - $exception = null; - $process->run([], 60, 10); - } catch (\Exception $e) { - $exception = $e; - } - - $logMessages = $handler->getRecords(); - - $this->assertGreaterThan(2, \count($logMessages)); - $this->assertStringContainsString('first line', $logMessages[\count($logMessages) - 2]['message']); - $this->assertStringContainsString('second line', $logMessages[\count($logMessages) - 1]['message']); - - $this->assertNotNull($exception); + $this->calls[] = ['pactffi_verifier_shutdown', $this->handle, null]; + $this->assertClientCalls($this->calls); + $this->verifier = new Verifier($this->config, $hasLogger ? $this->logger : null, $this->client); + $this->assertSame($success, $this->verifier->verify()); } } diff --git a/tests/PhpPact/Standalone/ProviderVerifier/verifier.bat b/tests/PhpPact/Standalone/ProviderVerifier/verifier.bat deleted file mode 100755 index 4cdede33..00000000 --- a/tests/PhpPact/Standalone/ProviderVerifier/verifier.bat +++ /dev/null @@ -1,8 +0,0 @@ -@ECHO OFF - -REM this script simulates a command (like pact-verifier) which prints several lines to stdout and stderr - -ECHO "first line" -ECHO "second line" 1>&2 - -exit 42 \ No newline at end of file diff --git a/tests/PhpPact/Standalone/ProviderVerifier/verifier.sh b/tests/PhpPact/Standalone/ProviderVerifier/verifier.sh deleted file mode 100755 index c196300a..00000000 --- a/tests/PhpPact/Standalone/ProviderVerifier/verifier.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -# this script simulates a command (like pact-verifier) which prints several lines to stdout and stderr - -echoerr() { echo "$@" 1>&2; } - -echo "first line" -echoerr "second line" - -exit 42 \ No newline at end of file diff --git a/tests/PhpPact/Standalone/Runner/ProcessRunnerTest.php b/tests/PhpPact/Standalone/Runner/ProcessRunnerTest.php deleted file mode 100644 index ff1b45f4..00000000 --- a/tests/PhpPact/Standalone/Runner/ProcessRunnerTest.php +++ /dev/null @@ -1,74 +0,0 @@ -runBlocking(); - $exitCode = $p->getExitCode(); - - $this->assertEquals($exitCode, 0, 'Expect the exit code to be 0'); - $this->assertStringContainsString($expectedOutput, $p->getOutput(), "Expect '{$expectedOutput}' to be in the output"); - $this->assertEquals(null, $p->getStderr(), 'Expect a null stderr'); - - // try an app that does not exists - if ('\\' !== \DIRECTORY_SEPARATOR) { - $p = new ProcessRunner('failedApp', []); - $expectedErr = 'failedApp'; - } else { - $p = new ProcessRunner('cmd /c echo myError 1>&2 && exit 42', []); - $expectedErr = 'myError'; - } - - try { - $p->runBlocking(); - } catch (\Exception $e) { - $exitCode = $p->getExitCode(); - $this->assertEquals($exitCode, $e->getCode()); - $this->assertStringContainsString("PactPHP Process returned non-zero exit code: $exitCode", $e->getMessage()); - $this->assertNotEquals($exitCode, 0, 'Expect the exit code to be non-zero: ' . $exitCode); - $this->assertStringContainsString($expectedErr, $p->getStderr(), "Expect '{$expectedErr}' to be in the stderr"); - $this->assertEquals(null, $p->getOutput(), 'Expect a null stdout'); - } - } - - /** - * @throws \Exception - */ - public function testProcessRunnerShouldReturnCompleteOutput() - { - if ('\\' !== \DIRECTORY_SEPARATOR) { - $cmd = __DIR__ . \DIRECTORY_SEPARATOR . 'verifier.sh'; - } else { - $cmd = 'cmd /c' . __DIR__ . \DIRECTORY_SEPARATOR . 'verifier.bat'; - } - - $p = new ProcessRunner($cmd, []); - $expectedOutput = 'third line'; - $expectedErr = 'fourth line'; - try { - $p->runBlocking(); - } catch (\Exception $e) { - $this->assertEquals(42, $e->getCode()); - $this->assertStringContainsString("PactPHP Process returned non-zero exit code: 42", $e->getMessage()); - } - $this->assertTrue((\stripos($p->getOutput(), $expectedOutput) !== false), "Expect '{$expectedOutput}' to be in the output:"); - $this->assertTrue((\stripos($p->getStderr(), $expectedErr) !== false), "Expect '{$expectedErr}' to be in the stderr"); - } -} diff --git a/tests/PhpPact/Standalone/Runner/verifier.bat b/tests/PhpPact/Standalone/Runner/verifier.bat deleted file mode 100755 index 3639d40f..00000000 --- a/tests/PhpPact/Standalone/Runner/verifier.bat +++ /dev/null @@ -1,11 +0,0 @@ -@ECHO OFF - -REM this script simulates a command (like pact-verifier) which prints several lines to stdout and stderr - -ECHO "first line" -ECHO "second line" 1>&2 -ECHO "third line" -ECHO "fourth line" 1>&2 -ECHO "fifth line" - -exit 42 \ No newline at end of file diff --git a/tests/PhpPact/Standalone/Runner/verifier.sh b/tests/PhpPact/Standalone/Runner/verifier.sh deleted file mode 100755 index 8290c308..00000000 --- a/tests/PhpPact/Standalone/Runner/verifier.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -# this script simulates a command (like pact-verifier) which prints several lines to stdout and stderr - -echoerr() { echo "$@" 1>&2; } - -echo "first line" -echoerr "second line" -echo "third line" -echoerr "fourth line" -echo "fifth line" - -exit 42 \ No newline at end of file diff --git a/tests/PhpPact/Standalone/StubServer/Service/StubServerHttpServiceTest.php b/tests/PhpPact/Standalone/StubServer/Service/StubServerHttpServiceTest.php deleted file mode 100644 index 2990bd58..00000000 --- a/tests/PhpPact/Standalone/StubServer/Service/StubServerHttpServiceTest.php +++ /dev/null @@ -1,63 +0,0 @@ -config = (new StubServerConfig()) - ->setPactLocation($pactLocation) - ->setHost($host) - ->setPort($port) - ->setEndpoint($endpoint); - - $this->stubServer = new StubServer($this->config); - $this->stubServer->start(10); - $this->service = new StubServerHttpService(new GuzzleClient(), $this->config); - } - - protected function tearDown(): void - { - $this->stubServer->stop(); - } - - public function testHealthCheck() - { - $result = $this->service->healthCheck(); - $this->assertTrue($result); - } - - public function testGetJson() - { - $result = $this->service->getJson(); - $this->assertEquals('{"results":[{"name":"Games"}]}', $result); - } -} diff --git a/tests/PhpPact/Standalone/StubServer/StubServerConfigTest.php b/tests/PhpPact/Standalone/StubServer/StubServerConfigTest.php index db9aed12..1e0e77f3 100644 --- a/tests/PhpPact/Standalone/StubServer/StubServerConfigTest.php +++ b/tests/PhpPact/Standalone/StubServer/StubServerConfigTest.php @@ -2,6 +2,7 @@ namespace PhpPactTest\Standalone\StubServer; +use GuzzleHttp\Psr7\Uri; use PhpPact\Standalone\StubService\StubServerConfig; use PHPUnit\Framework\TestCase; @@ -9,20 +10,59 @@ class StubServerConfigTest extends TestCase { public function testSetters() { - $pactLocation = __DIR__ . '/../../../_resources/someconsumer-someprovider.json'; - $host = 'test-host'; - $port = 1234; - $log = 'test-log-dir/'; + $brokerUrl = new Uri('http://localhost'); + $port = 1234; + $extension = 'json'; + $logLevel = 'debug'; + $providerState = 'state'; + $providerStateHeaderName = 'header'; + $token = 'token'; + $user = 'user:password'; + $dirs = ['/path/to/pacts']; + $files = ['/path/to/pact.json']; + $urls = ['http://example.com/path/to/file.json']; + $consumerNames = ['consumer-1', 'consumer-2']; + $providerNames = ['provider-1', 'provider-2']; + $cors = true; + $corsReferer = true; + $emptyProviderState = true; + $insecureTls = true; $subject = (new StubServerConfig()) - ->setPactLocation($pactLocation) - ->setHost($host) + ->setBrokerUrl($brokerUrl) ->setPort($port) - ->setLog($log); + ->setExtension($extension) + ->setLogLevel($logLevel) + ->setProviderState($providerState) + ->setProviderStateHeaderName($providerStateHeaderName) + ->setToken($token) + ->setUser($user) + ->setDirs($dirs) + ->setFiles($files) + ->setUrls($urls) + ->setConsumerNames($consumerNames) + ->setProviderNames($providerNames) + ->setCors($cors) + ->setCorsReferer($corsReferer) + ->setEmptyProviderState($emptyProviderState) + ->setInsecureTls($insecureTls); - static::assertSame($pactLocation, $subject->getPactLocation()); - static::assertSame($host, $subject->getHost()); + static::assertSame($brokerUrl, $subject->getBrokerUrl()); static::assertSame($port, $subject->getPort()); - static::assertSame($log, $subject->getLog()); + static::assertSame($extension, $subject->getExtension()); + static::assertSame($logLevel, $subject->getLogLevel()); + static::assertSame($providerState, $subject->getProviderState()); + static::assertSame($providerStateHeaderName, $subject->getProviderStateHeaderName()); + static::assertSame($token, $subject->getToken()); + static::assertSame($user, $subject->getUser()); + static::assertSame($dirs, $subject->getDirs()); + static::assertSame($files, $subject->getFiles()); + static::assertSame($urls, $subject->getUrls()); + static::assertSame($consumerNames, $subject->getConsumerNames()); + static::assertSame($providerNames, $subject->getProviderNames()); + static::assertSame($cors, $subject->isCors()); + static::assertSame($corsReferer, $subject->isCorsReferer()); + static::assertSame($emptyProviderState, $subject->isEmptyProviderState()); + static::assertSame($insecureTls, $subject->isInsecureTls()); } } diff --git a/tests/PhpPact/Standalone/StubServer/StubServerTest.php b/tests/PhpPact/Standalone/StubServer/StubServerTest.php index 963fabf8..defb0901 100644 --- a/tests/PhpPact/Standalone/StubServer/StubServerTest.php +++ b/tests/PhpPact/Standalone/StubServer/StubServerTest.php @@ -2,35 +2,105 @@ namespace PhpPactTest\Standalone\StubServer; +use PhpPact\Standalone\StubService\Exception\LogLevelNotSupportedException; use PhpPact\Standalone\StubService\StubServer; use PhpPact\Standalone\StubService\StubServerConfig; +use PhpPact\Standalone\StubService\StubServerConfigInterface; +use PHPUnit\Framework\Attributes\TestWith; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\Process; class StubServerTest extends TestCase { - /** - * @throws \Exception - */ - public function testStartAndStop() + protected StubServer $server; + protected StubServerConfigInterface $config; + protected Process|MockObject $process; + + public function setUp(): void + { + $this->process = $this->createMock(Process::class); + $this->config = new StubServerConfig(); + $this->server = new StubServer($this->config, $this->process); + } + + #[TestWith([null , true])] + #[TestWith(['trace', true])] + #[TestWith(['debug', true])] + #[TestWith(['info' , true])] + #[TestWith(['warn' , true])] + #[TestWith(['error', true])] + #[TestWith(['none' , false])] + public function testStart(?string $logLevel, bool $hasCallback): void + { + $this->config->setLogLevel($logLevel); + $this->config->setPort(123); + $this->process + ->expects($this->once()) + ->method('start') + ->with($hasCallback ? $this->callback(fn ($callback) => is_callable($callback)) : null); + $this->process + ->expects($this->once()) + ->method('getPid') + ->willReturn($pid = 1234); + $this->assertSame($pid, $this->server->start()); + } + + #[TestWith([null , false])] + #[TestWith(['trace', false])] + #[TestWith(['debug', false])] + #[TestWith(['info' , false])] + #[TestWith([null , true])] + #[TestWith(['trace', true])] + #[TestWith(['debug', true])] + #[TestWith(['info' , true])] + public function testStartWithSupportedLogLevelOnRandomPort(?string $logLevel, bool $started): void + { + $this->config->setLogLevel($logLevel); + $this->config->setPort(0); + $this->process + ->expects($this->once()) + ->method('start'); + $this->process + ->expects($this->once()) + ->method('getPid') + ->willReturn($pid = 1234); + $this->process + ->expects($this->once()) + ->method('waitUntil') + ->with($this->callback(function (callable $callback) use ($started) { + $port = 123; + $this->assertSame($started ? 1 : 0, $callback('out', $started ? "Server started on port $port" : 'not started')); + $this->assertSame($started ? $port : 0, $this->config->getPort()); + + return true; + })); + $this->assertSame($pid, $this->server->start()); + } + + #[TestWith(['warn'])] + #[TestWith(['error'])] + #[TestWith(['none'])] + public function testStartWithUnsupportedLogLevelOnRandomPort(string $logLevel): void + { + $this->config->setLogLevel($logLevel); + $this->config->setPort(0); + $this->process + ->expects($this->once()) + ->method('start'); + $this->process + ->expects($this->never()) + ->method('getPid'); + $this->expectException(LogLevelNotSupportedException::class); + $this->expectExceptionMessage("Setting random port for stub server required log level 'info', 'debug' or 'trace'. '$logLevel' given."); + $this->server->start(); + } + + public function testStop(): void { - try { - $pactLocation = __DIR__ . '/../../../_resources/someconsumer-someprovider.json'; - $host = 'localhost'; - $port = 7201; - $endpoint = 'test'; - - $subject = (new StubServerConfig()) - ->setPactLocation($pactLocation) - ->setHost($host) - ->setPort($port) - ->setEndpoint($endpoint); - - $stubServer = new StubServer($subject); - $pid = $stubServer->start(); - $this->assertTrue(\is_int($pid)); - } finally { - $result = $stubServer->stop(); - $this->assertTrue($result); - } + $this->process + ->expects($this->once()) + ->method('stop'); + $this->assertTrue($this->server->stop()); } } diff --git a/tests/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriverTest.php b/tests/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriverTest.php new file mode 100644 index 00000000..de252d19 --- /dev/null +++ b/tests/PhpPact/SyncMessage/Driver/Interaction/SyncMessageDriverTest.php @@ -0,0 +1,198 @@ + [ + 'id' => 12, + 'name' => 'abc', + ] + ]; + private array $metadata = [ + 'key1' => 'value1', + 'key2' => 'value2', + ]; + + public function setUp(): void + { + $this->mockServer = $this->createMock(MockServerInterface::class); + $this->client = $this->createMock(ClientInterface::class); + $this->pactDriver = $this->createMock(PactDriverInterface::class); + $this->messageBodyDriver = $this->createMock(MessageBodyDriverInterface::class); + $this->driver = new SyncMessageDriver($this->mockServer, $this->client, $this->pactDriver, $this->messageBodyDriver); + $this->message = new Message(); + $this->message->setDescription($this->description); + foreach ($this->providerStates as $name => $params) { + $this->message->addProviderState($name, $params); + } + $this->message->setMetadata($this->metadata); + } + + public function testVerifyMessage(): void + { + $result = new VerifyResult(false, 'some mismatches'); + $this->mockServer + ->expects($this->once()) + ->method('verify') + ->willReturn($result); + $this->assertSame($result, $this->driver->verifyMessage()); + } + + public function testWritePactAndCleanUp(): void + { + $this->mockServer + ->expects($this->once()) + ->method('writePact'); + $this->mockServer + ->expects($this->once()) + ->method('cleanUp'); + $this->driver->writePactAndCleanUp(); + } + + public function testRegisterInteraction(): void + { + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $this->messageBodyDriver + ->expects($this->once()) + ->method('registerBody') + ->with($this->message); + $calls = [ + ['pactffi_new_sync_message_interaction', $this->pactHandle, $this->description, $this->messageHandle], + ['pactffi_given', $this->messageHandle, 'item exist', null], + ['pactffi_given_with_param', $this->messageHandle, 'item exist', 'id', '12', null], + ['pactffi_given_with_param', $this->messageHandle, 'item exist', 'name', 'abc', null], + ['pactffi_message_expects_to_receive', $this->messageHandle, $this->description, null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key1', 'value1', null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key2', 'value2', null], + ]; + $this->assertClientCalls($calls); + $this->driver->registerMessage($this->message); + $this->assertSame($this->messageHandle, $this->message->getHandle()); + } + + #[TestWith([null, true])] + #[TestWith([null, true])] + #[TestWith(['123ABC', false])] + #[TestWith(['123ABC', true])] + public function testSetKey(?string $key, $success): void + { + $this->message->setKey($key); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_sync_message_interaction', $this->pactHandle, $this->description, $this->messageHandle], + ['pactffi_given', $this->messageHandle, 'item exist', null], + ['pactffi_given_with_param', $this->messageHandle, 'item exist', 'id', '12', null], + ['pactffi_given_with_param', $this->messageHandle, 'item exist', 'name', 'abc', null], + ['pactffi_message_expects_to_receive', $this->messageHandle, $this->description, null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key1', 'value1', null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key2', 'value2', null], + ]; + if (is_string($key)) { + $calls[] = ['pactffi_set_key', $this->messageHandle, $key, $success]; + } + if (!$success) { + $this->expectException(InteractionKeyNotSetException::class); + $this->expectExceptionMessage("Can not set the key '$key' for the interaction '{$this->description}'"); + } + $this->assertClientCalls($calls); + $this->driver->registerMessage($this->message); + } + + #[TestWith([null, true])] + #[TestWith([null, true])] + #[TestWith([true, false])] + #[TestWith([true, true])] + #[TestWith([false, false])] + #[TestWith([false, true])] + public function testSetPending(?bool $pending, $success): void + { + $this->message->setPending($pending); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_sync_message_interaction', $this->pactHandle, $this->description, $this->messageHandle], + ['pactffi_given', $this->messageHandle, 'item exist', null], + ['pactffi_given_with_param', $this->messageHandle, 'item exist', 'id', '12', null], + ['pactffi_given_with_param', $this->messageHandle, 'item exist', 'name', 'abc', null], + ['pactffi_message_expects_to_receive', $this->messageHandle, $this->description, null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key1', 'value1', null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key2', 'value2', null], + ]; + if (is_bool($pending)) { + $calls[] = ['pactffi_set_pending', $this->messageHandle, $pending, $success]; + } + if (!$success) { + $this->expectException(InteractionPendingNotSetException::class); + $this->expectExceptionMessage("Can not mark interaction '{$this->description}' as pending"); + } + $this->assertClientCalls($calls); + $this->driver->registerMessage($this->message); + } + + #[TestWith([[], true])] + #[TestWith([['key1' => 'value1'], false])] + #[TestWith([['key2' => 'value2', 'key3' => 'value3'], true])] + public function testSetComments(array $comments, $success): void + { + $this->message->setComments($comments); + $this->pactDriver + ->expects($this->once()) + ->method('getPact') + ->willReturn(new Pact($this->pactHandle)); + $calls = [ + ['pactffi_new_sync_message_interaction', $this->pactHandle, $this->description, $this->messageHandle], + ['pactffi_given', $this->messageHandle, 'item exist', null], + ['pactffi_given_with_param', $this->messageHandle, 'item exist', 'id', '12', null], + ['pactffi_given_with_param', $this->messageHandle, 'item exist', 'name', 'abc', null], + ['pactffi_message_expects_to_receive', $this->messageHandle, $this->description, null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key1', 'value1', null], + ['pactffi_message_with_metadata_v2', $this->messageHandle, 'key2', 'value2', null], + ]; + foreach ($comments as $key => $value) { + $calls[] = ['pactffi_set_comment', $this->messageHandle, $key, $value, $success]; + } + if (!$success) { + $this->expectException(InteractionCommentNotSetException::class); + $this->expectExceptionMessage("Can add comment '$key' to the interaction '{$this->description}'"); + } + $this->assertClientCalls($calls); + $this->driver->registerMessage($this->message); + } +} diff --git a/tests/PhpPact/SyncMessage/Factory/SyncMessageDriverFactoryTest.php b/tests/PhpPact/SyncMessage/Factory/SyncMessageDriverFactoryTest.php new file mode 100644 index 00000000..3ca3f95e --- /dev/null +++ b/tests/PhpPact/SyncMessage/Factory/SyncMessageDriverFactoryTest.php @@ -0,0 +1,41 @@ +config = $this->createMock(MockServerConfigInterface::class); + $this->config + ->expects($this->any()) + ->method('getPactSpecificationVersion') + ->willReturn('3.0.0'); + } + + public function testCreate(): void + { + $this->factory = new SyncMessageDriverFactory(); + $driver = $this->factory->create($this->config); + $this->assertPropertiesInstanceOf($driver, null, [ + 'client' => Client::class, + 'mockServer' => MockServer::class, + 'pactDriver' => PactDriver::class, + ]); + } +} diff --git a/tests/PhpPact/SyncMessage/SyncMessageBuilderTest.php b/tests/PhpPact/SyncMessage/SyncMessageBuilderTest.php new file mode 100644 index 00000000..6559009b --- /dev/null +++ b/tests/PhpPact/SyncMessage/SyncMessageBuilderTest.php @@ -0,0 +1,138 @@ +driver = $this->createMock(SyncMessageDriverInterface::class); + $this->config = $this->createMock(MockServerConfigInterface::class); + $this->driverFactory = $this->createMock(SyncMessageDriverFactoryInterface::class); + $this->driverFactory + ->expects($this->once()) + ->method('create') + ->with($this->config) + ->willReturn($this->driver); + $this->builder = new SyncMessageBuilder($this->config, $this->driverFactory); + } + + public function testGiven(): void + { + $this->assertSame($this->builder, $this->builder->given('test', ['key' => 'value'])); + $message = $this->getMessage(); + $providerStates = $message->getProviderStates(); + $this->assertCount(1, $providerStates); + $providerState = $providerStates[0]; + $this->assertInstanceOf(ProviderState::class, $providerState); + $this->assertSame('test', $providerState->getName()); + $this->assertSame(['key' => 'value'], $providerState->getParams()); + } + + public function testExpectsToReceive(): void + { + $description = 'message description'; + $this->assertSame($this->builder, $this->builder->expectsToReceive($description)); + $message = $this->getMessage(); + $this->assertSame($description, $message->getDescription()); + } + + public function testWithMetadata(): void + { + $metadata = ['key' => 'value']; + $this->assertSame($this->builder, $this->builder->withMetadata($metadata)); + $message = $this->getMessage(); + $this->assertSame($metadata, $message->getMetadata()); + } + + #[TestWith([null , null])] + #[TestWith([new Text('example', 'text/plain') , null])] + #[TestWith([new Binary('/path/to/image.jpg', 'image/jpeg'), null])] + #[TestWith(['example text' , Text::class])] + #[TestWith([['key' => 'value'] , Text::class])] + public function testWithContent(mixed $content, ?string $contentClass): void + { + $this->assertSame($this->builder, $this->builder->withContent($content)); + $message = $this->getMessage(); + if ($contentClass) { + $this->assertInstanceOf($contentClass, $message->getContents()); + } else { + $this->assertSame($content, $message->getContents()); + } + } + + #[TestWith([null])] + #[TestWith(['key'])] + public function testSetKey(?string $key): void + { + $this->assertSame($this->builder, $this->builder->key($key)); + $message = $this->getMessage(); + $this->assertSame($key, $message->getKey()); + } + + #[TestWith([null])] + #[TestWith([false])] + #[TestWith([true])] + public function testSetPending(?bool $pending): void + { + $this->assertSame($this->builder, $this->builder->pending($pending)); + $message = $this->getMessage(); + $this->assertSame($pending, $message->getPending()); + } + + #[TestWith([[]])] + #[TestWith([['key' => 'value']])] + public function testSetComments(array $comments): void + { + $this->assertSame($this->builder, $this->builder->comments($comments)); + $message = $this->getMessage(); + $this->assertSame($comments, $message->getComments()); + } + + public function testRegisterMessage(): void + { + $message = $this->getMessage(); + $this->driver + ->expects($this->once()) + ->method('registerMessage') + ->with($message); + $this->builder->registerMessage(); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testVerify(bool $matched): void + { + $this->driver + ->expects($this->once()) + ->method('verifyMessage') + ->willReturn(new VerifyResult($matched, '')); + $this->assertSame($matched, $this->builder->verify()); + } + + private function getMessage(): Message + { + $reflection = new ReflectionProperty($this->builder, 'message'); + + return $reflection->getValue($this->builder); + } +} diff --git a/tests/PhpPact/Xml/XmlBuilderTest.php b/tests/PhpPact/Xml/XmlBuilderTest.php new file mode 100644 index 00000000..0c5de8d6 --- /dev/null +++ b/tests/PhpPact/Xml/XmlBuilderTest.php @@ -0,0 +1,158 @@ +builder = new XmlBuilder('1.0', 'UTF-8'); + $this->matcher = new Matcher(); + } + + public function testJsonSerializeInvalidXmlElement(): void + { + $this->expectException(InvalidXmlElementException::class); + $this->expectExceptionMessage("Xml element's name is required"); + + $this->builder + ->root( + $this->builder->name('Root'), + $this->builder->add(), + ) + ; + + json_encode($this->builder); + } + + public function testJsonSerialize(): void + { + $this->builder + ->root( + $this->builder->name('ns1:projects'), + $this->builder->attribute('id', '1234'), + $this->builder->attribute('xmlns:ns1', 'http://some.namespace/and/more/stuff'), + $this->builder->content('List of projects'), + $this->builder->eachLike( + $this->builder->examples(2), + $this->builder->name('ns1:project'), + $this->builder->attribute('id', $this->matcher->integerV3(1)), + $this->builder->attribute('type', 'activity'), + $this->builder->attribute('name', $this->matcher->string('Project 1')), + $this->builder->attribute('due', $this->matcher->datetime("yyyy-MM-dd'T'HH:mm:ss.SZ", '2016-02-11T09:46:56.023Z')), + $this->builder->contentLike('Project 1 description'), + $this->builder->add( + $this->builder->name('ns1:tasks'), + $this->builder->content('List of tasks'), + $this->builder->eachLike( + $this->builder->examples(5), + $this->builder->name('ns1:task'), + $this->builder->attribute('id', $this->matcher->integerV3(1)), + $this->builder->attribute('name', $this->matcher->string('Task 1')), + $this->builder->attribute('done', $this->matcher->boolean()), + $this->builder->contentLike('Task 1 description'), + ), + ), + ), + ); + + $expectedArray = [ + 'version' => '1.0', + 'charset' => 'UTF-8', + 'root' => [ + 'name' => 'ns1:projects', + 'children' => [ + [ + 'pact:matcher:type' => 'type', + 'value' => [ + 'name' => 'ns1:project', + 'children' => [ + [ + 'name' => 'ns1:tasks', + 'children' => [ + [ + 'pact:matcher:type' => 'type', + 'value' => [ + 'name' => 'ns1:task', + 'children' => [ + [ + 'content' => + 'Task 1 description', + 'matcher' => [ + 'pact:matcher:type' => + 'type', + ], + ], + ], + 'attributes' => [ + 'id' => [ + 'pact:matcher:type' => + 'integer', + 'value' => 1, + ], + 'name' => [ + 'pact:matcher:type' => 'type', + 'value' => 'Task 1', + ], + 'done' => [ + 'pact:matcher:type' => 'type', + 'value' => true, + ], + ], + ], + 'examples' => 5, + ], + [ + 'content' => 'List of tasks', + ], + ], + 'attributes' => [], + ], + [ + 'content' => 'Project 1 description', + 'matcher' => ['pact:matcher:type' => 'type'], + ], + ], + 'attributes' => [ + 'id' => [ + 'pact:matcher:type' => 'integer', + 'value' => 1, + ], + 'type' => 'activity', + 'name' => [ + 'pact:matcher:type' => 'type', + 'value' => 'Project 1', + ], + 'due' => [ + 'pact:matcher:type' => 'datetime', + 'format' => "yyyy-MM-dd'T'HH:mm:ss.SZ", + 'value' => '2016-02-11T09:46:56.023Z', + ], + ], + ], + 'examples' => 2, + ], + [ + 'content' => 'List of projects', + ], + ], + 'attributes' => [ + 'id' => '1234', + 'xmlns:ns1' => 'http://some.namespace/and/more/stuff', + ], + ], + ]; + + $this->assertSame(json_encode($expectedArray), json_encode($this->builder)); + } +} diff --git a/tests/PhpPact/Xml/XmlElementTest.php b/tests/PhpPact/Xml/XmlElementTest.php new file mode 100644 index 00000000..9b989f77 --- /dev/null +++ b/tests/PhpPact/Xml/XmlElementTest.php @@ -0,0 +1,72 @@ + $element->setName('Child'), + ); + $this->element = new XmlElement( + fn (XmlElement $element) => $element->setName('Parent'), + fn (XmlElement $element) => $element->addAttribute('myAttr', 'attr-value'), + fn (XmlElement $element) => $element->setExamples(7), + fn (XmlElement $element) => $element->addChild($child), + ); + } + + public function testJsonSerializeWithoutText(): void + { + $this->assertSame( + json_encode([ + 'name' => 'Parent', + 'children' => [ + [ + 'name' => 'Child', + 'children' => [], + 'attributes' => [], + ], + ], + 'attributes' => [ + 'myAttr' => 'attr-value', + ], + 'examples' => 7, + ]), + json_encode($this->element) + ); + } + + public function testJsonSerializeWithText(): void + { + $this->element->setText(new XmlText('Inner text')); + + $this->assertSame( + json_encode([ + 'name' => 'Parent', + 'children' => [ + [ + 'name' => 'Child', + 'children' => [], + 'attributes' => [], + ], + [ + 'content' => 'Inner text', + ], + ], + 'attributes' => [ + 'myAttr' => 'attr-value', + ], + 'examples' => 7, + ]), + json_encode($this->element) + ); + } +} diff --git a/tests/PhpPact/Xml/XmlTextTest.php b/tests/PhpPact/Xml/XmlTextTest.php new file mode 100644 index 00000000..4094b9e4 --- /dev/null +++ b/tests/PhpPact/Xml/XmlTextTest.php @@ -0,0 +1,46 @@ +assertSame(json_encode(['content' => $content]), json_encode($text)); + } + + public function testJsonSerializeMatcher(): void + { + $matcher = new Integer(); + $matcher->setGenerator(new RandomInt(2, 8)); + $matcher->setFormatter(new XmlContentFormatter()); + $text = new XmlText($matcher); + $this->assertSame( + json_encode([ + 'content' => null, + 'matcher' => [ + 'pact:matcher:type' => 'integer', + 'min' => 2, + 'max' => 8, + ], + 'pact:generator:type' => 'RandomInt' + ]), + json_encode($text) + ); + } +} diff --git a/tests/_resources/image.jpg b/tests/_resources/image.jpg new file mode 100644 index 00000000..8c1702a1 Binary files /dev/null and b/tests/_resources/image.jpg differ