Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 26 additions & 14 deletions docs/steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

---
Expand All @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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
```
Expand All @@ -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`
Expand Down Expand Up @@ -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"
Expand All @@ -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 (<time()>, <uniqid()>, etc.) are dynamically evaluated.
- ✅ Saved values can be reused across steps for chaining and correlation.
Expand Down
27 changes: 23 additions & 4 deletions src/Context/ApiContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<header>[^"]+)" request header contains "(?P<value>[^"]*)"$#
*/
public function theRequestHeaderContains(string $header, string $value): void
{
Expand All @@ -92,7 +96,7 @@ public function theRequestHeaderContains(string $header, string $value): void
}

/**
* @Given the :headerName request header contains multiline value:
* @Given #^the "(?P<header>[^"]+)" request header contains multiline value:$#
*/
public function theRequestHeaderContainsMultiline(string $header, PyStringNode $params): void
{
Expand All @@ -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
*/
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -338,15 +357,15 @@ protected function compareStructureResponse(
}

/**
* @Then the :headerName response headers contains :headerValue
* @Then #^the "(?P<headerName>[^"]+)" response headers contains "(?P<headerValue>[^"]*)"$#
*/
public function theResponseHeadersContains(string $headerName, string $headerValue): void
{
$this->checkResponseHeader($headerName, $headerValue);
}

/**
* @And the :headerName response headers contains :headerValue
* @And #^the "(?P<headerName>[^"]+)" response headers contains "(?P<headerValue>[^"]*)"$#
*/
public function theAndResponseHeadersContains(string $headerName, string $headerValue): void
{
Expand Down
79 changes: 79 additions & 0 deletions tests/Unit/Context/Api/ApiContextStepPatternTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace BehatApiContext\Tests\Unit\Context\Api;

use Behat\Behat\Definition\Pattern\PatternTransformer;
use Behat\Behat\Definition\Pattern\Policy\RegexPatternPolicy;
use Behat\Behat\Definition\Pattern\Policy\TurnipPatternPolicy;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;

final class ApiContextStepPatternTest extends TestCase
{
private PatternTransformer $patternTransformer;

protected function setUp(): void
{
parent::setUp();

$this->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<int|string, string>|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);
}
}
19 changes: 19 additions & 0 deletions tests/Unit/Context/Api/GivenApiContextsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading