diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 48d77afe..9b94da07 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,132 +6,57 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [8.2, 8.3, 8.4, 8.5] - symfony: ["5.4.*", "6.4.*", "6.4wApi", "7.3.*", "7.4.*"] - env: - only_sf_latest: &only_sf_latest ${{ matrix.symfony == '7.4.*' }} + php: ["8.2", "8.5"] + symfony: ["5.4", "6.4", "7.4", "8.0"] + exclude: + - php: "8.2" + symfony: "8.0" steps: - - name: Checkout code - uses: actions/checkout@v6 + - uses: actions/checkout@v6 - - name: Setup PHP != 8.5 - if: ${{ matrix.php != '8.5' }} - uses: shivammathur/setup-php@v2 + - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - tools: composer:v2 - extensions: ctype, iconv, intl, json, mbstring, pdo, pdo_sqlite - coverage: none + coverage: pcov - - name: Setup PHP 8.5 - if: ${{ matrix.php == '8.5' }} - uses: shivammathur/setup-php@v2 + - uses: actions/cache@v5 with: - php-version: ${{ matrix.php }} - tools: composer:v2 - extensions: ctype, iconv, intl, json, mbstring, pdo, pdo_sqlite - coverage: none - # this ini directive seems to be off by default in PHP 8.5 - # see https://github.com/php/php-src/issues/20279 - # enable it because codeception relies on it. - ini-values: register_argc_argv=1 - - - name: Set Symfony version reference - env: - MATRIX_SYMFONY: ${{ matrix.symfony }} - run: | - if [[ "$MATRIX_SYMFONY" == *'*' ]]; then - echo "SF_REF=${MATRIX_SYMFONY%.*}" >> "$GITHUB_ENV" - else - echo "SF_REF=$MATRIX_SYMFONY" >> "$GITHUB_ENV" - fi - - - name: Set Composer Symfony constraint - env: - MATRIX_SYMFONY: ${{ matrix.symfony }} - run: | - if [[ "$MATRIX_SYMFONY" == "6.4wApi" ]]; then - echo "COMP_SYMFONY=6.4.*" >> "$GITHUB_ENV" - else - echo "COMP_SYMFONY=$MATRIX_SYMFONY" >> "$GITHUB_ENV" - fi + path: ~/.cache/composer + key: php-${{ matrix.php }}-sf-${{ matrix.symfony }}-${{ hashFiles('**/composer.json') }} + restore-keys: php-${{ matrix.php }}-sf-${{ matrix.symfony }}- - - name: Checkout Symfony ${{ env.SF_REF }} sample - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + name: Checkout Symfony ${{ matrix.symfony }} app with: repository: Codeception/symfony-module-tests path: framework-tests - ref: ${{ env.SF_REF }} - - - name: Get composer cache directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: Cache Composer dependencies - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - 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 + ref: ${{ matrix.symfony }} - name: Install dependencies - env: - MATRIX_SYMFONY: ${{ matrix.symfony }} - run: | - 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 - - - name: Run PHPStan (max) - if: *only_sf_latest - run: composer phpstan - - - name: Run PHP-CS-Fixer - if: *only_sf_latest - run: composer cs-check - - - name: Run Composer Audit - if: *only_sf_latest - run: composer audit - - - 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 - working-directory: framework-tests - - - name: Prepare Symfony sample run: | - composer remove codeception/codeception codeception/module-asserts codeception/module-doctrine codeception/lib-innerbrowser codeception/module-symfony --dev --no-update - composer update --no-progress - working-directory: framework-tests + sed -i "s/\^5.4 | \^6.4 | \^7.4 | \^8.0/${{ matrix.symfony }}.*/g" composer.json + composer install --prefer-dist --no-progress - - name: Setup Database - run: | - php bin/console doctrine:schema:update --force - php bin/console doctrine:fixtures:load --quiet - working-directory: framework-tests + - name: Quality checks + if: matrix.symfony == '8.0' + run: composer validate --strict && composer audit && composer cs-check && composer phpstan - - name: Generate JWT keypair - if: ${{ matrix.symfony == '6.4wApi' }} - run: php bin/console lexik:jwt:generate-keypair --skip-if-exists - working-directory: framework-tests + - name: Run module tests + run: vendor/bin/phpunit tests ${{ matrix.symfony == '8.0' && '--coverage-text --coverage-filter src' || '' }} - - name: Run tests + - name: Prepare Symfony app & run tests run: | - php vendor/bin/codecept build -c framework-tests + composer -d framework-tests remove codeception/module-symfony --dev --no-update + composer -d framework-tests install --no-progress + php framework-tests/bin/console doctrine:schema:update --force + php framework-tests/bin/console doctrine:fixtures:load --quiet php vendor/bin/codecept run Functional -c framework-tests + + if [ "${{ matrix.symfony }}" = "7.4" ]; then + composer require codeception/module-rest --dev + git -C framework-tests apply resetFormatsAfterRequest_issue_test.patch + composer -d framework-tests install --no-progress + php framework-tests/bin/console lexik:jwt:generate-keypair --skip-if-exists + php vendor/bin/codecept run Functional -c framework-tests + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index a8169301..9f073db4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /vendor/ /composer.lock /framework-tests -/.php-cs-fixer.cache \ No newline at end of file +/.php-cs-fixer.cache +var/ \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 00000000..67cd3cae --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,26 @@ +setParallelConfig(ParallelConfigFactory::detect()) + ->setRiskyAllowed(true) + ->setRules([ + '@PER-CS' => true, + '@PHP82Migration' => true, + 'array_syntax' => ['syntax' => 'short'], + 'strict_param' => true, + 'declare_strict_types' => true, + ]) + ->setFinder( + (new Finder()) + ->in(__DIR__) + ->exclude(['var', 'vendor']) + ->ignoreDotFiles(true) + ->ignoreVCS(true) + ) +; diff --git a/composer.json b/composer.json index 54ba3eba..ea47ec8d 100644 --- a/composer.json +++ b/composer.json @@ -24,47 +24,58 @@ "require": { "php": "^8.2", "ext-json": "*", - "codeception/codeception": "^5.3", - "codeception/lib-innerbrowser": "^3.1 | ^4.0" + "codeception/lib-innerbrowser": "^3.1 | ^4.1" }, "require-dev": { - "codeception/module-asserts": "^3.0", - "codeception/module-doctrine": "^3.1", - "doctrine/orm": "^3.5", - "friendsofphp/php-cs-fixer": "^3.85", + "codeception/module-asserts": "^3.3", + "codeception/module-doctrine": "^3.3", + "doctrine/orm": "^3.6", + "friendsofphp/php-cs-fixer": "^3.94", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0", - "symfony/browser-kit": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/cache": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/config": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/dependency-injection": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/dom-crawler": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/dotenv": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/error-handler": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/filesystem": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/form": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/framework-bundle": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/http-client": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/http-foundation": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/http-kernel": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/mailer": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/mime": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/notifier": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/options-resolver": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/property-access": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/property-info": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/routing": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/security-bundle": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/security-core": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/security-csrf": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/security-http": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/translation": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/twig-bundle": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/validator": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "symfony/var-exporter": "^5.4 | ^6.4 | ^7.3 | ^8.0", - "vlucas/phpdotenv": "^4.2 | ^5.4" + "phpunit/phpunit": "^11.0 | ^12.0", + "symfony/browser-kit": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/cache": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/clock": "^6.4 | ^7.4 | ^8.0", + "symfony/config": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/console": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/css-selector": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/dependency-injection": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/dom-crawler": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/dotenv": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/error-handler": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/event-dispatcher": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/filesystem": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/finder": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/form": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/framework-bundle": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/http-client": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/http-foundation": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/http-kernel": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/mailer": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/mime": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/notifier": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/options-resolver": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/password-hasher": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/process": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/property-access": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/property-info": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/routing": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/security-bundle": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/security-core": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/security-csrf": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/security-http": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/stopwatch": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/string": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/translation": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/twig-bundle": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/validator": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/var-dumper": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "symfony/var-exporter": "^5.4 | ^6.4 | ^7.4", + "symfony/yaml": "^5.4 | ^6.4 | ^7.4 | ^8.0", + "vlucas/phpdotenv": "^4.2 | ^5.6" }, "suggest": { + "codeception/codeception": "Install Codeception to run tests using this module", "codeception/module-asserts": "Include traditional PHPUnit assertions in your tests", "symfony/web-profiler-bundle": "Tool that gives information about the execution of requests" }, @@ -73,6 +84,12 @@ "Codeception\\": "src/Codeception/" } }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests", + "Tests\\App\\": "tests/_app" + } + }, "config": { "sort-packages": true }, diff --git a/src/Codeception/Lib/Connector/Symfony.php b/src/Codeception/Lib/Connector/Symfony.php index bf18eb21..7d129736 100644 --- a/src/Codeception/Lib/Connector/Symfony.php +++ b/src/Codeception/Lib/Connector/Symfony.php @@ -5,7 +5,6 @@ namespace Codeception\Lib\Connector; use InvalidArgumentException; -use LogicException; use ReflectionMethod; use ReflectionProperty; use Symfony\Bundle\FrameworkBundle\Test\TestContainer; @@ -17,7 +16,7 @@ use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\HttpKernel\Profiler\Profiler; -use function codecept_debug; +use function function_exists; /** * @property KernelInterface $kernel @@ -57,23 +56,28 @@ protected function doRequest(object $request): Response */ public function rebootKernel(): void { - foreach (array_keys($this->persistentServices) as $service) { - if ($this->container->has($service)) { - $this->persistentServices[$service] = $this->container->get($service); + foreach ($this->persistentServices as $name => $_) { + if ($this->container->has($name)) { + $this->persistentServices[$name] = $this->container->get($name); } } $this->persistDoctrineConnections(); + if ($this->kernel instanceof Kernel) { $this->ensureKernelShutdown(); $this->kernel->boot(); } + $this->container = $this->resolveContainer(); + foreach ($this->persistentServices as $name => $service) { try { $this->container->set($name, $service); } catch (InvalidArgumentException $e) { - codecept_debug("[Symfony] Can't set persistent service {$name}: {$e->getMessage()}"); + if (function_exists('codecept_debug')) { + codecept_debug("[Symfony] Can't set persistent service {$name}: {$e->getMessage()}"); + } } } @@ -90,15 +94,10 @@ private function resolveContainer(): ContainerInterface { $container = $this->kernel->getContainer(); - 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; - } + /** @var ContainerInterface $testContainer */ + $testContainer = $container->has('test.service_container') ? $container->get('test.service_container') : $container; - return $container; + return $testContainer; } private function getProfiler(): ?Profiler @@ -117,29 +116,21 @@ private function persistDoctrineConnections(): void if (!$this->container->hasParameter('doctrine.connections')) { return; } + $target = $this->container instanceof TestContainer + ? (new ReflectionMethod($this->container, 'getPublicContainer'))->invoke($this->container) + : $this->container; - if ($this->container instanceof TestContainer) { - $method = new ReflectionMethod($this->container, 'getPublicContainer'); - $publicContainer = $method->invoke($this->container); - } else { - $publicContainer = $this->container; - } - - if (!is_object($publicContainer) || !method_exists($publicContainer, 'getParameterBag')) { + if (!is_object($target) || !method_exists($target, 'getParameterBag')) { return; } + $bag = property_exists($target, 'parameters') ? $target : $target->getParameterBag(); - $target = property_exists($publicContainer, 'parameters') - ? $publicContainer - : $publicContainer->getParameterBag(); - - if (!is_object($target) || !property_exists($target, 'parameters')) { + if (!is_object($bag) || !property_exists($bag, 'parameters')) { return; } - $prop = new ReflectionProperty($target, 'parameters'); - - $params = (array) $prop->getValue($target); + $prop = new ReflectionProperty($bag, 'parameters'); + $params = (array) $prop->getValue($bag); unset($params['doctrine.connections']); - $prop->setValue($target, $params); + $prop->setValue($bag, $params); } } diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index 2c87f529..1f2c603c 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -11,6 +11,7 @@ use Codeception\Lib\Interfaces\DoctrineProvider; use Codeception\Lib\Interfaces\PartedModule; use Codeception\Module\Symfony\BrowserAssertionsTrait; +use Codeception\Module\Symfony\CacheTrait; use Codeception\Module\Symfony\ConsoleAssertionsTrait; use Codeception\Module\Symfony\DataCollectorName; use Codeception\Module\Symfony\DoctrineAssertionsTrait; @@ -18,6 +19,7 @@ use Codeception\Module\Symfony\EventsAssertionsTrait; use Codeception\Module\Symfony\FormAssertionsTrait; use Codeception\Module\Symfony\HttpClientAssertionsTrait; +use Codeception\Module\Symfony\HttpKernelAssertionsTrait; use Codeception\Module\Symfony\LoggerAssertionsTrait; use Codeception\Module\Symfony\MailerAssertionsTrait; use Codeception\Module\Symfony\MimeAssertionsTrait; @@ -35,43 +37,31 @@ use Doctrine\ORM\EntityManagerInterface; 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\Notifier\DataCollector\NotificationDataCollector; -use Symfony\Component\Translation\DataCollector\TranslationDataCollector; use Symfony\Component\VarDumper\Cloner\Data; -use function array_keys; +use function array_filter; 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 extension_loaded; use function file_exists; use function implode; -use function in_array; -use function extension_loaded; use function ini_get; use function ini_set; use function is_object; -use function iterator_to_array; +use function is_subclass_of; use function sprintf; /** @@ -149,12 +139,14 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule { use BrowserAssertionsTrait; + use CacheTrait; use ConsoleAssertionsTrait; use DoctrineAssertionsTrait; use DomCrawlerAssertionsTrait; use EventsAssertionsTrait; use FormAssertionsTrait; use HttpClientAssertionsTrait; + use HttpKernelAssertionsTrait; use LoggerAssertionsTrait; use MailerAssertionsTrait; use MimeAssertionsTrait; @@ -164,8 +156,8 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule use SecurityAssertionsTrait; use ServicesAssertionsTrait; use SessionAssertionsTrait; - use TranslationAssertionsTrait; use TimeAssertionsTrait; + use TranslationAssertionsTrait; use TwigAssertionsTrait; use ValidatorAssertionsTrait; @@ -201,23 +193,8 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule '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 list */ public function _parts(): array { @@ -252,7 +229,9 @@ public function _initialize(): void */ public function _before(TestInterface $test): void { - $this->persistentServices = array_merge($this->persistentServices, $this->permanentServices); + $this->persistentServices = $this->persistentServices === [] + ? $this->permanentServices + : [...$this->persistentServices, ...$this->permanentServices]; $this->client = new SymfonyConnector( $this->kernel, @@ -266,7 +245,7 @@ public function _before(TestInterface $test): void */ public function _after(TestInterface $test): void { - foreach (array_keys($this->permanentServices) as $serviceName) { + foreach ($this->permanentServices as $serviceName => $_) { $service = $this->getService($serviceName); if (is_object($service)) { $this->permanentServices[$serviceName] = $service; @@ -274,6 +253,7 @@ public function _after(TestInterface $test): void unset($this->permanentServices[$serviceName]); } } + $this->persistentServices = []; parent::_after($test); } @@ -296,9 +276,7 @@ public function _getEntityManager(): EntityManagerInterface if (!isset($this->permanentServices[$emService])) { $this->persistPermanentService($emService); $container = $this->_getContainer(); - foreach ( - ['doctrine', 'doctrine.orm.default_entity_manager', 'doctrine.dbal.default_connection'] as $service - ) { + foreach (['doctrine', 'doctrine.orm.default_entity_manager', 'doctrine.dbal.default_connection'] as $service) { if ($container->has($service)) { $this->persistPermanentService($service); } @@ -309,21 +287,9 @@ public function _getEntityManager(): EntityManagerInterface return $this->permanentServices[$emService]; } - public function _getContainer(): ContainerInterface - { - $container = $this->kernel->getContainer(); - /** @var ContainerInterface $testContainer */ - $testContainer = $container->has('test.service_container') ? $container->get('test.service_container') : $container; - return $testContainer; - } - protected function getClient(): SymfonyConnector { - if ($this->client === null) { - Assert::fail('Client is not initialized'); - } - - return $this->client; + return $this->client ?? Assert::fail('Client is not initialized'); } /** @@ -334,11 +300,11 @@ protected function getClient(): SymfonyConnector */ protected function getKernelClass(): string { - /** @var class-string $kernelClass */ $kernelClass = $this->config['kernel_class']; $this->requireAdditionalAutoloader(); - if (class_exists($kernelClass)) { + if (class_exists($kernelClass) && is_subclass_of($kernelClass, Kernel::class)) { + /** @var class-string $kernelClass */ return $kernelClass; } @@ -349,26 +315,27 @@ protected function getKernelClass(): string 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}.\nDirectory does not exist. Set `app_path` in your suite configuration to a valid application path." ); } - $finder = new Finder(); - $finder->name('*Kernel.php')->depth('0')->in($path); - - foreach ($finder as $file) { - include_once $file->getRealPath(); + $expectedKernelPath = $path . DIRECTORY_SEPARATOR . 'Kernel.php'; + if (file_exists($expectedKernelPath)) { + include_once $expectedKernelPath; + } else { + foreach ((new Finder())->name('*Kernel.php')->depth('0')->in($path) as $file) { + include_once $file->getRealPath(); + } } - if (class_exists($kernelClass, false)) { + if (class_exists($kernelClass, false) && is_subclass_of($kernelClass, Kernel::class)) { + /** @var class-string $kernelClass */ return $kernelClass; } throw new ModuleRequireException( self::class, - "Kernel class was not found at {$path}.\n" . - 'Specify directory where file with Kernel class for your application is located with `app_path` parameter.' + "Kernel class was not found at {$path}.\nSpecify directory where file with Kernel class for your application is located with `app_path` parameter." ); } @@ -385,51 +352,20 @@ protected function getProfile(): ?Profile } try { - return $profiler->loadProfileFromResponse($this->getClient()->getResponse()); - } catch (BadMethodCallException) { - Assert::fail('You must perform a request before using this method.'); - } - } - - /** - * 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 : - * ($collector is DataCollectorName::NOTIFIER ? NotificationDataCollector : - * DataCollectorInterface - * ))))))))) - * ) - * - * @throws AssertionFailedError - */ - protected function grabCollector(DataCollectorName $collector, string $function, ?string $message = null): DataCollectorInterface - { - $profile = $this->getProfile(); + $response = $this->getClient()->getResponse(); + if ($profile = $this->getProfileFromCache($response)) { + return $profile; + } - if ($profile === null) { - Assert::fail(sprintf("The Profile is needed to use the '%s' function.", $function)); - } + $profile = $profiler->loadProfileFromResponse($response); + if ($profile !== null) { + $this->cacheProfile($response, $profile); + } - if (!$profile->hasCollector($collector->value)) { - Assert::fail( - $message ?: sprintf( - "The '%s' collector is needed to use the '%s' function.", - $collector->value, - $function - ) - ); + return $profile; + } catch (BadMethodCallException) { + Assert::fail('You must perform a request before using this method.'); } - - return $profile->getCollector($collector->value); } /** @@ -439,44 +375,21 @@ protected function debugResponse(mixed $url): void { parent::debugResponse($url); - $profile = $this->getProfile(); - if ($profile === null) { + if (!$profile = $this->getProfile()) { return; } - $collectors = [ - DataCollectorName::SECURITY->value => [$this->debugSecurityData(...), SecurityDataCollector::class], - DataCollectorName::MAILER->value => [$this->debugMailerData(...), MessageDataCollector::class], - DataCollectorName::NOTIFIER->value => [$this->debugNotifierData(...), NotificationDataCollector::class], - DataCollectorName::TIME->value => [$this->debugTimeData(...), TimeDataCollector::class], - ]; - - foreach ($collectors as $name => [$callback, $expectedClass]) { - if ($profile->hasCollector($name)) { - $collector = $profile->getCollector($name); - if ($collector instanceof $expectedClass) { - $callback($collector); - } - } - } + $this->debugCollector($profile, DataCollectorName::SECURITY->value); + $this->debugCollector($profile, DataCollectorName::MAILER->value); + $this->debugCollector($profile, DataCollectorName::NOTIFIER->value); + $this->debugCollector($profile, DataCollectorName::TIME->value); } - /** @return list */ - protected function getInternalDomains(): array + protected function doRebootClientKernel(): void { - $domains = []; - - foreach ($this->grabRouterService()->getRouteCollection() as $route) { - if ($route->getHost() !== '') { - $regex = $route->compile()->getHostRegex(); - if ($regex !== null && $regex !== '') { - $domains[] = $regex; - } - } + if ($this->client instanceof SymfonyConnector) { + $this->client->rebootKernel(); } - - /** @var list */ - return array_values(array_unique($domains)); } /** @@ -527,14 +440,12 @@ private function debugSecurityData(SecurityDataCollector $securityCollector): vo private function debugMailerData(MessageDataCollector $messageCollector): void { - $count = count($messageCollector->getEvents()->getMessages()); - $this->debugSection('Emails', sprintf('%d sent', $count)); + $this->debugSection('Emails', sprintf('%d sent', count($messageCollector->getEvents()->getMessages()))); } private function debugNotifierData(NotificationDataCollector $notificationCollector): void { - $count = count($notificationCollector->getEvents()->getMessages()); - $this->debugSection('Notifications', sprintf('%d sent', $count)); + $this->debugSection('Notifications', sprintf('%d sent', count($notificationCollector->getEvents()->getMessages()))); } private function debugTimeData(TimeDataCollector $timeCollector): void @@ -542,6 +453,22 @@ private function debugTimeData(TimeDataCollector $timeCollector): void $this->debugSection('Time', sprintf('%.2f ms', $timeCollector->getDuration())); } + private function debugCollector(Profile $profile, string $name): void + { + if (!$profile->hasCollector($name)) { + return; + } + + $collector = $profile->getCollector($name); + match (true) { + $collector instanceof SecurityDataCollector => $this->debugSecurityData($collector), + $collector instanceof MessageDataCollector => $this->debugMailerData($collector), + $collector instanceof NotificationDataCollector => $this->debugNotifierData($collector), + $collector instanceof TimeDataCollector => $this->debugTimeData($collector), + default => null, + }; + } + /** * Ensures autoloader loading of additional directories. * It is only required for CI jobs to run correctly. @@ -556,4 +483,18 @@ private function requireAdditionalAutoloader(): void include_once $autoload; } } + + /** @param non-empty-string $name */ + protected function updateClientPersistentService(string $name, ?object $service): void + { + if (!$this->client instanceof SymfonyConnector) { + return; + } + + if ($service === null) { + unset($this->client->persistentServices[$name]); + } else { + $this->client->persistentServices[$name] = $service; + } + } } diff --git a/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php b/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php index d04b69e1..7b37d5e4 100644 --- a/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php @@ -5,6 +5,7 @@ namespace Codeception\Module\Symfony; use PHPUnit\Framework\Constraint\Constraint; +use PHPUnit\Framework\Constraint\LogicalAnd; use PHPUnit\Framework\Constraint\LogicalNot; use Symfony\Component\BrowserKit\Test\Constraint\BrowserCookieValueSame; use Symfony\Component\BrowserKit\Test\Constraint\BrowserHasCookie; @@ -34,8 +35,10 @@ trait BrowserAssertionsTrait */ public function assertBrowserCookieValueSame(string $name, string $expectedValue, bool $raw = false, string $path = '/', ?string $domain = null, string $message = ''): void { - $this->assertThatForClient(new BrowserHasCookie($name, $path, $domain), $message); - $this->assertThatForClient(new BrowserCookieValueSame($name, $expectedValue, $raw, $path, $domain), $message); + $this->assertThatForClient(LogicalAnd::fromConstraints( + new BrowserHasCookie($name, $path, $domain), + new BrowserCookieValueSame($name, $expectedValue, $raw, $path, $domain) + ), $message); } /** @@ -89,8 +92,10 @@ public function assertRequestAttributeValueSame(string $name, string $expectedVa */ public function assertResponseCookieValueSame(string $name, string $expectedValue, string $path = '/', ?string $domain = null, string $message = ''): void { - $this->assertThatForResponse(new ResponseHasCookie($name, $path, $domain), $message); - $this->assertThatForResponse(new ResponseCookieValueSame($name, $expectedValue, $path, $domain), $message); + $this->assertThatForResponse(LogicalAnd::fromConstraints( + new ResponseHasCookie($name, $path, $domain), + new ResponseCookieValueSame($name, $expectedValue, $path, $domain) + ), $message); } /** @@ -268,7 +273,7 @@ public function assertRouteSame(string $expectedRoute, array $parameters = [], s $this->assertThat($request, new RequestAttributeValueSame('_route', $expectedRoute)); foreach ($parameters as $key => $value) { - $this->assertThat($request, new RequestAttributeValueSame($key, (string)$value), $message); + $this->assertThat($request, new RequestAttributeValueSame($key, (string) $value), $message); } } @@ -289,9 +294,11 @@ public function assertRouteSame(string $expectedRoute, array $parameters = [], s */ public function rebootClientKernel(): void { - $this->getClient()->rebootKernel(); + $this->doRebootClientKernel(); } + protected function doRebootClientKernel(): void {} + /** * Verifies that a page is available. * By default, it checks the current page. Specify the `$url` parameter to change the page being checked. @@ -309,8 +316,8 @@ public function rebootClientKernel(): void public function seePageIsAvailable(?string $url = null): void { if ($url !== null) { - $this->amOnPage($url); - $this->seeInCurrentUrl($url); + $this->getClient()->request('GET', $url); + $this->assertStringContainsString($url, $this->getClient()->getRequest()->getRequestUri()); } $this->assertResponseIsSuccessful(); @@ -328,12 +335,12 @@ public function seePageRedirectsTo(string $page, string $redirectsTo): void { $client = $this->getClient(); $client->followRedirects(false); - $this->amOnPage($page); + $client->request('GET', $page); $this->assertThatForResponse(new ResponseIsRedirected(), 'The response is not a redirection.'); $client->followRedirect(); - $this->seeInCurrentUrl($redirectsTo); + $this->assertStringContainsString($redirectsTo, $client->getRequest()->getRequestUri()); } /** @@ -362,9 +369,10 @@ public function submitSymfonyForm(string $name, array $fields): void $params[$name . $key] = $value; } - $button = sprintf('%s_submit', $name); - - $this->submitForm($selector, $params, $button); + $node = $this->getClient()->getCrawler()->filter($selector); + $this->assertGreaterThan(0, count($node), sprintf('Form "%s" not found.', $selector)); + $form = $node->form(); + $this->getClient()->submit($form, $params); } protected function assertThatForClient(Constraint $constraint, string $message = ''): void diff --git a/src/Codeception/Module/Symfony/CacheTrait.php b/src/Codeception/Module/Symfony/CacheTrait.php new file mode 100644 index 00000000..2cca834b --- /dev/null +++ b/src/Codeception/Module/Symfony/CacheTrait.php @@ -0,0 +1,81 @@ +|null */ + private ?array $cachedInternalDomains = null; + + public function _getContainer(): ContainerInterface + { + $container = $this->kernel->getContainer(); + + if ($this->cachedContainer === $container && $this->cachedTestContainer !== null) { + return $this->cachedTestContainer; + } + + $this->cachedContainer = $container; + /** @var ContainerInterface $testContainer */ + $testContainer = $container->has('test.service_container') ? $container->get('test.service_container') : $container; + $this->cachedTestContainer = $testContainer; + + return $testContainer; + } + + protected function getProfileFromCache(object $response): ?Profile + { + if ($this->cachedProfileResponse === $response && $this->cachedProfile !== null) { + return $this->cachedProfile; + } + + return null; + } + + protected function cacheProfile(object $response, Profile $profile): void + { + $this->cachedProfileResponse = $response; + $this->cachedProfile = $profile; + } + + /** @return list */ + protected function getInternalDomains(): array + { + if ($this->cachedInternalDomains !== null) { + return $this->cachedInternalDomains; + } + + $domains = []; + + foreach ($this->grabRouterService()->getRouteCollection() as $route) { + if ($route->getHost() !== '') { + $regex = $route->compile()->getHostRegex(); + if ($regex !== null && $regex !== '') { + $domains[] = $regex; + } + } + } + + $this->cachedInternalDomains = array_values(array_unique($domains)); + + return $this->cachedInternalDomains; + } + + protected function clearInternalDomainsCache(): void + { + $this->cachedInternalDomains = null; + } +} diff --git a/src/Codeception/Module/Symfony/CodeceptTestCase.php b/src/Codeception/Module/Symfony/CodeceptTestCase.php new file mode 100644 index 00000000..95a38ca7 --- /dev/null +++ b/src/Codeception/Module/Symfony/CodeceptTestCase.php @@ -0,0 +1,206 @@ + */ + protected array $config = ['guard' => false, 'authenticator' => false]; + + protected function setUp(): void + { + $this->kernel = $this->createKernel(); + $this->kernel->boot(); + + $container = $this->_getContainer(); + + if ($container->has('doctrine.orm.entity_manager')) { + /** @var EntityManagerInterface $em */ + $em = $container->get('doctrine.orm.entity_manager'); + $this->setUpDatabase($em); + } + + $testClient = $container->has('test.client') ? $container->get('test.client') : null; + $this->client = $testClient instanceof KernelBrowser ? $testClient : new KernelBrowser($this->kernel); + + if ($this->profilerEnabled) { + $this->client->enableProfiler(); + } + } + + protected function tearDown(): void + { + if (isset($this->kernel)) { + $this->kernel->shutdown(); + } + + $this->restoreErrorHandler(); + parent::tearDown(); + } + + private function restoreErrorHandler(): void + { + if (!class_exists(ErrorHandler::class)) { + return; + } + + $exceptionHandler = set_exception_handler(null); + restore_exception_handler(); + if (is_array($exceptionHandler) && $exceptionHandler[0] instanceof ErrorHandler) { + restore_exception_handler(); + } + + $errorHandler = set_error_handler(null); + restore_error_handler(); + if (is_array($errorHandler) && $errorHandler[0] instanceof ErrorHandler) { + restore_error_handler(); + } + } + + protected function createKernel(): KernelInterface + { + $kernelClass = $this->getKernelClass(); + + $environment = $_ENV['APP_ENV'] ?? $_SERVER['APP_ENV'] ?? 'test'; + if (!is_scalar($environment)) { + $environment = 'test'; + } + + $debug = $_ENV['APP_DEBUG'] ?? $_SERVER['APP_DEBUG'] ?? true; + if (!is_bool($debug)) { + $debug = is_scalar($debug) + ? filter_var((string) $debug, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true + : true; + } + + /** @var KernelInterface $kernel */ + $kernel = new $kernelClass((string) $environment, $debug); + + return $kernel; + } + + protected function getKernelClass(): string + { + if (!isset($_SERVER['KERNEL_CLASS']) && !isset($_ENV['KERNEL_CLASS'])) { + throw new LogicException(sprintf( + 'You must set the KERNEL_CLASS environment variable in phpunit.xml or override %1$s::createKernel() / %1$s::getKernelClass().', + static::class, + )); + } + + $class = $_ENV['KERNEL_CLASS'] ?? $_SERVER['KERNEL_CLASS']; + + if (!is_string($class) || !class_exists($class)) { + throw new RuntimeException(sprintf( + 'Class "%s" doesn\'t exist or cannot be autoloaded. Check KERNEL_CLASS or override %s::createKernel().', + is_scalar($class) ? (string) $class : gettype($class), + static::class, + )); + } + + return $class; + } + + protected function setUpDatabase(EntityManagerInterface $em): void + { + // Override this method to perform database setup + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function _getEntityManager(): EntityManagerInterface + { + /** @var EntityManagerInterface $em */ + $em = $this->_getContainer()->get('doctrine.orm.entity_manager'); + return $em; + } + + protected function getProfile(): ?Profile + { + $client = $this->getClient(); + $profile = $client->getProfile(); + + if ($profile instanceof Profile) { + return $profile; + } + + try { + $response = $client->getResponse(); + $request = $client->getRequest(); + } catch (BadMethodCallException) { + return null; + } + + if ($cachedProfile = $this->getProfileFromCache($response)) { + return $cachedProfile; + } + + $container = $this->_getContainer(); + if (!$container->has('profiler')) { + return null; + } + + /** @var Profiler $profiler */ + $profiler = $container->get('profiler'); + $profile = $profiler->collect($request, $response); + + if ($profile instanceof Profile) { + $this->cacheProfile($response, $profile); + return $profile; + } + + return null; + } +} diff --git a/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php b/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php index 5239bca1..914ccec3 100644 --- a/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ConsoleAssertionsTrait.php @@ -101,8 +101,6 @@ private function configureOptions(array $parameters): array protected function grabKernelService(): KernelInterface { - /** @var KernelInterface $kernel */ - $kernel = $this->grabService(KernelInterface::class); - return $kernel; + return $this->grabService(KernelInterface::class); } } diff --git a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php index 59c022cb..74f5496a 100644 --- a/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/DoctrineAssertionsTrait.php @@ -29,17 +29,7 @@ trait DoctrineAssertionsTrait */ public function grabNumRecords(string $entityClass, array $criteria = []): int { - $em = $this->_getEntityManager(); - $repository = $em->getRepository($entityClass); - - if ($criteria === []) { - return (int)$repository->createQueryBuilder('e') - ->select('count(e.id)') - ->getQuery() - ->getSingleScalarResult(); - } - - return $repository->count($criteria); + return $this->_getEntityManager()->getRepository($entityClass)->count($criteria); } /** diff --git a/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php b/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php index ec313bd6..e5bfb853 100644 --- a/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php @@ -23,7 +23,7 @@ trait DomCrawlerAssertionsTrait */ public function assertCheckboxChecked(string $fieldName, string $message = ''): void { - $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked"), $message); + $this->assertCheckboxState($fieldName, true, $message); } /** @@ -36,12 +36,7 @@ public function assertCheckboxChecked(string $fieldName, string $message = ''): */ public function assertCheckboxNotChecked(string $fieldName, string $message = ''): void { - $this->assertThatCrawler( - new LogicalNot( - new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked") - ), - $message - ); + $this->assertCheckboxState($fieldName, false, $message); } /** @@ -54,13 +49,7 @@ public function assertCheckboxNotChecked(string $fieldName, string $message = '' */ public function assertInputValueNotSame(string $fieldName, string $expectedValue, string $message = ''): void { - $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]"), $message); - $this->assertThatCrawler( - new LogicalNot( - new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $expectedValue) - ), - $message - ); + $this->assertInputValue($fieldName, $expectedValue, false, $message); } /** @@ -73,11 +62,7 @@ public function assertInputValueNotSame(string $fieldName, string $expectedValue */ public function assertInputValueSame(string $fieldName, string $expectedValue, string $message = ''): void { - $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]"), $message); - $this->assertThatCrawler( - new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $expectedValue), - $message - ); + $this->assertInputValue($fieldName, $expectedValue, true, $message); } /** @@ -142,7 +127,7 @@ public function assertSelectorNotExists(string $selector, string $message = ''): */ public function assertSelectorTextContains(string $selector, string $text, string $message = ''): void { - $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); + $this->assertSelectorExists($selector, $message); $this->assertThatCrawler(new CrawlerSelectorTextContains($selector, $text), $message); } @@ -156,7 +141,7 @@ public function assertSelectorTextContains(string $selector, string $text, strin */ public function assertSelectorTextNotContains(string $selector, string $text, string $message = ''): void { - $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); + $this->assertSelectorExists($selector, $message); $this->assertThatCrawler(new LogicalNot(new CrawlerSelectorTextContains($selector, $text)), $message); } @@ -170,7 +155,7 @@ public function assertSelectorTextNotContains(string $selector, string $text, st */ public function assertSelectorTextSame(string $selector, string $text, string $message = ''): void { - $this->assertThatCrawler(new CrawlerSelectorExists($selector), $message); + $this->assertSelectorExists($selector, $message); $this->assertThatCrawler(new CrawlerSelectorTextSame($selector, $text), $message); } @@ -178,4 +163,23 @@ protected function assertThatCrawler(Constraint $constraint, string $message): v { $this->assertThat($this->getClient()->getCrawler(), $constraint, $message); } + + private function assertCheckboxState(string $fieldName, bool $checked, string $message): void + { + $constraint = new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked"); + if (!$checked) { + $constraint = new LogicalNot($constraint); + } + $this->assertThatCrawler($constraint, $message); + } + + private function assertInputValue(string $fieldName, string $value, bool $same, string $message): void + { + $this->assertThatCrawler(new CrawlerSelectorExists("input[name=\"$fieldName\"]"), $message); + $constraint = new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $value); + if (!$same) { + $constraint = new LogicalNot($constraint); + } + $this->assertThatCrawler($constraint, $message); + } } diff --git a/src/Codeception/Module/Symfony/EventsAssertionsTrait.php b/src/Codeception/Module/Symfony/EventsAssertionsTrait.php index 3108351e..bc699337 100644 --- a/src/Codeception/Module/Symfony/EventsAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/EventsAssertionsTrait.php @@ -8,13 +8,16 @@ use Symfony\Component\HttpKernel\DataCollector\EventDataCollector; use function array_column; -use function array_merge; +use function array_flip; +use function array_values; use function count; -use function in_array; +use function get_debug_type; use function is_array; use function is_object; use function is_string; +use function sprintf; use function str_starts_with; +use function trigger_error; trait EventsAssertionsTrait { @@ -32,8 +35,7 @@ trait EventsAssertionsTrait */ public function dontSeeEvent(array|string|null $expected = null): void { - $actual = $this->collectEvents(orphanOnly: false); - $this->assertEventTriggered($expected, $actual, shouldExist: false); + $this->assertEventTriggered($expected, $this->collectEvents(orphanOnly: false), shouldExist: false); } /** @@ -70,10 +72,7 @@ public function dontSeeEventListenerIsCalled(array|object|string $expected, arra */ public function dontSeeEventTriggered(array|object|string $expected): void { - trigger_error( - 'dontSeeEventTriggered is deprecated, please use dontSeeEventListenerIsCalled instead', - E_USER_DEPRECATED - ); + trigger_error('dontSeeEventTriggered is deprecated, please use dontSeeEventListenerIsCalled instead', E_USER_DEPRECATED); $this->dontSeeEventListenerIsCalled($expected); } @@ -95,8 +94,7 @@ public function dontSeeEventTriggered(array|object|string $expected): void */ public function dontSeeOrphanEvent(array|string|null $expected = null): void { - $actual = $this->collectEvents(orphanOnly: true); - $this->assertEventTriggered($expected, $actual, shouldExist: false); + $this->assertEventTriggered($expected, $this->collectEvents(orphanOnly: true), shouldExist: false); } /** @@ -112,8 +110,7 @@ public function dontSeeOrphanEvent(array|string|null $expected = null): void */ public function seeEvent(array|string $expected): void { - $actual = $this->collectEvents(orphanOnly: false); - $this->assertEventTriggered($expected, $actual, shouldExist: true); + $this->assertEventTriggered($expected, $this->collectEvents(orphanOnly: false), shouldExist: true); } /** @@ -150,10 +147,7 @@ public function seeEventListenerIsCalled(array|object|string $expected, array|st */ public function seeEventTriggered(array|object|string $expected): void { - trigger_error( - 'seeEventTriggered is deprecated, please use seeEventListenerIsCalled instead', - E_USER_DEPRECATED - ); + trigger_error('seeEventTriggered is deprecated, please use seeEventListenerIsCalled instead', E_USER_DEPRECATED); $this->seeEventListenerIsCalled($expected); } @@ -174,73 +168,66 @@ public function seeEventTriggered(array|object|string $expected): void */ public function seeOrphanEvent(array|string $expected): void { - $actual = $this->collectEvents(orphanOnly: true); - $this->assertEventTriggered($expected, $actual, shouldExist: true); + $this->assertEventTriggered($expected, $this->collectEvents(orphanOnly: true), shouldExist: true); } /** @return list */ protected function getDispatchedEvents(): array { - $eventCollector = $this->grabEventCollector(__FUNCTION__); - $calledListeners = $eventCollector->getCalledListeners($this->getDefaultDispatcher()); - + $calledListeners = $this->grabEventCollector(__FUNCTION__)->getCalledListeners($this->getDefaultDispatcher()); + if (!is_array($calledListeners)) { + $calledListeners = $calledListeners->getValue(true); + } /** @var list */ - return is_array($calledListeners) - ? array_values($calledListeners) - : $calledListeners->getValue(true); + return is_array($calledListeners) ? array_values($calledListeners) : []; } /** @return list */ protected function getOrphanedEvents(): array { - $eventCollector = $this->grabEventCollector(__FUNCTION__); - $orphanedEvents = $eventCollector->getOrphanedEvents($this->getDefaultDispatcher()); - + $orphanedEvents = $this->grabEventCollector(__FUNCTION__)->getOrphanedEvents($this->getDefaultDispatcher()); + if (!is_array($orphanedEvents)) { + $orphanedEvents = $orphanedEvents->getValue(true); + } /** @var list */ - return is_array($orphanedEvents) - ? array_values($orphanedEvents) - : $orphanedEvents->getValue(true); + return is_array($orphanedEvents) ? array_values($orphanedEvents) : []; } - /** @return list> */ + /** @return list */ private function collectEvents(bool $orphanOnly): array { - return $orphanOnly - ? [$this->getOrphanedEvents()] - : [$this->getOrphanedEvents(), array_column($this->getDispatchedEvents(), 'event')]; + $orphaned = $this->getOrphanedEvents(); + return $orphanOnly ? $orphaned : [...$orphaned, ...array_column($this->getDispatchedEvents(), 'event')]; } /** * @param class-string|object|list|null $expected - * @param list> $actual + * @param list $actualEvents */ - protected function assertEventTriggered(array|object|string|null $expected, array $actual, bool $shouldExist): void + protected function assertEventTriggered(array|object|string|null $expected, array $actualEvents, bool $shouldExist): void { - $actualEvents = array_merge(...$actual); - if ($shouldExist) { $this->assertNotEmpty($actualEvents, 'No event was triggered.'); } + if ($expected === null) { $this->assertEmpty($actualEvents); return; } - $expectedEvents = is_object($expected) ? [$expected] : (array) $expected; - foreach ($expectedEvents as $expectedEvent) { - $eventName = is_object($expectedEvent) ? $expectedEvent::class : $expectedEvent; - $wasTriggered = in_array($eventName, $actualEvents, true); - + $actualEventsMap = array_flip($actualEvents); + foreach (is_array($expected) ? $expected : [$expected] as $expectedEvent) { + $eventName = is_object($expectedEvent) ? $expectedEvent::class : $expectedEvent; $this->assertSame( $shouldExist, - $wasTriggered, + isset($actualEventsMap[$eventName]), sprintf("The '%s' event %s triggered", $eventName, $shouldExist ? 'did not' : 'was') ); } } /** - * @param class-string|object|list $expectedListeners + * @param class-string|object|list> $expectedListeners * @param string|list $expectedEvents */ protected function assertListenerCalled( @@ -248,37 +235,34 @@ protected function assertListenerCalled( array|string $expectedEvents, bool $shouldBeCalled ): void { + $hasMultipleExpectedEvents = is_array($expectedEvents) && count($expectedEvents) > 1; + $expectedListeners = is_array($expectedListeners) ? $expectedListeners : [$expectedListeners]; - $expectedEvents = is_array($expectedEvents) ? $expectedEvents : [$expectedEvents]; + $expectedEvents = (array) $expectedEvents ?: [null]; - if ($expectedEvents === []) { - $expectedEvents = [null]; - } elseif (count($expectedListeners) > 1) { + if (count($expectedListeners) > 1 && $hasMultipleExpectedEvents) { Assert::fail('Cannot check for events when using multiple listeners. Make multiple assertions instead.'); } $actualEvents = $this->getDispatchedEvents(); - if ($shouldBeCalled && $actualEvents === []) { Assert::fail('No event listener was called.'); } - foreach ($expectedListeners as $expectedListener) { - $expectedListener = is_string($expectedListener) ? $expectedListener : $expectedListener::class; - - foreach ($expectedEvents as $expectedEvent) { - $eventName = $expectedEvent ?: null; - $wasCalled = $this->listenerWasCalled($expectedListener, $eventName, $actualEvents); + foreach ($expectedListeners as $listener) { + $listenerName = match (true) { + is_array($listener) && isset($listener[0]) => is_string($listener[0]) ? $listener[0] : (is_object($listener[0]) ? $listener[0]::class : 'array'), + is_object($listener) => $listener::class, + is_string($listener) => $listener, + default => get_debug_type($listener), + }; + foreach ($expectedEvents as $event) { + $eventStr = (string) $event; $this->assertSame( $shouldBeCalled, - $wasCalled, - sprintf( - "The '%s' listener was %scalled%s", - $expectedListener, - $shouldBeCalled ? 'not ' : '', - $eventName ? " for the '{$eventName}' event" : '' - ) + $this->listenerWasCalled($listenerName, $event, $actualEvents), + sprintf("The '%s' listener was %scalled%s", $listenerName, $shouldBeCalled ? 'not ' : '', $event ? " for the '{$eventStr}' event" : '') ); } } @@ -288,9 +272,10 @@ protected function assertListenerCalled( private function listenerWasCalled(string $expectedListener, ?string $expectedEvent, array $actualEvents): bool { foreach ($actualEvents as $actualEvent) { - if (str_starts_with($actualEvent['pretty'], $expectedListener) - && ($expectedEvent === null || $actualEvent['event'] === $expectedEvent) - ) { + if ($expectedEvent !== null && $actualEvent['event'] !== $expectedEvent) { + continue; + } + if (str_starts_with($actualEvent['pretty'], $expectedListener)) { return true; } } diff --git a/src/Codeception/Module/Symfony/FormAssertionsTrait.php b/src/Codeception/Module/Symfony/FormAssertionsTrait.php index 09314c6e..c00238f4 100644 --- a/src/Codeception/Module/Symfony/FormAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/FormAssertionsTrait.php @@ -9,6 +9,7 @@ use function is_array; use function is_int; +use function is_numeric; use function is_string; use function sprintf; @@ -25,7 +26,7 @@ trait FormAssertionsTrait public function assertFormValue(string $formSelector, string $fieldName, string $value, string $message = ''): void { $node = $this->getClient()->getCrawler()->filter($formSelector); - $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); + $this->assertGreaterThan(0, count($node), sprintf('Form "%s" not found.', $formSelector)); $values = $node->form()->getValues(); $this->assertArrayHasKey( @@ -47,7 +48,7 @@ public function assertFormValue(string $formSelector, string $fieldName, string public function assertNoFormValue(string $formSelector, string $fieldName, string $message = ''): void { $node = $this->getClient()->getCrawler()->filter($formSelector); - $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); + $this->assertGreaterThan(0, count($node), sprintf('Form "%s" not found.', $formSelector)); $values = $node->form()->getValues(); $this->assertArrayNotHasKey( @@ -67,10 +68,7 @@ public function assertNoFormValue(string $formSelector, string $fieldName, strin */ public function dontSeeFormErrors(): void { - $formCollector = $this->grabFormCollector(__FUNCTION__); - $errors = $this->extractFormCollectorScalar($formCollector, 'nb_errors'); - - $this->assertSame(0, $errors, 'Expecting that the form does not have errors, but there were!'); + $this->assertSame(0, $this->getFormErrorsCount(__FUNCTION__), 'Expecting that the form does not have errors, but there were!'); } /** @@ -85,86 +83,46 @@ 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'] : []); - - $fieldExists = false; - $errorsForField = []; + $errors = $this->getErrorsForField($field); - 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; - } - - $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 (!$fieldExists) { - $this->fail("The field '{$field}' does not exist in the form."); - } - - if ($errorsForField === []) { + if ($errors === []) { $this->fail("No form error message for field '{$field}'."); } - if ($message === null) { - return; + if ($message !== null) { + $this->assertStringContainsString( + $message, + implode("\n", $errors), + sprintf("There is an error message for the field '%s', but it does not match the expected message.", $field) + ); } - - $this->assertStringContainsString( - $message, - implode("\n", $errorsForField), - sprintf( - "There is an error message for the field '%s', but it does not match the expected message.", - $field - ) - ); } /** * Verifies that multiple fields on a form have errors. * - * If you only specify the name of the fields, this method will - * verify that the field contains at least one error of any type: + * Use a list of field names when you only need to assert that each field + * has at least one validation error: * * ```php * seeFormErrorMessages(['telephone', 'address']); * ``` * - * If you want to specify the error messages, you can do so - * by sending an associative array instead, with the key being - * the name of the field and the error message the value. - * This method will validate that the expected error message - * is contained in the actual error message, that is, - * you can specify either the entire error message or just a part of it: + * Use an associative array to also verify the error text for one or more + * fields. The expected message is matched as a substring, so partial + * fragments are allowed: * * ```php * seeFormErrorMessages([ * 'address' => 'The address is too long', - * 'telephone' => 'too short', // the full error message is 'The telephone is too short' + * 'telephone' => 'too short', * ]); * ``` * - * If you don't want to specify the error message for some fields, - * you can pass `null` as value instead of the message string, - * or you can directly omit the value of that field. If that is the case, - * it will be validated that that field has at least one error of any type: + * You can mix both styles in the same call. If a field maps to `null`, + * only the existence of an error is checked for that field: * * ```php * $msg) { - if (is_int($field)) { - $this->seeFormErrorMessage((string) $msg); - } else { - $this->seeFormErrorMessage($field, $msg); - } + is_int($field) ? $this->seeFormErrorMessage((string) $msg) : $this->seeFormErrorMessage($field, $msg); } } @@ -198,36 +152,71 @@ public function seeFormErrorMessages(array $expectedErrors): void */ public function seeFormHasErrors(): void { - $formCollector = $this->grabFormCollector(__FUNCTION__); - $errors = $this->extractFormCollectorScalar($formCollector, 'nb_errors'); + $this->assertGreaterThan(0, $this->getFormErrorsCount(__FUNCTION__), 'Expecting that the form has errors, but there were none!'); + } - $this->assertGreaterThan(0, $errors, 'Expecting that the form has errors, but there were none!'); + protected function grabFormCollector(string $function): FormDataCollector + { + return $this->grabCollector(DataCollectorName::FORM, $function); } - private function extractFormCollectorScalar(FormDataCollector $collector, string $key): int + private function getFormErrorsCount(string $function): int { - $rawData = $this->getRawCollectorData($collector); - $valueRaw = $rawData[$key] ?? null; + $collector = $this->grabFormCollector($function); + $rawData = $this->getRawCollectorData($collector); - return is_numeric($valueRaw) ? (int) $valueRaw : 0; + return isset($rawData['nb_errors']) && is_numeric($rawData['nb_errors']) ? (int) $rawData['nb_errors'] : 0; } - /** @return array */ - private function getRawCollectorData(FormDataCollector $collector): array + /** + * @return list + */ + private function getErrorsForField(string $field): array { - $data = $collector->getData(); + $collector = $this->grabFormCollector('seeFormErrorMessage'); + $formsData = $this->getRawCollectorData($collector)['forms'] ?? []; + if (!is_array($formsData)) { + return []; + } - if ($data instanceof Data) { - $data = $data->getValue(true); + $errorsForField = []; + $fieldFound = false; + + foreach ($formsData as $form) { + if (!is_array($form) || !isset($form['children']) || !is_array($form['children'])) { + continue; + } + + foreach ($form['children'] as $child) { + if (!is_array($child) || ($child['name'] ?? null) !== $field) { + continue; + } + $fieldFound = true; + if (isset($child['errors']) && is_array($child['errors'])) { + foreach ($child['errors'] as $error) { + if (is_array($error) && isset($error['message']) && is_string($error['message'])) { + $errorsForField[] = $error['message']; + } + } + } + } } - /** @var array $result */ - $result = is_array($data) ? $data : []; - return $result; + if (!$fieldFound) { + $this->fail("The field '{$field}' does not exist in the form."); + } + + return $errorsForField; } - protected function grabFormCollector(string $function): FormDataCollector + /** @return array */ + private function getRawCollectorData(FormDataCollector $collector): array { - return $this->grabCollector(DataCollectorName::FORM, $function); + $data = $collector->getData(); + if ($data instanceof Data) { + $data = $data->getValue(true); + } + /** @var array */ + return is_array($data) ? $data : []; } } diff --git a/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php b/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php index bc55ae8f..55ce1b69 100644 --- a/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php @@ -4,16 +4,17 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Assert; 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 in_array; use function is_array; use function is_object; +use function is_string; use function method_exists; use function sprintf; @@ -45,29 +46,35 @@ public function assertHttpClientRequest( array $expectedHeaders = [], string $httpClientId = 'http_client', ): void { - $matchingRequests = array_filter( + $matchingTraces = array_filter( $this->getHttpClientTraces($httpClientId, __FUNCTION__), - function (array $trace) use ($expectedUrl, $expectedMethod, $expectedBody, $expectedHeaders): bool { - if (!$this->matchesUrlAndMethod($trace, $expectedUrl, $expectedMethod)) { + function ($trace) use ($expectedUrl, $expectedMethod, $expectedBody, $expectedHeaders): bool { + if (!is_array($trace) || !$this->matchesUrlAndMethod($trace, $expectedUrl, $expectedMethod)) { return false; } - $options = $trace['options'] ?? []; - $actualBody = $this->extractValue($options['body'] ?? $options['json'] ?? null); - $bodyMatches = $expectedBody === null || $expectedBody === $actualBody; + $options = $this->extractValue($trace['options'] ?? []); + $options = is_array($options) ? $options : []; - $headersMatch = $expectedHeaders === [] || ( - is_array($headerValues = $this->extractValue($options['headers'] ?? [])) - && ($normalizedExpected = array_change_key_case($expectedHeaders)) - === array_intersect_key(array_change_key_case($headerValues), $normalizedExpected) - ); + $expectedTraceBody = $this->extractValue($options['body'] ?? $options['json'] ?? null); + if ($expectedBody !== null && $expectedBody !== $expectedTraceBody) { + return false; + } + + if ($expectedHeaders === []) { + return true; + } - return $bodyMatches && $headersMatch; + $actualHeaders = $this->extractValue($options['headers'] ?? []); + $expected = array_change_key_case($expectedHeaders); + + return is_array($actualHeaders) + && $expected === array_intersect_key(array_change_key_case($actualHeaders), $expected); }, ); $this->assertNotEmpty( - $matchingRequests, + $matchingTraces, sprintf('The expected request has not been called: "%s" - "%s"', $expectedMethod, $expectedUrl) ); } @@ -90,6 +97,7 @@ public function assertHttpClientRequestCount(int $count, string $httpClientId = * 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 * $I->assertNotHttpClientRequest('https://example.com/unexpected', 'GET'); * ``` @@ -99,50 +107,45 @@ public function assertNotHttpClientRequest( string $unexpectedMethod = 'GET', string $httpClientId = 'http_client', ): void { - $matchingRequests = array_filter( + $matchingTraces = array_filter( $this->getHttpClientTraces($httpClientId, __FUNCTION__), - fn (array $trace): bool => $this->matchesUrlAndMethod($trace, $unexpectedUrl, $unexpectedMethod) + fn($trace): bool => is_array($trace) && $this->matchesUrlAndMethod($trace, $unexpectedUrl, $unexpectedMethod), ); $this->assertEmpty( - $matchingRequests, + $matchingTraces, sprintf('Unexpected URL was called: "%s" - "%s"', $unexpectedMethod, $unexpectedUrl) ); } - /** - * @return list - */ + /** @return array */ private function getHttpClientTraces(string $httpClientId, string $function): array { - $httpClientCollector = $this->grabHttpClientCollector($function); - - /** @var array}> $clients - */ - $clients = $httpClientCollector->getClients(); - - if (!array_key_exists($httpClientId, $clients)) { + $clients = $this->grabHttpClientCollector($function)->getClients(); + + if (!isset($clients[$httpClientId])) { $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); } - return $clients[$httpClientId]['traces']; + /** @var array{traces: array} $clientData */ + $clientData = $clients[$httpClientId]; + return $clientData['traces']; } - /** @param array{info: array{url: string}, url: string, method: string} $trace */ + /** @param array $trace */ private function matchesUrlAndMethod(array $trace, string $expectedUrl, string $expectedMethod): bool { - return in_array($expectedUrl, [$trace['info']['url'], $trace['url']], true) - && $expectedMethod === $trace['method']; + $method = $trace['method'] ?? null; + $url = $trace['url'] ?? null; + + if (!is_string($method) || !is_string($url) || $expectedMethod !== $method) { + return false; + } + + $info = $this->extractValue($trace['info'] ?? []); + $infoUrl = is_array($info) ? ($info['url'] ?? $info['original_url'] ?? null) : null; + + return in_array($expectedUrl, [$infoUrl, $url], true); } private function extractValue(mixed $value): mixed diff --git a/src/Codeception/Module/Symfony/HttpKernelAssertionsTrait.php b/src/Codeception/Module/Symfony/HttpKernelAssertionsTrait.php new file mode 100644 index 00000000..6a025f7c --- /dev/null +++ b/src/Codeception/Module/Symfony/HttpKernelAssertionsTrait.php @@ -0,0 +1,62 @@ +getProfile(); + + if ($profile === null) { + Assert::fail(sprintf("The Profile is needed to use the '%s' function.", $function)); + } + + if (!$profile->hasCollector($name->value)) { + Assert::fail($message ?: sprintf("The '%s' collector is needed to use the '%s' function.", $name->value, $function)); + } + + return $profile->getCollector($name->value); + } +} diff --git a/src/Codeception/Module/Symfony/LoggerAssertionsTrait.php b/src/Codeception/Module/Symfony/LoggerAssertionsTrait.php index f7fdbbcc..2f766508 100644 --- a/src/Codeception/Module/Symfony/LoggerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/LoggerAssertionsTrait.php @@ -46,7 +46,7 @@ public function dontSeeDeprecations(string $message = ''): void "Found %d deprecation message%s in the log:\n%s", $count, $count !== 1 ? 's' : '', - implode("\n", array_map(static fn (string $m): string => " - $m", $foundDeprecations)), + implode("\n", array_map(static fn(string $m): string => " - $m", $foundDeprecations)), ); $this->assertEmpty($foundDeprecations, $errorMessage); } diff --git a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php index 8da45f54..c1e604e6 100644 --- a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php @@ -12,8 +12,12 @@ use Symfony\Component\Mailer\Test\Constraint as MailerConstraint; use Symfony\Component\Mime\Email; +use function array_key_last; + trait MailerAssertionsTrait { + private ?string $messageLoggerServiceId = null; + /** * Asserts that the expected number of emails was sent. * @@ -102,9 +106,8 @@ public function grabLastSentEmail(): ?Email { /** @var Email[] $emails */ $emails = $this->getMessageMailerEvents()->getMessages(); - $lastEmail = end($emails); - return $lastEmail ?: null; + return $emails ? $emails[array_key_last($emails)] : null; } /** @@ -157,20 +160,26 @@ public function seeEmailIsSent(int $expectedCount = 1): void */ public function getMailerEvent(int $index = 0, ?string $transport = null): ?MessageEvent { - $mailerEvents = $this->getMessageMailerEvents(); - $events = $mailerEvents->getEvents($transport); - return $events[$index] ?? null; + return $this->getMessageMailerEvents()->getEvents($transport)[$index] ?? null; } protected function getMessageMailerEvents(): MessageEvents { - $services = ['mailer.message_logger_listener', 'mailer.logger_message_listener']; - foreach ($services as $serviceId) { - $mailer = $this->getService($serviceId); - if ($mailer instanceof MessageLoggerListener) { - return $mailer->getEvents(); + if ($this->messageLoggerServiceId !== null) { + $logger = $this->getService($this->messageLoggerServiceId); + if ($logger instanceof MessageLoggerListener) { + return $logger->getEvents(); + } + } + + foreach (['mailer.message_logger_listener', 'mailer.logger_message_listener'] as $serviceId) { + $logger = $this->getService($serviceId); + if ($logger instanceof MessageLoggerListener) { + $this->messageLoggerServiceId = $serviceId; + return $logger->getEvents(); } } + Assert::fail("Emails can't be tested without Symfony Mailer service."); } } diff --git a/src/Codeception/Module/Symfony/NotifierAssertionsTrait.php b/src/Codeception/Module/Symfony/NotifierAssertionsTrait.php index 765d8d03..f63203d5 100644 --- a/src/Codeception/Module/Symfony/NotifierAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/NotifierAssertionsTrait.php @@ -6,15 +6,20 @@ use PHPUnit\Framework\Assert; use PHPUnit\Framework\Constraint\LogicalNot; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Notifier\Event\MessageEvent; use Symfony\Component\Notifier\Event\NotificationEvents; use Symfony\Component\Notifier\EventListener\NotificationLoggerListener; use Symfony\Component\Notifier\Message\MessageInterface; use Symfony\Component\Notifier\Test\Constraint as NotifierConstraint; -use Symfony\Component\HttpKernel\Kernel; + +use function end; +use function version_compare; trait NotifierAssertionsTrait { + private ?string $notifierLoggerServiceId = null; + /** * Asserts that the expected number of notifications was sent. * @@ -50,7 +55,7 @@ public function assertNotificationIsNotQueued(MessageEvent $event, string $messa * ```php * getNotifierEvent(); - * $I->assertNotificationlIsQueued($event); + * $I->assertNotificationIsQueued($event); * ``` */ public function assertNotificationIsQueued(MessageEvent $event, string $message = ''): void @@ -192,7 +197,7 @@ public function grabSentNotifications(): array * * ```php * seeNotificatoinIsSent(2); + * $I->seeNotificationIsSent(2); * ``` * * @param int $expectedCount The expected number of notifications sent @@ -202,9 +207,7 @@ public function seeNotificationIsSent(int $expectedCount = 1): void $this->assertThat($this->getNotificationEvents(), new NotifierConstraint\NotificationCount($expectedCount)); } - /** - * @return MessageEvent[] - */ + /** @return MessageEvent[] */ public function getNotifierEvents(?string $transportName = null): array { return $this->getNotificationEvents()->getEvents($transportName); @@ -223,9 +226,7 @@ public function getNotifierEvent(int $index = 0, ?string $transportName = null): return $this->getNotifierEvents($transportName)[$index] ?? null; } - /** - * @return MessageInterface[] - */ + /** @return MessageInterface[] */ public function getNotifierMessages(?string $transportName = null): array { return $this->getNotificationEvents()->getMessages($transportName); @@ -251,13 +252,32 @@ protected function getNotificationEvents(): NotificationEvents Assert::fail('Notifier assertions require Symfony 6.2 or higher.'); } - $services = ['notifier.notification_logger_listener', 'notifier.logger_notification_listener']; - foreach ($services as $serviceId) { + $notifier = $this->getNotifierLoggerListener(); + + if ($notifier !== null) { + return $notifier->getEvents(); + } + + Assert::fail("Notifications can't be tested without Symfony Notifier service."); + } + + private function getNotifierLoggerListener(): ?NotificationLoggerListener + { + if ($this->notifierLoggerServiceId !== null) { + $notifier = $this->getService($this->notifierLoggerServiceId); + if ($notifier instanceof NotificationLoggerListener) { + return $notifier; + } + } + + foreach (['notifier.notification_logger_listener', 'notifier.logger_notification_listener'] as $serviceId) { $notifier = $this->getService($serviceId); if ($notifier instanceof NotificationLoggerListener) { - return $notifier->getEvents(); + $this->notifierLoggerServiceId = $serviceId; + return $notifier; } } - Assert::fail("Notifications can't be tested without Symfony Notifier service."); + + return null; } } diff --git a/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php b/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php index 7d54dfd0..ed09d5a2 100644 --- a/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ParameterAssertionsTrait.php @@ -28,8 +28,6 @@ public function grabParameter(string $parameterName): array|bool|string|int|floa protected function grabParameterBagService(): ParameterBagInterface { - /** @var ParameterBagInterface $parameterBag */ - $parameterBag = $this->grabService(ParameterBagInterface::class); - return $parameterBag; + return $this->grabService(ParameterBagInterface::class); } } diff --git a/src/Codeception/Module/Symfony/RouterAssertionsTrait.php b/src/Codeception/Module/Symfony/RouterAssertionsTrait.php index cdbd41ee..ed2d209e 100644 --- a/src/Codeception/Module/Symfony/RouterAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/RouterAssertionsTrait.php @@ -25,7 +25,7 @@ trait RouterAssertionsTrait * $I->amOnAction('ArticleController', ['slug' => 'lorem-ipsum']); * ``` * - * @param array $params + * @param array $params */ public function amOnAction(string $action, array $params = []): void { @@ -55,6 +55,7 @@ public function amOnRoute(string $routeName, array $params = []): void public function invalidateCachedRouter(): void { $this->unpersistService('router'); + $this->clearInternalDomainsCache(); } /** @@ -65,11 +66,11 @@ public function invalidateCachedRouter(): void * $I->seeCurrentActionIs('PostController::index'); * $I->seeCurrentActionIs('HomeController'); * ``` + * @param non-empty-string $action */ public function seeCurrentActionIs(string $action): void { $this->findRouteByActionOrFail($action); - /** @var string $current */ $current = $this->getClient()->getRequest()->attributes->get('_controller'); $this->assertStringEndsWith($action, $current, "Current action is '{$current}'."); @@ -88,7 +89,7 @@ public function seeCurrentActionIs(string $action): void */ public function seeCurrentRouteIs(string $routeName, array $params = []): void { - $match = $this->getCurrentRouteMatch($routeName); + $match = $this->getCurrentRouteMatch($routeName); $expected = ['_route' => $routeName] + $params; $this->assertSame($expected, array_intersect_assoc($expected, $match)); } @@ -111,14 +112,8 @@ public function seeInCurrentRoute(string $routeName): void 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; + /** @var array */ + return $this->grabRouterService()->match((string) parse_url($this->getClient()->getRequest()->getRequestUri(), PHP_URL_PATH)); } private function findRouteByActionOrFail(string $action): string @@ -143,13 +138,12 @@ private function assertRouteExists(string $routeName): void /** @param array $params */ private function openRoute(string $routeName, array $params = []): void { - $this->amOnPage($this->grabRouterService()->generate($routeName, $params)); + $this->getClient()->request('GET', $this->grabRouterService()->generate($routeName, $params)); } protected function grabRouterService(): RouterInterface { - /** @var RouterInterface $router */ - $router = $this->grabService('router'); - return $router; + /** @var RouterInterface */ + return $this->grabService('router'); } } diff --git a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php index 38025985..3e5eb662 100644 --- a/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SecurityAssertionsTrait.php @@ -25,9 +25,8 @@ trait SecurityAssertionsTrait */ public function dontSeeAuthentication(): void { - $security = $this->grabSecurityService(); $this->assertFalse( - $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY), + $this->grabSecurityService()->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY), 'There is an user authenticated.' ); } @@ -42,12 +41,7 @@ public function dontSeeAuthentication(): void */ public function dontSeeRememberedAuthentication(): void { - $security = $this->grabSecurityService(); - $client = $this->getClient(); - $hasCookie = $client->getCookieJar()->get('REMEMBERME') !== null; - $hasRole = $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); - - $this->assertFalse($hasCookie && $hasRole, 'User does have remembered authentication.'); + $this->assertFalse($this->isRemembered(), 'User does have remembered authentication.'); } /** @@ -60,9 +54,8 @@ public function dontSeeRememberedAuthentication(): void */ public function seeAuthentication(): void { - $security = $this->grabSecurityService(); $this->assertTrue( - $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY), + $this->grabSecurityService()->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_FULLY), 'There is no authenticated user.' ); } @@ -77,12 +70,7 @@ public function seeAuthentication(): void */ public function seeRememberedAuthentication(): void { - $security = $this->grabSecurityService(); - $client = $this->getClient(); - $hasCookie = $client->getCookieJar()->get('REMEMBERME') !== null; - $hasRole = $security->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); - - $this->assertTrue($hasCookie && $hasRole, 'User does not have remembered authentication.'); + $this->assertTrue($this->isRemembered(), 'User does not have remembered authentication.'); } /** @@ -95,12 +83,9 @@ public function seeRememberedAuthentication(): void */ public function seeUserHasRole(string $role): void { - $user = $this->getAuthenticatedUser(); - $identifier = $user->getUserIdentifier(); - $this->assertTrue( $this->grabSecurityService()->isGranted($role), - sprintf('User %s has no role %s', $identifier, $role) + sprintf('User %s has no role %s', $this->getAuthenticatedUser()->getUserIdentifier(), $role) ); } @@ -143,31 +128,30 @@ public function seeUserPasswordDoesNotNeedRehash(?UserInterface $user = null): v Assert::fail('Provided user does not implement PasswordAuthenticatedUserInterface.'); } - $hasher = $this->grabPasswordHasherService(); - $this->assertFalse($hasher->needsRehash($userToValidate), 'User password needs rehash.'); + $this->assertFalse($this->grabPasswordHasherService()->needsRehash($userToValidate), 'User password needs rehash.'); } private function getAuthenticatedUser(): UserInterface { - $user = $this->grabSecurityService()->getUser(); - if ($user === null) { - Assert::fail('No user found in session to perform this check.'); - } - return $user; + return $this->grabSecurityService()->getUser() ?? Assert::fail('No user found in session to perform this check.'); + } + + private function isRemembered(): bool + { + return $this->getClient()->getCookieJar()->get('REMEMBERME') !== null + && $this->grabSecurityService()->isGranted(AuthenticatedVoter::IS_AUTHENTICATED_REMEMBERED); } /** @return Security */ protected function grabSecurityService() { - /** @var Security $security */ - $security = $this->grabService('security.helper'); - return $security; + /** @var Security */ + return $this->grabService('security.helper'); } protected function grabPasswordHasherService(): UserPasswordHasherInterface { - /** @var UserPasswordHasherInterface $hasher */ - $hasher = $this->getService('security.password_hasher'); - return $hasher; + /** @var UserPasswordHasherInterface */ + return $this->getService('security.password_hasher'); } } diff --git a/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php b/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php index 3ea06adf..f2fb0765 100644 --- a/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ServicesAssertionsTrait.php @@ -4,11 +4,24 @@ namespace Codeception\Module\Symfony; -use Codeception\Lib\Connector\Symfony as SymfonyConnector; use PHPUnit\Framework\Assert; trait ServicesAssertionsTrait { + /** + * Services that should be persistent during test execution between kernel reboots + * + * @var array + */ + protected array $persistentServices = []; + + /** + * Services that should be persistent permanently for all tests + * + * @var array + */ + protected array $permanentServices = []; + /** * Grabs a service from the Symfony dependency injection container (DIC). * In the "test" environment, Symfony uses a special `test.service_container`. @@ -21,20 +34,24 @@ trait ServicesAssertionsTrait * ``` * * @part services - * @param non-empty-string $serviceId + * @template T of object + * @param string|class-string $serviceId + * @return ($serviceId is class-string ? T : object) */ 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 + $service = $this->getService($serviceId); + + if ($service !== null) { + return $service; + } + + 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" - ); - } - - return $service; + ); } /** @@ -45,11 +62,7 @@ public function grabService(string $serviceId): object */ public function persistService(string $serviceName): void { - $service = $this->grabService($serviceName); - $this->persistentServices[$serviceName] = $service; - if ($this->client instanceof SymfonyConnector) { - $this->client->persistentServices[$serviceName] = $service; - } + $this->doPersistService($serviceName, false); } /** @@ -61,37 +74,38 @@ public function persistService(string $serviceName): void */ public function persistPermanentService(string $serviceName): void { - $service = $this->grabService($serviceName); - $this->persistentServices[$serviceName] = $service; - $this->permanentServices[$serviceName] = $service; - if ($this->client instanceof SymfonyConnector) { - $this->client->persistentServices[$serviceName] = $service; - } + $this->doPersistService($serviceName, true); } /** * Remove service $serviceName from the lists of persistent services. * * @part services + * @param non-empty-string $serviceName */ public function unpersistService(string $serviceName): void { - unset($this->persistentServices[$serviceName]); - unset($this->permanentServices[$serviceName]); - - if ($this->client instanceof SymfonyConnector) { - unset($this->client->persistentServices[$serviceName]); - } + unset($this->persistentServices[$serviceName], $this->permanentServices[$serviceName]); + $this->updateClientPersistentService($serviceName, null); } - /** @param non-empty-string $serviceId */ + /** @param non-empty-string $name */ + protected function updateClientPersistentService(string $name, ?object $service): void {} + protected function getService(string $serviceId): ?object { $container = $this->_getContainer(); - if (!$container->has($serviceId)) { - return null; - } + return $container->has($serviceId) ? $container->get($serviceId) : null; + } - return $container->get($serviceId); + /** @param non-empty-string $name */ + private function doPersistService(string $name, bool $permanent): void + { + $service = $this->grabService($name); + $this->persistentServices[$name] = $service; + if ($permanent) { + $this->permanentServices[$name] = $service; + } + $this->updateClientPersistentService($name, $service); } } diff --git a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php index 70528946..6e1777f9 100644 --- a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php @@ -19,9 +19,12 @@ use Symfony\Component\Security\Http\Logout\LogoutUrlGenerator; use function class_exists; +use function get_debug_type; use function in_array; use function is_int; +use function is_string; use function serialize; +use function sprintf; trait SessionAssertionsTrait { @@ -66,12 +69,9 @@ public function amLoggedInWithToken(TokenInterface $token, string $firewallName public function dontSeeInSession(string $attribute, mixed $value = null): void { $session = $this->getCurrentSession(); - - if ($value === null) { - $this->assertFalse($session->has($attribute), "Session attribute '{$attribute}' exists."); - } else { - $this->assertNotSame($value, $session->get($attribute)); - } + $value === null + ? $this->assertFalse($session->has($attribute), "Session attribute '{$attribute}' exists.") + : $this->assertNotSame($value, $session->get($attribute)); } /** @@ -82,7 +82,7 @@ public function dontSeeInSession(string $attribute, mixed $value = null): void */ public function goToLogoutPath(): void { - $this->amOnPage($this->getLogoutUrlGenerator()->getLogoutPath()); + $this->getClient()->request('GET', $this->getLogoutUrlGenerator()->getLogoutPath()); } /** @@ -110,17 +110,14 @@ public function logout(): void public function logoutProgrammatically(): void { $this->getTokenStorage()->setToken(null); - - $session = $this->getCurrentSession(); + $session = $this->getCurrentSession(); $sessionName = $session->getName(); $session->invalidate(); - $cookieJar = $this->getClient()->getCookieJar(); - $cookiesToExpire = ['MOCKSESSID', 'REMEMBERME', $sessionName]; + $cookieJar = $this->getClient()->getCookieJar(); foreach ($cookieJar->all() as $cookie) { - $cookieName = $cookie->getName(); - if (in_array($cookieName, $cookiesToExpire, true)) { - $cookieJar->expire($cookieName); + if (in_array($cookie->getName(), ['MOCKSESSID', 'REMEMBERME', $sessionName], true)) { + $cookieJar->expire($cookie->getName()); } } $cookieJar->flushExpiredCookies(); @@ -159,38 +156,27 @@ public function seeInSession(string $attribute, mixed $value = null): void 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 { + if (!is_int($key)) { $this->seeInSession($key, $value); + continue; + } + if (!is_string($value)) { + throw new InvalidArgumentException(sprintf('Attribute name must be string, %s given.', get_debug_type($value))); } + $this->seeInSession($value); } } protected function getTokenStorage(): TokenStorageInterface { - /** @var TokenStorageInterface $storage */ - $storage = $this->grabService('security.token_storage'); - return $storage; + /** @var TokenStorageInterface */ + return $this->grabService('security.token_storage'); } protected function getLogoutUrlGenerator(): LogoutUrlGenerator { - /** @var LogoutUrlGenerator $generator */ - $generator = $this->grabService('security.logout_url_generator'); - return $generator; - } - - protected function getAuthenticator(): AuthenticatorInterface - { - /** @var AuthenticatorInterface $authenticator */ - $authenticator = $this->grabService(AuthenticatorInterface::class); - return $authenticator; + /** @var LogoutUrlGenerator */ + return $this->grabService('security.logout_url_generator'); } protected function getCurrentSession(): SessionInterface @@ -198,42 +184,40 @@ protected function getCurrentSession(): SessionInterface $container = $this->_getContainer(); if ($this->getSymfonyMajorVersion() < 6 || $container->has('session')) { - /** @var SessionInterface $session */ - $session = $container->get('session'); - return $session; + /** @var SessionInterface */ + return $container->get('session'); } /** @var SessionFactoryInterface $factory */ - $factory = $container->get('session.factory'); + $factory = $this->grabService('session.factory'); $session = $factory->createSession(); $container->set('session', $session); - return $session; } - protected function getSymfonyMajorVersion(): int - { - return Kernel::MAJOR_VERSION; - } - protected function createAuthenticationToken(UserInterface $user, string $firewallName): TokenInterface { $roles = $user->getRoles(); 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); + /** @var AuthenticatorInterface $authenticator */ + $authenticator = $this->grabService(AuthenticatorInterface::class); + return $authenticator->createToken(new SelfValidatingPassport(new UserBadge($user->getUserIdentifier(), static fn() => $user)), $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; + /** @var TokenInterface */ + return new $postClass($user, $firewallName, $roles); } } return new UsernamePasswordToken($user, $firewallName, $roles); } + + private function getSymfonyMajorVersion(): int + { + return Kernel::MAJOR_VERSION; + } } diff --git a/src/Codeception/Module/Symfony/TranslationAssertionsTrait.php b/src/Codeception/Module/Symfony/TranslationAssertionsTrait.php index 05927717..0315b092 100644 --- a/src/Codeception/Module/Symfony/TranslationAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/TranslationAssertionsTrait.php @@ -23,8 +23,8 @@ public function dontSeeFallbackTranslations(): void $fallbacks = $translationCollector->getCountFallbacks(); $this->assertSame( - $fallbacks, 0, + $fallbacks, "Expected no fallback translations, but found {$fallbacks}." ); } @@ -43,8 +43,8 @@ public function dontSeeMissingTranslations(): void $missings = $translationCollector->getCountMissings(); $this->assertSame( - $missings, 0, + $missings, "Expected no missing translations, but found {$missings}." ); } diff --git a/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php b/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php index bfbdac07..fffee4d8 100644 --- a/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/ValidatorAssertionsTrait.php @@ -71,7 +71,7 @@ public function seeViolatedConstraintMessage(string $expected, object $subject, $violations = $this->getViolationsForSubject($subject, $propertyPath); $containsExpected = false; foreach ($violations as $violation) { - if ($violation->getPropertyPath() === $propertyPath && str_contains((string)$violation->getMessage(), $expected)) { + if ($violation->getPropertyPath() === $propertyPath && str_contains((string) $violation->getMessage(), $expected)) { $containsExpected = true; break; } @@ -89,10 +89,11 @@ protected function getViolationsForSubject(object $subject, ?string $propertyPat $violations = iterator_to_array($violations); if ($constraint !== null) { - return (array)array_filter( + return (array) array_filter( $violations, - static fn (ConstraintViolationInterface $violation): bool => get_class((object)$violation->getConstraint()) === $constraint && - ($propertyPath === null || $violation->getPropertyPath() === $propertyPath) + static fn(ConstraintViolationInterface $violation): bool => $violation->getConstraint() !== null + && $violation->getConstraint()::class === $constraint + && ($propertyPath === null || $violation->getPropertyPath() === $propertyPath) ); } @@ -101,8 +102,6 @@ protected function getViolationsForSubject(object $subject, ?string $propertyPat protected function getValidatorService(): ValidatorInterface { - /** @var ValidatorInterface $validator */ - $validator = $this->grabService(ValidatorInterface::class); - return $validator; + return $this->grabService(ValidatorInterface::class); } } diff --git a/tests/BrowserAssertionsTest.php b/tests/BrowserAssertionsTest.php new file mode 100644 index 00000000..7c4bddbc --- /dev/null +++ b/tests/BrowserAssertionsTest.php @@ -0,0 +1,151 @@ +client->followRedirects(false); + $this->client->getCookieJar()->set(new Cookie('browser_cookie', 'value')); + } + + public function testAssertBrowserCookieValueSame(): void + { + $this->assertBrowserCookieValueSame('browser_cookie', 'value'); + } + + public function testAssertBrowserHasCookie(): void + { + $this->assertBrowserHasCookie('browser_cookie'); + } + + public function testAssertBrowserNotHasCookie(): void + { + $this->client->getCookieJar()->expire('browser_cookie'); + $this->assertBrowserNotHasCookie('browser_cookie'); + } + + public function testAssertRequestAttributeValueSame(): void + { + $this->client->request('GET', '/request_attr'); + $this->assertRequestAttributeValueSame('page', 'register'); + } + + public function testAssertResponseCookieValueSame(): void + { + $this->client->request('GET', '/response_cookie'); + $this->assertResponseCookieValueSame('TESTCOOKIE', 'codecept'); + } + + public function testAssertResponseFormatSame(): void + { + $this->client->request('GET', '/response_json'); + $this->assertResponseFormatSame('json'); + } + + public function testAssertResponseHasCookie(): void + { + $this->client->request('GET', '/response_cookie'); + $this->assertResponseHasCookie('TESTCOOKIE'); + } + + public function testAssertResponseHasHeader(): void + { + $this->client->request('GET', '/response_json'); + $this->assertResponseHasHeader('content-type'); + } + + public function testAssertResponseHeaderNotSame(): void + { + $this->client->request('GET', '/response_json'); + $this->assertResponseHeaderNotSame('content-type', 'application/octet-stream'); + } + + public function testAssertResponseHeaderSame(): void + { + $this->client->request('GET', '/response_json'); + $this->assertResponseHeaderSame('content-type', 'application/json'); + } + + public function testAssertResponseIsSuccessful(): void + { + $this->client->request('GET', '/'); + $this->assertResponseIsSuccessful(); + } + + public function testAssertResponseIsUnprocessable(): void + { + $this->client->request('GET', '/unprocessable_entity'); + $this->assertResponseIsUnprocessable(); + } + + public function testAssertResponseNotHasCookie(): void + { + $this->client->request('GET', '/'); + $this->assertResponseNotHasCookie('TESTCOOKIE'); + } + + public function testAssertResponseNotHasHeader(): void + { + $this->client->request('GET', '/'); + $this->assertResponseNotHasHeader('accept-charset'); + } + + public function testAssertResponseRedirects(): void + { + $this->client->followRedirects(false); + $this->client->request('GET', '/redirect_home'); + $this->assertResponseRedirects(); + $this->assertResponseRedirects('/'); + } + + public function testAssertResponseStatusCodeSame(): void + { + $this->client->followRedirects(false); + $this->client->request('GET', '/redirect_home'); + $this->assertResponseStatusCodeSame(302); + } + + public function testAssertRouteSame(): void + { + $this->client->request('GET', '/'); + $this->assertRouteSame('index'); + $this->client->request('GET', '/login'); + $this->assertRouteSame('app_login'); + } + + public function testRebootClientKernel(): void + { + $this->markTestSkipped('This method relies on Codeception\Lib\Connector\Symfony::rebootKernel(), which is not available in KernelBrowser.'); + } + + public function testSeePageIsAvailable(): void + { + $this->seePageIsAvailable('/login'); + $this->client->request('GET', '/register'); + $this->seePageIsAvailable(); + } + + public function testSeePageRedirectsTo(): void + { + $this->seePageRedirectsTo('/dashboard', '/login'); + } + + public function testSubmitSymfonyForm(): void + { + $this->client->request('GET', '/register'); + $this->submitSymfonyForm('registration_form', [ + '[email]' => 'jane_doe@gmail.com', + '[password]' => '123456', + '[agreeTerms]' => true, + ]); + $this->assertResponseRedirects('/dashboard'); + } +} diff --git a/tests/ConsoleAssertionsTest.php b/tests/ConsoleAssertionsTest.php new file mode 100644 index 00000000..c8702708 --- /dev/null +++ b/tests/ConsoleAssertionsTest.php @@ -0,0 +1,18 @@ +assertStringContainsString('No option', $this->runSymfonyConsoleCommand('app:test-command')); + $this->assertStringContainsString('Option selected', $this->runSymfonyConsoleCommand('app:test-command', ['--opt' => true])); + $this->assertStringContainsString('Option selected', $this->runSymfonyConsoleCommand('app:test-command', ['-o' => true])); + $this->assertSame('', $this->runSymfonyConsoleCommand('app:test-command', ['-q'])); + } +} diff --git a/tests/DoctrineAssertionsTest.php b/tests/DoctrineAssertionsTest.php new file mode 100644 index 00000000..a294a084 --- /dev/null +++ b/tests/DoctrineAssertionsTest.php @@ -0,0 +1,31 @@ +assertSame(1, $this->grabNumRecords(User::class)); + } + + public function testGrabRepository(): void + { + $this->assertInstanceOf(UserRepository::class, $this->grabRepository(User::class)); + $this->assertInstanceOf(UserRepository::class, $this->grabRepository(UserRepository::class)); + $this->assertInstanceOf(UserRepository::class, $this->grabRepository($this->grabRepository(User::class)->findOneBy(['email' => 'john_doe@gmail.com']))); + $this->assertInstanceOf(UserRepository::class, $this->grabRepository(UserRepositoryInterface::class)); + } + + public function testSeeNumRecords(): void + { + $this->seeNumRecords(1, User::class); + } +} diff --git a/tests/DomCrawlerAssertionsTest.php b/tests/DomCrawlerAssertionsTest.php new file mode 100644 index 00000000..67b9c2c4 --- /dev/null +++ b/tests/DomCrawlerAssertionsTest.php @@ -0,0 +1,71 @@ +client->request('GET', '/test_page'); + } + + public function testAssertCheckboxChecked(): void + { + $this->assertCheckboxChecked('exampleCheckbox', 'The checkbox should be checked.'); + } + + public function testAssertCheckboxNotChecked(): void + { + $this->assertCheckboxNotChecked('nonExistentCheckbox', 'This checkbox should not be checked.'); + } + + public function testAssertInputValueNotSame(): void + { + $this->assertInputValueNotSame('exampleInput', 'Wrong Value', 'The input value should not be "Wrong Value".'); + } + + public function testAssertInputValueSame(): void + { + $this->assertInputValueSame('exampleInput', 'Expected Value', 'The input value should be "Expected Value".'); + } + + public function testAssertPageTitleContains(): void + { + $this->assertPageTitleContains('Test', 'The page title should contain "Test".'); + } + + public function testAssertPageTitleSame(): void + { + $this->assertPageTitleSame('Test Page', 'The page title should be "Test Page".'); + } + + public function testAssertSelectorExists(): void + { + $this->assertSelectorExists('h1', 'The

element should be present.'); + } + + public function testAssertSelectorNotExists(): void + { + $this->assertSelectorNotExists('.non-existent-class', 'This selector should not exist.'); + } + + public function testAssertSelectorTextContains(): void + { + $this->assertSelectorTextContains('h1', 'Test', 'The

tag should contain "Test".'); + } + + public function testAssertSelectorTextNotContains(): void + { + $this->assertSelectorTextNotContains('h1', 'Error', 'The

tag should not contain "Error".'); + } + + public function testAssertSelectorTextSame(): void + { + $this->assertSelectorTextSame('h1', 'Test Page', 'The text in the

tag should be exactly "Test Page".'); + } +} diff --git a/tests/EventsAssertionsTest.php b/tests/EventsAssertionsTest.php new file mode 100644 index 00000000..c7fb48ae --- /dev/null +++ b/tests/EventsAssertionsTest.php @@ -0,0 +1,67 @@ +client->request('GET', '/dispatch-orphan-event'); + $this->dontSeeEvent(TestEvent::class); + } + + public function testDontSeeEventListenerIsCalled(): void + { + $this->client->request('GET', '/dispatch-orphan-event'); + $this->dontSeeEventListenerIsCalled(TestEventListener::class); + } + + public function testDontSeeEventTriggered(): void + { + $this->client->request('GET', '/dispatch-orphan-event'); + $this->dontSeeEventTriggered(TestEventListener::class); + } + + public function testDontSeeOrphanEvent(): void + { + if (Kernel::VERSION_ID < 60000) { + $this->markTestSkipped('Orphan event detection requires Symfony 6.0+'); + } + $this->client->request('GET', '/dispatch-event'); + $this->dontSeeOrphanEvent(); + } + + public function testSeeEvent(): void + { + $this->client->request('GET', '/dispatch-event'); + $this->seeEvent(TestEvent::class); + } + + public function testSeeEventListenerIsCalled(): void + { + $this->client->request('GET', '/dispatch-event'); + $this->seeEventListenerIsCalled(TestEventListener::class, TestEvent::class); + + $this->client->request('GET', '/dispatch-named-event'); + $this->seeEventListenerIsCalled(TestEventListener::class, 'named.event'); + } + + public function testSeeEventTriggered(): void + { + $this->client->request('GET', '/dispatch-event'); + $this->seeEventTriggered(TestEventListener::class); + } + + public function testSeeOrphanEvent(): void + { + $this->client->request('GET', '/dispatch-orphan-event'); + $this->seeOrphanEvent('orphan.event'); + } +} diff --git a/tests/FormAssertionsTest.php b/tests/FormAssertionsTest.php new file mode 100644 index 00000000..ace970a7 --- /dev/null +++ b/tests/FormAssertionsTest.php @@ -0,0 +1,50 @@ +client->request('GET', '/sample'); + } + + public function testAssertFormValue(): void + { + $this->assertFormValue('#testForm', 'field1', 'value1'); + } + + public function testAssertNoFormValue(): void + { + $this->assertNoFormValue('#testForm', 'missing_field'); + } + + public function testDontSeeFormErrors(): void + { + $this->client->request('POST', '/form', ['registration_form' => ['email' => 'john@example.com', 'password' => 'top-secret']]); + $this->dontSeeFormErrors(); + } + + public function testSeeFormErrorMessage(): void + { + $this->client->request('POST', '/form', ['registration_form' => ['email' => 'not-an-email', 'password' => '']]); + $this->seeFormErrorMessage('email', 'valid email address'); + } + + public function testSeeFormErrorMessages(): void + { + $this->client->request('POST', '/form', ['registration_form' => ['email' => 'not-an-email', 'password' => '']]); + $this->seeFormErrorMessages(['email' => 'valid email address', 'password' => 'not be blank']); + } + + public function testSeeFormHasErrors(): void + { + $this->client->request('POST', '/form', ['registration_form' => ['email' => 'not-an-email', 'password' => '']]); + $this->seeFormHasErrors(); + } +} diff --git a/tests/HttpClientAssertionsTest.php b/tests/HttpClientAssertionsTest.php new file mode 100644 index 00000000..836f6453 --- /dev/null +++ b/tests/HttpClientAssertionsTest.php @@ -0,0 +1,38 @@ +markTestSkipped('HttpClient data collection is not reliable in this test environment for Symfony 5.4'); + } + $this->client->request('GET', '/http-client'); + } + + public function testAssertHttpClientRequest(): void + { + $this->assertHttpClientRequest('https://example.com/default', 'GET', null, ['X-Test' => 'yes'], 'app.http_client'); + $this->assertHttpClientRequest('https://example.com/body', 'POST', ['example' => 'payload'], [], 'app.http_client'); + $this->assertHttpClientRequest('https://api.example.com/resource', 'GET', null, [], 'app.http_client.json_client'); + } + + public function testAssertHttpClientRequestCount(): void + { + $this->assertHttpClientRequestCount(2, 'app.http_client'); + $this->assertHttpClientRequestCount(1, 'app.http_client.json_client'); + } + + public function testAssertNotHttpClientRequest(): void + { + $this->assertNotHttpClientRequest('https://example.com/missing', 'GET', 'app.http_client'); + } +} diff --git a/tests/LoggerAssertionsTest.php b/tests/LoggerAssertionsTest.php new file mode 100644 index 00000000..df568af2 --- /dev/null +++ b/tests/LoggerAssertionsTest.php @@ -0,0 +1,28 @@ +client->request('GET', '/deprecated'); + try { + $this->dontSeeDeprecations(); + self::fail('Expected deprecations to be reported.'); + } catch (AssertionFailedError $error) { + $this->assertStringContainsString('deprecation', $error->getMessage()); + } + } + + public function testDontSeeDeprecations(): void + { + $this->client->request('GET', '/sample'); + $this->dontSeeDeprecations(); + } +} diff --git a/tests/MailerAssertionsTest.php b/tests/MailerAssertionsTest.php new file mode 100644 index 00000000..5a5c8985 --- /dev/null +++ b/tests/MailerAssertionsTest.php @@ -0,0 +1,82 @@ +getService('mailer.message_logger_listener')->reset(); + } + + public function testAssertEmailCount(): void + { + $this->client->request('GET', '/send-email'); + $this->assertEmailCount(1); + } + + public function testAssertEmailIsNotQueued(): void + { + $this->client->request('GET', '/send-email'); + $this->assertEmailIsNotQueued($this->getMailerEvent()); + } + + public function testAssertEmailIsQueued(): void + { + $queuedEvent = $this->createQueuedEvent(); + $this->getService('mailer.message_logger_listener')->onMessage($queuedEvent); + $this->assertEmailIsQueued($queuedEvent); + } + + public function testAssertQueuedEmailCount(): void + { + $this->getService('mailer.message_logger_listener')->onMessage($this->createQueuedEvent()); + $this->assertQueuedEmailCount(1); + $this->assertQueuedEmailCount(1, 'smtp'); + } + + public function testDontSeeEmailIsSent(): void + { + $this->dontSeeEmailIsSent(); + } + + public function testGetMailerEvent(): void + { + $this->client->request('GET', '/send-email'); + $this->assertInstanceOf(MessageEvent::class, $this->getMailerEvent()); + } + + public function testGrabLastSentEmail(): void + { + $this->client->request('GET', '/send-email'); + $email = $this->grabLastSentEmail(); + $this->assertInstanceOf(Email::class, $email); + $this->assertSame('jane_doe@example.com', $email->getTo()[0]->getAddress()); + } + + public function testGrabSentEmails(): void + { + $this->client->request('GET', '/send-email'); + $this->assertCount(1, $this->grabSentEmails()); + } + + public function testSeeEmailIsSent(): void + { + $this->client->request('GET', '/send-email'); + $this->seeEmailIsSent(); + } + + private function createQueuedEvent(): MessageEvent + { + return new MessageEvent((new Email())->from('queued@example.com')->to('queued@example.com'), new Envelope(new Address('queued@example.com'), [new Address('queued@example.com')]), 'smtp', true); + } +} diff --git a/tests/MimeAssertionsTest.php b/tests/MimeAssertionsTest.php new file mode 100644 index 00000000..d1de461e --- /dev/null +++ b/tests/MimeAssertionsTest.php @@ -0,0 +1,77 @@ +getService('mailer.message_logger_listener')->reset(); + $this->client->request('GET', '/send-email'); + } + + public function testAssertEmailAddressContains(): void + { + $this->assertEmailAddressContains('To', 'jane_doe@example.com'); + } + + public function testAssertEmailAttachmentCount(): void + { + $this->assertEmailAttachmentCount(1); + } + + public function testAssertEmailHasHeader(): void + { + $this->assertEmailHasHeader('To'); + } + + public function testAssertEmailHeaderSame(): void + { + $this->assertEmailHeaderSame('To', 'jane_doe@example.com'); + } + + public function testAssertEmailHeaderNotSame(): void + { + $this->assertEmailHeaderNotSame('To', 'john_doe@example.com'); + } + + public function testAssertEmailHtmlBodyContains(): void + { + $this->assertEmailHtmlBodyContains('Example Email'); + } + + public function testAssertEmailHtmlBodyNotContains(): void + { + $this->assertEmailHtmlBodyNotContains('userpassword'); + } + + public function testAssertEmailNotHasHeader(): void + { + $this->assertEmailNotHasHeader('Bcc'); + } + + public function testAssertEmailTextBodyContains(): void + { + $this->assertEmailTextBodyContains('Example text body'); + } + + public function testAssertEmailTextBodyNotContains(): void + { + $this->assertEmailTextBodyNotContains('My secret text body'); + } + + public function testAssertionsWorkWithProvidedEmail(): void + { + $email = (new Email())->from('custom@example.com')->to('custom@example.com')->text('Custom body text'); + + $this->assertEmailAddressContains('To', 'custom@example.com', $email); + $this->assertEmailTextBodyContains('Custom body text', $email); + $this->assertEmailNotHasHeader('Cc', $email); + } +} diff --git a/tests/NotifierAssertionsTest.php b/tests/NotifierAssertionsTest.php new file mode 100644 index 00000000..2deb7e89 --- /dev/null +++ b/tests/NotifierAssertionsTest.php @@ -0,0 +1,131 @@ +markTestSkipped('Notifier assertions require Symfony 6.2+'); + } + $this->grabService('notifier.notification_logger_listener')->reset(); + } + + public function testAssertNotificationCount(): void + { + $this->sendNotifications(); + $this->assertNotificationCount(1); + $this->assertNotificationCount(1, 'primary'); + } + + public function testAssertNotificationIsNotQueued(): void + { + $this->assertNotificationIsNotQueued($this->sendNotifications()['sent']); + } + + public function testAssertNotificationIsQueued(): void + { + $this->assertNotificationIsQueued($this->sendNotifications()['queued']); + } + + public function testAssertNotificationSubjectContains(): void + { + $this->sendNotifications(); + $this->assertNotificationSubjectContains($this->getNotifierMessage(), 'Welcome'); + } + + public function testAssertNotificationSubjectNotContains(): void + { + $this->sendNotifications(); + $this->assertNotificationSubjectNotContains($this->getNotifierMessage(), 'missing'); + } + + public function testAssertNotificationTransportIsEqual(): void + { + $this->sendNotifications(); + $this->grabLastSentNotification(); + $this->grabService(NotifierFixture::class)->sendNotification('Primary alert', 'chat'); + $this->assertNotificationTransportIsEqual($this->grabLastSentNotification(), 'chat'); + } + + public function testAssertNotificationTransportIsNotEqual(): void + { + $this->grabService(NotifierFixture::class)->sendNotification('Primary alert', 'chat'); + $this->assertNotificationTransportIsNotEqual($this->grabLastSentNotification(), 'email'); + } + + public function testAssertQueuedNotificationCount(): void + { + $this->sendNotifications(); + $this->assertQueuedNotificationCount(1); + $this->assertQueuedNotificationCount(1, 'queued'); + } + + public function testDontSeeNotificationIsSent(): void + { + $this->dontSeeNotificationIsSent(); + } + + public function testGetNotifierEvent(): void + { + $this->sendNotifications(); + $this->assertInstanceOf(MessageEvent::class, $this->getNotifierEvent()); + } + + public function testGetNotifierEvents(): void + { + $this->sendNotifications(); + $this->assertCount(2, $this->getNotifierEvents()); + } + + public function testGetNotifierMessage(): void + { + $this->sendNotifications(); + $this->assertInstanceOf(ChatMessage::class, $this->getNotifierMessage()); + } + + public function testGetNotifierMessages(): void + { + $this->sendNotifications(); + $this->assertCount(2, $this->getNotifierMessages()); + } + + public function testGrabLastSentNotification(): void + { + $this->grabService(NotifierFixture::class)->sendNotification('Last One', 'chat'); + $last = $this->grabLastSentNotification(); + $this->assertInstanceOf(ChatMessage::class, $last); + $this->assertSame('Last One', $last->getSubject()); + } + + public function testGrabSentNotifications(): void + { + $this->sendNotifications(); + $this->assertCount(2, $this->grabSentNotifications()); + } + + public function testSeeNotificationIsSent(): void + { + $this->sendNotifications(); + $this->seeNotificationIsSent(); + } + + private function sendNotifications(): array + { + $fixture = $this->grabService(NotifierFixture::class); + return [ + 'sent' => $fixture->sendNotification('Welcome notification', 'primary'), + 'queued' => $fixture->sendNotification('Queued notification', 'queued', true), + ]; + } +} diff --git a/tests/ParameterAssertionsTest.php b/tests/ParameterAssertionsTest.php new file mode 100644 index 00000000..e8acc9f5 --- /dev/null +++ b/tests/ParameterAssertionsTest.php @@ -0,0 +1,16 @@ +assertSame('Codeception', $this->grabParameter('app.business_name')); + $this->assertSame('value', $this->grabParameter('app.param')); + } +} diff --git a/tests/RouterAssertionsTest.php b/tests/RouterAssertionsTest.php new file mode 100644 index 00000000..109050e9 --- /dev/null +++ b/tests/RouterAssertionsTest.php @@ -0,0 +1,50 @@ +amOnAction(AppController::class . '::index'); + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + $this->assertStringContainsString('Hello World!', $this->client->getResponse()->getContent()); + } + + public function testAmOnRoute(): void + { + $this->amOnRoute('index'); + $this->assertStringContainsString('Hello World!', $this->client->getResponse()->getContent()); + } + + public function testInvalidateCachedRouter(): void + { + $this->persistService('router'); + $this->assertArrayHasKey('router', $this->persistentServices); + $this->invalidateCachedRouter(); + $this->assertArrayNotHasKey('router', $this->persistentServices); + } + + public function testSeeCurrentActionIs(): void + { + $this->client->request('GET', '/'); + $this->seeCurrentActionIs(AppController::class . '::index'); + } + + public function testSeeCurrentRouteIs(): void + { + $this->client->request('GET', '/login'); + $this->seeCurrentRouteIs('app_login'); + } + + public function testSeeInCurrentRoute(): void + { + $this->client->request('GET', '/register'); + $this->seeInCurrentRoute('app_register'); + } +} diff --git a/tests/SecurityAssertionsTest.php b/tests/SecurityAssertionsTest.php new file mode 100644 index 00000000..bf19be26 --- /dev/null +++ b/tests/SecurityAssertionsTest.php @@ -0,0 +1,66 @@ +_getContainer()); + } + + public function testDontSeeAuthentication(): void + { + $this->client->request('GET', '/dashboard'); + $this->dontSeeAuthentication(); + } + + public function testDontSeeRememberedAuthentication(): void + { + $this->client->loginUser($this->createTestUser(['ROLE_USER'])); + $this->dontSeeRememberedAuthentication(); + } + + public function testSeeAuthentication(): void + { + $this->client->loginUser($this->createTestUser(['ROLE_USER'])); + $this->seeAuthentication(); + } + + public function testSeeRememberedAuthentication(): void + { + $this->client->loginUser($this->createTestUser(['ROLE_USER'])); + $this->client->getCookieJar()->set(new Cookie('REMEMBERME', 'test-remember')); + $this->seeRememberedAuthentication(); + } + + public function testSeeUserHasRole(): void + { + $this->client->loginUser($this->createTestUser(['ROLE_USER', 'ROLE_ADMIN'])); + $this->seeUserHasRole('ROLE_ADMIN'); + } + + public function testSeeUserHasRoles(): void + { + $this->client->loginUser($this->createTestUser(['ROLE_USER', 'ROLE_CUSTOMER'])); + $this->seeUserHasRoles(['ROLE_USER', 'ROLE_CUSTOMER']); + } + + public function testSeeUserPasswordDoesNotNeedRehash(): void + { + $this->client->loginUser($this->createTestUser(['ROLE_USER'])); + $this->seeUserPasswordDoesNotNeedRehash(); + } + + private function createTestUser(array $roles): User + { + return User::create('john_doe@gmail.com', $this->grabPasswordHasherService()->hashPassword(User::create('tmp', ''), '123456'), $roles); + } +} diff --git a/tests/ServicesAssertionsTest.php b/tests/ServicesAssertionsTest.php new file mode 100644 index 00000000..5b8a6006 --- /dev/null +++ b/tests/ServicesAssertionsTest.php @@ -0,0 +1,35 @@ +assertIsObject($this->grabService('security.helper')); + } + + public function testPersistService(): void + { + $this->persistService('router'); + $this->assertArrayHasKey('router', $this->persistentServices); + } + + public function testPersistPermanentService(): void + { + $this->persistPermanentService('router'); + $this->assertArrayHasKey('router', $this->permanentServices); + $this->assertArrayHasKey('router', $this->persistentServices); + } + + public function testUnpersistService(): void + { + $this->persistService('router'); + $this->unpersistService('router'); + $this->assertArrayNotHasKey('router', $this->persistentServices); + } +} diff --git a/tests/SessionAssertionsTest.php b/tests/SessionAssertionsTest.php new file mode 100644 index 00000000..37f4a62e --- /dev/null +++ b/tests/SessionAssertionsTest.php @@ -0,0 +1,102 @@ +amLoggedInAs($this->getTestUser()); + $this->client->request('GET', '/dashboard'); + $this->seeAuthentication(); + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + $this->assertStringContainsString('You are in the Dashboard!', $this->client->getResponse()->getContent()); + } + + public function testAmLoggedInWithToken(): void + { + $user = $this->getTestUser(); + $this->amLoggedInWithToken(new PostAuthenticationToken($user, 'main', $user->getRoles())); + $this->client->request('GET', '/dashboard'); + $this->seeAuthentication(); + $this->assertStringContainsString('You are in the Dashboard!', $this->client->getResponse()->getContent()); + } + + public function testDontSeeInSession(): void + { + $this->client->request('GET', '/'); + $this->dontSeeInSession('_security_main'); + + $this->initSession(['key1' => 'value1']); + $this->dontSeeInSession('missing'); + $this->dontSeeInSession('key1', 'other'); + } + + public function testGoToLogoutPath(): void + { + $this->amLoggedInAs($this->getTestUser()); + $this->client->request('GET', '/dashboard'); + $this->assertStringContainsString('You are in the Dashboard!', $this->client->getResponse()->getContent()); + + $this->goToLogoutPath(); + $this->assertSame('/logout', $this->client->getRequest()->getPathInfo()); + $this->assertSame(302, $this->client->getResponse()->getStatusCode()); + $this->client->followRedirect(); + + $this->dontSeeAuthentication(); + $this->assertSame('/', $this->client->getRequest()->getPathInfo()); + } + + public function testLogout(): void + { + $this->amLoggedInAs($this->getTestUser()); + $this->logout(); + $this->client->request('GET', '/dashboard'); + $this->dontSeeAuthentication(); + $this->assertSame(302, $this->client->getResponse()->getStatusCode()); + } + + public function testLogoutProgrammatically(): void + { + $this->amLoggedInAs($this->getTestUser()); + $this->logoutProgrammatically(); + $this->client->request('GET', '/dashboard'); + $this->dontSeeAuthentication(); + $this->assertSame(302, $this->client->getResponse()->getStatusCode()); + } + + public function testSeeInSession(): void + { + $this->initSession(['key1' => 'value1']); + $this->seeInSession('key1'); + $this->seeInSession('key1', 'value1'); + } + + public function testSeeSessionHasValues(): void + { + $this->initSession(['key1' => 'value1', 'key2' => 'value2']); + $this->seeSessionHasValues(['key1', 'key2']); + $this->seeSessionHasValues(['key1' => 'value1', 'key2' => 'value2']); + } + + private function getTestUser(): User + { + return $this->grabService(UserRepository::class)->getByEmail('john_doe@gmail.com') ?? $this->fail('User not found'); + } + + private function initSession(array $data): void + { + $session = $this->getCurrentSession(); + foreach ($data as $key => $value) { + $session->set($key, $value); + } + $session->save(); + } +} diff --git a/tests/Support/KernelTestCase.php b/tests/Support/KernelTestCase.php new file mode 100644 index 00000000..a3cb81cc --- /dev/null +++ b/tests/Support/KernelTestCase.php @@ -0,0 +1,23 @@ +client->request('GET', '/register'); + $this->assertStringContainsString('register', $this->client->getRequest()->getPathInfo()); + $this->seeRequestTimeIsLessThan(400); + } +} diff --git a/tests/TranslationAssertionsTest.php b/tests/TranslationAssertionsTest.php new file mode 100644 index 00000000..32ab7bdc --- /dev/null +++ b/tests/TranslationAssertionsTest.php @@ -0,0 +1,58 @@ +client->request('GET', '/register'); + $this->dontSeeFallbackTranslations(); + } + + public function testDontSeeMissingTranslations(): void + { + $this->client->request('GET', '/'); + $this->dontSeeMissingTranslations(); + } + + public function testGrabDefinedTranslationsCount(): void + { + $this->client->request('GET', '/register'); + $this->assertSame(6, $this->grabDefinedTranslationsCount()); + } + + public function testSeeAllTranslationsDefined(): void + { + $this->client->request('GET', '/register'); + $this->seeAllTranslationsDefined(); + } + + public function testSeeDefaultLocaleIs(): void + { + $this->client->request('GET', '/register'); + $this->seeDefaultLocaleIs('en'); + } + + public function testSeeFallbackLocalesAre(): void + { + $this->client->request('GET', '/register'); + $this->seeFallbackLocalesAre(['es']); + } + + public function testSeeFallbackTranslationsCountLessThan(): void + { + $this->client->request('GET', '/register'); + $this->seeFallbackTranslationsCountLessThan(1); + } + + public function testSeeMissingTranslationsCountLessThan(): void + { + $this->client->request('GET', '/'); + $this->seeMissingTranslationsCountLessThan(1); + } +} diff --git a/tests/TwigAssertionsTest.php b/tests/TwigAssertionsTest.php new file mode 100644 index 00000000..fc42ab98 --- /dev/null +++ b/tests/TwigAssertionsTest.php @@ -0,0 +1,29 @@ +client->request('GET', '/register'); + $this->dontSeeRenderedTemplate('security/login.html.twig'); + } + + public function testSeeCurrentTemplateIs(): void + { + $this->client->request('GET', '/login'); + $this->seeCurrentTemplateIs('security/login.html.twig'); + } + + public function testSeeRenderedTemplate(): void + { + $this->client->request('GET', '/login'); + $this->seeRenderedTemplate('layout.html.twig'); + $this->seeRenderedTemplate('security/login.html.twig'); + } +} diff --git a/tests/ValidatorAssertionsTest.php b/tests/ValidatorAssertionsTest.php new file mode 100644 index 00000000..7bbb65a0 --- /dev/null +++ b/tests/ValidatorAssertionsTest.php @@ -0,0 +1,62 @@ +dontSeeViolatedConstraint($user); + $this->dontSeeViolatedConstraint($user, 'email'); + $this->dontSeeViolatedConstraint($user, 'email', Assert\Email::class); + + $user->setEmail('invalid_email'); + $this->dontSeeViolatedConstraint($user, 'password'); + + $user->setEmail('test@example.com'); + $user->setPassword('weak'); + $this->dontSeeViolatedConstraint($user, 'email'); + $this->dontSeeViolatedConstraint($user, 'password', Assert\NotBlank::class); + } + + public function testSeeViolatedConstraint(): void + { + $user = User::create('invalid_email', 'password123'); + $this->seeViolatedConstraint($user); + $this->seeViolatedConstraint($user, 'email'); + + $user->setEmail('test@example.com'); + $user->setPassword('weak'); + $this->seeViolatedConstraint($user); + $this->seeViolatedConstraint($user, 'password'); + $this->seeViolatedConstraint($user, 'password', Assert\Length::class); + } + + public function testSeeViolatedConstraintsCount(): void + { + $user = User::create('invalid_email', 'weak'); + $this->seeViolatedConstraintsCount(2, $user); + $this->seeViolatedConstraintsCount(1, $user, 'email'); + + $user->setEmail('test@example.com'); + $this->seeViolatedConstraintsCount(1, $user); + $this->seeViolatedConstraintsCount(0, $user, 'email'); + } + + public function testSeeViolatedConstraintMessage(): void + { + $user = User::create('invalid_email', 'weak'); + $this->seeViolatedConstraintMessage('valid email', $user, 'email'); + + $user->setEmail(''); + $this->seeViolatedConstraintMessage('should not be blank', $user, 'email'); + $this->seeViolatedConstraintMessage('This value is too short', $user, 'email'); + } +} diff --git a/tests/_app/Command/TestCommand.php b/tests/_app/Command/TestCommand.php new file mode 100644 index 00000000..d4f41814 --- /dev/null +++ b/tests/_app/Command/TestCommand.php @@ -0,0 +1,34 @@ +addOption('opt', 'o', InputOption::VALUE_NONE, 'Option'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if ($input->getOption('opt')) { + $io->text('Option selected'); + } else { + $io->text('No option'); + } + + return Command::SUCCESS; + } +} diff --git a/tests/_app/Controller/AppController.php b/tests/_app/Controller/AppController.php new file mode 100644 index 00000000..c70139bc --- /dev/null +++ b/tests/_app/Controller/AppController.php @@ -0,0 +1,213 @@ +getToken(); + if ($token === null || !is_object($token->getUser())) { + return new RedirectResponse('/login'); + } + + return new Response('You are in the Dashboard!'); + } + + public function deprecated(LoggerInterface $logger): Response + { + trigger_error('Deprecated endpoint', E_USER_DEPRECATED); + $logger->info('Deprecated endpoint', ['scream' => false]); + + return new Response('Deprecated'); + } + + public function dispatchEvent(EventDispatcherInterface $dispatcher): Response + { + $dispatcher->dispatch(new TestEvent()); + + return new Response('Event dispatched'); + } + + public function dispatchNamedEvent(EventDispatcherInterface $dispatcher): Response + { + $dispatcher->dispatch(new TestEvent(), 'named.event'); + + return new Response('Named event dispatched'); + } + + public function dispatchOrphanEvent(EventDispatcherInterface $dispatcher): Response + { + $dispatcher->dispatch(new TestEvent(), 'orphan.event'); + + return new Response('Orphan event dispatched'); + } + + public function form(Request $request, FormFactoryInterface $formFactory, Environment $twig): Response + { + $builder = $formFactory->createNamedBuilder('registration_form', options: ['csrf_protection' => false]); + $builder->add('email', EmailType::class, ['constraints' => [new NotBlank(), new EmailConstraint()]]); + $builder->add('password', PasswordType::class, ['constraints' => [new NotBlank()]]); + $form = $builder->getForm(); + + $form->handleRequest($request); + + $status = $form->isSubmitted() && !$form->isValid() ? 422 : 200; + + return new Response($twig->render('security/register.html.twig', ['action' => '']), $status); + } + + public function httpClientRequests( + #[Autowire(service: 'app.http_client')] + HttpClientInterface $httpClient, + #[Autowire(service: 'app.http_client.json_client')] + HttpClientInterface $jsonClient, + ): Response { + $httpClient->request('GET', 'https://example.com/default', ['headers' => ['X-Test' => 'yes']]); + $httpClient->request('POST', 'https://example.com/body', ['json' => ['example' => 'payload']]); + $jsonClient->request('GET', 'https://api.example.com/resource', ['headers' => ['Accept' => 'application/json']]); + + return new Response('HTTP client calls executed'); + } + + public function index(): Response + { + return new Response('Hello World!'); + } + + public function login(Environment $twig): Response + { + return new Response($twig->render('security/login.html.twig')); + } + + public function logout(Request $request, TokenStorageInterface $tokenStorage): RedirectResponse + { + $tokenStorage->setToken(null); + + $sessionName = null; + if ($request->hasSession()) { + $session = $request->getSession(); + $sessionName = $session->getName(); + $session->invalidate(); + } + + $response = new RedirectResponse('/'); + if ($sessionName !== null) { + $response->headers->clearCookie($sessionName); + } + + $response->headers->clearCookie('MOCKSESSID'); + $response->headers->clearCookie('REMEMBERME'); + + return $response; + } + + public function redirectToHome(): RedirectResponse + { + return new RedirectResponse('/'); + } + + public function redirectToSample(): RedirectResponse + { + return new RedirectResponse('/sample'); + } + + public function register(Request $request, Environment $twig): Response + { + if ($request->isMethod('POST')) { + return new RedirectResponse('/dashboard'); + } + + return new Response($twig->render('security/register.html.twig')); + } + + public function requestWithAttribute(Request $request): Response + { + $request->attributes->set('page', 'register'); + + return new Response('Request attribute set'); + } + + public function responseJsonFormat(Request $request): JsonResponse + { + $request->setRequestFormat('json'); + + return new JsonResponse([ + 'status' => 'success', + 'message' => "Expected format: 'json'.", + ]); + } + + public function responseWithCookie(): Response + { + $response = new Response('TESTCOOKIE has been set.'); + $response->headers->setCookie(new Cookie('TESTCOOKIE', 'codecept')); + + return $response; + } + + public function sample(Request $request, Environment $twig): Response + { + $request->attributes->set('foo', 'bar'); + + $response = new Response($twig->render('sample.html.twig'), 200, ['X-Test' => '1']); + $response->headers->setCookie(new Cookie('response_cookie', 'yum')); + + return $response; + } + + public function sendEmail(RegistrationMailer $mailer): Response + { + $mailer->sendConfirmationEmail('jane_doe@example.com'); + + return new Response('Email sent'); + } + + public function testPage(Environment $twig): Response + { + return new Response($twig->render('test_page.html.twig')); + } + + public function translation(TranslatorInterface $translator): Response + { + $translator->trans('defined_message'); + + return new Response('Translation'); + } + + public function twig(Environment $twig): Response + { + return new Response($twig->render('home.html.twig')); + } + + public function unprocessableEntity(): JsonResponse + { + return new JsonResponse([ + 'status' => 'error', + 'message' => 'The request was well-formed but could not be processed.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } +} diff --git a/tests/_app/Doctrine/DoctrineSetup.php b/tests/_app/Doctrine/DoctrineSetup.php new file mode 100644 index 00000000..4d4ecd8f --- /dev/null +++ b/tests/_app/Doctrine/DoctrineSetup.php @@ -0,0 +1,55 @@ +getConnection(); + } + + public static function createEntityManager(): EntityManagerInterface + { + if (self::$entityManager !== null && self::$entityManager->isOpen()) { + return self::$entityManager; + } + + $entityDir = dirname(__DIR__) . '/Entity'; + + if (method_exists(ORMSetup::class, 'createAttributeMetadataConfig')) { + $config = ORMSetup::createAttributeMetadataConfig([$entityDir], true); + } else { + $config = ORMSetup::createAttributeMetadataConfiguration([$entityDir], true); + } + + $proxyDir = sys_get_temp_dir() . '/doctrine-proxies'; + if (!is_dir($proxyDir)) { + mkdir($proxyDir, 0o777, true); + } + + $config->setProxyDir($proxyDir); + $config->setProxyNamespace('TestsProxies'); + $config->setAutoGenerateProxyClasses(true); + + $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + + if (method_exists(EntityManager::class, 'create')) { + self::$entityManager = EntityManager::create($connection, $config); + } else { + self::$entityManager = new EntityManager($connection, $config); + } + + return self::$entityManager; + } +} diff --git a/tests/_app/Doctrine/TestDatabaseSetup.php b/tests/_app/Doctrine/TestDatabaseSetup.php new file mode 100644 index 00000000..7e8207d9 --- /dev/null +++ b/tests/_app/Doctrine/TestDatabaseSetup.php @@ -0,0 +1,28 @@ +clear(); + + $schemaTool = new SchemaTool($entityManager); + $metadata = $entityManager->getMetadataFactory()->getAllMetadata(); + + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + + $user = User::create('john_doe@gmail.com', 'secret', ['ROLE_TEST']); + $entityManager->persist($user); + $entityManager->flush(); + $entityManager->clear(); + } +} diff --git a/tests/_app/Entity/User.php b/tests/_app/Entity/User.php new file mode 100644 index 00000000..1dc4f2be --- /dev/null +++ b/tests/_app/Entity/User.php @@ -0,0 +1,102 @@ +email = $email; + $user->password = $password; + $user->roles = $roles; + + return $user; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + public function getRoles(): array + { + return $this->roles; + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + public function getUserIdentifier(): string + { + return $this->email; + } + + public function getUsername(): string + { + return $this->getUserIdentifier(); + } + + public function getSalt(): ?string + { + return null; + } + + public function eraseCredentials(): void {} +} diff --git a/tests/_app/Event/TestEvent.php b/tests/_app/Event/TestEvent.php new file mode 100644 index 00000000..7e7df32d --- /dev/null +++ b/tests/_app/Event/TestEvent.php @@ -0,0 +1,9 @@ + 201, + 'https://api.example.com/resource' => 202, + default => 200, + }; + + return new MockResponse( + json_encode([ + 'method' => $method, + 'url' => $url, + 'options' => $options, + ], JSON_THROW_ON_ERROR), + [ + 'http_code' => $statusCode, + 'response_headers' => ['Content-Type' => 'application/json'], + ] + ); + } +} diff --git a/tests/_app/Listener/TestEventListener.php b/tests/_app/Listener/TestEventListener.php new file mode 100644 index 00000000..96769331 --- /dev/null +++ b/tests/_app/Listener/TestEventListener.php @@ -0,0 +1,14 @@ +> */ + private array $logs = []; + + public function clear(): void + { + $this->logs = []; + } + + public function countErrors(?Request $request = null): int + { + return count(array_filter( + $this->logs, + static fn(array $log): bool => $log['priority'] >= 400, + )); + } + + public function getLogs(?Request $request = null): array + { + return $this->logs; + } + + public function log($level, Stringable|string $message, array $context = []): void + { + $priorityName = strtoupper((string) $level); + + $priority = match ((string) $level) { + LogLevel::DEBUG => 100, + LogLevel::INFO => 200, + LogLevel::NOTICE => 250, + LogLevel::WARNING => 300, + LogLevel::ERROR => 400, + LogLevel::CRITICAL => 500, + LogLevel::ALERT => 550, + LogLevel::EMERGENCY => 600, + default => 200, + }; + + $this->logs[] = [ + 'message' => (string) $message, + 'context' => $context, + 'priority' => $priority, + 'priorityName' => $priorityName, + 'channel' => 'app', + 'timestamp' => time(), + 'timestamp_rfc3339' => date(DATE_RFC3339), + ]; + } +} diff --git a/tests/_app/Mailer/RegistrationMailer.php b/tests/_app/Mailer/RegistrationMailer.php new file mode 100644 index 00000000..10bf1e78 --- /dev/null +++ b/tests/_app/Mailer/RegistrationMailer.php @@ -0,0 +1,27 @@ +from(new Address('jeison_doe@gmail.com', 'No Reply')) + ->to(new Address($recipient)) + ->subject('Account created successfully') + ->attach('Example attachment') + ->text('Example text body') + ->htmlTemplate('emails/registration.html.twig'); + + $this->mailer->send($email); + } +} diff --git a/tests/_app/Notifier/NotifierFixture.php b/tests/_app/Notifier/NotifierFixture.php new file mode 100644 index 00000000..e14bc513 --- /dev/null +++ b/tests/_app/Notifier/NotifierFixture.php @@ -0,0 +1,23 @@ +transport($transport); + $event = new MessageEvent($message, $queued); + $this->dispatcher->dispatch($event); + + return $event; + } +} diff --git a/tests/_app/Repository/UserRepository.php b/tests/_app/Repository/UserRepository.php new file mode 100644 index 00000000..449b46ea --- /dev/null +++ b/tests/_app/Repository/UserRepository.php @@ -0,0 +1,24 @@ + */ +final class UserRepository extends EntityRepository implements UserRepositoryInterface +{ + public function save(User $user): void + { + $this->_em->persist($user); + $this->_em->flush(); + $this->_em->clear(); + } + + public function getByEmail(string $email): ?User + { + return $this->findOneBy(['email' => $email]); + } +} diff --git a/tests/_app/Repository/UserRepositoryInterface.php b/tests/_app/Repository/UserRepositoryInterface.php new file mode 100644 index 00000000..72ecc565 --- /dev/null +++ b/tests/_app/Repository/UserRepositoryInterface.php @@ -0,0 +1,14 @@ +repository->getByEmail($identifier); + + if ($user === null) { + $exception = new UserNotFoundException(); + $exception->setUserIdentifier($identifier); + throw $exception; + } + + return $user; + } + + public function loadUserByUsername(string $username): UserInterface + { + return $this->loadUserByIdentifier($username); + } + + public function refreshUser(UserInterface $user): UserInterface + { + if (!$this->supportsClass($user::class)) { + throw new UnsupportedUserException(); + } + + return $this->loadUserByIdentifier($user->getUserIdentifier()); + } + + public function supportsClass(string $class): bool + { + return $class === User::class || is_subclass_of($class, User::class); + } +} diff --git a/tests/_app/TestKernel.php b/tests/_app/TestKernel.php new file mode 100644 index 00000000..2dd0420b --- /dev/null +++ b/tests/_app/TestKernel.php @@ -0,0 +1,94 @@ +configureExtensions($container); + + $container->import(__DIR__ . '/config/services.php'); + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->import(__DIR__ . '/config/routes.php'); + } + + private function configureExtensions(ContainerConfigurator $container): void + { + $profilerConfig = ['enabled' => true, 'collect' => true]; + if (BaseKernel::VERSION_ID >= 60200 && class_exists(SerializerDataCollector::class)) { + $profilerConfig['collect_serializer_data'] = true; + } + + $container->extension('framework', [ + 'secret' => 'test', + 'test' => true, + 'profiler' => $profilerConfig, + 'property_info' => ['enabled' => true], + 'session' => ['handler_id' => null, 'storage_factory_id' => 'session.storage.factory.mock_file'], + 'mailer' => ['dsn' => 'null://null'], + 'default_locale' => 'en', + 'translator' => ['default_path' => __DIR__ . '/translations', 'fallbacks' => ['es'], 'logging' => true], + 'validation' => ['enabled' => true], + 'form' => ['enabled' => true], + 'notifier' => ['chatter_transports' => ['async' => 'null://null'], 'texter_transports' => ['sms' => 'null://null']], + ]); + + $container->extension('twig', ['default_path' => __DIR__ . '/templates', 'debug' => true]); + + $this->configureSecurity($container); + } + + private function configureSecurity(ContainerConfigurator $container): void + { + $mainFirewall = [ + 'lazy' => BaseKernel::VERSION_ID >= 60000, + 'pattern' => '^/', + 'provider' => 'doctrine_users', + 'logout' => ['path' => 'logout'], + 'form_login' => ['login_path' => 'app_login', 'check_path' => 'app_login'], + 'remember_me' => ['secret' => 'test', 'remember_me_parameter' => '_remember_me'], + ]; + + if (BaseKernel::VERSION_ID < 60000) { + $mainFirewall['anonymous'] = true; + } + + $container->extension('security', [ + 'password_hashers' => [PasswordAuthenticatedUserInterface::class => 'auto'], + 'providers' => ['doctrine_users' => ['id' => 'security.user.provider.test']], + 'firewalls' => ['main' => $mainFirewall], + ]); + + $container->parameters()->set('app.param', 'value'); + $container->parameters()->set('app.business_name', 'Codeception'); + } +} diff --git a/tests/_app/config/routes.php b/tests/_app/config/routes.php new file mode 100644 index 00000000..cf15cfc5 --- /dev/null +++ b/tests/_app/config/routes.php @@ -0,0 +1,31 @@ +add('app_login', '/login')->controller(AppController::class . '::login'); + $routes->add('app_register', '/register')->controller(AppController::class . '::register'); + $routes->add('dashboard', '/dashboard')->controller(AppController::class . '::dashboard'); + $routes->add('deprecated', '/deprecated')->controller(AppController::class . '::deprecated'); + $routes->add('dispatch_event', '/dispatch-event')->controller(AppController::class . '::dispatchEvent'); + $routes->add('dispatch_named_event', '/dispatch-named-event')->controller(AppController::class . '::dispatchNamedEvent'); + $routes->add('dispatch_orphan_event', '/dispatch-orphan-event')->controller(AppController::class . '::dispatchOrphanEvent'); + $routes->add('form_handler', '/form')->controller(AppController::class . '::form'); + $routes->add('http_client', '/http-client')->controller(AppController::class . '::httpClientRequests'); + $routes->add('index', '/')->controller(AppController::class . '::index'); + $routes->add('logout', '/logout')->controller(AppController::class . '::logout'); + $routes->add('redirect', '/redirect')->controller(AppController::class . '::redirectToSample'); + $routes->add('redirect_home', '/redirect_home')->controller(AppController::class . '::redirectToHome'); + $routes->add('request_attr', '/request_attr')->controller(AppController::class . '::requestWithAttribute'); + $routes->add('response_cookie', '/response_cookie')->controller(AppController::class . '::responseWithCookie'); + $routes->add('response_json', '/response_json')->controller(AppController::class . '::responseJsonFormat'); + $routes->add('sample', '/sample')->controller(AppController::class . '::sample'); + $routes->add('send_email', '/send-email')->controller(AppController::class . '::sendEmail'); + $routes->add('test_page', '/test_page')->controller(AppController::class . '::testPage'); + $routes->add('translation', '/translation')->controller(AppController::class . '::translation'); + $routes->add('twig', '/twig')->controller(AppController::class . '::twig'); + $routes->add('unprocessable_entity', '/unprocessable_entity')->controller(AppController::class . '::unprocessableEntity'); +}; diff --git a/tests/_app/config/services.php b/tests/_app/config/services.php new file mode 100644 index 00000000..80085e96 --- /dev/null +++ b/tests/_app/config/services.php @@ -0,0 +1,114 @@ +services(); + $services->defaults()->autowire()->autoconfigure()->public(); + + $services->set(AppController::class); + $services->set(TestCommand::class)->tag('console.command', ['command' => 'app:test-command']); + + $services->set('doctrine.orm.entity_manager', EntityManagerInterface::class) + ->factory([DoctrineSetup::class, 'createEntityManager']); + $services->alias('doctrine.orm.default_entity_manager', 'doctrine.orm.entity_manager')->public(); + $services->set('doctrine.dbal.default_connection', Connection::class) + ->factory([DoctrineSetup::class, 'createConnection']); + + $services->set(UserRepository::class) + ->factory([service('doctrine.orm.entity_manager'), 'getRepository']) + ->arg(0, User::class); + $services->alias(UserRepositoryInterface::class, UserRepository::class)->public(); + + $services->set('security.user.provider.test', TestUserProvider::class) + ->arg('$repository', service(UserRepository::class)) + ->tag('security.user_provider'); + + $services->alias('security.password_hasher', 'security.user_password_hasher')->public(); + $services->alias(UserPasswordHasherInterface::class, 'security.user_password_hasher')->public(); + + if (class_exists(Security::class)) { + $services->set(Security::class)->arg('$container', service('test.service_container')); + $services->alias('security.helper', Security::class)->public(); + } + + $services->set('mailer.message_logger_listener', MessageLoggerListener::class)->tag('kernel.event_subscriber'); + $services->set('notifier.notification_logger_listener', NotificationLoggerListener::class)->tag('kernel.event_subscriber'); + $services->alias('notifier.logger_notification_listener', 'notifier.notification_logger_listener')->public(); + + $services->set(RegistrationMailer::class)->arg('$mailer', service('mailer')); + $services->set(NotifierFixture::class)->arg('$dispatcher', service('event_dispatcher')); + + $services->set(TestEventListener::class) + ->tag('kernel.event_listener', ['event' => TestEvent::class, 'method' => 'onTestEvent']) + ->tag('kernel.event_listener', ['event' => 'named.event', 'method' => 'onNamedEvent']); + + $services->set('logger', ArrayLogger::class); + $services->alias(LoggerInterface::class, 'logger')->public(); + + $services->set(Profile::class); + $services->set(ProfilerExtension::class)->arg('$profile', service(Profile::class))->tag('twig.extension'); + + $services->set(MockResponseFactory::class); + + $services->set('app.http_client.inner', MockHttpClient::class) + ->arg('$responseFactory', service(MockResponseFactory::class)); + + $services->set('app.http_client', TraceableHttpClient::class) + ->args([service('app.http_client.inner'), service('debug.stopwatch')->nullOnInvalid()]); + + $services->set('app.http_client.json_client.inner', MockHttpClient::class) + ->args([service(MockResponseFactory::class), 'https://api.example.com/']); + + $services->set('app.http_client.json_client', TraceableHttpClient::class) + ->args([service('app.http_client.json_client.inner'), service('debug.stopwatch')->nullOnInvalid()]); + + $services->set(HttpClientDataCollector::class) + ->call('registerClient', ['app.http_client', service('app.http_client')]) + ->call('registerClient', ['app.http_client.json_client', service('app.http_client.json_client')]) + ->tag('data_collector', ['id' => 'http_client', 'template' => '@WebProfiler/Collector/http_client.html.twig', 'priority' => 100]); + $services->alias('data_collector.http_client', HttpClientDataCollector::class)->public(); + + $services->set(LoggerDataCollector::class) + ->arg('$logger', service('logger')) + ->tag('data_collector', ['id' => 'logger', 'template' => '@WebProfiler/Collector/logger.html.twig', 'priority' => 300]); + $services->alias('data_collector.logger', LoggerDataCollector::class)->public(); + + if (BaseKernel::VERSION_ID < 60100) { + $services->defaults() + ->bind(\Symfony\Contracts\HttpClient\HttpClientInterface::class . ' $httpClient', service('app.http_client')) + ->bind(\Symfony\Contracts\HttpClient\HttpClientInterface::class . ' $jsonClient', service('app.http_client.json_client')); + } +}; diff --git a/tests/_app/templates/emails/registration.html.twig b/tests/_app/templates/emails/registration.html.twig new file mode 100644 index 00000000..69744e00 --- /dev/null +++ b/tests/_app/templates/emails/registration.html.twig @@ -0,0 +1,5 @@ +{% extends 'layout.html.twig' %} + +{% block content %} +

Example Email.

+{% endblock %} diff --git a/tests/_app/templates/home.html.twig b/tests/_app/templates/home.html.twig new file mode 100644 index 00000000..d2f72c40 --- /dev/null +++ b/tests/_app/templates/home.html.twig @@ -0,0 +1,2 @@ +{% extends "layout.html.twig" %} +{% block content %}Home{% endblock %} diff --git a/tests/_app/templates/layout.html.twig b/tests/_app/templates/layout.html.twig new file mode 100644 index 00000000..0131177c --- /dev/null +++ b/tests/_app/templates/layout.html.twig @@ -0,0 +1,8 @@ + + + +{% block content %} + {% block body %}{% endblock %} +{% endblock %} + + diff --git a/tests/_app/templates/sample.html.twig b/tests/_app/templates/sample.html.twig new file mode 100644 index 00000000..87ae7a5d --- /dev/null +++ b/tests/_app/templates/sample.html.twig @@ -0,0 +1,13 @@ + + Test Page + + + + + +
+ +
+
Hello World
+ + diff --git a/tests/_app/templates/security/login.html.twig b/tests/_app/templates/security/login.html.twig new file mode 100644 index 00000000..db8c465e --- /dev/null +++ b/tests/_app/templates/security/login.html.twig @@ -0,0 +1,19 @@ +{% extends 'layout.html.twig' %} + +{% block body %} +
+

