From 2781b74a83f5bec17e1abc6d3797de89c6c3bb79 Mon Sep 17 00:00:00 2001 From: TavoNiievez Date: Wed, 28 May 2025 19:44:02 -0500 Subject: [PATCH] Module code adapted for PHPStan --- .github/workflows/main.yml | 29 +- src/Codeception/Lib/Connector/Symfony.php | 110 +++--- src/Codeception/Module/Symfony.php | 347 +++++++++++------- .../Module/Symfony/ConsoleAssertionsTrait.php | 16 +- .../Module/Symfony/DataCollectorName.php | 21 ++ .../Symfony/DoctrineAssertionsTrait.php | 94 ++--- .../Module/Symfony/EventsAssertionsTrait.php | 201 ++++++---- .../Module/Symfony/FormAssertionsTrait.php | 111 +++--- .../Symfony/HttpClientAssertionsTrait.php | 187 +++++----- .../Module/Symfony/LoggerAssertionsTrait.php | 37 +- .../Module/Symfony/MailerAssertionsTrait.php | 13 +- .../Symfony/ParameterAssertionsTrait.php | 6 +- .../Module/Symfony/RouterAssertionsTrait.php | 135 ++++--- .../Symfony/SecurityAssertionsTrait.php | 105 +++--- .../Symfony/ServicesAssertionsTrait.php | 16 +- .../Module/Symfony/SessionAssertionsTrait.php | 109 +++--- .../Module/Symfony/TimeAssertionsTrait.php | 2 +- .../Symfony/TranslationAssertionsTrait.php | 2 +- .../Module/Symfony/TwigAssertionsTrait.php | 4 +- .../Symfony/ValidatorAssertionsTrait.php | 4 +- 20 files changed, 862 insertions(+), 687 deletions(-) create mode 100644 src/Codeception/Module/Symfony/DataCollectorName.php diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c95a2224..12453230 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - tools: composer:v2 + tools: composer:v2, phpstan extensions: ctype, iconv, intl, json, mbstring, pdo, pdo_sqlite coverage: none @@ -56,39 +56,38 @@ jobs: uses: actions/cache@v3 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json', 'composer.lock') }} + key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.{json,lock}') }} restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-composer- - name: Install PHPUnit 10 - run: composer require --dev --no-update "phpunit/phpunit=^10.0" + run: composer require --dev --no-update phpunit/phpunit:^10.0 - name: Install dependencies env: MATRIX_SYMFONY: ${{ matrix.symfony }} run: | - composer require symfony/finder=${{ env.COMP_SYMFONY }} --no-update - composer require symfony/yaml=${{ env.COMP_SYMFONY }} --no-update - composer require symfony/console=${{ env.COMP_SYMFONY }} --no-update - composer require symfony/event-dispatcher=${{ env.COMP_SYMFONY }} --no-update - composer require symfony/css-selector=${{ env.COMP_SYMFONY }} --no-update - composer require symfony/dom-crawler=${{ env.COMP_SYMFONY }} --no-update - composer require symfony/browser-kit=${{ env.COMP_SYMFONY }} --no-update - composer require vlucas/phpdotenv --no-update - composer require codeception/module-asserts="3.*" --no-update - composer require codeception/module-doctrine="3.*" --no-update + composer require --no-update \ + symfony/{finder,yaml,console,event-dispatcher,css-selector,dom-crawler,browser-kit}:${{ env.COMP_SYMFONY }} \ + vlucas/phpdotenv \ + codeception/module-asserts:"3.*" \ + codeception/module-doctrine:"3.*" if [[ "$MATRIX_SYMFONY" == "6.4wApi" ]]; then composer require codeception/module-rest="3.*" --no-update fi - composer update --prefer-dist --no-progress --no-dev + composer update --prefer-dist --no-progress + + - name: Run PHPStan (max) + if: ${{ matrix.symfony == '7.2.*' }} + run: phpstan analyse src --level=max --no-progress --error-format=github --memory-limit=1G - name: Validate Composer files run: composer validate --strict working-directory: framework-tests - name: Install PHPUnit in framework-tests - run: composer require --dev --no-update "phpunit/phpunit=^10.0" + run: composer require --dev --no-update phpunit/phpunit:^10.0 working-directory: framework-tests - name: Prepare Symfony sample diff --git a/src/Codeception/Lib/Connector/Symfony.php b/src/Codeception/Lib/Connector/Symfony.php index 019317af..08641fa8 100644 --- a/src/Codeception/Lib/Connector/Symfony.php +++ b/src/Codeception/Lib/Connector/Symfony.php @@ -5,44 +5,44 @@ namespace Codeception\Lib\Connector; use InvalidArgumentException; -use ReflectionClass; +use LogicException; use ReflectionMethod; use ReflectionProperty; use Symfony\Bundle\FrameworkBundle\Test\TestContainer; use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\HttpKernelBrowser; +use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\HttpKernel\Profiler\Profiler; -use function array_keys; + use function codecept_debug; +/** + * @property KernelInterface $kernel + */ class Symfony extends HttpKernelBrowser { + private ContainerInterface $container; private bool $hasPerformedRequest = false; - private ?ContainerInterface $container; public function __construct( - Kernel $kernel, + HttpKernelInterface $kernel, + /** @var array */ public array $persistentServices = [], - private readonly bool $rebootable = true + private bool $reboot = true ) { parent::__construct($kernel); $this->followRedirects(); - $this->container = $this->getContainer(); + $this->container = $this->resolveContainer(); $this->rebootKernel(); } - /** @param Request $request */ protected function doRequest(object $request): Response { - if ($this->rebootable) { - if ($this->hasPerformedRequest) { - $this->rebootKernel(); - } else { - $this->hasPerformedRequest = true; - } + if ($this->reboot) { + $this->hasPerformedRequest ? $this->rebootKernel() : $this->hasPerformedRequest = true; } return parent::doRequest($request); @@ -57,30 +57,27 @@ protected function doRequest(object $request): Response */ public function rebootKernel(): void { - if ($this->container) { - foreach (array_keys($this->persistentServices) as $serviceName) { - if ($service = $this->getService($serviceName)) { - $this->persistentServices[$serviceName] = $service; - } + foreach (array_keys($this->persistentServices) as $service) { + if ($this->container->has($service)) { + $this->persistentServices[$service] = $this->container->get($service); } } $this->persistDoctrineConnections(); - $this->ensureKernelShutdown(); - $this->kernel->boot(); - $this->container = $this->getContainer(); - - foreach ($this->persistentServices as $serviceName => $service) { + if ($this->kernel instanceof Kernel) { + $this->ensureKernelShutdown(); + $this->kernel->boot(); + } + $this->container = $this->resolveContainer(); + foreach ($this->persistentServices as $name => $service) { try { - $this->container->set($serviceName, $service); + $this->container->set($name, $service); } catch (InvalidArgumentException $e) { - codecept_debug("[Symfony] Can't set persistent service {$serviceName}: " . $e->getMessage()); + codecept_debug("[Symfony] Can't set persistent service {$name}: {$e->getMessage()}"); } } - if ($profiler = $this->getProfiler()) { - $profiler->enable(); - } + $this->getProfiler()?->enable(); } protected function ensureKernelShutdown(): void @@ -89,27 +86,25 @@ protected function ensureKernelShutdown(): void $this->kernel->shutdown(); } - private function getContainer(): ?ContainerInterface + private function resolveContainer(): ContainerInterface { - /** @var ContainerInterface $container */ $container = $this->kernel->getContainer(); - return $container->has('test.service_container') - ? $container->get('test.service_container') - : $container; - } - private function getProfiler(): ?Profiler - { - return $this->container->has('profiler') - ? $this->container->get('profiler') - : null; + if ($container->has('test.service_container')) { + $testContainer = $container->get('test.service_container'); + if (!$testContainer instanceof ContainerInterface) { + throw new LogicException('Service "test.service_container" must implement ' . ContainerInterface::class); + } + $container = $testContainer; + } + + return $container; } - private function getService(string $serviceName): ?object + private function getProfiler(): ?Profiler { - return $this->container->has($serviceName) - ? $this->container->get($serviceName) - : null; + $profiler = $this->container->get('profiler'); + return $profiler instanceof Profiler ? $profiler : null; } private function persistDoctrineConnections(): void @@ -119,20 +114,27 @@ private function persistDoctrineConnections(): void } if ($this->container instanceof TestContainer) { - $reflectedTestContainer = new ReflectionMethod($this->container, 'getPublicContainer'); - $reflectedTestContainer->setAccessible(true); - $publicContainer = $reflectedTestContainer->invoke($this->container); + $method = new ReflectionMethod($this->container, 'getPublicContainer'); + $publicContainer = $method->invoke($this->container); } else { $publicContainer = $this->container; } - $reflectedContainer = new ReflectionClass($publicContainer); - $reflectionTarget = $reflectedContainer->hasProperty('parameters') ? $publicContainer : $publicContainer->getParameterBag(); + if (!is_object($publicContainer) || !method_exists($publicContainer, 'getParameterBag')) { + return; + } + + $target = property_exists($publicContainer, 'parameters') + ? $publicContainer + : $publicContainer->getParameterBag(); + + if (!is_object($target) || !property_exists($target, 'parameters')) { + return; + } + $prop = new ReflectionProperty($target, 'parameters'); - $reflectedParameters = new ReflectionProperty($reflectionTarget, 'parameters'); - $reflectedParameters->setAccessible(true); - $parameters = $reflectedParameters->getValue($reflectionTarget); - unset($parameters['doctrine.connections']); - $reflectedParameters->setValue($reflectionTarget, $parameters); + $params = (array) $prop->getValue($target); + unset($params['doctrine.connections']); + $prop->setValue($target, $params); } } diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index 3ac2bc79..c3f93dac 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -12,6 +12,7 @@ use Codeception\Lib\Interfaces\PartedModule; use Codeception\Module\Symfony\BrowserAssertionsTrait; use Codeception\Module\Symfony\ConsoleAssertionsTrait; +use Codeception\Module\Symfony\DataCollectorName; use Codeception\Module\Symfony\DoctrineAssertionsTrait; use Codeception\Module\Symfony\DomCrawlerAssertionsTrait; use Codeception\Module\Symfony\EventsAssertionsTrait; @@ -31,34 +32,42 @@ use Codeception\Module\Symfony\ValidatorAssertionsTrait; use Codeception\TestInterface; use Doctrine\ORM\EntityManagerInterface; -use Exception; -use LogicException; +use PHPUnit\Framework\Assert; +use PHPUnit\Framework\AssertionFailedError; use ReflectionClass; use ReflectionException; +use Symfony\Bridge\Twig\DataCollector\TwigDataCollector; use Symfony\Bundle\SecurityBundle\DataCollector\SecurityDataCollector; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Finder\Finder; +use Symfony\Component\Form\Extension\DataCollector\FormDataCollector; +use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; +use Symfony\Component\HttpKernel\DataCollector\EventDataCollector; +use Symfony\Component\HttpKernel\DataCollector\LoggerDataCollector; use Symfony\Component\HttpKernel\DataCollector\TimeDataCollector; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\HttpKernel\Profiler\Profile; use Symfony\Component\HttpKernel\Profiler\Profiler; use Symfony\Component\Mailer\DataCollector\MessageDataCollector; +use Symfony\Component\Translation\DataCollector\TranslationDataCollector; use Symfony\Component\VarDumper\Cloner\Data; use function array_keys; use function array_map; use function array_unique; +use function array_values; use function class_exists; use function codecept_root_dir; use function count; use function file_exists; use function implode; +use function in_array; use function ini_get; use function ini_set; +use function is_object; use function iterator_to_array; -use function number_format; use function sprintf; /** @@ -132,7 +141,6 @@ * * If you're using Symfony with Eloquent ORM (instead of Doctrine), you can load the [`ORM` part of Laravel module](https://codeception.com/docs/modules/Laravel#Parts) * in addition to Symfony module. - * */ class Symfony extends Framework implements DoctrineProvider, PartedModule { @@ -158,40 +166,54 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule public Kernel $kernel; - /** - * @var SymfonyConnector - */ + /** @var SymfonyConnector|null */ public ?AbstractBrowser $client = null; /** - * @var array + * @var array{ + * app_path:string, + * kernel_class:string, + * environment:string, + * debug:bool, + * cache_router:bool, + * em_service:string, + * rebootable_client:bool, + * authenticator:bool, + * bootstrap:bool, + * guard:bool + * } */ public array $config = [ - 'app_path' => 'app', - 'kernel_class' => 'App\Kernel', - 'environment' => 'test', - 'debug' => true, - 'cache_router' => false, - 'em_service' => 'doctrine.orm.entity_manager', + 'app_path' => 'app', + 'kernel_class' => 'App\\Kernel', + 'environment' => 'test', + 'debug' => true, + 'cache_router' => false, + 'em_service' => 'doctrine.orm.entity_manager', 'rebootable_client' => true, - 'authenticator' => false, - 'bootstrap' => false, - 'guard' => false + 'authenticator' => false, + 'bootstrap' => false, + 'guard' => false, ]; + /** @var class-string|null */ protected ?string $kernelClass = null; + /** * Services that should be persistent permanently for all tests + * + * @var array */ protected array $permanentServices = []; + /** * Services that should be persistent during test execution between kernel reboots + * + * @var array */ protected array $persistentServices = []; - /** - * @return string[] - */ + /** @return list */ public function _parts(): array { return ['services']; @@ -201,36 +223,56 @@ public function _initialize(): void { $this->kernelClass = $this->getKernelClass(); $this->setXdebugMaxNestingLevel(200); - $this->kernel = new $this->kernelClass($this->config['environment'], $this->config['debug']); + + /** @var class-string $kernelClass */ + $kernelClass = $this->kernelClass; + $this->kernel = new $kernelClass( + $this->config['environment'], + $this->config['debug'] + ); + if ($this->config['bootstrap']) { $this->bootstrapEnvironment(); } + $this->kernel->boot(); + if ($this->config['cache_router']) { $this->persistPermanentService('router'); } } /** - * Initialize new client instance before each test + * Initialize new client instance before each test. */ public function _before(TestInterface $test): void { $this->persistentServices = array_merge($this->persistentServices, $this->permanentServices); - $this->client = new SymfonyConnector($this->kernel, $this->persistentServices, $this->config['rebootable_client']); + + $this->client = new SymfonyConnector( + $this->kernel, + $this->persistentServices, + $this->config['rebootable_client'] + ); } /** - * Update permanent services after each test + * Update permanent services after each test. */ public function _after(TestInterface $test): void { foreach (array_keys($this->permanentServices) as $serviceName) { - $this->permanentServices[$serviceName] = $this->grabService($serviceName); + $service = $this->getService($serviceName); + if (is_object($service)) { + $this->permanentServices[$serviceName] = $service; + } else { + unset($this->permanentServices[$serviceName]); + } } parent::_after($test); } + /** @param array $settings */ protected function onReconfigure(array $settings = []): void { parent::_beforeSuite($settings); @@ -238,213 +280,267 @@ protected function onReconfigure(array $settings = []): void } /** - * Retrieve Entity Manager. - * - * EM service is retrieved once and then that instance returned on each call + * Retrieve the Doctrine EntityManager. + * EntityManager service is retrieved once and then reused. */ public function _getEntityManager(): EntityManagerInterface { - if ($this->kernel === null) { - $this->fail('Symfony module is not loaded'); - } - + /** @var non-empty-string $emService */ $emService = $this->config['em_service']; + if (!isset($this->permanentServices[$emService])) { $this->persistPermanentService($emService); $container = $this->_getContainer(); - $services = ['doctrine', 'doctrine.orm.default_entity_manager', 'doctrine.dbal.default_connection']; - foreach ($services as $service) { + foreach ( + ['doctrine', 'doctrine.orm.default_entity_manager', 'doctrine.dbal.default_connection'] + as $service + ) { if ($container->has($service)) { $this->persistPermanentService($service); } } } + /** @var EntityManagerInterface */ return $this->permanentServices[$emService]; } public function _getContainer(): ContainerInterface { $container = $this->kernel->getContainer(); - - return $container->has('test.service_container') ? $container->get('test.service_container') : $container; + /** @var ContainerInterface $testContainer */ + $testContainer = $container->has('test.service_container') ? $container->get('test.service_container') : $container; + return $testContainer; } protected function getClient(): SymfonyConnector { - return $this->client ?: $this->fail('Client is not initialized'); + if ($this->client === null) { + Assert::fail('Client is not initialized'); + } + + return $this->client; } /** - * Attempts to guess the kernel location. - * When the Kernel is located, the file is required. + * Find and require the Kernel class file. * - * @return string The Kernel class name + * @return class-string * @throws ModuleRequireException|ReflectionException */ protected function getKernelClass(): string { - $path = codecept_root_dir() . $this->config['app_path']; + /** @var string $rootDir */ + $rootDir = codecept_root_dir(); + $path = $rootDir . $this->config['app_path']; + if (!file_exists($path)) { throw new ModuleRequireException( self::class, - "Can't load Kernel from {$path}.\n" - . 'Directory does not exist. Set `app_path` in your suite configuration to a valid application path.' + "Can't load Kernel from {$path}.\n" . + 'Directory does not exist. Set `app_path` in your suite configuration to a valid application path.' ); } $this->requireAdditionalAutoloader(); - $finder = new Finder(); + $finder = new Finder(); $results = iterator_to_array($finder->name('*Kernel.php')->depth('0')->in($path)); + if ($results === []) { throw new ModuleRequireException( self::class, - "File with Kernel class was not found at {$path}.\n" - . 'Specify directory where file with Kernel class for your application is located with `app_path` parameter.' + "File with Kernel class was not found at {$path}.\n" . + 'Specify directory where file with Kernel class for your application is located with `app_path` parameter.' ); } - $kernelClass = $this->config['kernel_class']; - $filesRealPath = array_map(static function ($file) { - require_once $file; - return $file->getRealPath(); - }, $results); + $kernelClass = $this->config['kernel_class']; + $filesRealPath = []; + + foreach ($results as $file) { + include_once $file->getRealPath(); + $filesRealPath[] = $file->getRealPath(); + } if (class_exists($kernelClass)) { - $reflectionClass = new ReflectionClass($kernelClass); - if (in_array($reflectionClass->getFileName(), $filesRealPath, true)) { + $ref = new ReflectionClass($kernelClass); + $fileName = $ref->getFileName(); + if ($fileName !== false && in_array($fileName, $filesRealPath, true)) { + /** @var class-string $kernelClass */ return $kernelClass; } } throw new ModuleRequireException( self::class, - "Kernel class was not found.\n" - . 'Specify directory where file with Kernel class for your application is located with `kernel_class` parameter.' + "Kernel class was not found.\n" . + 'Specify directory where file with Kernel class for your application is located with `kernel_class` parameter.' ); } + /** + * @throws AssertionFailedError + */ protected function getProfile(): ?Profile { - /** @var Profiler $profiler */ + /** @var Profiler|null $profiler */ $profiler = $this->getService('profiler'); + + if ($profiler === null) { + return null; + } + try { - return $profiler?->loadProfileFromResponse($this->getClient()->getResponse()); + return $profiler->loadProfileFromResponse($this->getClient()->getResponse()); } catch (BadMethodCallException) { - $this->fail('You must perform a request before using this method.'); - } catch (Exception $e) { - $this->fail($e->getMessage()); + Assert::fail('You must perform a request before using this method.'); } - - return null; } /** - * Grabs a Symfony Data Collector + * Grab a Symfony Data Collector from the current profile. + * + * @phpstan-return ( + * $collector is DataCollectorName::EVENTS ? EventDataCollector : + * ($collector is DataCollectorName::FORM ? FormDataCollector : + * ($collector is DataCollectorName::HTTP_CLIENT ? HttpClientDataCollector : + * ($collector is DataCollectorName::LOGGER ? LoggerDataCollector : + * ($collector is DataCollectorName::TIME ? TimeDataCollector : + * ($collector is DataCollectorName::TRANSLATION ? TranslationDataCollector : + * ($collector is DataCollectorName::TWIG ? TwigDataCollector : + * ($collector is DataCollectorName::SECURITY ? SecurityDataCollector : + * ($collector is DataCollectorName::MAILER ? MessageDataCollector : + * DataCollectorInterface + * )))))))) + * ) + * + * @throws AssertionFailedError */ - protected function grabCollector(string $collector, string $function, ?string $message = null): DataCollectorInterface + protected function grabCollector(DataCollectorName $collector, string $function, ?string $message = null): DataCollectorInterface { $profile = $this->getProfile(); + if ($profile === null) { - $this->fail(sprintf("The Profile is needed to use the '%s' function.", $function)); + Assert::fail(sprintf("The Profile is needed to use the '%s' function.", $function)); } - if (!$profile->hasCollector($collector)) { - $this->fail($message ?: "The '{$collector}' collector is needed to use the '{$function}' function."); + + if (!$profile->hasCollector($collector->value)) { + Assert::fail( + $message ?: sprintf( + "The '%s' collector is needed to use the '%s' function.", + $collector->value, + $function + ) + ); } - return $profile->getCollector($collector); + return $profile->getCollector($collector->value); } /** - * Set the data that will be displayed when running a test with the `--debug` flag - * - * @param mixed $url + * Set the data that will be displayed when running a test with the `--debug` flag. */ - protected function debugResponse($url): void + protected function debugResponse(mixed $url): void { parent::debugResponse($url); - if ($profile = $this->getProfile()) { - $collectors = [ - 'security' => 'debugSecurityData', - 'mailer' => 'debugMailerData', - 'time' => 'debugTimeData', - ]; - foreach ($collectors as $collector => $method) { - if ($profile->hasCollector($collector)) { - $this->$method($profile->getCollector($collector)); - } + + $profile = $this->getProfile(); + if ($profile === null) { + return; + } + + if ($profile->hasCollector(DataCollectorName::SECURITY->value)) { + $securityCollector = $profile->getCollector(DataCollectorName::SECURITY->value); + if ($securityCollector instanceof SecurityDataCollector) { + $this->debugSecurityData($securityCollector); + } + } + + if ($profile->hasCollector(DataCollectorName::MAILER->value)) { + $mailerCollector = $profile->getCollector(DataCollectorName::MAILER->value); + if ($mailerCollector instanceof MessageDataCollector) { + $this->debugMailerData($mailerCollector); + } + } + + if ($profile->hasCollector(DataCollectorName::TIME->value)) { + $timeCollector = $profile->getCollector(DataCollectorName::TIME->value); + if ($timeCollector instanceof TimeDataCollector) { + $this->debugTimeData($timeCollector); } } } - /** - * Returns a list of recognized domain names. - */ + /** @return list */ protected function getInternalDomains(): array { - $internalDomains = []; - $router = $this->grabRouterService(); - $routes = $router->getRouteCollection(); - - foreach ($routes as $route) { - if ($route->getHost() !== null) { - $compiledRoute = $route->compile(); - if ($compiledRoute->getHostRegex() !== null) { - $internalDomains[] = $compiledRoute->getHostRegex(); + $domains = []; + + foreach ($this->grabRouterService()->getRouteCollection() as $route) { + if ($route->getHost() !== '') { + $regex = $route->compile()->getHostRegex(); + if ($regex !== null && $regex !== '') { + $domains[] = $regex; } } } - return array_unique($internalDomains); + /** @var list */ + return array_values(array_unique($domains)); } - private function setXdebugMaxNestingLevel(int $maxNestingLevel): void + /** + * Ensure Xdebug allows deep nesting. + */ + private function setXdebugMaxNestingLevel(int $max): void { - if (ini_get('xdebug.max_nesting_level') < $maxNestingLevel) { - ini_set('xdebug.max_nesting_level', (string)$maxNestingLevel); + if ((int) ini_get('xdebug.max_nesting_level') < $max) { + ini_set('xdebug.max_nesting_level', (string) $max); } } + /** + * Bootstrap environment via tests/bootstrap.php or Dotenv. + */ private function bootstrapEnvironment(): void { $bootstrapFile = $this->kernel->getProjectDir() . '/tests/bootstrap.php'; + if (file_exists($bootstrapFile)) { - require_once $bootstrapFile; - } else { - if (!method_exists(Dotenv::class, 'bootEnv')) { - throw new LogicException( - "Symfony DotEnv is missing. Try running 'composer require symfony/dotenv'\n" . - "If you can't install DotEnv add your env files to the 'params' key in codeception.yml\n" . - "or update your symfony/framework-bundle recipe by running:\n" . - 'composer recipes:install symfony/framework-bundle --force' - ); - } - $_ENV['APP_ENV'] = $this->config['environment']; - (new Dotenv())->bootEnv('.env'); + include_once $bootstrapFile; + return; } + + $_ENV['APP_ENV'] = $this->config['environment']; + (new Dotenv())->bootEnv('.env'); } - private function debugSecurityData(SecurityDataCollector $security): void + private function debugSecurityData(SecurityDataCollector $securityCollector): void { - if ($security->isAuthenticated()) { - $roles = $security->getRoles(); - $rolesString = implode(',', $roles instanceof Data ? $roles->getValue() : $roles); - $userInfo = $security->getUser() . ' [' . $rolesString . ']'; - } else { - $userInfo = 'Anonymous'; + if (!$securityCollector->isAuthenticated()) { + $this->debugSection('User', 'Anonymous'); + return; } - $this->debugSection('User', $userInfo); + + $roles = $securityCollector->getRoles(); + if ($roles instanceof Data) { + $roles = $roles->getValue(true); + } + + $rolesStr = implode(',', array_map('strval', array_filter((array) $roles, 'is_scalar'))); + $this->debugSection('User', sprintf('%s [%s]', $securityCollector->getUser(), $rolesStr)); } - private function debugMailerData(MessageDataCollector $mailerCollector): void + private function debugMailerData(MessageDataCollector $messageCollector): void { - $this->debugSection('Emails', count($mailerCollector->getEvents()->getMessages()) . ' sent'); + $count = count($messageCollector->getEvents()->getMessages()); + $this->debugSection('Emails', sprintf('%d sent', $count)); } private function debugTimeData(TimeDataCollector $timeCollector): void { - $this->debugSection('Time', number_format($timeCollector->getDuration(), 2) . ' ms'); + $this->debugSection('Time', sprintf('%.2f ms', $timeCollector->getDuration())); } /** @@ -453,9 +549,12 @@ private function debugTimeData(TimeDataCollector $timeCollector): void */ private function requireAdditionalAutoloader(): void { - $autoLoader = codecept_root_dir() . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php'; - if (file_exists($autoLoader)) { - require_once $autoLoader; + /** @var string $rootDir */ + $rootDir = codecept_root_dir(); + $autoload = $rootDir . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php'; + + if (file_exists($autoload)) { + include_once $autoload; } } } diff --git a/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php b/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php index 763f977e..7b9dab27 100644 --- a/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php @@ -23,10 +23,10 @@ trait ConsoleAssertionsTrait * $result = $I->runSymfonyConsoleCommand('hello:world', ['arg' => 'argValue', 'opt1' => 'optValue'], ['input']); * ``` * - * @param string $command The console command to execute. - * @param array $parameters Arguments and options passed to the command - * @param list $consoleInputs Inputs for interactive questions. - * @param int $expectedExitCode Expected exit code. + * @param string $command The console command to execute. + * @param array $parameters Arguments and options passed to the command + * @param list $consoleInputs Inputs for interactive questions. + * @param int $expectedExitCode Expected exit code. * @return string Console output (stdout). */ public function runSymfonyConsoleCommand( @@ -56,8 +56,8 @@ public function runSymfonyConsoleCommand( } /** - * @param array $parameters - * @return array Options array supported by CommandTester. + * @param array $parameters + * @return array Options array supported by CommandTester. */ private function configureOptions(array $parameters): array { @@ -101,6 +101,8 @@ private function configureOptions(array $parameters): array protected function grabKernelService(): KernelInterface { - return $this->grabService('kernel'); + /** @var KernelInterface $kernel */ + $kernel = $this->grabService(KernelInterface::class); + return $kernel; } } diff --git a/src/Codeception/Module/Symfony/DataCollectorName.php b/src/Codeception/Module/Symfony/DataCollectorName.php new file mode 100644 index 00000000..efa86872 --- /dev/null +++ b/src/Codeception/Module/Symfony/DataCollectorName.php @@ -0,0 +1,21 @@ +grabNumRecords('User::class', ['name' => 'davert']); + * $I->grabNumRecords(User::class, ['status' => 'active']); * ``` * - * @param string $entityClass The entity class - * @param array $criteria Optional query criteria + * @param class-string $entityClass Fully-qualified entity class name + * @param array $criteria Optional query criteria */ public function grabNumRecords(string $entityClass, array $criteria = []): int { $em = $this->_getEntityManager(); $repository = $em->getRepository($entityClass); - if (empty($criteria)) { - return (int)$repository->createQueryBuilder('a') - ->select('count(a.id)') + if ($criteria === []) { + return (int)$repository->createQueryBuilder('e') + ->select('count(e.id)') ->getQuery() ->getSingleScalarResult(); } @@ -43,65 +43,42 @@ public function grabNumRecords(string $entityClass, array $criteria = []): int } /** - * Grab a Doctrine entity repository. - * Works with objects, entities, repositories, and repository interfaces. + * Obtains the Doctrine entity repository {@see EntityRepository} + * for a given entity, repository class or interface. * * ```php * grabRepository($user); - * $I->grabRepository(User::class); - * $I->grabRepository(UserRepository::class); - * $I->grabRepository(UserRepositoryInterface::class); + * $I->grabRepository($user); // entity object + * $I->grabRepository(User::class); // entity class + * $I->grabRepository(UserRepository::class); // concrete repo + * $I->grabRepository(UserRepositoryInterface::class); // interface * ``` + * + * @param object|class-string $mixed + * @return EntityRepository */ - public function grabRepository(object|string $mixed): ?EntityRepository + public function grabRepository(object|string $mixed): EntityRepository { - $entityRepoClass = EntityRepository::class; - $isNotARepo = function () use ($mixed): void { - $this->fail( - sprintf("'%s' is not an entity repository", $mixed) - ); - }; - $getRepo = function () use ($mixed, $entityRepoClass, $isNotARepo): ?EntityRepository { - if (!$repo = $this->grabService($mixed)) return null; + $id = is_object($mixed) ? $mixed::class : $mixed; - /** @var EntityRepository $repo */ - if (!$repo instanceof $entityRepoClass) { - $isNotARepo(); - return null; + if (interface_exists($id) || is_subclass_of($id, EntityRepository::class)) { + $repo = $this->grabService($id); + if (!($repo instanceof EntityRepository && $repo instanceof $id)) { + Assert::fail(sprintf("'%s' is not an entity repository", $id)); } - return $repo; - }; - - if (is_object($mixed)) { - $mixed = $mixed::class; - } - - if (interface_exists($mixed)) { - return $getRepo(); - } - - if (!is_string($mixed) || !class_exists($mixed)) { - $isNotARepo(); - return null; - } - - if (is_subclass_of($mixed, $entityRepoClass)) { - return $getRepo(); } $em = $this->_getEntityManager(); - if ($em->getMetadataFactory()->isTransient($mixed)) { - $isNotARepo(); - return null; + if ($em->getMetadataFactory()->isTransient($id)) { + Assert::fail(sprintf("'%s' is not a managed Doctrine entity", $id)); } - return $em->getRepository($mixed); + return $em->getRepository($id); } /** - * Checks that number of given records were found in database. + * Asserts that a given number of records exists for the entity. * 'id' is the default search parameter. * * ```php @@ -110,9 +87,9 @@ public function grabRepository(object|string $mixed): ?EntityRepository * $I->seeNumRecords(80, User::class); * ``` * - * @param int $expectedNum Expected number of records - * @param string $className A doctrine entity - * @param array $criteria Optional query criteria + * @param int $expectedNum Expected count + * @param class-string $className Entity class + * @param array $criteria Optional criteria */ public function seeNumRecords(int $expectedNum, string $className, array $criteria = []): void { @@ -123,8 +100,11 @@ public function seeNumRecords(int $expectedNum, string $className, array $criter $currentNum, sprintf( 'The number of found %s (%d) does not match expected number %d with %s', - $className, $currentNum, $expectedNum, json_encode($criteria, JSON_THROW_ON_ERROR) + $className, + $currentNum, + $expectedNum, + json_encode($criteria, JSON_THROW_ON_ERROR) ) ); } -} \ No newline at end of file +} diff --git a/src/Codeception/Module/Symfony/EventsAssertionsTrait.php b/src/Codeception/Module/Symfony/EventsAssertionsTrait.php index f761499b..d9ba09f0 100644 --- a/src/Codeception/Module/Symfony/EventsAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/EventsAssertionsTrait.php @@ -4,49 +4,54 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Assert; use Symfony\Component\HttpKernel\DataCollector\EventDataCollector; +use function array_column; +use function array_merge; +use function count; +use function in_array; use function is_array; use function is_object; +use function is_string; +use function str_starts_with; trait EventsAssertionsTrait { /** - * Verifies that there were no events during the test. - * Both regular and orphan events are checked. + * Verifies that **no** events (regular **or** orphan) were dispatched during the test. * * ```php - * dontSeeEvent(); - * $I->dontSeeEvent('App\MyEvent'); - * $I->dontSeeEvent(['App\MyEvent', 'App\MyOtherEvent']); - * ``` + * dontSeeEvent(); + * $I->dontSeeEvent('App\MyEvent'); + * $I->dontSeeEvent(['App\MyEvent', 'App\MyOtherEvent']); + * ``` * - * @param string|string[]|null $expected + * @param class-string|list|null $expected Fully-qualified event class(es) that must **not** appear. */ public function dontSeeEvent(array|string|null $expected = null): void { - $actualEvents = [...array_column($this->getCalledListeners(), 'event')]; - $actual = [$this->getOrphanedEvents(), $actualEvents]; - $this->assertEventTriggered(false, $expected, $actual); + $actual = $this->collectEvents(orphanOnly: false); + $this->assertEventTriggered($expected, $actual, shouldExist: false); } /** - * Verifies that one or more event listeners were not called during the test. + * Verifies that one or more **listeners** were **not** called during the test. * * ```php * dontSeeEventListenerIsCalled('App\MyEventListener'); * $I->dontSeeEventListenerIsCalled(['App\MyEventListener', 'App\MyOtherEventListener']); - * $I->dontSeeEventListenerIsCalled('App\MyEventListener', 'my.event); + * $I->dontSeeEventListenerIsCalled('App\MyEventListener', 'my.event'); * $I->dontSeeEventListenerIsCalled('App\MyEventListener', ['my.event', 'my.other.event']); * ``` * - * @param class-string|class-string[] $expected - * @param string|string[] $events + * @param class-string|object|list $expected Listeners (class-strings or object instances). + * @param string|list $events Event name(s) (empty = any). */ public function dontSeeEventListenerIsCalled(array|object|string $expected, array|string $events = []): void { - $this->assertListenerCalled(false, $expected, $events); + $this->assertListenerCalled($expected, $events, shouldBeCalled: false); } /** @@ -59,8 +64,8 @@ public function dontSeeEventListenerIsCalled(array|object|string $expected, arra * $I->dontSeeEventTriggered(['App\MyEvent', 'App\MyOtherEvent']); * ``` * - * @param object|string|string[] $expected - * @deprecated Use `dontSeeEventListenerIsCalled` instead. + * @param class-string|object|list $expected + * @deprecated Use {@see dontSeeEventListenerIsCalled()} instead. */ public function dontSeeEventTriggered(array|object|string $expected): void { @@ -75,8 +80,8 @@ public function dontSeeEventTriggered(array|object|string $expected): void * Verifies that there were no orphan events during the test. * * An orphan event is an event that was triggered by manually executing the - * [`dispatch()`](https://symfony.com/doc/current/components/event_dispatcher.html#dispatch-the-event) method - * of the EventDispatcher but was not handled by any listener after it was dispatched. + * {@link https://symfony.com/doc/current/components/event_dispatcher.html#dispatch-the-event dispatch()} + * method of the EventDispatcher but was not handled by any listener after it was dispatched. * * ```php * dontSeeOrphanEvent(['App\MyEvent', 'App\MyOtherEvent']); * ``` * - * @param string|string[] $expected + * @param class-string|list|null $expected Event class(es) that must **not** appear as orphan. */ public function dontSeeOrphanEvent(array|string|null $expected = null): void { - $actual = [$this->getOrphanedEvents()]; - $this->assertEventTriggered(false, $expected, $actual); + $actual = $this->collectEvents(orphanOnly: true); + $this->assertEventTriggered($expected, $actual, shouldExist: false); } /** - * Verifies that one or more events were dispatched during the test. - * Both regular and orphan events are checked. - * - * If you need to verify that expected event is not orphan, - * add `dontSeeOrphanEvent` call. + * Verifies that at least one of the given events **was** dispatched (regular **or** orphan). * * ```php - * seeEvent('App\MyEvent'); - * $I->seeEvent(['App\MyEvent', 'App\MyOtherEvent']); - * ``` + * seeEvent('App\MyEvent'); + * $I->seeEvent(['App\MyEvent', 'App\MyOtherEvent']); + * ``` * - * @param string|string[] $expected + * @param class-string|list $expected Fully-qualified class-name(s) of the expected event(s). */ public function seeEvent(array|string $expected): void { - $actualEvents = [...array_column($this->getCalledListeners(), 'event')]; - $actual = [$this->getOrphanedEvents(), $actualEvents]; - $this->assertEventTriggered(true, $expected, $actual); + $actual = $this->collectEvents(orphanOnly: false); + $this->assertEventTriggered($expected, $actual, shouldExist: true); } /** - * Verifies that one or more event listeners were called during the test. + * Verifies that one or more **listeners** were called during the test. * * ```php * seeEventListenerIsCalled('App\MyEventListener'); * $I->seeEventListenerIsCalled(['App\MyEventListener', 'App\MyOtherEventListener']); - * $I->seeEventListenerIsCalled('App\MyEventListener', 'my.event); + * $I->seeEventListenerIsCalled('App\MyEventListener', 'my.event'); * $I->seeEventListenerIsCalled('App\MyEventListener', ['my.event', 'my.other.event']); * ``` * - * @param class-string|class-string[] $expected - * @param string|string[] $events + * @param class-string|object|list $expected Listeners (class-strings or object instances). + * @param string|list $events Event name(s) (empty = any). */ public function seeEventListenerIsCalled(array|object|string $expected, array|string $events = []): void { - $this->assertListenerCalled(true, $expected, $events); + $this->assertListenerCalled($expected, $events, shouldBeCalled: true); } /** @@ -144,8 +144,8 @@ public function seeEventListenerIsCalled(array|object|string $expected, array|st * $I->seeEventTriggered(['App\MyEvent', 'App\MyOtherEvent']); * ``` * - * @param object|string|string[] $expected - * @deprecated Use `seeEventListenerIsCalled` instead. + * @param class-string|object|list $expected + * @deprecated Use {@see seeEventListenerIsCalled()} instead. */ public function seeEventTriggered(array|object|string $expected): void { @@ -157,11 +157,11 @@ public function seeEventTriggered(array|object|string $expected): void } /** - * Verifies that one or more orphan events were dispatched during the test. + * Verifies that one or more orphan events **were** dispatched during the test. * * An orphan event is an event that was triggered by manually executing the - * [`dispatch()`](https://symfony.com/doc/current/components/event_dispatcher.html#dispatch-the-event) method - * of the EventDispatcher but was not handled by any listener after it was dispatched. + * {@link https://symfony.com/doc/current/components/event_dispatcher.html#dispatch-the-event dispatch()} + * method of the EventDispatcher but was not handled by any listener after it was dispatched. * * ```php * seeOrphanEvent(['App\MyEvent', 'App\MyOtherEvent']); * ``` * - * @param string|string[] $expected + * @param class-string|list $expected Event class-name(s) expected to be orphan. */ public function seeOrphanEvent(array|string $expected): void { - $actual = [$this->getOrphanedEvents()]; - $this->assertEventTriggered(true, $expected, $actual); + $actual = $this->collectEvents(orphanOnly: true); + $this->assertEventTriggered($expected, $actual, shouldExist: true); } - protected function getCalledListeners(): array + /** @return list */ + protected function getDispatchedEvents(): array { - $eventCollector = $this->grabEventCollector(__FUNCTION__); + $eventCollector = $this->grabEventCollector(__FUNCTION__); $calledListeners = $eventCollector->getCalledListeners($this->getDefaultDispatcher()); - return [...$calledListeners->getValue(true)]; + + /** @var list */ + return is_array($calledListeners) + ? array_values($calledListeners) + : $calledListeners->getValue(true); } + /** @return list */ protected function getOrphanedEvents(): array { $eventCollector = $this->grabEventCollector(__FUNCTION__); $orphanedEvents = $eventCollector->getOrphanedEvents($this->getDefaultDispatcher()); - return [...$orphanedEvents->getValue(true)]; + + /** @var list */ + return is_array($orphanedEvents) + ? array_values($orphanedEvents) + : $orphanedEvents->getValue(true); } - protected function assertEventTriggered(bool $assertTrue, array|object|string|null $expected, array $actual): void + /** @return list> */ + private function collectEvents(bool $orphanOnly): array + { + return $orphanOnly + ? [$this->getOrphanedEvents()] + : [$this->getOrphanedEvents(), array_column($this->getDispatchedEvents(), 'event')]; + } + + /** + * @param class-string|object|list|null $expected + * @param list> $actual + */ + protected function assertEventTriggered(array|object|string|null $expected, array $actual, bool $shouldExist): void { $actualEvents = array_merge(...$actual); - if ($assertTrue) $this->assertNotEmpty($actualEvents, 'No event was triggered'); + if ($shouldExist) { + $this->assertNotEmpty($actualEvents, 'No event was triggered.'); + } if ($expected === null) { $this->assertEmpty($actualEvents); return; } - $expected = is_object($expected) ? $expected::class : $expected; - foreach ((array)$expected as $expectedEvent) { - $expectedEvent = is_object($expectedEvent) ? $expectedEvent::class : $expectedEvent; - $eventTriggered = in_array($expectedEvent, $actualEvents); + $expectedEvents = is_object($expected) ? [$expected] : (array) $expected; + foreach ($expectedEvents as $expectedEvent) { + $eventName = is_object($expectedEvent) ? $expectedEvent::class : $expectedEvent; + $wasTriggered = in_array($eventName, $actualEvents, true); - $message = $assertTrue - ? "The '{$expectedEvent}' event did not trigger" - : "The '{$expectedEvent}' event triggered"; - $this->assertSame($assertTrue, $eventTriggered, $message); + $this->assertSame( + $shouldExist, + $wasTriggered, + sprintf("The '%s' event %s triggered", $eventName, $shouldExist ? 'did not' : 'was') + ); } } - protected function assertListenerCalled(bool $assertTrue, array|object|string $expectedListeners, array|object|string $expectedEvents): void - { + /** + * @param class-string|object|list $expectedListeners + * @param string|list $expectedEvents + */ + protected function assertListenerCalled( + array|object|string $expectedListeners, + array|string $expectedEvents, + bool $shouldBeCalled + ): void { $expectedListeners = is_array($expectedListeners) ? $expectedListeners : [$expectedListeners]; - $expectedEvents = is_array($expectedEvents) ? $expectedEvents : [$expectedEvents]; + $expectedEvents = is_array($expectedEvents) ? $expectedEvents : [$expectedEvents]; - if (empty($expectedEvents)) { + if ($expectedEvents === []) { $expectedEvents = [null]; } elseif (count($expectedListeners) > 1) { - $this->fail('You cannot check for events when using multiple listeners. Make multiple assertions instead.'); + Assert::fail('Cannot check for events when using multiple listeners. Make multiple assertions instead.'); } - $actualEvents = $this->getCalledListeners(); - if ($assertTrue && empty($actualEvents)) { - $this->fail('No event listener was called'); + $actualEvents = $this->getDispatchedEvents(); + + if ($shouldBeCalled && $actualEvents === []) { + Assert::fail('No event listener was called.'); } foreach ($expectedListeners as $expectedListener) { - $expectedListener = is_object($expectedListener) ? $expectedListener::class : $expectedListener; + $expectedListener = is_string($expectedListener) ? $expectedListener : $expectedListener::class; foreach ($expectedEvents as $expectedEvent) { - $listenerCalled = $this->listenerWasCalled($expectedListener, $expectedEvent, $actualEvents); - $message = "The '{$expectedListener}' listener was called" - . ($expectedEvent ? " for the '{$expectedEvent}' event" : ''); - $this->assertSame($assertTrue, $listenerCalled, $message); + $eventName = $expectedEvent ?: null; + $wasCalled = $this->listenerWasCalled($expectedListener, $eventName, $actualEvents); + + $this->assertSame( + $shouldBeCalled, + $wasCalled, + sprintf( + "The '%s' listener was %scalled%s", + $expectedListener, + $shouldBeCalled ? 'not ' : '', + $eventName ? " for the '{$eventName}' event" : '' + ) + ); } } } + /** @param list $actualEvents */ private function listenerWasCalled(string $expectedListener, ?string $expectedEvent, array $actualEvents): bool { foreach ($actualEvents as $actualEvent) { - if ( - isset($actualEvent['pretty'], $actualEvent['event']) - && str_starts_with($actualEvent['pretty'], $expectedListener) + if (str_starts_with($actualEvent['pretty'], $expectedListener) && ($expectedEvent === null || $actualEvent['event'] === $expectedEvent) ) { return true; @@ -262,6 +303,6 @@ protected function getDefaultDispatcher(): string protected function grabEventCollector(string $function): EventDataCollector { - return $this->grabCollector('events', $function); + return $this->grabCollector(DataCollectorName::EVENTS, $function); } } diff --git a/src/Codeception/Module/Symfony/FormAssertionsTrait.php b/src/Codeception/Module/Symfony/FormAssertionsTrait.php index f77403bd..26ab54aa 100644 --- a/src/Codeception/Module/Symfony/FormAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/FormAssertionsTrait.php @@ -5,9 +5,10 @@ namespace Codeception\Module\Symfony; use Symfony\Component\Form\Extension\DataCollector\FormDataCollector; -use function array_key_exists; -use function in_array; +use Symfony\Component\VarDumper\Cloner\Data; +use function is_array; use function is_int; +use function is_string; use function sprintf; trait FormAssertionsTrait @@ -22,10 +23,15 @@ trait FormAssertionsTrait */ public function assertFormValue(string $formSelector, string $fieldName, string $value, string $message = ''): void { - $node = $this->getCLient()->getCrawler()->filter($formSelector); + $node = $this->getClient()->getCrawler()->filter($formSelector); $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); + $values = $node->form()->getValues(); - $this->assertArrayHasKey($fieldName, $values, $message ?: sprintf('Field "%s" not found in form "%s".', $fieldName, $formSelector)); + $this->assertArrayHasKey( + $fieldName, + $values, + $message ?: sprintf('Field "%s" not found in form "%s".', $fieldName, $formSelector) + ); $this->assertSame($value, $values[$fieldName]); } @@ -39,10 +45,15 @@ public function assertFormValue(string $formSelector, string $fieldName, string */ public function assertNoFormValue(string $formSelector, string $fieldName, string $message = ''): void { - $node = $this->getCLient()->getCrawler()->filter($formSelector); + $node = $this->getClient()->getCrawler()->filter($formSelector); $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); + $values = $node->form()->getValues(); - $this->assertArrayNotHasKey($fieldName, $values, $message ?: sprintf('Field "%s" has a value in form "%s".', $fieldName, $formSelector)); + $this->assertArrayNotHasKey( + $fieldName, + $values, + $message ?: sprintf('Field "%s" has a value in form "%s".', $fieldName, $formSelector) + ); } /** @@ -56,14 +67,9 @@ public function assertNoFormValue(string $formSelector, string $fieldName, strin public function dontSeeFormErrors(): void { $formCollector = $this->grabFormCollector(__FUNCTION__); + $errors = $this->extractFormCollectorScalar($formCollector, 'nb_errors'); - $errors = (int)$formCollector->getData()->offsetGet('nb_errors'); - - $this->assertSame( - 0, - $errors, - 'Expecting that the form does not have errors, but there were!' - ); + $this->assertSame(0, $errors, 'Expecting that the form does not have errors, but there were!'); } /** @@ -79,44 +85,48 @@ public function dontSeeFormErrors(): void public function seeFormErrorMessage(string $field, ?string $message = null): void { $formCollector = $this->grabFormCollector(__FUNCTION__); + $rawData = $this->getRawCollectorData($formCollector); + $formsData = array_values(is_array($rawData['forms'] ?? null) ? $rawData['forms'] : []); - if (!$forms = $formCollector->getData()->getValue(true)['forms']) { - $this->fail('No forms found on the current page.'); - } - - $fields = []; - $errors = []; - - foreach ($forms as $form) { - foreach ($form['children'] as $child) { - $fieldName = $child['name']; - $fields[] = $fieldName; + $fieldExists = false; + $errorsForField = []; - if (!array_key_exists('errors', $child)) { + foreach ($formsData as $form) { + if (!is_array($form)) { + continue; + } + $children = is_array($form['children'] ?? null) ? $form['children'] : []; + foreach ($children as $child) { + if (!is_array($child) || ($child['name'] ?? null) !== $field) { continue; } - foreach ($child['errors'] as $error) { - $errors[$fieldName] = $error['message']; + $fieldExists = true; + + $errs = is_array($child['errors'] ?? null) ? $child['errors'] : []; + foreach ($errs as $error) { + if (is_array($error) && is_string($error['message'] ?? null)) { + $errorsForField[] = $error['message']; + } } } } - if (!in_array($field, $fields)) { + if (!$fieldExists) { $this->fail("The field '{$field}' does not exist in the form."); } - if (!array_key_exists($field, $errors)) { + if ($errorsForField === []) { $this->fail("No form error message for field '{$field}'."); } - if (!$message) { + if ($message === null) { return; } $this->assertStringContainsString( $message, - $errors[$field], + implode("\n", $errorsForField), sprintf( "There is an error message for the field '%s', but it does not match the expected message.", $field @@ -164,15 +174,15 @@ public function seeFormErrorMessage(string $field, ?string $message = null): voi * ]); * ``` * - * @param string[] $expectedErrors + * @param array $expectedErrors */ public function seeFormErrorMessages(array $expectedErrors): void { - foreach ($expectedErrors as $field => $message) { + foreach ($expectedErrors as $field => $msg) { if (is_int($field)) { - $this->seeFormErrorMessage($message); + $this->seeFormErrorMessage((string) $msg); } else { - $this->seeFormErrorMessage($field, $message); + $this->seeFormErrorMessage($field, $msg); } } } @@ -188,16 +198,35 @@ public function seeFormErrorMessages(array $expectedErrors): void public function seeFormHasErrors(): void { $formCollector = $this->grabFormCollector(__FUNCTION__); + $errors = $this->extractFormCollectorScalar($formCollector, 'nb_errors'); - $this->assertGreaterThan( - 0, - $formCollector->getData()->offsetGet('nb_errors'), - 'Expecting that the form has errors, but there were none!' - ); + $this->assertGreaterThan(0, $errors, 'Expecting that the form has errors, but there were none!'); + } + + private function extractFormCollectorScalar(FormDataCollector $collector, string $key): int + { + $rawData = $this->getRawCollectorData($collector); + $valueRaw = $rawData[$key] ?? null; + + return is_numeric($valueRaw) ? (int) $valueRaw : 0; + } + + /** @return array */ + private function getRawCollectorData(FormDataCollector $collector): array + { + $data = $collector->getData(); + + if ($data instanceof Data) { + $data = $data->getValue(true); + } + + /** @var array $result */ + $result = is_array($data) ? $data : []; + return $result; } protected function grabFormCollector(string $function): FormDataCollector { - return $this->grabCollector('form', $function); + return $this->grabCollector(DataCollectorName::FORM, $function); } } diff --git a/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php b/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php index f6f322eb..a8124b5f 100644 --- a/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php @@ -5,15 +5,24 @@ namespace Codeception\Module\Symfony; use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; +use Symfony\Component\VarDumper\Cloner\Data; +use function array_change_key_case; +use function array_filter; +use function array_intersect_key; use function array_key_exists; -use function is_string; +use function in_array; +use function is_array; +use function is_object; +use function method_exists; +use function sprintf; trait HttpClientAssertionsTrait { /** - * Asserts that the given URL has been called using, if specified, the given method body and headers. - * By default, it will check on the HttpClient, but you can also pass a specific HttpClient ID. - * (It will succeed if the request has been called multiple times.) + * Asserts that the given URL has been called using, if specified, the given method, body and/or headers. + * By default, it will inspect the default Symfony HttpClient; you may check a different one by passing its + * service-id in $httpClientId. + * It succeeds even if the request was executed multiple times. * * ```php * 'Bearer token'] * ); * ``` + * + * @param string|array|null $expectedBody + * @param array $expectedHeaders */ - public function assertHttpClientRequest(string $expectedUrl, string $expectedMethod = 'GET', string|array|null $expectedBody = null, array $expectedHeaders = [], string $httpClientId = 'http_client'): void - { - $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); - $expectedRequestHasBeenFound = false; - - if (!array_key_exists($httpClientId, $httpClientCollector->getClients())) { - $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); - } - - foreach ($httpClientCollector->getClients()[$httpClientId]['traces'] as $trace) { - if (($expectedUrl !== $trace['info']['url'] && $expectedUrl !== $trace['url']) - || $expectedMethod !== $trace['method'] - ) { - continue; - } - - if (null !== $expectedBody) { - $actualBody = null; - - if (null !== $trace['options']['body'] && null === $trace['options']['json']) { - $actualBody = is_string($trace['options']['body']) ? $trace['options']['body'] : $trace['options']['body']->getValue(true); + public function assertHttpClientRequest( + string $expectedUrl, + string $expectedMethod = 'GET', + string|array|null $expectedBody = null, + array $expectedHeaders = [], + string $httpClientId = 'http_client', + ): void { + $matchingRequests = array_filter( + $this->getHttpClientTraces($httpClientId, __FUNCTION__), + function (array $trace) use ($expectedUrl, $expectedMethod, $expectedBody, $expectedHeaders): bool { + if (!$this->matchesUrlAndMethod($trace, $expectedUrl, $expectedMethod)) { + return false; } - if (null === $trace['options']['body'] && null !== $trace['options']['json']) { - $actualBody = $trace['options']['json']->getValue(true); - } - - if (!$actualBody) { - continue; - } + $options = $trace['options'] ?? []; + $actualBody = $this->extractValue($options['body'] ?? $options['json'] ?? null); + $bodyMatches = $expectedBody === null || $expectedBody === $actualBody; - if ($expectedBody === $actualBody) { - $expectedRequestHasBeenFound = true; + $headersMatch = $expectedHeaders === [] || ( + is_array($headerValues = $this->extractValue($options['headers'] ?? [])) + && ($normalizedExpected = array_change_key_case($expectedHeaders)) + === array_intersect_key(array_change_key_case($headerValues), $normalizedExpected) + ); - if (!$expectedHeaders) { - break; - } - } - } - - if ($expectedHeaders) { - $actualHeaders = $trace['options']['headers'] ?? []; - - foreach ($actualHeaders as $headerKey => $actualHeader) { - if (array_key_exists($headerKey, $expectedHeaders) - && $expectedHeaders[$headerKey] === $actualHeader->getValue(true) - ) { - $expectedRequestHasBeenFound = true; - break 2; - } - } - } - - $expectedRequestHasBeenFound = true; - break; - } + return $bodyMatches && $headersMatch; + }, + ); - $this->assertTrue($expectedRequestHasBeenFound, 'The expected request has not been called: "' . $expectedMethod . '" - "' . $expectedUrl . '"'); + $this->assertNotEmpty( + $matchingRequests, + sprintf('The expected request has not been called: "%s" - "%s"', $expectedMethod, $expectedUrl) + ); } /** - * Asserts that the given number of requests has been made on the HttpClient. - * By default, it will check on the HttpClient, but you can also pass a specific HttpClient ID. + * Asserts that exactly $count requests have been executed by the given HttpClient. + * By default, it will inspect the default Symfony HttpClient; you may check a different one by passing its + * service-id in $httpClientId. * * ```php - * assertHttpClientRequestCount(3); * ``` */ public function assertHttpClientRequestCount(int $count, string $httpClientId = 'http_client'): void { - $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); - - $this->assertCount($count, $httpClientCollector->getClients()[$httpClientId]['traces']); + $this->assertCount($count, $this->getHttpClientTraces($httpClientId, __FUNCTION__)); } /** - * Asserts that the given URL has not been called using GET or the specified method. - * By default, it will check on the HttpClient, but a HttpClient id can be specified. - * + * Asserts that the given URL *has not* been requested with the supplied HTTP method. + * By default, it will inspect the default Symfony HttpClient; you may check a different one by passing its + * service-id in $httpClientId. * ```php - * assertNotHttpClientRequest('https://example.com/unexpected', 'GET'); * ``` */ - public function assertNotHttpClientRequest(string $unexpectedUrl, string $expectedMethod = 'GET', string $httpClientId = 'http_client'): void - { - $httpClientCollector = $this->grabHttpClientCollector(__FUNCTION__); - $unexpectedUrlHasBeenFound = false; + public function assertNotHttpClientRequest( + string $unexpectedUrl, + string $unexpectedMethod = 'GET', + string $httpClientId = 'http_client', + ): void { + $matchingRequests = array_filter( + $this->getHttpClientTraces($httpClientId, __FUNCTION__), + fn(array $trace): bool => $this->matchesUrlAndMethod($trace, $unexpectedUrl, $unexpectedMethod) + ); + + $this->assertEmpty( + $matchingRequests, + sprintf('Unexpected URL was called: "%s" - "%s"', $unexpectedMethod, $unexpectedUrl) + ); + } - if (!array_key_exists($httpClientId, $httpClientCollector->getClients())) { + /** + * @return list + */ + private function getHttpClientTraces(string $httpClientId, string $function): array + { + $httpClientCollector = $this->grabHttpClientCollector($function); + + /** @var array}> $clients + */ + $clients = $httpClientCollector->getClients(); + + if (!array_key_exists($httpClientId, $clients)) { $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); } - foreach ($httpClientCollector->getClients()[$httpClientId]['traces'] as $trace) { - if (($unexpectedUrl === $trace['info']['url'] || $unexpectedUrl === $trace['url']) - && $expectedMethod === $trace['method'] - ) { - $unexpectedUrlHasBeenFound = true; - break; - } - } + return $clients[$httpClientId]['traces']; + } - $this->assertFalse($unexpectedUrlHasBeenFound, sprintf('Unexpected URL called: "%s" - "%s"', $expectedMethod, $unexpectedUrl)); + /** @param array{info: array{url: string}, url: string, method: string} $trace */ + private function matchesUrlAndMethod(array $trace, string $expectedUrl, string $expectedMethod): bool + { + return in_array($expectedUrl, [$trace['info']['url'], $trace['url']], true) + && $expectedMethod === $trace['method']; + } + + private function extractValue(mixed $value): mixed + { + return match (true) { + $value instanceof Data => $value->getValue(true), + is_object($value) && method_exists($value, 'getValue') => $value->getValue(true), + is_object($value) && method_exists($value, '__toString') => (string) $value, + default => $value, + }; } protected function grabHttpClientCollector(string $function): HttpClientDataCollector { - return $this->grabCollector('http_client', $function); + return $this->grabCollector(DataCollectorName::HTTP_CLIENT, $function); } } diff --git a/src/Codeception/Module/Symfony/LoggerAssertionsTrait.php b/src/Codeception/Module/Symfony/LoggerAssertionsTrait.php index 4cd0266a..149b1a08 100644 --- a/src/Codeception/Module/Symfony/LoggerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/LoggerAssertionsTrait.php @@ -23,38 +23,35 @@ trait LoggerAssertionsTrait */ public function dontSeeDeprecations(string $message = ''): void { - $loggerCollector = $this->grabLoggerCollector(__FUNCTION__); - $logs = $loggerCollector->getProcessedLogs(); - + $logs = $this->grabLoggerCollector(__FUNCTION__)->getProcessedLogs(); $foundDeprecations = []; + /** @var array $log */ foreach ($logs as $log) { - if (isset($log['type']) && $log['type'] === 'deprecation') { - $msg = $log['message']; - if ($msg instanceof Data) { - $msg = $msg->getValue(true); - } - if (!is_string($msg)) { - $msg = (string)$msg; - } - $foundDeprecations[] = $msg; + if (!isset($log['type']) || $log['type'] !== 'deprecation') { + continue; + } + $msg = $log['message']; + if ($msg instanceof Data) { + $msg = $msg->getValue(true); + } + if (!is_string($msg) && !is_scalar($msg)) { + $msg = json_encode($msg, JSON_THROW_ON_ERROR); } + $foundDeprecations[] = (string) $msg; } - + $count = count($foundDeprecations); $errorMessage = $message ?: sprintf( "Found %d deprecation message%s in the log:\n%s", - count($foundDeprecations), - count($foundDeprecations) > 1 ? 's' : '', - implode("\n", array_map(static function ($msg) { - return " - " . $msg; - }, $foundDeprecations)) + $count, + $count !== 1 ? 's' : '', + implode("\n", array_map(static fn(string $m): string => " - $m", $foundDeprecations)), ); - $this->assertEmpty($foundDeprecations, $errorMessage); } protected function grabLoggerCollector(string $function): LoggerDataCollector { - return $this->grabCollector('logger', $function); + return $this->grabCollector(DataCollectorName::LOGGER, $function); } } diff --git a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php index 5a31e6d8..649bfcd9 100644 --- a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php @@ -4,6 +4,7 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\Constraint\LogicalNot; use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mailer\Event\MessageEvents; @@ -117,7 +118,7 @@ public function grabLastSentEmail(): ?Email * $emails = $I->grabSentEmails(); * ``` * - * @return \Symfony\Component\Mime\Email[] + * @return \Symfony\Component\Mime\RawMessage[] */ public function grabSentEmails(): array { @@ -163,14 +164,14 @@ public function getMailerEvent(int $index = 0, ?string $transport = null): ?Mess protected function getMessageMailerEvents(): MessageEvents { - if ($mailer = $this->getService('mailer.message_logger_listener')) { - /** @var MessageLoggerListener $mailer */ + $mailer = $this->getService('mailer.message_logger_listener'); + if ($mailer instanceof MessageLoggerListener) { return $mailer->getEvents(); } - if ($mailer = $this->getService('mailer.logger_message_listener')) { - /** @var MessageLoggerListener $mailer */ + $mailer = $this->getService('mailer.logger_message_listener'); + if ($mailer instanceof MessageLoggerListener) { return $mailer->getEvents(); } - $this->fail("Emails can't be tested without Symfony Mailer service."); + Assert::fail("Emails can't be tested without Symfony Mailer service."); } } diff --git a/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php b/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php index ecbbbbc7..7d54dfd0 100644 --- a/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php @@ -17,6 +17,8 @@ trait ParameterAssertionsTrait * $I->grabParameter('app.business_name'); * ``` * This only works for explicitly set parameters (just using `bind` for Symfony's dependency injection is not enough). + * + * @return array|bool|string|int|float|UnitEnum|null */ public function grabParameter(string $parameterName): array|bool|string|int|float|UnitEnum|null { @@ -26,6 +28,8 @@ public function grabParameter(string $parameterName): array|bool|string|int|floa protected function grabParameterBagService(): ParameterBagInterface { - return $this->grabService('parameter_bag'); + /** @var ParameterBagInterface $parameterBag */ + $parameterBag = $this->grabService(ParameterBagInterface::class); + return $parameterBag; } } diff --git a/src/Codeception/Module/Symfony/RouterAssertionsTrait.php b/src/Codeception/Module/Symfony/RouterAssertionsTrait.php index 699b23b1..cdbd41ee 100644 --- a/src/Codeception/Module/Symfony/RouterAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/RouterAssertionsTrait.php @@ -4,12 +4,14 @@ namespace Codeception\Module\Symfony; -use Symfony\Component\Routing\Exception\ResourceNotFoundException; -use Symfony\Component\Routing\Route; +use PHPUnit\Framework\Assert; use Symfony\Component\Routing\RouterInterface; + use function array_intersect_assoc; -use function explode; +use function is_string; +use function parse_url; use function sprintf; +use function str_ends_with; trait RouterAssertionsTrait { @@ -22,25 +24,12 @@ trait RouterAssertionsTrait * $I->amOnAction('HomeController'); * $I->amOnAction('ArticleController', ['slug' => 'lorem-ipsum']); * ``` + * + * @param array $params */ public function amOnAction(string $action, array $params = []): void { - $router = $this->grabRouterService(); - $routes = $router->getRouteCollection()->getIterator(); - - /** @var Route $route */ - foreach ($routes as $route) { - $controller = $route->getDefault('_controller'); - if (str_ends_with((string) $controller, $action)) { - $resource = $router->match($route->getPath()); - $url = $router->generate( - $resource['_route'], - $params - ); - $this->amOnPage($url); - return; - } - } + $this->openRoute($this->findRouteByActionOrFail($action), $params); } /** @@ -51,16 +40,13 @@ public function amOnAction(string $action, array $params = []): void * $I->amOnRoute('posts.create'); * $I->amOnRoute('posts.show', ['id' => 34]); * ``` + * + * @param array $params */ public function amOnRoute(string $routeName, array $params = []): void { - $router = $this->grabRouterService(); - if ($router->getRouteCollection()->get($routeName) === null) { - $this->fail(sprintf('Route with name "%s" does not exist.', $routeName)); - } - - $url = $router->generate($routeName, $params); - $this->amOnPage($url); + $this->assertRouteExists($routeName); + $this->openRoute($routeName, $params); } /** @@ -82,22 +68,11 @@ public function invalidateCachedRouter(): void */ public function seeCurrentActionIs(string $action): void { - $router = $this->grabRouterService(); - $routes = $router->getRouteCollection()->getIterator(); - - /** @var Route $route */ - foreach ($routes as $route) { - $controller = $route->getDefault('_controller'); - if (str_ends_with((string) $controller, $action)) { - $request = $this->getClient()->getRequest(); - $currentActionFqcn = $request->attributes->get('_controller'); - - $this->assertStringEndsWith($action, $currentActionFqcn, "Current action is '{$currentActionFqcn}'."); - return; - } - } + $this->findRouteByActionOrFail($action); - $this->fail("Action '{$action}' does not exist"); + /** @var string $current */ + $current = $this->getClient()->getRequest()->attributes->get('_controller'); + $this->assertStringEndsWith($action, $current, "Current action is '{$current}'."); } /** @@ -108,32 +83,19 @@ public function seeCurrentActionIs(string $action): void * $I->seeCurrentRouteIs('posts.index'); * $I->seeCurrentRouteIs('posts.show', ['id' => 8]); * ``` + * + * @param array $params */ public function seeCurrentRouteIs(string $routeName, array $params = []): void { - $router = $this->grabRouterService(); - if ($router->getRouteCollection()->get($routeName) === null) { - $this->fail(sprintf('Route with name "%s" does not exist.', $routeName)); - } - - $uri = explode('?', $this->grabFromCurrentUrl())[0]; - $uri = explode('#', $uri)[0]; - $match = []; - try { - $match = $router->match($uri); - } catch (ResourceNotFoundException) { - $this->fail(sprintf('The "%s" url does not match with any route', $uri)); - } - - $expected = ['_route' => $routeName, ...$params]; - $intersection = array_intersect_assoc($expected, $match); - - $this->assertSame($expected, $intersection); + $match = $this->getCurrentRouteMatch($routeName); + $expected = ['_route' => $routeName] + $params; + $this->assertSame($expected, array_intersect_assoc($expected, $match)); } /** * Checks that current url matches route. - * Unlike seeCurrentRouteIs, this can matches without exact route parameters + * Unlike seeCurrentRouteIs, this can match without exact route parameters * * ```php * grabRouterService(); - if ($router->getRouteCollection()->get($routeName) === null) { - $this->fail(sprintf('Route with name "%s" does not exist.', $routeName)); - } + $this->assertSame($routeName, $this->getCurrentRouteMatch($routeName)['_route']); + } + + /** @return array */ + private function getCurrentRouteMatch(string $routeName): array + { + $this->assertRouteExists($routeName); + + $url = $this->grabFromCurrentUrl(); + Assert::assertIsString($url, 'Unable to obtain current URL.'); + $path = (string) parse_url($url, PHP_URL_PATH); + + /** @var array $match */ + $match = $this->grabRouterService()->match($path); + return $match; + } - $uri = explode('?', $this->grabFromCurrentUrl())[0]; - $uri = explode('#', $uri)[0]; - $matchedRouteName = ''; - try { - $matchedRouteName = (string)$router->match($uri)['_route']; - } catch (ResourceNotFoundException) { - $this->fail(sprintf('The "%s" url does not match with any route', $uri)); + private function findRouteByActionOrFail(string $action): string + { + foreach ($this->grabRouterService()->getRouteCollection()->all() as $name => $route) { + $ctrl = $route->getDefault('_controller'); + if (is_string($ctrl) && str_ends_with($ctrl, $action)) { + return $name; + } } + Assert::fail(sprintf("Action '%s' does not exist.", $action)); + } - $this->assertSame($matchedRouteName, $routeName); + private function assertRouteExists(string $routeName): void + { + $this->assertNotNull( + $this->grabRouterService()->getRouteCollection()->get($routeName), + sprintf('Route "%s" does not exist.', $routeName) + ); + } + + /** @param array $params */ + private function openRoute(string $routeName, array $params = []): void + { + $this->amOnPage($this->grabRouterService()->generate($routeName, $params)); } protected function grabRouterService(): RouterInterface { - return $this->grabService('router'); + /** @var RouterInterface $router */ + $router = $this->grabService('router'); + return $router; } } diff --git a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php index 81559730..3d0ecaf6 100644 --- a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php @@ -4,11 +4,11 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Assert; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter; -use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; -use Symfony\Component\Security\Core\Security as LegacySecurity; +use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use function sprintf; @@ -25,10 +25,9 @@ trait SecurityAssertionsTrait public function dontSeeAuthentication(): void { $security = $this->grabSecurityService(); - $this->assertFalse( $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY), - 'There is an user authenticated' + 'There is an user authenticated.' ); } @@ -42,16 +41,12 @@ public function dontSeeAuthentication(): void */ public function dontSeeRememberedAuthentication(): void { - $security = $this->grabSecurityService(); + $security = $this->grabSecurityService(); + $client = $this->getClient(); + $hasCookie = $client->getCookieJar()->get('REMEMBERME') !== null; + $hasRole = $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); - $hasRememberMeCookie = $this->client->getCookieJar()->get('REMEMBERME'); - $hasRememberMeRole = $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); - - $isRemembered = $hasRememberMeCookie && $hasRememberMeRole; - $this->assertFalse( - $isRemembered, - 'User does have remembered authentication' - ); + $this->assertFalse($hasCookie && $hasRole, 'User does have remembered authentication.'); } /** @@ -65,14 +60,9 @@ public function dontSeeRememberedAuthentication(): void public function seeAuthentication(): void { $security = $this->grabSecurityService(); - - if (!$security->getUser()) { - $this->fail('There is no user in session'); - } - $this->assertTrue( $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY), - 'There is no authenticated user' + 'There is no authenticated user.' ); } @@ -86,20 +76,12 @@ public function seeAuthentication(): void */ public function seeRememberedAuthentication(): void { - $security = $this->grabSecurityService(); - - if ($security->getUser() === null) { - $this->fail('There is no user in session'); - } + $security = $this->grabSecurityService(); + $client = $this->getClient(); + $hasCookie = $client->getCookieJar()->get('REMEMBERME') !== null; + $hasRole = $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); - $hasRememberMeCookie = $this->client->getCookieJar()->get('REMEMBERME'); - $hasRememberMeRole = $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); - - $isRemembered = $hasRememberMeCookie && $hasRememberMeRole; - $this->assertTrue( - $isRemembered, - 'User does not have remembered authentication' - ); + $this->assertTrue($hasCookie && $hasRole, 'User does not have remembered authentication.'); } /** @@ -112,23 +94,12 @@ public function seeRememberedAuthentication(): void */ public function seeUserHasRole(string $role): void { - $security = $this->grabSecurityService(); - - if (!$user = $security->getUser()) { - $this->fail('There is no user in session'); - } - - $userIdentifier = method_exists($user, 'getUserIdentifier') ? - $user->getUserIdentifier() : - $user->getUsername(); + $user = $this->getAuthenticatedUser(); + $identifier = $user->getUserIdentifier(); $this->assertTrue( - $security->isGranted($role), - sprintf( - 'User %s has no role %s', - $userIdentifier, - $role - ) + $this->grabSecurityService()->isGranted($role), + sprintf('User %s has no role %s', $identifier, $role) ); } @@ -151,7 +122,7 @@ public function seeUserHasRoles(array $roles): void /** * Checks that the user's password would not benefit from rehashing. - * If the user is not provided it is taken from the current session. + * If the user is not provided, it is taken from the current session. * * You might use this function after performing tasks like registering a user or submitting a password update form. * @@ -165,31 +136,37 @@ public function seeUserHasRoles(array $roles): void */ public function seeUserPasswordDoesNotNeedRehash(?UserInterface $user = null): void { - if ($user === null) { - $security = $this->grabSecurityService(); - if (!$user = $security->getUser()) { - $this->fail('No user found to validate'); - } + $userToValidate = $user ?? $this->getAuthenticatedUser(); + + if (!$userToValidate instanceof PasswordAuthenticatedUserInterface) { + Assert::fail('Provided user does not implement PasswordAuthenticatedUserInterface.'); } $hasher = $this->grabPasswordHasherService(); - - $this->assertFalse($hasher->needsRehash($user), 'User password needs rehash'); + $this->assertFalse($hasher->needsRehash($userToValidate), 'User password needs rehash.'); } - protected function grabSecurityService(): Security|LegacySecurity + private function getAuthenticatedUser(): UserInterface { - return $this->grabService('security.helper'); + $user = $this->grabSecurityService()->getUser(); + if ($user === null) { + Assert::fail('No user found in session to perform this check.'); + } + return $user; } - protected function grabPasswordHasherService(): UserPasswordHasherInterface|UserPasswordEncoderInterface + /** @return Security */ + protected function grabSecurityService() { - $hasher = $this->getService('security.password_hasher') ?: $this->getService('security.password_encoder'); - - if ($hasher === null) { - $this->fail('Password hasher service could not be found.'); - } + /** @var Security $security */ + $security = $this->grabService('security.helper'); + return $security; + } + protected function grabPasswordHasherService(): UserPasswordHasherInterface + { + /** @var UserPasswordHasherInterface $hasher */ + $hasher = $this->getService('security.password_hasher'); return $hasher; } -} \ No newline at end of file +} diff --git a/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php b/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php index 1286e252..3ea06adf 100644 --- a/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php @@ -21,14 +21,17 @@ trait ServicesAssertionsTrait * ``` * * @part services + * @param non-empty-string $serviceId */ public function grabService(string $serviceId): object { if (!$service = $this->getService($serviceId)) { - Assert::fail("Service `{$serviceId}` is required by Codeception, but not loaded by Symfony. Possible solutions:\n + Assert::fail( + "Service `{$serviceId}` is required by Codeception, but not loaded by Symfony. Possible solutions:\n In your `config/packages/framework.php`/`.yaml`, set `test` to `true` (when in test environment), see https://symfony.com/doc/current/reference/configuration/framework.html#test\n If you're still getting this message, you're not using that service in your app, so Symfony isn't loading it at all.\n - Solution: Set it to `public` in your `config/services.php`/`.yaml`, see https://symfony.com/doc/current/service_container/alias_private.html#marking-services-as-public-private\n"); + Solution: Set it to `public` in your `config/services.php`/`.yaml`, see https://symfony.com/doc/current/service_container/alias_private.html#marking-services-as-public-private\n" + ); } return $service; @@ -38,6 +41,7 @@ public function grabService(string $serviceId): object * Get service $serviceName and add it to the lists of persistent services. * * @part services + * @param non-empty-string $serviceName */ public function persistService(string $serviceName): void { @@ -53,6 +57,7 @@ public function persistService(string $serviceName): void * making that service persistent between tests. * * @part services + * @param non-empty-string $serviceName */ public function persistPermanentService(string $serviceName): void { @@ -79,13 +84,14 @@ public function unpersistService(string $serviceName): void } } + /** @param non-empty-string $serviceId */ protected function getService(string $serviceId): ?object { $container = $this->_getContainer(); - if ($container->has($serviceId)) { - return $container->get($serviceId); + if (!$container->has($serviceId)) { + return null; } - return null; + return $container->get($serviceId); } } diff --git a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php index aa7ac9e9..9382184a 100644 --- a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php @@ -4,19 +4,21 @@ namespace Codeception\Module\Symfony; +use InvalidArgumentException; use Symfony\Component\BrowserKit\Cookie; +use Symfony\Component\HttpFoundation\Session\SessionFactoryInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken; -use Symfony\Component\Security\Guard\Token\GuardTokenInterface; use Symfony\Component\Security\Http\Authenticator\AuthenticatorInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; -use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken; use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator; +use function class_exists; +use function in_array; use function is_int; use function serialize; @@ -37,26 +39,18 @@ trait SessionAssertionsTrait */ public function amLoggedInAs(UserInterface $user, string $firewallName = 'main', ?string $firewallContext = null): void { - $token = $this->createAuthenticationToken($user, $firewallName); - $this->loginWithToken($token, $firewallName, $firewallContext); + $this->amLoggedInWithToken($this->createAuthenticationToken($user, $firewallName), $firewallName, $firewallContext); } public function amLoggedInWithToken(TokenInterface $token, string $firewallName = 'main', ?string $firewallContext = null): void - { - $this->loginWithToken($token, $firewallName, $firewallContext); - } - - protected function loginWithToken(TokenInterface $token, string $firewallName, ?string $firewallContext): void { $this->getTokenStorage()->setToken($token); $session = $this->getCurrentSession(); - $sessionKey = $firewallContext ? "_security_{$firewallContext}" : "_security_{$firewallName}"; - $session->set($sessionKey, serialize($token)); + $session->set("_security_" . ($firewallContext ?? $firewallName), serialize($token)); $session->save(); - $cookie = new Cookie($session->getName(), $session->getId()); - $this->client->getCookieJar()->set($cookie); + $this->getClient()->getCookieJar()->set(new Cookie($session->getName(), $session->getId())); } /** @@ -72,10 +66,9 @@ public function dontSeeInSession(string $attribute, mixed $value = null): void { $session = $this->getCurrentSession(); - $attributeExists = $session->has($attribute); - $this->assertFalse($attributeExists, "Session attribute '{$attribute}' exists."); - - if (null !== $value) { + if ($value === null) { + $this->assertFalse($session->has($attribute), "Session attribute '{$attribute}' exists."); + } else { $this->assertNotSame($value, $session->get($attribute)); } } @@ -88,8 +81,7 @@ public function dontSeeInSession(string $attribute, mixed $value = null): void */ public function goToLogoutPath(): void { - $logoutPath = $this->getLogoutUrlGenerator()->getLogoutPath(); - $this->amOnPage($logoutPath); + $this->amOnPage($this->getLogoutUrlGenerator()->getLogoutPath()); } /** @@ -116,23 +108,20 @@ public function logout(): void */ public function logoutProgrammatically(): void { - if ($tokenStorage = $this->getTokenStorage()) { - $tokenStorage->setToken(); - } + $this->getTokenStorage()->setToken(null); - $session = $this->getCurrentSession(); + $session = $this->getCurrentSession(); $sessionName = $session->getName(); $session->invalidate(); - $cookieJar = $this->client->getCookieJar(); - $cookiesToExpire = ['MOCKSESSID', 'REMEMBERME', $sessionName]; + $cookieJar = $this->getClient()->getCookieJar(); + $cookiesToExpire = ['MOCKSESSID', 'REMEMBERME', $sessionName]; foreach ($cookieJar->all() as $cookie) { $cookieName = $cookie->getName(); if (in_array($cookieName, $cookiesToExpire, true)) { $cookieJar->expire($cookieName); } } - $cookieJar->flushExpiredCookies(); } @@ -148,11 +137,9 @@ public function logoutProgrammatically(): void public function seeInSession(string $attribute, mixed $value = null): void { $session = $this->getCurrentSession(); + $this->assertTrue($session->has($attribute), "No session attribute with name '{$attribute}'"); - $attributeExists = $session->has($attribute); - $this->assertTrue($attributeExists, "No session attribute with name '{$attribute}'"); - - if (null !== $value) { + if ($value !== null) { $this->assertSame($value, $session->get($attribute)); } } @@ -165,11 +152,18 @@ public function seeInSession(string $attribute, mixed $value = null): void * $I->seeSessionHasValues(['key1', 'key2']); * $I->seeSessionHasValues(['key1' => 'value1', 'key2' => 'value2']); * ``` + * + * @param array $bindings */ public function seeSessionHasValues(array $bindings): void { foreach ($bindings as $key => $value) { if (is_int($key)) { + if (!is_string($value)) { + throw new InvalidArgumentException( + sprintf('Attribute name must be string, %s given.', get_debug_type($value)) + ); + } $this->seeInSession($value); } else { $this->seeInSession($key, $value); @@ -177,19 +171,25 @@ public function seeSessionHasValues(array $bindings): void } } - protected function getTokenStorage(): ?TokenStorageInterface + protected function getTokenStorage(): TokenStorageInterface { - return $this->getService('security.token_storage'); + /** @var TokenStorageInterface $storage */ + $storage = $this->grabService('security.token_storage'); + return $storage; } - protected function getLogoutUrlGenerator(): ?LogoutUrlGenerator + protected function getLogoutUrlGenerator(): LogoutUrlGenerator { - return $this->getService('security.logout_url_generator'); + /** @var LogoutUrlGenerator $generator */ + $generator = $this->grabService('security.logout_url_generator'); + return $generator; } - protected function getAuthenticator(): ?AuthenticatorInterface + protected function getAuthenticator(): AuthenticatorInterface { - return $this->getService(AuthenticatorInterface::class); + /** @var AuthenticatorInterface $authenticator */ + $authenticator = $this->grabService(AuthenticatorInterface::class); + return $authenticator; } protected function getCurrentSession(): SessionInterface @@ -197,10 +197,14 @@ protected function getCurrentSession(): SessionInterface $container = $this->_getContainer(); if ($this->getSymfonyMajorVersion() < 6 || $container->has('session')) { - return $container->get('session'); + /** @var SessionInterface $session */ + $session = $container->get('session'); + return $session; } - $session = $container->get('session.factory')->createSession(); + /** @var SessionFactoryInterface $factory */ + $factory = $container->get('session.factory'); + $session = $factory->createSession(); $container->set('session', $session); return $session; @@ -208,28 +212,27 @@ protected function getCurrentSession(): SessionInterface protected function getSymfonyMajorVersion(): int { - return $this->kernel::MAJOR_VERSION; + return Kernel::MAJOR_VERSION; } - /** - * @return TokenInterface|GuardTokenInterface - */ - protected function createAuthenticationToken(UserInterface $user, string $firewallName) + protected function createAuthenticationToken(UserInterface $user, string $firewallName): TokenInterface { $roles = $user->getRoles(); - if ($this->getSymfonyMajorVersion() < 6) { - return $this->config['guard'] - ? new PostAuthenticationGuardToken($user, $firewallName, $roles) - : new UsernamePasswordToken($user, null, $firewallName, $roles); + + if ($this->getSymfonyMajorVersion() >= 6 && ($this->config['authenticator'] ?? false) === true) { + $passport = new SelfValidatingPassport(new UserBadge($user->getUserIdentifier(), static fn () => $user)); + return $this->getAuthenticator()->createToken($passport, $firewallName); } - if ($this->config['authenticator']) { - if ($authenticator = $this->getAuthenticator()) { - $passport = new SelfValidatingPassport(new UserBadge($user->getUserIdentifier(), fn () => $user)); - return $authenticator->createToken($passport, $firewallName); + if ($this->getSymfonyMajorVersion() < 6 && ($this->config['guard'] ?? false) === true) { + $postClass = 'Symfony\\Component\\Security\\Guard\\Token\\PostAuthenticationGuardToken'; + if (class_exists($postClass)) { + /** @var TokenInterface $token */ + $token = new $postClass($user, $firewallName, $roles); + return $token; } - return new PostAuthenticationToken($user, $firewallName, $roles); } + return new UsernamePasswordToken($user, $firewallName, $roles); } } diff --git a/src/Codeception/Module/Symfony/TimeAssertionsTrait.php b/src/Codeception/Module/Symfony/TimeAssertionsTrait.php index a1067f37..1affaba6 100644 --- a/src/Codeception/Module/Symfony/TimeAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/TimeAssertionsTrait.php @@ -45,6 +45,6 @@ public function seeRequestTimeIsLessThan(int|float $expectedMilliseconds): void protected function grabTimeCollector(string $function): TimeDataCollector { - return $this->grabCollector('time', $function); + return $this->grabCollector(DataCollectorName::TIME, $function); } } diff --git a/src/Codeception/Module/Symfony/TranslationAssertionsTrait.php b/src/Codeception/Module/Symfony/TranslationAssertionsTrait.php index 5fa91725..05927717 100644 --- a/src/Codeception/Module/Symfony/TranslationAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/TranslationAssertionsTrait.php @@ -173,6 +173,6 @@ public function seeMissingTranslationsCountLessThan(int $limit): void protected function grabTranslationCollector(string $function): TranslationDataCollector { - return $this->grabCollector('translation', $function); + return $this->grabCollector(DataCollectorName::TRANSLATION, $function); } } diff --git a/src/Codeception/Module/Symfony/TwigAssertionsTrait.php b/src/Codeception/Module/Symfony/TwigAssertionsTrait.php index e664932c..61899979 100644 --- a/src/Codeception/Module/Symfony/TwigAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/TwigAssertionsTrait.php @@ -43,7 +43,7 @@ public function seeCurrentTemplateIs(string $expectedTemplate): void $twigCollector = $this->grabTwigCollector(__FUNCTION__); $templates = $twigCollector->getTemplates(); - $actualTemplate = empty($templates) ? 'N/A' : (string) array_key_first($templates); + $actualTemplate = $templates === [] ? 'N/A' : (string) array_key_first($templates); $this->assertSame( $expectedTemplate, @@ -77,6 +77,6 @@ public function seeRenderedTemplate(string $template): void protected function grabTwigCollector(string $function): TwigDataCollector { - return $this->grabCollector('twig', $function); + return $this->grabCollector(DataCollectorName::TWIG, $function); } } diff --git a/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php b/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php index 508cfa5e..01875a04 100644 --- a/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php @@ -101,6 +101,8 @@ protected function getViolationsForSubject(object $subject, ?string $propertyPat protected function getValidatorService(): ValidatorInterface { - return $this->grabService(ValidatorInterface::class); + /** @var ValidatorInterface $validator */ + $validator = $this->grabService(ValidatorInterface::class); + return $validator; } }