From aa2ae7100af792727939f0899c42ca20dc9a56d5 Mon Sep 17 00:00:00 2001 From: Sascha Greuel Date: Mon, 5 Oct 2026 13:14:14 +0200 Subject: [PATCH] Add JSON payload contract assertions --- .github/workflows/main.yml | 4 + composer.json | 3 +- src/Codeception/Module/REST.php | 89 ++++++++++++++++++++++ tests/unit/Codeception/Module/RestTest.php | 81 ++++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48b431d..942e715 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -39,6 +39,10 @@ jobs: - name: Validate composer.json and composer.lock run: composer validate + - name: Add optional JSON Payload Contract integration + if: ${{ matrix.php != '8.2' }} + run: composer require --dev --no-update softcreatr/json-payload-contract:^1.0 + - name: Install dependencies run: composer update --prefer-dist --no-progress --no-interaction --no-suggest ${{ matrix.composer-options }} diff --git a/composer.json b/composer.json index 689ceab..b3a16da 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "codeception/util-universalframework": "^2.0" }, "suggest": { - "aws/aws-sdk-php": "For using AWS Auth" + "aws/aws-sdk-php": "For using AWS Auth", + "softcreatr/json-payload-contract": "For validating and normalizing JSON responses against payload contracts" }, "minimum-stability": "RC", "autoload": { diff --git a/src/Codeception/Module/REST.php b/src/Codeception/Module/REST.php index b7b6c33..7da42e9 100644 --- a/src/Codeception/Module/REST.php +++ b/src/Codeception/Module/REST.php @@ -28,6 +28,9 @@ use JsonSchema\Validator as JsonSchemaValidator; use JsonSerializable; use PHPUnit\Framework\Assert; +use SoftCreatR\JsonPayloadContract\Contract; +use SoftCreatR\JsonPayloadContract\Result; +use SoftCreatR\JsonPayloadContract\Violation; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\HttpKernel\HttpKernelBrowser; @@ -1063,6 +1066,92 @@ public function grabDataFromResponseByJsonPath(string $jsonPath): array return (new JsonArray($this->connectionModule->_getResponseContent()))->filterByJsonPath($jsonPath); } + /** + * Checks whether the last JSON response satisfies a JSON Payload Contract. + * + * This assertion validates and normalizes the response in one pass. Contract + * violations include the output field, stable violation code, message, and + * selector when available. + * + * JSON Payload Contract is an optional integration and requires PHP 8.3 or + * newer. Install it with: + * + * ``` shell + * composer require --dev softcreatr/json-payload-contract + * ``` + * + * Example: + * + * ``` php + * Field::required('$.data.user.id') + * ->fallback('$.user.id') + * ->integer(), + * 'email' => Field::required('$.data.user.email') + * ->fallback('$.user.email') + * ->string() + * ->email(), + * ]); + * + * $I->seeResponseMatchesJsonPayloadContract($contract); + * ``` + * + * @part json + */ + public function seeResponseMatchesJsonPayloadContract(Contract $contract): void + { + $result = $contract->extractJson($this->connectionModule->_getResponseContent()); + + Assert::assertTrue($result->isValid(), $this->formatJsonPayloadContractViolations($result)); + } + + /** + * Returns normalized data from the last JSON response using a JSON Payload Contract. + * + * The action throws an extraction exception containing every contract + * violation when the response is malformed or does not satisfy the contract. + * + * Example: + * + * ``` php + * grabDataFromResponseByJsonPayloadContract($contract); + * $I->sendPost('/users', $user); + * ``` + * + * @part json + * @return array Normalized contract output + * @throws \SoftCreatR\JsonPayloadContract\Exception\ExtractionException + */ + public function grabDataFromResponseByJsonPayloadContract(Contract $contract): array + { + return $contract->applyJson($this->connectionModule->_getResponseContent()); + } + + private function formatJsonPayloadContractViolations(Result $result): string + { + $violations = array_map( + static function (Violation $violation): string { + $selector = $violation->selector === null ? '' : sprintf(' (selector: %s)', $violation->selector); + + return sprintf( + '- %s [%s]: %s%s', + $violation->field, + $violation->code->value, + $violation->message, + $selector + ); + }, + $result->violations() + ); + + return "Response does not satisfy the JSON payload contract:\n" . implode("\n", $violations); + } + /** * Checks if json structure in response matches the xpath provided. * JSON is not supposed to be checked against XPath, yet it can be converted to xml and used with XPath. diff --git a/tests/unit/Codeception/Module/RestTest.php b/tests/unit/Codeception/Module/RestTest.php index 114800a..8e62066 100644 --- a/tests/unit/Codeception/Module/RestTest.php +++ b/tests/unit/Codeception/Module/RestTest.php @@ -16,6 +16,9 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\ExpectationFailedException; +use SoftCreatR\JsonPayloadContract\Contract; +use SoftCreatR\JsonPayloadContract\Exception\ExtractionException; +use SoftCreatR\JsonPayloadContract\Field; use Symfony\Component\BrowserKit\Request as SymfonyRequest; use Symfony\Component\BrowserKit\Response as SymfonyResponse; @@ -130,6 +133,77 @@ public function testGrabDataFromResponseByJsonPath() $this->assertSame([], $this->module->grabDataFromResponseByJsonPath('$.address.street')); } + public function testSeeResponseMatchesJsonPayloadContract() + { + $this->requireJsonPayloadContract(); + $this->setStubResponse('{"data":{"user":{"id":42,"email":"john@example.com"}}}'); + + $contract = Contract::define([ + 'id' => Field::required('$.data.user.id')->integer(), + 'email' => Field::required('$.data.user.email')->string()->email(), + ]); + + $this->module->seeResponseMatchesJsonPayloadContract($contract); + } + + public function testSeeResponseMatchesJsonPayloadContractReportsAllViolations() + { + $this->requireJsonPayloadContract(); + $this->setStubResponse('{"data":{"user":{"id":"42","email":"invalid"}}}'); + + $contract = Contract::define([ + 'id' => Field::required('$.data.user.id')->integer(), + 'email' => Field::required('$.data.user.email')->string()->email(), + 'name' => Field::required('$.data.user.name')->string(), + ]); + + $this->expectException(ExpectationFailedException::class); + $this->expectExceptionMessage( + "Response does not satisfy the JSON payload contract:\n" + . '- id [unexpected_type]: Expected integer, got string. (selector: $.data.user.id)' . "\n" + . '- email [invalid_email]: The value must be a valid email address. (selector: $.data.user.email)' . "\n" + . '- name [missing_required]: The required field did not match any value.' + ); + + $this->module->seeResponseMatchesJsonPayloadContract($contract); + } + + public function testGrabDataFromResponseByJsonPayloadContractNormalizesFallbackData() + { + $this->requireJsonPayloadContract(); + $this->setStubResponse('{"user":{"id":"42","roles":["admin","billing"]}}'); + + $contract = Contract::define([ + 'id' => Field::required('$.data.user.id') + ->fallback('$.user.id') + ->integer() + ->coerce(), + 'roles' => Field::many('$.data.user.roles[*]') + ->fallback('$.user.roles[*]') + ->string(), + ]); + + $this->assertSame( + ['id' => 42, 'roles' => ['admin', 'billing']], + $this->module->grabDataFromResponseByJsonPayloadContract($contract) + ); + } + + public function testGrabDataFromResponseByJsonPayloadContractRejectsInvalidJson() + { + $this->requireJsonPayloadContract(); + $this->setStubResponse('{invalid'); + + $contract = Contract::define([ + 'id' => Field::required('$.id')->integer(), + ]); + + $this->expectException(ExtractionException::class); + $this->expectExceptionMessage('Payload does not satisfy the contract: $: Syntax error'); + + $this->module->grabDataFromResponseByJsonPayloadContract($contract); + } + public function testValidJson() { $this->setStubResponse('{"xxx": "yyy"}'); @@ -139,6 +213,13 @@ public function testValidJson() $this->module->seeResponseEquals('{"xxx": "yyy", "zzz": ["a","b"]}'); } + private function requireJsonPayloadContract(): void + { + if (!class_exists(Contract::class)) { + $this->markTestSkipped('JSON Payload Contract requires PHP 8.3 or newer.'); + } + } + public function testInvalidJson() { $this->expectException(ExpectationFailedException::class);