Please sign in

+ + + + + +
+ +
+ + +
+{% endblock %} diff --git a/tests/_app/templates/security/register.html.twig b/tests/_app/templates/security/register.html.twig new file mode 100644 index 00000000..27b0cbd4 --- /dev/null +++ b/tests/_app/templates/security/register.html.twig @@ -0,0 +1,20 @@ +{% extends 'layout.html.twig' %} + +{% block body %} +

{{ 'register.title'|trans }}

+ +

{{ 'register.heading'|trans }}

+ +
+ + + + + + + + + + +
+{% endblock %} diff --git a/tests/_app/templates/test_page.html.twig b/tests/_app/templates/test_page.html.twig new file mode 100644 index 00000000..34bf35ab --- /dev/null +++ b/tests/_app/templates/test_page.html.twig @@ -0,0 +1,8 @@ + + Test Page + +

Test Page

+ + + + diff --git a/tests/_app/translations/messages.en.yaml b/tests/_app/translations/messages.en.yaml new file mode 100644 index 00000000..9159a7ee --- /dev/null +++ b/tests/_app/translations/messages.en.yaml @@ -0,0 +1,8 @@ +defined_message: "Hello" +register: + title: "Register" + heading: "Sign Up" + email_label: "Email Address" + password_label: "Password" + agree_terms_label: "I agree to the terms and conditions" + submit_button: "Sign Up" diff --git a/tests/_app/translations/messages.es.yaml b/tests/_app/translations/messages.es.yaml new file mode 100644 index 00000000..9e0585c3 --- /dev/null +++ b/tests/_app/translations/messages.es.yaml @@ -0,0 +1,7 @@ +register: + title: "Registro" + heading: "Registrarse" + email_label: "Correo Electrónico" + password_label: "Contraseña" + agree_terms_label: "Acepto los términos y condiciones" + submit_button: "Registrarse"