From 88bbe6ab9dc8abd03cd468e7f02eb65bda8a8994 Mon Sep 17 00:00:00 2001 From: Zakhar Huzenko Date: Thu, 29 Jan 2026 15:24:22 +0200 Subject: [PATCH 1/8] fix(ApiContext): enhance request handling for JSON content --- src/Context/ApiContext.php | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/Context/ApiContext.php b/src/Context/ApiContext.php index a4ad1a0..aea4f0e 100644 --- a/src/Context/ApiContext.php +++ b/src/Context/ApiContext.php @@ -130,19 +130,32 @@ public function iSendRequestToRoute( $routeParams = $this->popRouteAttributesFromRequestParams($route, $this->requestParams); $postFields = []; $queryString = ''; + $content = null; $url = $this->router->generate($route, $routeParams); - $url = preg_replace('|^/app[^\.]*\.php|', '', $url); + $url = preg_replace('|^/app[^.]*\.php|', '', $url); if (Request::METHOD_GET === $method) { $queryString = http_build_query($this->requestParams); } if (in_array($method, [Request::METHOD_POST, Request::METHOD_PATCH, Request::METHOD_PUT], true)) { - $postFields = $this->requestParams; + $isJsonRequest = array_key_exists('Content-Type', $this->headers) && + str_contains(strtolower($this->headers['Content-Type']), 'application/json'); + + if ($isJsonRequest) { + $content = json_encode($this->requestParams, JSON_THROW_ON_ERROR); + } else { + $postFields = $this->requestParams; + } } - $request = Request::create($url . '?' . $queryString, $method, $postFields); + $request = Request::create( + uri: $url . '?' . $queryString, + method: $method, + parameters: $postFields, + content: $content + ); $request->headers->add($this->headers); $request->server->add($this->serverParams); @@ -282,15 +295,20 @@ public function responseShouldBeJsonWithVariableFields(string $variableFields, P $this->compareStructureResponse($variableFields, $string, $this->getResponse()->getContent()); } - protected function compareStructureResponse(string $variableFields, PyStringNode $string, string $actualJSON): void - { + protected function compareStructureResponse( + string $variableFieldsString, + PyStringNode $string, + string $actualJSON + ): void { if ($actualJSON === '') { throw new RuntimeException('Response is not JSON'); } - $expectedResponse = (array) json_decode(trim($string->getRaw()), true); - $actualResponse = (array) json_decode($actualJSON, true); - $variableFields = $variableFields ? array_map('trim', explode(',', $variableFields)) : []; + $expectedResponse = json_decode(trim($string->getRaw()), true, 512, JSON_THROW_ON_ERROR); + $actualResponse = json_decode($actualJSON, true, 512, JSON_THROW_ON_ERROR); + $variableFields = $variableFieldsString + ? array_map('trim', explode(',', $variableFieldsString)) + : []; if (!$this->similarArrayManager->isArraysSimilar($expectedResponse, $actualResponse, $variableFields)) { $prettyJSON = json_encode($actualResponse, JSON_PRETTY_PRINT); @@ -343,7 +361,7 @@ protected function checkResponseHeader(string $headerName, string $headerValue): $responseHeaderValue = $response->headers->get($givenHeaderName); - if (null === $responseHeaderValue || !substr_count($responseHeaderValue, $givenHeaderValue) > 0) { + if (null === $responseHeaderValue || substr_count($responseHeaderValue, $givenHeaderValue) < 1) { $message = sprintf( 'Response header %s does not match. Expected: %s, given value: %s', $givenHeaderName, From c9c7f4e47e4b76b6e76ec286d79128643f831baa Mon Sep 17 00:00:00 2001 From: Zakhar Huzenko Date: Thu, 29 Jan 2026 15:30:18 +0200 Subject: [PATCH 2/8] chore!: drop PHP 7.4 support --- .github/workflows/ci.yaml | 8 +------- .github/workflows/security.yaml | 2 +- .github/workflows/static-analysis.yaml | 6 +++--- composer.json | 4 ++-- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d0d5a4e..a8bd6f8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,12 +24,6 @@ jobs: - php: '8.1' symfony-versions: '7.0.*' include: - - php: '7.4' - symfony-versions: '^4.4' - coverage: 'none' - - php: '7.4' - symfony-versions: '^5.4' - coverage: 'none' - php: '8.0' symfony-versions: '^4.4' coverage: 'none' @@ -44,7 +38,7 @@ jobs: name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }} ${{ matrix.description }} steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - uses: actions/cache@v4 with: diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml index 248d5d0..4cad68c 100644 --- a/.github/workflows/security.yaml +++ b/.github/workflows/security.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index 2e1fd92..60d4da9 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/composer.json b/composer.json index 4f892e8..5aa91c6 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "license": "MIT", "require": { "ext-json": "*", - "php": "^7.4 || ^8.0", + "php": "^8.0", "behat/behat": "^3.0", "symfony/config": "^4.4 || ^5.4 || ^6.0 || ^7.0", "symfony/dependency-injection": "^4.4 || ^5.4.34 || ^6.0 || ^7.0.2", @@ -37,7 +37,7 @@ "macpaw/similar-arrays": "^1.0" }, "require-dev": { - "phpstan/phpstan": "^1.4", + "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^9.3", "slevomat/coding-standard": "^7.0", "squizlabs/php_codesniffer": "^3.6" From 5144e81dbaf87fbc1d59832c743aebe8aa750cb6 Mon Sep 17 00:00:00 2001 From: Zakhar Huzenko Date: Thu, 29 Jan 2026 15:44:00 +0200 Subject: [PATCH 3/8] chore!: drop Symfony 4.4 support, increase minimal supported versions --- .github/workflows/ci.yaml | 13 ++++--------- composer.json | 10 +++++----- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a8bd6f8..cb9f907 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,25 +15,20 @@ jobs: - '8.3' coverage: ['none'] symfony-versions: - - '4.4.*' - '5.4.*' - - '6.0.*' - - '6.2.*' - - '7.0.*' + - '6.4.*' + - '7.1.*' exclude: - php: '8.1' - symfony-versions: '7.0.*' + symfony-versions: '7.1.*' include: - - php: '8.0' - symfony-versions: '^4.4' - coverage: 'none' - php: '8.0' symfony-versions: '^5.4' coverage: 'none' - description: 'Log Code Coverage' php: '8.2' coverage: 'xdebug' - symfony-versions: '^7.0' + symfony-versions: '^7.1' name: PHP ${{ matrix.php }} Symfony ${{ matrix.symfony-versions }} ${{ matrix.description }} steps: diff --git a/composer.json b/composer.json index 5aa91c6..5217bcb 100644 --- a/composer.json +++ b/composer.json @@ -29,11 +29,11 @@ "ext-json": "*", "php": "^8.0", "behat/behat": "^3.0", - "symfony/config": "^4.4 || ^5.4 || ^6.0 || ^7.0", - "symfony/dependency-injection": "^4.4 || ^5.4.34 || ^6.0 || ^7.0.2", - "symfony/http-client": "^4.4 || ^5.4 || ^6.0 || ^7.0", - "symfony/http-kernel": "^4.4 || ^5.4 || ^6.0 || ^7.0", - "symfony/routing": "^4.4 || ^5.4 || ^6.0 || ^7.0", + "symfony/config": "^5.4 || ^6.4 || ^7.0", + "symfony/dependency-injection": "^5.4.34 || ^6.4 || ^7.1", + "symfony/http-client": "^5.4 || ^6.4 || ^7.1", + "symfony/http-kernel": "^5.4 || ^6.4 || ^7.1", + "symfony/routing": "^5.4 || ^6.4 || ^7.1", "macpaw/similar-arrays": "^1.0" }, "require-dev": { From 127c2b8472434c63e9601a2d68e0010dea7e3cd8 Mon Sep 17 00:00:00 2001 From: Zakhar Huzenko Date: Thu, 29 Jan 2026 16:15:41 +0200 Subject: [PATCH 4/8] feat(ApiContext): improve type hints and request handling --- .github/workflows/ci.yaml | 7 +--- .github/workflows/static-analysis.yaml | 6 --- composer.json | 5 ++- phpstan.neon.dist | 21 ---------- src/Context/ApiContext.php | 38 +++++++++++-------- .../BehatApiContextExtension.php | 12 +++--- .../ResetManager/DoctrineResetManager.php | 4 +- src/Service/StringManager.php | 3 ++ 8 files changed, 40 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cb9f907..e6eb308 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,11 +22,11 @@ jobs: - php: '8.1' symfony-versions: '7.1.*' include: - - php: '8.0' + - php: '8.1' symfony-versions: '^5.4' coverage: 'none' - description: 'Log Code Coverage' - php: '8.2' + php: '8.4' coverage: 'xdebug' symfony-versions: '^7.1' @@ -72,9 +72,6 @@ jobs: - name: Install dependencies run: composer install - - name: Add doctrine/orm - run: composer require --no-progress --no-interaction --prefer-dist doctrine/orm:^2.0 - - name: Run PHPUnit tests run: composer phpunit if: matrix.coverage == 'none' diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index 60d4da9..58f5daf 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -19,9 +19,6 @@ jobs: - name: Install dependencies run: composer install --no-progress --no-interaction --prefer-dist - - name: Add doctrine/orm - run: composer require --no-progress --no-interaction --prefer-dist doctrine/orm:^2.0 - - name: Run script run: composer code-style @@ -38,9 +35,6 @@ jobs: - name: Install dependencies run: composer install --no-progress --no-interaction --prefer-dist - - name: Add doctrine/orm - run: composer require --no-progress --no-interaction --prefer-dist doctrine/orm:^2.0 - - name: Run script run: composer phpstan diff --git a/composer.json b/composer.json index 5217bcb..ccb5ea0 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "license": "MIT", "require": { "ext-json": "*", - "php": "^8.0", + "php": "^8.1", "behat/behat": "^3.0", "symfony/config": "^5.4 || ^6.4 || ^7.0", "symfony/dependency-injection": "^5.4.34 || ^6.4 || ^7.1", @@ -40,7 +40,8 @@ "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^9.3", "slevomat/coding-standard": "^7.0", - "squizlabs/php_codesniffer": "^3.6" + "squizlabs/php_codesniffer": "^3.6", + "doctrine/orm": "^2.0" }, "autoload": { "psr-4": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fe51577..c3428b2 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,4 @@ parameters: - excludes_analyse: paths: - src level: max @@ -9,29 +8,9 @@ parameters: message: '#Parameter \#1 \$json of function json_decode expects string, string\|false given.*#' count: 3 path: ./src/Context/ApiContext.php - - - message: '#Call to an undefined method Symfony\\Component\\HttpKernel\\KernelInterface::terminate\(\).*#' - count: 1 - path: ./src/Context/ApiContext.php - message: '#Parameter \#3 \$actualJSON of method BehatApiContext\\Context\\ApiContext::compareStructureResponse\(\) expects string, string\|false given.*#' count: 1 path: ./src/Context/ApiContext.php - - - message: '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\).#' - count: 1 - path: ./src/DependencyInjection - - - message: '#.*NodeParentInterface|null.*#' - count: 1 - path: ./src/DependencyInjection - - - message: '#Call to an undefined method object::clear().*#' - count: 1 - path: ./src/Service/ResetManager/DoctrineResetManager - - - message: '#Call to an undefined method object::getConnection().*#' - count: 1 - path: ./src/Service/ResetManager/DoctrineResetManager - identifier: missingType.iterableValue diff --git a/src/Context/ApiContext.php b/src/Context/ApiContext.php index aea4f0e..ecfe1b7 100644 --- a/src/Context/ApiContext.php +++ b/src/Context/ApiContext.php @@ -14,6 +14,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Routing\RouterInterface; use Throwable; @@ -23,8 +24,12 @@ class ApiContext implements Context private StringManager $stringManager; private RouterInterface $router; private RequestStack $requestStack; - private ?Response $response; - private KernelInterface $kernel; + private Response $response; + private KernelInterface&TerminableInterface $kernel; + + /** + * @var list + */ private array $resetManagers = []; /** @@ -38,19 +43,19 @@ class ApiContext implements Context protected array $serverParams = []; /** - * @var array $requestParams + * @var array $requestParams */ protected array $requestParams = []; /** - * @var array $savedValues + * @var array> $savedValues */ protected array $savedValues = []; public function __construct( RouterInterface $router, RequestStack $requestStack, - KernelInterface $kernel + KernelInterface&TerminableInterface $kernel ) { $this->router = $router; $this->requestStack = $requestStack; @@ -116,8 +121,13 @@ public function theRequestContainsParams(PyStringNode $params): void $newRequestParams = (array) json_decode($processedParams, true, 512, JSON_THROW_ON_ERROR); $newRequestParams = $this->convertRunnableCodeParams($newRequestParams); - $this->requestParams = array_merge($this->requestParams, $newRequestParams); - $this->savedValues = array_merge($this->savedValues, $newRequestParams); + /** @var array $requestParams */ + $requestParams = array_merge($this->requestParams, $newRequestParams); + $this->requestParams = $requestParams; + + /** @var array $savedValues */ + $savedValues = array_merge($this->savedValues, $newRequestParams); + $this->savedValues = $savedValues; } /** @@ -184,19 +194,21 @@ private function handleRequestWithKernel(Request $request): Response } /** - * @param array $requestParams + * @param array $requestParams * - * @return array + * @return array */ private function popRouteAttributesFromRequestParams(string $route, array &$requestParams): array { $routeParams = []; + $routeDecl = $this->router->getRouteCollection()->get($route); - if (is_array($requestParams) && ($routeDecl = $this->router->getRouteCollection()->get($route))) { + if ($routeDecl !== null) { + /** @var array $requirements */ $requirements = $routeDecl->getRequirements(); foreach ($requirements as $attribute => $requirement) { - if (isset($requestParams[$attribute]) && strpos($attribute, '_') !== 0) { + if (isset($requestParams[$attribute]) && !str_starts_with($attribute, '_')) { $routeParams[$attribute] = $requestParams[$attribute]; unset($requestParams[$attribute]); } @@ -431,10 +443,6 @@ private function resetRequestOptions(): void protected function getResponse(): Response { - if ($this->response === null) { - throw new RuntimeException('Response is null.'); - } - return $this->response; } diff --git a/src/DependencyInjection/BehatApiContextExtension.php b/src/DependencyInjection/BehatApiContextExtension.php index 8592916..3571305 100644 --- a/src/DependencyInjection/BehatApiContextExtension.php +++ b/src/DependencyInjection/BehatApiContextExtension.php @@ -12,14 +12,10 @@ class BehatApiContextExtension extends Extension { - /** - * @param array $configs - * - * {@inheritdoc} - */ public function load(array $configs, ContainerBuilder $container): void { $configuration = new Configuration(); + /** @var array $config */ $config = $this->processConfiguration($configuration, $configs); $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); @@ -28,7 +24,7 @@ public function load(array $configs, ContainerBuilder $container): void } /** - * @param array $config + * @param array $config */ private function loadApiContext( array $config, @@ -44,6 +40,10 @@ private function loadApiContext( ); } + /** + * @param array $config + * @param class-string $contextClass + */ private function configureKernelResetManagers( array $config, ContainerBuilder $container, diff --git a/src/Service/ResetManager/DoctrineResetManager.php b/src/Service/ResetManager/DoctrineResetManager.php index 089fe1b..913c0b5 100644 --- a/src/Service/ResetManager/DoctrineResetManager.php +++ b/src/Service/ResetManager/DoctrineResetManager.php @@ -4,6 +4,7 @@ namespace BehatApiContext\Service\ResetManager; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\KernelInterface; @@ -19,11 +20,12 @@ public function reset(KernelInterface $kernel): void $container = $kernel->getContainer(); if ($container->hasParameter('doctrine.entity_managers')) { - /** @var array $entityManagers */ + /** @var list> $entityManagers */ $entityManagers = $container->getParameter('doctrine.entity_managers'); foreach ($entityManagers as $entityManagerId) { if ($container->initialized($entityManagerId)) { + /** @var EntityManagerInterface $em */ $em = $container->get($entityManagerId); $em->clear(); diff --git a/src/Service/StringManager.php b/src/Service/StringManager.php index e1ab492..db13c54 100644 --- a/src/Service/StringManager.php +++ b/src/Service/StringManager.php @@ -11,6 +11,9 @@ class StringManager private const START_SEPARATOR = "{{"; private const END_SEPARATOR = "}}"; + /** + * @param array> $substitutionArray + */ public function substituteValues(array $substitutionArray, string $string): string { $start = strpos($string, self::START_SEPARATOR); From cee62dc22102b7e21f7cd0428e38de03c9249e34 Mon Sep 17 00:00:00 2001 From: Zakhar Huzenko Date: Thu, 29 Jan 2026 16:26:39 +0200 Subject: [PATCH 5/8] feat(ApiContext): improve type hints and request handling --- composer.json | 2 +- tests/Unit/Context/Api/AbstractApiContextTest.php | 6 +++--- tests/Unit/Context/Api/WhenApiContextsTest.php | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index ccb5ea0..da33e2a 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ }, "require-dev": { "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^9.3", + "phpunit/phpunit": "^10.0", "slevomat/coding-standard": "^7.0", "squizlabs/php_codesniffer": "^3.6", "doctrine/orm": "^2.0" diff --git a/tests/Unit/Context/Api/AbstractApiContextTest.php b/tests/Unit/Context/Api/AbstractApiContextTest.php index fb15f1a..6e713d3 100644 --- a/tests/Unit/Context/Api/AbstractApiContextTest.php +++ b/tests/Unit/Context/Api/AbstractApiContextTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Routing\RouterInterface; abstract class AbstractApiContextTest extends TestCase @@ -31,10 +32,9 @@ protected function configureRouter(): RouterInterface return $routerMock; } - protected function getKernelMock(): KernelInterface + protected function getKernelMock(): KernelInterface&TerminableInterface { - $kernel = $this->createMock(KernelInterface::class); - assert($kernel instanceof KernelInterface); + $kernel = $this->createMockForIntersectionOfInterfaces([KernelInterface::class, TerminableInterface::class]); return $kernel; } diff --git a/tests/Unit/Context/Api/WhenApiContextsTest.php b/tests/Unit/Context/Api/WhenApiContextsTest.php index 4c55bd5..400845b 100644 --- a/tests/Unit/Context/Api/WhenApiContextsTest.php +++ b/tests/Unit/Context/Api/WhenApiContextsTest.php @@ -9,6 +9,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Routing\Exception\RouteNotFoundException; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -29,7 +30,7 @@ protected function setUp(): void ['id' => '\d+'], ); - if ('testExceptionWhenRouteNotFound' === $this->getName()) { + if ('testExceptionWhenRouteNotFound' === $this->name()) { $this->invalidRouteMock = true; } @@ -71,7 +72,7 @@ protected function configureRouter(): RouterInterface return $router; } - protected function getKernelMock(): KernelInterface + protected function getKernelMock(): KernelInterface&TerminableInterface { $kernel = $this->createMock(Kernel::class); From 044c83aa44ece9a37dc05b7d7dc5922fc15007b5 Mon Sep 17 00:00:00 2001 From: Zakhar Huzenko Date: Thu, 29 Jan 2026 16:31:16 +0200 Subject: [PATCH 6/8] feat(ApiContext): improve type hints and request handling --- tests/Unit/Context/Api/ApiContextTest.php | 2 +- .../Api/{AbstractApiContextTest.php => ApiContextTestCase.php} | 2 +- tests/Unit/Context/Api/GivenApiContextsTest.php | 2 +- tests/Unit/Context/Api/InitApiContextsTest.php | 2 +- tests/Unit/Context/Api/ResponseHasHeaderTest.php | 2 +- tests/Unit/Context/Api/ThenApiContextTest.php | 2 +- tests/Unit/Context/Api/WhenApiContextsTest.php | 2 +- .../Unit/Context/Api/WhenSendRequestToRouteApiContextsTest.php | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) rename tests/Unit/Context/Api/{AbstractApiContextTest.php => ApiContextTestCase.php} (95%) diff --git a/tests/Unit/Context/Api/ApiContextTest.php b/tests/Unit/Context/Api/ApiContextTest.php index b07f064..b577e87 100644 --- a/tests/Unit/Context/Api/ApiContextTest.php +++ b/tests/Unit/Context/Api/ApiContextTest.php @@ -7,7 +7,7 @@ use Behat\Gherkin\Node\PyStringNode; use RuntimeException; -class ApiContextTest extends AbstractApiContextTest +class ApiContextTest extends ApiContextTestCase { private const PARAMS_VALUES = 'paramsValues'; private const INITIAL_PARAM_VALUE = 'initialParamValue'; diff --git a/tests/Unit/Context/Api/AbstractApiContextTest.php b/tests/Unit/Context/Api/ApiContextTestCase.php similarity index 95% rename from tests/Unit/Context/Api/AbstractApiContextTest.php rename to tests/Unit/Context/Api/ApiContextTestCase.php index 6e713d3..1abcc86 100644 --- a/tests/Unit/Context/Api/AbstractApiContextTest.php +++ b/tests/Unit/Context/Api/ApiContextTestCase.php @@ -11,7 +11,7 @@ use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Routing\RouterInterface; -abstract class AbstractApiContextTest extends TestCase +abstract class ApiContextTestCase extends TestCase { protected ApiContext $apiContext; diff --git a/tests/Unit/Context/Api/GivenApiContextsTest.php b/tests/Unit/Context/Api/GivenApiContextsTest.php index 376934c..f6cf6c5 100644 --- a/tests/Unit/Context/Api/GivenApiContextsTest.php +++ b/tests/Unit/Context/Api/GivenApiContextsTest.php @@ -8,7 +8,7 @@ use ReflectionClass; use ReflectionException; -final class GivenApiContextsTest extends AbstractApiContextTest +final class GivenApiContextsTest extends ApiContextTestCase { /** * @throws ReflectionException diff --git a/tests/Unit/Context/Api/InitApiContextsTest.php b/tests/Unit/Context/Api/InitApiContextsTest.php index 330abf6..e57bf02 100644 --- a/tests/Unit/Context/Api/InitApiContextsTest.php +++ b/tests/Unit/Context/Api/InitApiContextsTest.php @@ -8,7 +8,7 @@ use ReflectionClass; use ReflectionException; -final class InitApiContextsTest extends AbstractApiContextTest +final class InitApiContextsTest extends ApiContextTestCase { /** * @throws ReflectionException diff --git a/tests/Unit/Context/Api/ResponseHasHeaderTest.php b/tests/Unit/Context/Api/ResponseHasHeaderTest.php index 6b8b398..336e4d3 100644 --- a/tests/Unit/Context/Api/ResponseHasHeaderTest.php +++ b/tests/Unit/Context/Api/ResponseHasHeaderTest.php @@ -7,7 +7,7 @@ use RuntimeException; use Symfony\Component\HttpFoundation\Response; -final class ResponseHasHeaderTest extends AbstractApiContextTest +final class ResponseHasHeaderTest extends ApiContextTestCase { private Response $response; diff --git a/tests/Unit/Context/Api/ThenApiContextTest.php b/tests/Unit/Context/Api/ThenApiContextTest.php index 4572720..d242d56 100644 --- a/tests/Unit/Context/Api/ThenApiContextTest.php +++ b/tests/Unit/Context/Api/ThenApiContextTest.php @@ -11,7 +11,7 @@ use Symfony\Component\HttpFoundation\Response; use Throwable; -final class ThenApiContextTest extends AbstractApiContextTest +final class ThenApiContextTest extends ApiContextTestCase { public function testResponseStatusCodeShouldBe(): void { diff --git a/tests/Unit/Context/Api/WhenApiContextsTest.php b/tests/Unit/Context/Api/WhenApiContextsTest.php index 400845b..d293b62 100644 --- a/tests/Unit/Context/Api/WhenApiContextsTest.php +++ b/tests/Unit/Context/Api/WhenApiContextsTest.php @@ -15,7 +15,7 @@ use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; -final class WhenApiContextsTest extends AbstractApiContextTest +final class WhenApiContextsTest extends ApiContextTestCase { private Route $route; private ?Request $request = null; diff --git a/tests/Unit/Context/Api/WhenSendRequestToRouteApiContextsTest.php b/tests/Unit/Context/Api/WhenSendRequestToRouteApiContextsTest.php index ff06800..d22d852 100644 --- a/tests/Unit/Context/Api/WhenSendRequestToRouteApiContextsTest.php +++ b/tests/Unit/Context/Api/WhenSendRequestToRouteApiContextsTest.php @@ -9,7 +9,7 @@ use RuntimeException; use Symfony\Component\HttpFoundation\Response; -final class WhenSendRequestToRouteApiContextsTest extends AbstractApiContextTest +final class WhenSendRequestToRouteApiContextsTest extends ApiContextTestCase { /** * @throws ReflectionException From 98edcc7a76e0fb808e7bf11335ada4997451aa64 Mon Sep 17 00:00:00 2001 From: Zakhar Huzenko Date: Thu, 29 Jan 2026 16:35:21 +0200 Subject: [PATCH 7/8] feat(ApiContext): improve type hints and request handling --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index da33e2a..65da238 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,7 @@ "phpstan": "./vendor/bin/phpstan analyse -l max", "code-style": "./vendor/bin/phpcs", "code-style-fix": "./vendor/bin/phpcbf", - "phpunit": "./vendor/bin/phpunit", + "phpunit": "./vendor/bin/phpunit --no-coverage", "phpunit-html-coverage": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html=coverage", "dev-checks": [ "composer validate", From fd787fb377e787f66589bc7640e342385cb76b88 Mon Sep 17 00:00:00 2001 From: Zakhar Huzenko Date: Thu, 29 Jan 2026 16:47:46 +0200 Subject: [PATCH 8/8] feat(ApiContext): improve type hints and request handling --- .../Unit/Context/Api/WhenApiContextsTest.php | 280 +++++++++++++++++- 1 file changed, 278 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Context/Api/WhenApiContextsTest.php b/tests/Unit/Context/Api/WhenApiContextsTest.php index d293b62..ab96910 100644 --- a/tests/Unit/Context/Api/WhenApiContextsTest.php +++ b/tests/Unit/Context/Api/WhenApiContextsTest.php @@ -44,6 +44,277 @@ public function testExceptionWhenRouteNotFound(): void $this->apiContext->iSendRequestToRoute(Request::METHOD_GET, '/_api/users/{id}'); } + public function testSendGetRequestToRoute(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up request params that should be converted to query string + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['page' => 1, 'limit' => 10]); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_GET, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertNotNull($this->response); + $this->assertEquals(Request::METHOD_GET, $this->request->getMethod()); + + // Verify request params were reset + $this->assertEmpty($requestParamsProp->getValue($this->apiContext)); + } + + public function testSendPostRequestToRouteWithFormData(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up request params + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['name' => 'John', 'email' => 'john@example.com']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_POST, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_POST, $this->request->getMethod()); + + // Verify request params were reset after the request + $this->assertEmpty($requestParamsProp->getValue($this->apiContext)); + } + + public function testSendPostRequestToRouteWithJsonContent(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up headers to indicate JSON content + $headersProp = $reflectionClass->getProperty('headers'); + $headersProp->setAccessible(true); + $headersProp->setValue($this->apiContext, ['Content-Type' => 'application/json']); + + // Set up request params + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['name' => 'John', 'email' => 'john@example.com']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_POST, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_POST, $this->request->getMethod()); + + // Verify headers were reset + $this->assertEmpty($headersProp->getValue($this->apiContext)); + } + + public function testSendPatchRequestToRoute(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up headers to indicate JSON content + $headersProp = $reflectionClass->getProperty('headers'); + $headersProp->setAccessible(true); + $headersProp->setValue($this->apiContext, ['Content-Type' => 'application/json']); + + // Set up request params + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['name' => 'Jane']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_PATCH, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_PATCH, $this->request->getMethod()); + } + + public function testSendPutRequestToRoute(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up request params + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['name' => 'John Updated']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_PUT, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_PUT, $this->request->getMethod()); + } + + public function testSendDeleteRequestToRoute(): void + { + $this->apiContext->iSendRequestToRoute(Request::METHOD_DELETE, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_DELETE, $this->request->getMethod()); + } + + public function testSendRequestWithServerParams(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up server params + $serverParamsProp = $reflectionClass->getProperty('serverParams'); + $serverParamsProp->setAccessible(true); + $serverParamsProp->setValue($this->apiContext, ['REMOTE_ADDR' => '127.0.0.1']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_GET, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + + // Verify server params were reset + $this->assertEmpty($serverParamsProp->getValue($this->apiContext)); + } + + public function testSendRequestWithRouteParameters(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up request params including route parameter + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['id' => '123', 'extra' => 'value']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_GET, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertNotNull($this->response); + } + + public function testSendGetRequestWithEmptyQueryString(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // No request params should result in empty query string + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, []); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_GET, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_GET, $this->request->getMethod()); + } + + public function testSendPostRequestWithMixedCaseContentType(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up headers with mixed case Content-Type (should still detect JSON) + $headersProp = $reflectionClass->getProperty('headers'); + $headersProp->setAccessible(true); + $headersProp->setValue($this->apiContext, ['Content-Type' => 'APPLICATION/JSON; charset=utf-8']); + + // Set up request params + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['test' => 'value']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_POST, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_POST, $this->request->getMethod()); + } + + public function testSendPutRequestWithFormData(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up request params without JSON header (form data) + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['field' => 'value']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_PUT, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_PUT, $this->request->getMethod()); + } + + public function testSendPatchRequestWithFormData(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up request params without JSON header (form data) + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['status' => 'active']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_PATCH, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_PATCH, $this->request->getMethod()); + } + + public function testSendRequestWithMultipleHeaders(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up multiple headers + $headersProp = $reflectionClass->getProperty('headers'); + $headersProp->setAccessible(true); + $headersProp->setValue($this->apiContext, [ + 'Authorization' => 'Bearer token123', + 'Accept' => 'application/json', + 'X-Custom-Header' => 'custom-value' + ]); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_GET, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + + // Verify headers were reset + $this->assertEmpty($headersProp->getValue($this->apiContext)); + } + + public function testSendRequestWithComplexQueryParams(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up complex query params + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, [ + 'filter' => 'active', + 'sort' => 'name', + 'page' => 2, + 'limit' => 50 + ]); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_GET, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertNotNull($this->response); + } + + public function testSendDeleteRequestWithParams(): void + { + $reflectionClass = new \ReflectionClass($this->apiContext); + + // Set up request params (DELETE requests typically don't use body) + $requestParamsProp = $reflectionClass->getProperty('requestParams'); + $requestParamsProp->setAccessible(true); + $requestParamsProp->setValue($this->apiContext, ['cascade' => 'true']); + + $this->apiContext->iSendRequestToRoute(Request::METHOD_DELETE, 'api_users_get'); + + // Verify the request was made + $this->assertNotNull($this->request); + $this->assertEquals(Request::METHOD_DELETE, $this->request->getMethod()); + } + protected function configureRouter(): RouterInterface { $router = parent::configureRouter(); @@ -62,7 +333,7 @@ protected function configureRouter(): RouterInterface ->method('generate') ->willThrowException(new RouteNotFoundException()); } else { - $router->expects($this->once()) + $router->expects($this->any()) ->method('generate') ->willReturn('/api/users/1'); } @@ -80,7 +351,12 @@ protected function getKernelMock(): KernelInterface&TerminableInterface if (!$this->invalidRouteMock) { $kernel - ->expects($this->once()) + ->expects($this->any()) + ->method('handle') + ->willReturn(new Response('{"status": "ok"}', 200)); + + $kernel + ->expects($this->any()) ->method('terminate') ->will( $this->returnCallback(function (Request $request, Response $response): void {