diff --git a/docs/steps.md b/docs/steps.md index 7272023..c28276b 100644 --- a/docs/steps.md +++ b/docs/steps.md @@ -3,8 +3,9 @@ ## Table of Contents * [Introduction](#introduction) -* [🧪Step: `Given the ":headerName" request header contains ":value"`](#step-given-the-headername-request-header-contains-value) -* [🧪Step: `Given the ":headerName" request header contains multiline value`](#step-given-the-headername-request-header-contains-multiline-value) +* [🧪Step: `Given the "…" request header contains "…"`](#step-given-the--request-header-contains-) +* [🧪Step: `Given the "…" request header contains multiline value`](#step-given-the--request-header-contains-multiline-value) +* [🧪Step: `Given the request JSON content type is used`](#step-given-the-request-json-content-type-is-used) * [🧪Step: `Given the request ip is ":ip"`](#step-given-the-request-ip-is-ip) * [🧪Step: `Given the request contains params`](#step-given-the-request-contains-params) * [🧪Step: `When I send ":method" request to ":route" route`](#step-when-i-send-method-request-to-route-route) @@ -14,7 +15,7 @@ * [🧪Step: `Then response should be JSON`](#step-then-response-should-be-json) * [🧪Step: `When I save ":paramPath" param from json response as ":valueKey"`](#step-when-i-save-parampath-param-from-json-response-as-valuekey) * [🧪Step: `Then response should be JSON with variable fields ":variableFields"`](#step-then-response-should-be-json-with-variable-fields-variablefields) -* [🧪Step: `Then the ":headerName" response headers contains ":headerValue"`](#step-then-the-headername-response-headers-contains-headervalue) +* [🧪Step: `Then the "…" response headers contains "…"`](#step-then-the--response-headers-contains-) * [📝Notes](#-notes) --- @@ -25,19 +26,22 @@ This document describes the Behat step definitions used in the `ApiContext` clas --- -### 🧪Step: `Given the ":headerName" request header contains ":value"` +### 🧪Step: `Given the "…" request header contains "…"` Set or replace the specified HTTP request header with the given value. Supports variable substitutions from saved context variables. +The header name and value **must be double-quoted** in Gherkin. This avoids Turnip placeholder limits (unquoted tokens do not include `-` or `/`, so values like `application/json` and names like `Content-Type` would not match). + ```gherkin Given the "Authorization" request header contains "Bearer abc123" +Given the "Content-Type" request header contains "application/json" ``` --- -### 🧪Step: `Given the ":headerName" request header contains multiline value` +### 🧪Step: `Given the "…" request header contains multiline value` -Set or replace the specified HTTP request header with a multiline value block. +Set or replace the specified HTTP request header with a multiline value block. The header name must be double-quoted (same rules as the single-line header step). ```gherkin Given the "Authorization" request header contains multiline value: @@ -50,6 +54,16 @@ Given the "Authorization" request header contains multiline value: --- +### 🧪Step: `Given the request JSON content type is used` + +Equivalent to `Given the "Content-Type" request header contains "application/json"`. Use this so POST, PUT, and PATCH requests JSON-encode the payload from `Given the request contains params` (see the send step below). + +```gherkin +Given the request JSON content type is used +``` + +--- + ### 🧪Step: `Given the request ip is ":ip"` Set the client IP address for the request by modifying the `REMOTE_ADDR` server parameter. @@ -81,6 +95,8 @@ Given the request contains params: Sends an HTTP request with the specified method (`GET`, `POST`, `PUT`, `PATCH`) to the Symfony route named `:route`. Uses previously configured headers and parameters. +For **POST**, **PUT**, and **PATCH**, the body is JSON-encoded when `Content-Type` contains `application/json` (set with the quoted header step or `Given the request JSON content type is used`). Otherwise parameters are sent as form fields. + ```gherkin When I send "POST" request to "api_login" route ``` @@ -104,12 +120,7 @@ Asserts that the response body contains valid, non-empty JSON. ```gherkin Then response is JSON ``` - { - "name": "status", - "result": true, - "message": "up", - "params": [] - } + --- ### 🧪Step: `Then response should be empty` @@ -180,9 +191,9 @@ This allows flexibility in matching dynamic values while still validating the st --- -### 🧪Step: `Then the ":headerName" response headers contains ":headerValue"` +### 🧪Step: `Then the "…" response headers contains "…"` -Asserts that the response contains the specified header with a value that includes the given substring. +Asserts that the response contains the specified header with a value that includes the given substring. The header name and expected fragment **must be double-quoted** (same reason as request header steps). ```gherkin Then the "Content-Type" response headers contains "application/json" @@ -191,6 +202,7 @@ Then the "Content-Type" response headers contains "application/json" --- ### 📝 Notes +- ⚠️ **Breaking change (header steps):** Unquoted header names and values are no longer accepted for the request and response header steps; use double quotes (or `Given the request JSON content type is used` for JSON APIs). - ✅ Variable substitutions ({{variable}}) are supported in headers and body. - ✅ PHP expressions (, , etc.) are dynamically evaluated. - ✅ Saved values can be reused across steps for chaining and correlation. diff --git a/src/Context/ApiContext.php b/src/Context/ApiContext.php index 4998c91..e5ca3c9 100644 --- a/src/Context/ApiContext.php +++ b/src/Context/ApiContext.php @@ -79,7 +79,11 @@ public function beforeScenario(): void } /** - * @Given the :headerName request header contains :value + * Sets a request header. Header name and value must be double-quoted so names like + * Content-Type and values like application/json match reliably (Turnip placeholders do not + * capture hyphens or slashes in unquoted tokens). + * + * @Given #^the "(?P
[^"]+)" request header contains "(?P[^"]*)"$# */ public function theRequestHeaderContains(string $header, string $value): void { @@ -92,7 +96,7 @@ public function theRequestHeaderContains(string $header, string $value): void } /** - * @Given the :headerName request header contains multiline value: + * @Given #^the "(?P
[^"]+)" request header contains multiline value:$# */ public function theRequestHeaderContainsMultiline(string $header, PyStringNode $params): void { @@ -101,6 +105,17 @@ public function theRequestHeaderContainsMultiline(string $header, PyStringNode $ $this->headers[$header] = $processedParams; } + /** + * Sets Content-Type to application/json so POST, PUT, and PATCH requests JSON-encode + * requestParams in iSendRequestToRoute(). + * + * @Given the request JSON content type is used + */ + public function theRequestJsonContentTypeIsUsed(): void + { + $this->headers['Content-Type'] = 'application/json'; + } + /** * @Given the request ip is :ip */ @@ -131,6 +146,10 @@ public function theRequestContainsParams(PyStringNode $params): void } /** + * For POST, PUT, and PATCH, the body is JSON-encoded when Content-Type contains + * application/json (set via a quoted header step or Given the request JSON content type is used). + * Otherwise parameters are sent as form fields. + * * @When I send :method request to :route route */ public function iSendRequestToRoute( @@ -338,7 +357,7 @@ protected function compareStructureResponse( } /** - * @Then the :headerName response headers contains :headerValue + * @Then #^the "(?P[^"]+)" response headers contains "(?P[^"]*)"$# */ public function theResponseHeadersContains(string $headerName, string $headerValue): void { @@ -346,7 +365,7 @@ public function theResponseHeadersContains(string $headerName, string $headerVal } /** - * @And the :headerName response headers contains :headerValue + * @And #^the "(?P[^"]+)" response headers contains "(?P[^"]*)"$# */ public function theAndResponseHeadersContains(string $headerName, string $headerValue): void { diff --git a/tests/Unit/Context/Api/ApiContextStepPatternTest.php b/tests/Unit/Context/Api/ApiContextStepPatternTest.php new file mode 100644 index 0000000..84dce55 --- /dev/null +++ b/tests/Unit/Context/Api/ApiContextStepPatternTest.php @@ -0,0 +1,79 @@ +patternTransformer = new PatternTransformer(); + $this->patternTransformer->registerPatternPolicy(new RegexPatternPolicy()); + $this->patternTransformer->registerPatternPolicy(new TurnipPatternPolicy()); + } + + public function testRequestHeaderStepMatchesContentTypeAndJsonMime(): void + { + $pattern = $this->givenPatternForMethod('theRequestHeaderContains'); + $step = 'the "Content-Type" request header contains "application/json"'; + $match = $this->matchStep($pattern, $step); + $this->assertIsArray($match); + $this->assertSame('Content-Type', $match['header']); + $this->assertSame('application/json', $match['value']); + } + + public function testRequestHeaderStepDoesNotMatchUnquotedJsonMime(): void + { + $pattern = $this->givenPatternForMethod('theRequestHeaderContains'); + $step = 'the Content-Type request header contains application/json'; + $this->assertFalse($this->matchStep($pattern, $step)); + } + + public function testResponseHeaderStepMatchesQuotedNames(): void + { + $pattern = $this->givenPatternForMethod('theResponseHeadersContains'); + $step = 'the "Content-Type" response headers contains "application/json; charset=UTF-8"'; + $match = $this->matchStep($pattern, $step); + $this->assertIsArray($match); + $this->assertSame('Content-Type', $match['headerName']); + $this->assertSame('application/json; charset=UTF-8', $match['headerValue']); + } + + /** + * @return array|false + */ + private function matchStep(string $pattern, string $stepText): array|false + { + $regex = $this->patternTransformer->transformPatternToRegex($pattern); + if (preg_match($regex, $stepText, $matches) !== 1) { + return false; + } + + return $matches; + } + + private function givenPatternForMethod(string $methodName): string + { + $reflectionMethod = new ReflectionMethod( + \BehatApiContext\Context\ApiContext::class, + $methodName + ); + $doc = (string) $reflectionMethod->getDocComment(); + if (preg_match('/@Given\\s+(.+)/i', $doc, $m) || preg_match('/@Then\\s+(.+)/i', $doc, $m)) { + return trim($m[1]); + } + + $this->fail('No @Given/@Then pattern in docblock for ' . $methodName); + } +} diff --git a/tests/Unit/Context/Api/GivenApiContextsTest.php b/tests/Unit/Context/Api/GivenApiContextsTest.php index f6cf6c5..8e513a6 100644 --- a/tests/Unit/Context/Api/GivenApiContextsTest.php +++ b/tests/Unit/Context/Api/GivenApiContextsTest.php @@ -46,6 +46,25 @@ public function testGivenMultilineHeader(): void $headersProp->setAccessible(false); } + /** + * @throws ReflectionException + */ + public function testGivenJsonContentTypeStep(): void + { + $reflectionClass = new ReflectionClass($this->apiContext); + $headersProp = $reflectionClass->getProperty('headers'); + $headersProp->setAccessible(true); + + $this->assertEmpty($headersProp->getValue($this->apiContext)); + + $this->apiContext->theRequestJsonContentTypeIsUsed(); + + $headers = $headersProp->getValue($this->apiContext); + $this->assertSame('application/json', $headers['Content-Type'] ?? null); + + $headersProp->setAccessible(false); + } + public function testGivenIps(): void { $reflectionClass = new ReflectionClass($this->apiContext);