diff --git a/.gitignore b/.gitignore index a816930..763daa1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ /vendor/ /composer.lock /framework-tests -/.php-cs-fixer.cache \ No newline at end of file +/.php-cs-fixer.cache +tests/_output/ +tests/_support/_generated/ +tests/_support/FunctionalTester.php +var/ + diff --git a/codeception.yml b/codeception.yml new file mode 100644 index 0000000..aa0f0cb --- /dev/null +++ b/codeception.yml @@ -0,0 +1,7 @@ +namespace: Tests +actor_suffix: Tester +paths: + tests: tests + output: tests/_output + data: tests/_data + support: tests/_support diff --git a/composer.json b/composer.json index 03ca498..661b996 100644 --- a/composer.json +++ b/composer.json @@ -24,15 +24,16 @@ "require": { "php": "^8.2", "ext-json": "*", - "codeception/codeception": "^5.3", "codeception/lib-innerbrowser": "^3.1 | ^4.0" }, "require-dev": { + "codeception/codeception": "^5.3", "codeception/module-asserts": "^3.0", "codeception/module-doctrine": "^3.1", "doctrine/orm": "^3.5", "friendsofphp/php-cs-fixer": "^3.85", "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5", "symfony/browser-kit": "^5.4 | ^6.4 | ^7.3", "symfony/cache": "^5.4 | ^6.4 | ^7.3", "symfony/config": "^5.4 | ^6.4 | ^7.3", @@ -72,6 +73,20 @@ "Codeception\\": "src/Codeception/" } }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/", + "Tests\\_app\\": "tests/_app/" + }, + "files": [ + "tests/_app/TestKernel.php", + "tests/_app/ExampleCommand.php", + "tests/_app/DoctrineFixturesLoadCommand.php", + "tests/_app/HelloCommand.php", + "tests/_app/TestUser.php", + "tests/_app/ValidEntity.php" + ] + }, "config": { "sort-packages": true }, diff --git a/readme.md b/readme.md index 34cac75..25af797 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,7 @@ # Codeception Module Symfony A Codeception module for Symfony framework. +It can be used with Codeception or as a standalone Symfony BrowserKit client. [![Actions Status](https://github.com/Codeception/module-symfony/workflows/CI/badge.svg)](https://github.com/Codeception/module-symfony/actions) [![Latest Stable Version](https://poser.pugx.org/codeception/module-symfony/v/stable)](https://github.com/Codeception/module-symfony/releases) @@ -18,6 +19,9 @@ A Codeception module for Symfony framework. composer require "codeception/module-symfony" --dev ``` +To use the connector without Codeception, require the package and instantiate +`Codeception\\Lib\\Connector\\Symfony` with your kernel. + ## Documentation See [the module documentation](https://codeception.com/docs/modules/Symfony). diff --git a/src/Codeception/Lib/Connector/Symfony.php b/src/Codeception/Lib/Connector/Symfony.php index bf18eb2..e0aff60 100644 --- a/src/Codeception/Lib/Connector/Symfony.php +++ b/src/Codeception/Lib/Connector/Symfony.php @@ -17,7 +17,7 @@ use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\HttpKernel\Profiler\Profiler; -use function codecept_debug; +use function function_exists; /** * @property KernelInterface $kernel @@ -73,7 +73,9 @@ public function rebootKernel(): void 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()}"); + } } } diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index ff11a36..2c87f52 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -334,6 +334,14 @@ protected function getClient(): SymfonyConnector */ protected function getKernelClass(): string { + /** @var class-string $kernelClass */ + $kernelClass = $this->config['kernel_class']; + $this->requireAdditionalAutoloader(); + + if (class_exists($kernelClass)) { + return $kernelClass; + } + /** @var string $rootDir */ $rootDir = codecept_root_dir(); $path = $rootDir . $this->config['app_path']; @@ -346,40 +354,21 @@ protected function getKernelClass(): string ); } - $this->requireAdditionalAutoloader(); - - $finder = new Finder(); - $results = iterator_to_array($finder->name('*Kernel.php')->depth('0')->in($path)); - - if ($results === []) { - throw new ModuleRequireException( - self::class, - "File with Kernel class was not found at {$path}.\n" . - 'Specify directory where file with Kernel class for your application is located with `app_path` parameter.' - ); - } - - $kernelClass = $this->config['kernel_class']; - $filesRealPath = []; + $finder = new Finder(); + $finder->name('*Kernel.php')->depth('0')->in($path); - foreach ($results as $file) { + foreach ($finder as $file) { include_once $file->getRealPath(); - $filesRealPath[] = $file->getRealPath(); } - if (class_exists($kernelClass)) { - $ref = new ReflectionClass($kernelClass); - $fileName = $ref->getFileName(); - if ($fileName !== false && in_array($fileName, $filesRealPath, true)) { - /** @var class-string $kernelClass */ - return $kernelClass; - } + if (class_exists($kernelClass, false)) { + return $kernelClass; } throw new ModuleRequireException( self::class, - "Kernel class was not found.\n" . - 'Specify directory where file with Kernel class for your application is located with `kernel_class` parameter.' + "Kernel class was not found at {$path}.\n" . + 'Specify directory where file with Kernel class for your application is located with `app_path` parameter.' ); } @@ -455,31 +444,19 @@ protected function debugResponse(mixed $url): void return; } - if ($profile->hasCollector(DataCollectorName::SECURITY->value)) { - $securityCollector = $profile->getCollector(DataCollectorName::SECURITY->value); - if ($securityCollector instanceof SecurityDataCollector) { - $this->debugSecurityData($securityCollector); - } - } - - if ($profile->hasCollector(DataCollectorName::MAILER->value)) { - $mailerCollector = $profile->getCollector(DataCollectorName::MAILER->value); - if ($mailerCollector instanceof MessageDataCollector) { - $this->debugMailerData($mailerCollector); - } - } - - if ($profile->hasCollector(DataCollectorName::NOTIFIER->value)) { - $notifierCollector = $profile->getCollector(DataCollectorName::NOTIFIER->value); - if ($notifierCollector instanceof NotificationDataCollector) { - $this->debugNotifierData($notifierCollector); - } - } - - if ($profile->hasCollector(DataCollectorName::TIME->value)) { - $timeCollector = $profile->getCollector(DataCollectorName::TIME->value); - if ($timeCollector instanceof TimeDataCollector) { - $this->debugTimeData($timeCollector); + $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); + } } } } diff --git a/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php b/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php index 8bd940b..10c2165 100644 --- a/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php @@ -309,8 +309,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 +328,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()); } /** @@ -359,13 +359,19 @@ public function submitSymfonyForm(string $name, array $fields): void $params = []; foreach ($fields as $key => $value) { - $fixedKey = sprintf('%s%s', $name, $key); - $params[$fixedKey] = $value; + $params[$name . $key] = $value; } - $button = sprintf('%s_submit', $name); + if (method_exists($this, 'submitForm')) { // @phpstan-ignore-line + $button = sprintf('%s_submit', $name); + $this->submitForm($selector, $params, $button); + return; + } - $this->submitForm($selector, $params, $button); + $node = $this->getClient()->getCrawler()->filter($selector); + $this->assertNotEmpty($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/MailerAssertionsTrait.php b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php index 649bfcd..8da45f5 100644 --- a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php @@ -164,13 +164,12 @@ public function getMailerEvent(int $index = 0, ?string $transport = null): ?Mess protected function getMessageMailerEvents(): MessageEvents { - $mailer = $this->getService('mailer.message_logger_listener'); - if ($mailer instanceof MessageLoggerListener) { - return $mailer->getEvents(); - } - $mailer = $this->getService('mailer.logger_message_listener'); - if ($mailer instanceof MessageLoggerListener) { - return $mailer->getEvents(); + $services = ['mailer.message_logger_listener', 'mailer.logger_message_listener']; + foreach ($services as $serviceId) { + $mailer = $this->getService($serviceId); + if ($mailer instanceof MessageLoggerListener) { + return $mailer->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 77422c7..765d8d0 100644 --- a/src/Codeception/Module/Symfony/NotifierAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/NotifierAssertionsTrait.php @@ -251,13 +251,12 @@ protected function getNotificationEvents(): NotificationEvents Assert::fail('Notifier assertions require Symfony 6.2 or higher.'); } - $notifier = $this->getService('notifier.notification_logger_listener'); - if ($notifier instanceof NotificationLoggerListener) { - return $notifier->getEvents(); - } - $notifier = $this->getService('notifier.logger_notification_listener'); - if ($notifier instanceof NotificationLoggerListener) { - return $notifier->getEvents(); + $services = ['notifier.notification_logger_listener', 'notifier.logger_notification_listener']; + foreach ($services as $serviceId) { + $notifier = $this->getService($serviceId); + if ($notifier instanceof NotificationLoggerListener) { + return $notifier->getEvents(); + } } Assert::fail("Notifications can't be tested without Symfony Notifier service."); } diff --git a/src/Codeception/Module/Symfony/RouterAssertionsTrait.php b/src/Codeception/Module/Symfony/RouterAssertionsTrait.php index cdbd41e..85ec9aa 100644 --- a/src/Codeception/Module/Symfony/RouterAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/RouterAssertionsTrait.php @@ -112,8 +112,7 @@ private function getCurrentRouteMatch(string $routeName): array { $this->assertRouteExists($routeName); - $url = $this->grabFromCurrentUrl(); - Assert::assertIsString($url, 'Unable to obtain current URL.'); + $url = $this->getClient()->getRequest()->getRequestUri(); $path = (string) parse_url($url, PHP_URL_PATH); /** @var array $match */ @@ -143,7 +142,7 @@ 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 diff --git a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php index 7052894..47137f1 100644 --- a/src/Codeception/Module/Symfony/SessionAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/SessionAssertionsTrait.php @@ -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()); } /** diff --git a/tests/BrowserAssertionsTest.php b/tests/BrowserAssertionsTest.php new file mode 100644 index 0000000..0da4b09 --- /dev/null +++ b/tests/BrowserAssertionsTest.php @@ -0,0 +1,183 @@ +client = new KernelBrowser(self::$kernel); + $this->client->getCookieJar()->set(new Cookie('browser_cookie', 'value')); + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + public function testAssertBrowserCookieValueSame(): void + { + $this->assertBrowserCookieValueSame('browser_cookie', 'value'); + } + + public function testAssertBrowserHasCookie(): void + { + $this->assertBrowserHasCookie('browser_cookie'); + } + + public function testAssertBrowserNotHasCookie(): void + { + $this->client->getCookieJar()->clear('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 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/BrowserKitConnectorTest.php b/tests/BrowserKitConnectorTest.php new file mode 100644 index 0000000..6bb5ff2 --- /dev/null +++ b/tests/BrowserKitConnectorTest.php @@ -0,0 +1,26 @@ +boot(); + $browser = new SymfonyConnector($kernel); + + $browser->request('GET', '/'); + + $this->assertSame(200, $browser->getResponse()->getStatusCode()); + $this->assertSame('Hello World!', $browser->getResponse()->getContent()); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/ConsoleAssertionsTest.php b/tests/ConsoleAssertionsTest.php new file mode 100644 index 0000000..f7c01f4 --- /dev/null +++ b/tests/ConsoleAssertionsTest.php @@ -0,0 +1,55 @@ +get($serviceId); + } + + protected function unpersistService(string $serviceName): void + { + // no-op for tests + } + + public function testRunSymfonyConsoleCommand(): void + { + $output = $this->runSymfonyConsoleCommand('app:example-command'); + $this->assertStringContainsString('Hello world!', $output); + + $output = $this->runSymfonyConsoleCommand('app:example-command', ['-s' => true]); + $this->assertStringContainsString('Bye world!', $output); + + $output = $this->runSymfonyConsoleCommand('app:example-command', ['--something' => true]); + $this->assertStringContainsString('Bye world!', $output); + } + + public function testRunSymfonyConsoleCommandWithQuietOption(): void + { + DoctrineFixturesLoadCommand::reset(); + + $output = $this->runSymfonyConsoleCommand('doctrine:fixtures:load', ['-q']); + + $this->assertSame('', $output); + $this->assertSame(1, DoctrineFixturesLoadCommand::runs()); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/DoctrineAssertionsTest.php b/tests/DoctrineAssertionsTest.php new file mode 100644 index 0000000..079c79b --- /dev/null +++ b/tests/DoctrineAssertionsTest.php @@ -0,0 +1,75 @@ +get($serviceId); + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + protected function _getEntityManager(): EntityManagerInterface + { + return self::getContainer()->get('doctrine.orm.entity_manager'); + } + + protected function unpersistService(string $serviceName): void + { + // no-op for tests + } + + public function testGrabNumRecords(): void + { + $this->assertSame(1, $this->grabNumRecords(User::class)); + } + + public function testGrabRepository(): void + { + $repository = $this->grabRepository(User::class); + $this->assertInstanceOf(UserRepository::class, $repository); + + $repositoryFromId = $this->grabRepository(UserRepository::class); + $this->assertInstanceOf(UserRepository::class, $repositoryFromId); + + $user = $repository->findOneBy(['email' => 'john_doe@gmail.com']); + $this->assertNotNull($user); + + $repositoryFromEntity = $this->grabRepository($user); + $this->assertInstanceOf(UserRepository::class, $repositoryFromEntity); + + $repositoryFromInterface = $this->grabRepository(UserRepositoryInterface::class); + $this->assertInstanceOf(UserRepository::class, $repositoryFromInterface); + } + + public function testSeeNumRecords(): void + { + $this->seeNumRecords(1, User::class); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/DomCrawlerAssertionsTest.php b/tests/DomCrawlerAssertionsTest.php new file mode 100644 index 0000000..388664b --- /dev/null +++ b/tests/DomCrawlerAssertionsTest.php @@ -0,0 +1,77 @@ +client = new KernelBrowser(self::$kernel); + $this->client->request('GET', '/test_page'); + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + 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 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 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 0000000..f7c29f5 --- /dev/null +++ b/tests/EventsAssertionsTest.php @@ -0,0 +1,101 @@ + true]); + $this->client = new KernelBrowser(self::$kernel); + $this->client->enableProfiler(); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabCollector(DataCollectorName $name, string $function): DataCollectorInterface + { + return $this->getProfile()->getCollector($name->value); + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + private function getProfile(): \Symfony\Component\HttpKernel\Profiler\Profile + { + if ($this->client->getProfile() !== null) { + return $this->client->getProfile(); + } + + /** @var Profiler $profiler */ + $profiler = self::getContainer()->get('profiler'); + + return $profiler->collect($this->client->getRequest(), $this->client->getResponse()); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } + + public function testEventDispatchingAndListeners(): void + { + $this->client->request('GET', '/dispatch-event'); + + $this->seeEvent(SampleEvent::class); + $this->dontSeeEvent(OrphanEvent::class); + $this->seeEventListenerIsCalled(SampleEventListener::class, SampleEvent::class); + $this->dontSeeEventListenerIsCalled(NamedEventListener::class, SampleEvent::class); + $this->dontSeeOrphanEvent(); + } + + public function testNamedEventListenerFiltering(): void + { + $this->client->request('GET', '/dispatch-named-event'); + + $this->seeEventListenerIsCalled(NamedEventListener::class, 'named.event'); + $this->dontSeeEventListenerIsCalled(SampleEventListener::class, 'named.event'); + } + + public function testOrphanEventDetection(): void + { + $this->client->request('GET', '/dispatch-orphan-event'); + + $this->seeOrphanEvent(OrphanEvent::class); + $this->dontSeeEvent(SampleEvent::class); + } +} diff --git a/tests/FormAssertionsTest.php b/tests/FormAssertionsTest.php new file mode 100644 index 0000000..d6493fe --- /dev/null +++ b/tests/FormAssertionsTest.php @@ -0,0 +1,95 @@ + true]); + $this->client = new KernelBrowser(self::$kernel); + $this->client->request('GET', '/sample'); + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function grabCollector(DataCollectorName $name, string $function): DataCollectorInterface + { + /** @var Profiler $profiler */ + $profiler = self::getContainer()->get('profiler'); + $profile = $profiler->collect($this->client->getRequest(), $this->client->getResponse()); + + return $profile->getCollector($name->value); + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + public function testFormValues(): void + { + $this->assertFormValue('#testForm', 'field1', 'value1'); + $this->assertNoFormValue('#testForm', 'missing_field'); + } + + public function testFormErrorAssertions(): void + { + $this->client->request('POST', '/form', [ + 'registration_form' => [ + 'email' => 'not-an-email', + 'password' => '', + ], + ]); + + $this->seeFormHasErrors(); + $this->seeFormErrorMessage('email', 'valid email address'); + $this->seeFormErrorMessages([ + 'email' => 'valid email address', + 'password' => 'not be blank', + ]); + } + + public function testFormWithoutErrors(): void + { + $this->client->request('POST', '/form', [ + 'registration_form' => [ + 'email' => 'john@example.com', + 'password' => 'top-secret', + ], + ]); + + $this->dontSeeFormErrors(); + } +} diff --git a/tests/Functional.suite.yml b/tests/Functional.suite.yml new file mode 100644 index 0000000..e010b19 --- /dev/null +++ b/tests/Functional.suite.yml @@ -0,0 +1,8 @@ +actor: FunctionalTester +modules: + enabled: + - Symfony: + app_path: tests/_app + environment: 'test' + kernel_class: TestKernel + - Asserts diff --git a/tests/Functional/BrowserCest.php b/tests/Functional/BrowserCest.php new file mode 100644 index 0000000..d2b5875 --- /dev/null +++ b/tests/Functional/BrowserCest.php @@ -0,0 +1,135 @@ +setCookie('TESTCOOKIE', 'codecept'); + $I->assertBrowserCookieValueSame('TESTCOOKIE', 'codecept'); + } + + public function assertBrowserHasCookie(FunctionalTester $I): void + { + $I->setCookie('TESTCOOKIE', 'codecept'); + $I->assertBrowserHasCookie('TESTCOOKIE'); + } + + public function assertBrowserNotHasCookie(FunctionalTester $I): void + { + $I->setCookie('TESTCOOKIE', 'codecept'); + $I->resetCookie('TESTCOOKIE'); + $I->assertBrowserNotHasCookie('TESTCOOKIE'); + } + + public function assertRequestAttributeValueSame(FunctionalTester $I): void + { + $I->amOnPage('/request_attr'); + $I->assertRequestAttributeValueSame('page', 'register'); + } + + public function assertResponseCookieValueSame(FunctionalTester $I): void + { + $I->amOnPage('/response_cookie'); + $I->assertResponseCookieValueSame('TESTCOOKIE', 'codecept'); + } + + public function assertResponseFormatSame(FunctionalTester $I): void + { + $I->amOnPage('/response_json'); + $I->assertResponseFormatSame('json'); + } + + public function assertResponseHasCookie(FunctionalTester $I): void + { + $I->amOnPage('/response_cookie'); + $I->assertResponseHasCookie('TESTCOOKIE'); + } + + public function assertResponseHasHeader(FunctionalTester $I): void + { + $I->amOnPage('/response_json'); + $I->assertResponseHasHeader('content-type'); + } + + public function assertResponseHeaderNotSame(FunctionalTester $I): void + { + $I->amOnPage('/response_json'); + $I->assertResponseHeaderNotSame('content-type', 'application/octet-stream'); + } + + public function assertResponseHeaderSame(FunctionalTester $I): void + { + $I->amOnPage('/response_json'); + $I->assertResponseHeaderSame('content-type', 'application/json'); + } + + public function assertResponseIsSuccessful(FunctionalTester $I): void + { + $I->amOnPage('/'); + $I->assertResponseIsSuccessful(); + } + + public function assertResponseIsUnprocessable(FunctionalTester $I): void + { + $I->amOnPage('/unprocessable_entity'); + $I->assertResponseIsUnprocessable(); + } + + public function assertResponseNotHasCookie(FunctionalTester $I): void + { + $I->amOnPage('/'); + $I->assertResponseNotHasCookie('TESTCOOKIE'); + } + + public function assertResponseNotHasHeader(FunctionalTester $I): void + { + $I->amOnPage('/'); + $I->assertResponseNotHasHeader('accept-charset'); + } + + public function assertResponseRedirects(FunctionalTester $I): void + { + $I->stopFollowingRedirects(); + $I->amOnPage('/redirect_home'); + $I->assertResponseRedirects(); + $I->assertResponseRedirects('/'); + } + + public function assertResponseStatusCodeSame(FunctionalTester $I): void + { + $I->stopFollowingRedirects(); + $I->amOnPage('/redirect_home'); + $I->assertResponseStatusCodeSame(302); + } + + public function assertRouteSame(FunctionalTester $I): void + { + $I->amOnPage('/'); + $I->assertRouteSame('index'); + + $I->amOnPage('/login'); + $I->assertRouteSame('app_login'); + } + + public function seePageIsAvailable(FunctionalTester $I): void + { + $I->seePageIsAvailable('/login'); + + $I->amOnPage('/register'); + $I->seePageIsAvailable(); + } + + public function seePageRedirectsTo(FunctionalTester $I): void + { + $I->seePageRedirectsTo('/dashboard', '/login'); + } + + public function submitSymfonyForm(FunctionalTester $I): void + { + $I->registerUser('jane_doe@gmail.com', '123456', followRedirects: false); + $I->assertResponseRedirects('/dashboard'); + } +} diff --git a/tests/Functional/ConsoleCest.php b/tests/Functional/ConsoleCest.php new file mode 100644 index 0000000..1b2d72e --- /dev/null +++ b/tests/Functional/ConsoleCest.php @@ -0,0 +1,29 @@ +runSymfonyConsoleCommand('app:example-command'); + $I->assertStringContainsString('Hello world!', $output); + + $output = $I->runSymfonyConsoleCommand('app:example-command', ['-s' => true]); + $I->assertStringContainsString('Bye world!', $output); + + $output = $I->runSymfonyConsoleCommand('app:example-command', ['--something' => true]); + $I->assertStringContainsString('Bye world!', $output); + } + + public function runQuietCommand(FunctionalTester $I): void + { + \Tests\_app\DoctrineFixturesLoadCommand::reset(); + + $output = $I->runSymfonyConsoleCommand('doctrine:fixtures:load', ['-q']); + + $I->assertSame('', $output); + $I->assertSame(1, \Tests\_app\DoctrineFixturesLoadCommand::runs()); + } +} diff --git a/tests/Functional/DoctrineCest.php b/tests/Functional/DoctrineCest.php new file mode 100644 index 0000000..8b4077f --- /dev/null +++ b/tests/Functional/DoctrineCest.php @@ -0,0 +1,38 @@ +assertSame(1, $I->grabNumRecords(User::class)); + } + + public function grabRepository(FunctionalTester $I): void + { + $repository = $I->grabRepository(User::class); + $I->assertInstanceOf(UserRepository::class, $repository); + + $repositoryFromClass = $I->grabRepository(UserRepository::class); + $I->assertInstanceOf(UserRepository::class, $repositoryFromClass); + + $user = $repository->findOneBy(['email' => 'john_doe@gmail.com']); + $I->assertNotNull($user); + + $repositoryFromEntity = $I->grabRepository($user); + $I->assertInstanceOf(UserRepository::class, $repositoryFromEntity); + + $repositoryFromInterface = $I->grabRepository(UserRepositoryInterface::class); + $I->assertInstanceOf(UserRepository::class, $repositoryFromInterface); + } + + public function seeNumRecords(FunctionalTester $I): void + { + $I->seeNumRecords(1, User::class); + } +} diff --git a/tests/Functional/DomCrawlerCest.php b/tests/Functional/DomCrawlerCest.php new file mode 100644 index 0000000..61f9eae --- /dev/null +++ b/tests/Functional/DomCrawlerCest.php @@ -0,0 +1,52 @@ +amOnPage('/test_page'); + } + + public function assertCheckboxChecked(FunctionalTester $I): void + { + $I->assertCheckboxChecked('exampleCheckbox', 'The checkbox should be checked.'); + } + + public function assertCheckboxNotChecked(FunctionalTester $I): void + { + $I->assertCheckboxNotChecked('nonExistentCheckbox', 'This checkbox should not be checked.'); + } + + public function assertInputValueSame(FunctionalTester $I): void + { + $I->assertInputValueSame('exampleInput', 'Expected Value', 'The input value should be "Expected Value".'); + } + + public function assertPageTitleContains(FunctionalTester $I): void + { + $I->assertPageTitleContains('Test', 'The page title should contain "Test".'); + } + + public function assertPageTitleSame(FunctionalTester $I): void + { + $I->assertPageTitleSame('Test Page', 'The page title should be "Test Page".'); + } + + public function assertSelectorExists(FunctionalTester $I): void + { + $I->assertSelectorExists('h1', 'The

element should be present.'); + } + + public function assertSelectorNotExists(FunctionalTester $I): void + { + $I->assertSelectorNotExists('.non-existent-class', 'This selector should not exist.'); + } + + public function assertSelectorTextSame(FunctionalTester $I): void + { + $I->assertSelectorTextSame('h1', 'Test Page', 'The text in the

tag should be exactly "Test Page".'); + } +} diff --git a/tests/Functional/EventsCest.php b/tests/Functional/EventsCest.php new file mode 100644 index 0000000..8ce0da0 --- /dev/null +++ b/tests/Functional/EventsCest.php @@ -0,0 +1,38 @@ +amOnPage('/dispatch-event'); + + $I->seeEvent(SampleEvent::class); + $I->dontSeeEvent(OrphanEvent::class); + $I->seeEventListenerIsCalled(SampleEventListener::class, SampleEvent::class); + $I->dontSeeEventListenerIsCalled(NamedEventListener::class, SampleEvent::class); + $I->dontSeeOrphanEvent(); + } + + public function testNamedEventListenerFiltering(FunctionalTester $I): void + { + $I->amOnPage('/dispatch-named-event'); + + $I->seeEventListenerIsCalled(NamedEventListener::class, 'named.event'); + $I->dontSeeEventListenerIsCalled(SampleEventListener::class, 'named.event'); + } + + public function testOrphanEventDetection(FunctionalTester $I): void + { + $I->amOnPage('/dispatch-orphan-event'); + + $I->seeOrphanEvent(OrphanEvent::class); + $I->dontSeeEvent(SampleEvent::class); + } +} diff --git a/tests/Functional/ExampleCest.php b/tests/Functional/ExampleCest.php new file mode 100644 index 0000000..3b8a7d5 --- /dev/null +++ b/tests/Functional/ExampleCest.php @@ -0,0 +1,14 @@ +amOnPage('/'); + $I->seeResponseCodeIs(200); + $I->see('Hello World!'); + } +} diff --git a/tests/Functional/FormCest.php b/tests/Functional/FormCest.php new file mode 100644 index 0000000..6c8a242 --- /dev/null +++ b/tests/Functional/FormCest.php @@ -0,0 +1,42 @@ +amOnPage('/sample'); + + $I->assertFormValue('#testForm', 'field1', 'value1'); + $I->assertNoFormValue('#testForm', 'missing_field'); + } + + public function testFormErrors(FunctionalTester $I): void + { + $I->amOnPage('/form'); + $I->submitForm('form[name="registration_form"]', [ + 'registration_form[email]' => 'not-an-email', + 'registration_form[password]' => '', + ]); + + $I->seeFormHasErrors(); + $I->seeFormErrorMessage('email', 'valid email address'); + $I->seeFormErrorMessages([ + 'email' => 'valid email address', + 'password' => 'not be blank', + ]); + } + + public function testFormWithoutErrors(FunctionalTester $I): void + { + $I->amOnPage('/form'); + $I->submitForm('form[name="registration_form"]', [ + 'registration_form[email]' => 'john@example.com', + 'registration_form[password]' => 'top-secret', + ]); + + $I->dontSeeFormErrors(); + } +} diff --git a/tests/Functional/HttpClientCest.php b/tests/Functional/HttpClientCest.php new file mode 100644 index 0000000..d65a5d9 --- /dev/null +++ b/tests/Functional/HttpClientCest.php @@ -0,0 +1,18 @@ +amOnPage('/http-client'); + $I->assertHttpClientRequest('https://example.com/default', 'GET', null, ['X-Test' => 'yes'], 'app.http_client'); + $I->assertHttpClientRequest('https://example.com/body', 'POST', ['example' => 'payload'], [], 'app.http_client'); + $I->assertHttpClientRequest('https://api.example.com/resource', 'GET', null, [], 'app.http_client.json_client'); + $I->assertHttpClientRequestCount(2, 'app.http_client'); + $I->assertHttpClientRequestCount(1, 'app.http_client.json_client'); + $I->assertNotHttpClientRequest('https://example.com/missing', 'GET', 'app.http_client'); + } +} diff --git a/tests/Functional/LoggerCest.php b/tests/Functional/LoggerCest.php new file mode 100644 index 0000000..e04f599 --- /dev/null +++ b/tests/Functional/LoggerCest.php @@ -0,0 +1,40 @@ +amOnPage('/sample'); + $I->dontSeeDeprecations(); + } + + public function showsDeprecations(FunctionalTester $I): void + { + $I->amOnPage('/deprecated'); + $logger = $I->grabService('logger'); + + $deprecations = array_filter( + $logger->getLogs(), + static fn (array $log): bool => ($log['context']['scream'] ?? null) === false + || str_contains((string) $log['message'], 'Deprecated endpoint') + ); + + $I->assertNotEmpty($deprecations); + + $I->expectThrowable(AssertionFailedError::class, function () use ($I, $deprecations): void { + try { + $I->dontSeeDeprecations(); + } catch (AssertionFailedError $error) { + throw $error; + } + + if ($deprecations !== []) { + throw new AssertionFailedError('Deprecation logs were captured.'); + } + }); + } +} diff --git a/tests/Functional/MailerCest.php b/tests/Functional/MailerCest.php new file mode 100644 index 0000000..b86e426 --- /dev/null +++ b/tests/Functional/MailerCest.php @@ -0,0 +1,77 @@ +grabService('mailer.message_logger_listener'); + $logger->reset(); + } + + public function dontSeeEmailIsSent(FunctionalTester $I): void + { + $I->dontSeeEmailIsSent(); + } + + public function queuedEmailAssertions(FunctionalTester $I): void + { + /** @var MessageLoggerListener $logger */ + $logger = $I->grabService('mailer.message_logger_listener'); + + $queuedEmail = (new Email())->from('queued@example.com')->to('queued@example.com'); + $queuedEnvelope = new Envelope(new Address('queued@example.com'), [new Address('queued@example.com')]); + $queuedEvent = new MessageEvent($queuedEmail, $queuedEnvelope, 'smtp', true); + $logger->onMessage($queuedEvent); + + $I->assertQueuedEmailCount(1); + $I->assertEmailIsQueued($queuedEvent); + $I->assertEmailCount(0); + $I->assertQueuedEmailCount(1, 'smtp'); + } + + public function mailerEventAssertions(FunctionalTester $I): void + { + $I->amOnRoute('send_email'); + + $I->assertEmailCount(1); + $I->seeEmailIsSent(); + + $event = $I->getMailerEvent(); + $I->assertEmailIsNotQueued($event); + + $email = $I->grabLastSentEmail(); + $I->assertSame('jane_doe@example.com', $email->getTo()[0]->getAddress()); + + $emails = $I->grabSentEmails(); + $I->assertCount(1, $emails); + } + + public function transportSpecificMailerEvents(FunctionalTester $I): void + { + /** @var MessageLoggerListener $logger */ + $logger = $I->grabService('mailer.message_logger_listener'); + + $smtpEmail = (new Email())->from('smtp@example.com')->to('smtp@example.com'); + $smtpEnvelope = new Envelope(new Address('smtp@example.com'), [new Address('smtp@example.com')]); + $smtpEvent = new MessageEvent($smtpEmail, $smtpEnvelope, 'smtp', false); + + $nullEmail = (new Email())->from('null@example.com')->to('null@example.com'); + $nullEnvelope = new Envelope(new Address('null@example.com'), [new Address('null@example.com')]); + $nullEvent = new MessageEvent($nullEmail, $nullEnvelope, 'null', false); + + $logger->onMessage($smtpEvent); + $logger->onMessage($nullEvent); + + $I->assertEmailCount(1, 'smtp'); + $I->assertEmailCount(1, 'null'); + $I->assertEmailCount(2); + } +} diff --git a/tests/Functional/MimeCest.php b/tests/Functional/MimeCest.php new file mode 100644 index 0000000..f6c9b37 --- /dev/null +++ b/tests/Functional/MimeCest.php @@ -0,0 +1,78 @@ +grabService('mailer.message_logger_listener'); + $logger->reset(); + + $I->amOnRoute('send_email'); + } + + public function assertEmailAddressContains(FunctionalTester $I): void + { + $I->assertEmailAddressContains('To', 'jane_doe@example.com'); + } + + public function assertEmailAttachmentCount(FunctionalTester $I): void + { + $I->assertEmailAttachmentCount(1); + } + + public function assertEmailHasHeader(FunctionalTester $I): void + { + $I->assertEmailHasHeader('To'); + } + + public function assertEmailHeaderSame(FunctionalTester $I): void + { + $I->assertEmailHeaderSame('To', 'jane_doe@example.com'); + } + + public function assertEmailHeaderNotSame(FunctionalTester $I): void + { + $I->assertEmailHeaderNotSame('To', 'john_doe@example.com'); + } + + public function assertEmailHtmlBodyContains(FunctionalTester $I): void + { + $I->assertEmailHtmlBodyContains('Example Email'); + } + + public function assertEmailHtmlBodyNotContains(FunctionalTester $I): void + { + $I->assertEmailHtmlBodyNotContains('userpassword'); + } + + public function assertEmailNotHasHeader(FunctionalTester $I): void + { + $I->assertEmailNotHasHeader('Bcc'); + } + + public function assertEmailTextBodyContains(FunctionalTester $I): void + { + $I->assertEmailTextBodyContains('Example text body'); + } + + public function assertEmailTextBodyNotContains(FunctionalTester $I): void + { + $I->assertEmailTextBodyNotContains('My secret text body'); + } + + public function assertionsWorkWithProvidedEmail(FunctionalTester $I): void + { + $email = (new Email()) + ->from('custom@example.com') + ->to('custom@example.com') + ->text('Custom body text'); + + $I->assertEmailAddressContains('To', 'custom@example.com', $email); + $I->assertEmailTextBodyContains('Custom body text', $email); + $I->assertEmailNotHasHeader('Cc', $email); + } +} diff --git a/tests/Functional/NotifierCest.php b/tests/Functional/NotifierCest.php new file mode 100644 index 0000000..f540c35 --- /dev/null +++ b/tests/Functional/NotifierCest.php @@ -0,0 +1,57 @@ +grabService('notifier.notification_logger_listener')->reset(); + } + + public function dontSeeNotificationIsSent(FunctionalTester $I): void + { + $I->dontSeeNotificationIsSent(); + } + + public function queuedAndSentNotifications(FunctionalTester $I): void + { + /** @var NotifierFixture $fixture */ + $fixture = $I->grabService(NotifierFixture::class); + + $sentEvent = $fixture->sendNotification('Welcome notification', 'primary'); + $queuedEvent = $fixture->sendNotification('Queued notification', 'queued', true); + + $I->assertNotificationCount(1); + $I->assertNotificationCount(1, 'primary'); + $I->assertQueuedNotificationCount(1); + $I->assertQueuedNotificationCount(1, 'queued'); + + $I->assertNotificationIsNotQueued($sentEvent); + $I->assertNotificationIsQueued($queuedEvent); + } + + public function notificationSubjectAndTransportAssertions(FunctionalTester $I): void + { + /** @var NotifierFixture $fixture */ + $fixture = $I->grabService(NotifierFixture::class); + + $fixture->sendNotification('Primary alert', 'chat'); + $fixture->sendNotification('Secondary update', 'backup'); + + $lastNotification = $I->grabLastSentNotification(); + $I->assertInstanceOf(ChatMessage::class, $lastNotification); + + $I->assertNotificationSubjectContains($lastNotification, 'update'); + $I->assertNotificationSubjectNotContains($lastNotification, 'missing'); + $I->assertNotificationTransportIsEqual($lastNotification, 'backup'); + $I->assertNotificationTransportIsNotEqual($lastNotification, 'chat'); + + $notifications = $I->grabSentNotifications(); + $I->assertCount(2, $notifications); + $I->assertSame('chat', $I->getNotifierMessage(0)?->getTransport()); + } +} diff --git a/tests/Functional/ParameterCest.php b/tests/Functional/ParameterCest.php new file mode 100644 index 0000000..134012f --- /dev/null +++ b/tests/Functional/ParameterCest.php @@ -0,0 +1,13 @@ +assertSame('value', $I->grabParameter('app.param')); + $I->assertSame('Codeception', $I->grabParameter('app.business_name')); + } +} diff --git a/tests/Functional/RouterCest.php b/tests/Functional/RouterCest.php new file mode 100644 index 0000000..d929c2b --- /dev/null +++ b/tests/Functional/RouterCest.php @@ -0,0 +1,43 @@ +amOnAction('TestKernel::index'); + $I->see('Hello World!'); + } + + public function amOnRoute(FunctionalTester $I): void + { + $I->amOnRoute('index'); + $I->see('Hello World!'); + } + + public function seeCurrentActionIs(FunctionalTester $I): void + { + $I->amOnPage('/'); + $I->seeCurrentActionIs('TestKernel::index'); + } + + public function seeCurrentRouteIs(FunctionalTester $I): void + { + $I->amOnPage('/login'); + $I->seeCurrentRouteIs('app_login'); + } + + public function seeInCurrentRoute(FunctionalTester $I): void + { + $I->amOnPage('/register'); + $I->seeInCurrentRoute('app_register'); + } + + public function invalidateRouterCache(FunctionalTester $I): void + { + $I->amOnRoute('index'); + $I->invalidateCachedRouter(); + } +} diff --git a/tests/Functional/SecurityCest.php b/tests/Functional/SecurityCest.php new file mode 100644 index 0000000..ef314e1 --- /dev/null +++ b/tests/Functional/SecurityCest.php @@ -0,0 +1,70 @@ +amOnPage('/dashboard'); + $I->dontSeeAuthentication(); + } + + public function dontSeeRememberedAuthentication(FunctionalTester $I): void + { + $user = $this->createTestUser($I, ['ROLE_USER']); + $I->amLoggedInAs($user); + + $I->dontSeeRememberedAuthentication(); + } + + public function seeAuthentication(FunctionalTester $I): void + { + $user = $this->createTestUser($I, ['ROLE_USER']); + $I->amLoggedInAs($user); + + $I->seeAuthentication(); + } + + public function seeRememberedAuthentication(FunctionalTester $I): void + { + $user = $this->createTestUser($I, ['ROLE_USER']); + $I->setCookie('REMEMBERME', 'remember-token'); + $I->amLoggedInAs($user); + + $I->seeRememberedAuthentication(); + } + + public function seeUserHasRole(FunctionalTester $I): void + { + $user = $this->createTestUser($I, ['ROLE_USER', 'ROLE_ADMIN']); + $I->amLoggedInAs($user); + + $I->seeUserHasRole('ROLE_ADMIN'); + } + + public function seeUserHasRoles(FunctionalTester $I): void + { + $user = $this->createTestUser($I, ['ROLE_USER', 'ROLE_CUSTOMER']); + $I->amLoggedInAs($user); + + $I->seeUserHasRoles(['ROLE_USER', 'ROLE_CUSTOMER']); + } + + public function seeUserPasswordDoesNotNeedRehash(FunctionalTester $I): void + { + $user = $this->createTestUser($I, ['ROLE_USER']); + $I->amLoggedInAs($user); + + $I->seeUserPasswordDoesNotNeedRehash(); + } + + private function createTestUser(FunctionalTester $I, array $roles): \TestUser + { + $hasher = $I->grabService('security.password_hasher'); + $hashed = $hasher->hashPassword(new \TestUser('tmp', ''), '123456'); + + return new \TestUser('john_doe@gmail.com', $hashed, $roles); + } +} diff --git a/tests/Functional/ServicesCest.php b/tests/Functional/ServicesCest.php new file mode 100644 index 0000000..f55a1c0 --- /dev/null +++ b/tests/Functional/ServicesCest.php @@ -0,0 +1,22 @@ +grabService('security.helper'); + + $I->assertInstanceOf(Security::class, $securityHelper); + } + + public function servicesPersistence(FunctionalTester $I): void + { + $I->persistService('router'); + $I->persistPermanentService('router'); + $I->unpersistService('router'); + } +} diff --git a/tests/Functional/SessionCest.php b/tests/Functional/SessionCest.php new file mode 100644 index 0000000..6983618 --- /dev/null +++ b/tests/Functional/SessionCest.php @@ -0,0 +1,91 @@ +grabService('service_container'); + $factory = $I->grabService('session.factory'); + $session = $factory->createSession(); + $session->start(); + $container->set('session', $session); + $I->persistService('session'); + + $I->amOnRoute('session'); + $I->seeInSession('key1'); + $I->seeInSession('key1', 'value1'); + $I->dontSeeInSession('missing'); + $I->dontSeeInSession('key1', 'other'); + $I->seeSessionHasValues(['key1', 'key2']); + $I->seeSessionHasValues(['key1' => 'value1', 'key2' => 'value2']); + } + + public function loginAndLogoutAssertions(FunctionalTester $I): void + { + $container = $I->grabService('service_container'); + $factory = $I->grabService('session.factory'); + $session = $factory->createSession(); + $session->start(); + $container->set('session', $session); + + /** @var UserRepository $repository */ + $repository = $I->grabService(UserRepository::class); + $user = $repository->getByEmail('john_doe@gmail.com'); + + $I->amLoggedInAs($user); + $I->amOnPage('/dashboard'); + $I->seeAuthentication(); + /** @var TokenStorageInterface $tokenStorage */ + $tokenStorage = $I->grabService('security.token_storage'); + $I->assertNotNull($tokenStorage->getToken()); + $I->see('You are in the Dashboard!'); + + $token = new PostAuthenticationToken($user, 'main', $user->getRoles()); + $I->amLoggedInWithToken($token); + $I->amOnPage('/dashboard'); + $I->seeAuthentication(); + $I->see('You are in the Dashboard!'); + + $I->amLoggedInAs($user); + $I->amOnPage('/dashboard'); + $I->see('You are in the Dashboard!'); + + $I->goToLogoutPath(); + $I->seeCurrentRouteIs('index'); + $I->dontSeeAuthentication(); + + $I->amLoggedInAs($user); + $I->amOnPage('/dashboard'); + $I->see('You are in the Dashboard!'); + + $I->logoutProgrammatically(); + $I->amOnPage('/dashboard'); + $I->seeInCurrentUrl('login'); + $I->dontSeeAuthentication(); + + $session = $factory->createSession(); + $session->start(); + $container->set('session', $session); + $I->amLoggedInAs($user); + $I->amOnPage('/'); + $I->seeSessionHasValues(['_security_main', '_security_main']); + $I->unpersistService('session'); + } + + public function dontSeeInSession(FunctionalTester $I): void + { + $factory = $I->grabService('session.factory'); + $session = $factory->createSession(); + $session->start(); + $I->grabService('service_container')->set('session', $session); + + $I->amOnPage('/'); + $I->dontSeeInSession('_security_main'); + } +} diff --git a/tests/Functional/TimeCest.php b/tests/Functional/TimeCest.php new file mode 100644 index 0000000..01ebb1e --- /dev/null +++ b/tests/Functional/TimeCest.php @@ -0,0 +1,14 @@ +amOnRoute('app_register'); + $I->seeInCurrentUrl('register'); + $I->seeRequestTimeIsLessThan(400); + } +} diff --git a/tests/Functional/TranslationCest.php b/tests/Functional/TranslationCest.php new file mode 100644 index 0000000..11670f2 --- /dev/null +++ b/tests/Functional/TranslationCest.php @@ -0,0 +1,55 @@ +amOnPage('/register'); + $I->dontSeeFallbackTranslations(); + } + + public function dontSeeMissingTranslations(FunctionalTester $I): void + { + $I->amOnPage('/'); + $I->dontSeeMissingTranslations(); + } + + public function grabDefinedTranslationsCount(FunctionalTester $I): void + { + $I->amOnPage('/register'); + $I->assertSame(6, $I->grabDefinedTranslationsCount()); + } + + public function seeAllTranslationsDefined(FunctionalTester $I): void + { + $I->amOnPage('/register'); + $I->seeAllTranslationsDefined(); + } + + public function seeDefaultLocaleIs(FunctionalTester $I): void + { + $I->amOnPage('/register'); + $I->seeDefaultLocaleIs('en'); + } + + public function seeFallbackLocalesAre(FunctionalTester $I): void + { + $I->amOnPage('/register'); + $I->seeFallbackLocalesAre(['es']); + } + + public function seeFallbackTranslationsCountLessThan(FunctionalTester $I): void + { + $I->amOnPage('/register'); + $I->seeFallbackTranslationsCountLessThan(1); + } + + public function seeMissingTranslationsCountLessThan(FunctionalTester $I): void + { + $I->amOnPage('/'); + $I->seeMissingTranslationsCountLessThan(1); + } +} diff --git a/tests/Functional/TwigCest.php b/tests/Functional/TwigCest.php new file mode 100644 index 0000000..9f3de26 --- /dev/null +++ b/tests/Functional/TwigCest.php @@ -0,0 +1,18 @@ +amOnPage('/register'); + $I->dontSeeRenderedTemplate('security/login.html.twig'); + + $I->amOnPage('/login'); + $I->seeRenderedTemplate('layout.html.twig'); + $I->seeRenderedTemplate('security/login.html.twig'); + $I->seeCurrentTemplateIs('security/login.html.twig'); + } +} diff --git a/tests/Functional/ValidatorCest.php b/tests/Functional/ValidatorCest.php new file mode 100644 index 0000000..a08f6b5 --- /dev/null +++ b/tests/Functional/ValidatorCest.php @@ -0,0 +1,35 @@ +dontSeeViolatedConstraint($valid); + $I->dontSeeViolatedConstraint($valid, 'email'); + $I->dontSeeViolatedConstraint($valid, 'email', Assert\Email::class); + + $invalidEmail = \ValidEntity::create('invalid_email', 'password123'); + $I->seeViolatedConstraint($invalidEmail); + $I->seeViolatedConstraint($invalidEmail, 'email'); + + $weakPassword = \ValidEntity::create('test@example.com', 'weak'); + $I->seeViolatedConstraint($weakPassword); + $I->seeViolatedConstraint($weakPassword, 'password'); + $I->seeViolatedConstraint($weakPassword, 'password', Assert\Length::class); + + $I->seeViolatedConstraintsCount(2, \ValidEntity::create('invalid_email', 'weak')); + $I->seeViolatedConstraintsCount(1, $weakPassword); + $I->seeViolatedConstraintsCount(0, $weakPassword, 'email'); + + $userWithBlankEmail = \ValidEntity::create('', 'weak'); + $I->seeViolatedConstraintMessage('valid email', $invalidEmail, 'email'); + $I->seeViolatedConstraintMessage('should not be blank', $userWithBlankEmail, 'email'); + $I->seeViolatedConstraintMessage('This value is too short', $userWithBlankEmail, 'email'); + } +} diff --git a/tests/HttpClientAssertionsTest.php b/tests/HttpClientAssertionsTest.php new file mode 100644 index 0000000..e3da3a7 --- /dev/null +++ b/tests/HttpClientAssertionsTest.php @@ -0,0 +1,71 @@ +client = new KernelBrowser(self::$kernel); + $this->client->request('GET', '/http-client'); + } + + protected function tearDown(): void + { + parent::tearDown(); + restore_exception_handler(); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function unpersistService(string $serviceName): void + { + // no-op for tests + } + + public function testHttpClientAssertionsAcrossClients(): 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'); + $this->assertHttpClientRequestCount(2, 'app.http_client'); + $this->assertHttpClientRequestCount(1, 'app.http_client.json_client'); + $this->assertNotHttpClientRequest('https://example.com/missing', 'GET', 'app.http_client'); + } + + protected function grabCollector(DataCollectorName $name, string $function): DataCollectorInterface + { + /** @var Profiler $profiler */ + $profiler = self::getContainer()->get('profiler'); + $profile = $profiler->collect($this->client->getRequest(), $this->client->getResponse()); + + return $profile->getCollector($name->value); + } +} diff --git a/tests/LoggerAssertionsTest.php b/tests/LoggerAssertionsTest.php new file mode 100644 index 0000000..f164d1a --- /dev/null +++ b/tests/LoggerAssertionsTest.php @@ -0,0 +1,87 @@ +client = new KernelBrowser(self::$kernel); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function unpersistService(string $serviceName): void + { + // no-op for tests + } + + public function testDontSeeDeprecations(): void + { + $this->client->request('GET', '/sample'); + $this->dontSeeDeprecations(); + } + + public function testDeprecationsAreReported(): void + { + $this->client->request('GET', '/deprecated'); + try { + $this->dontSeeDeprecations(); + self::fail('Expected deprecations to be reported.'); + } catch (AssertionFailedError $error) { + $this->assertStringContainsString('deprecation', $error->getMessage()); + } + } + + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } + + protected function grabCollector(DataCollectorName $name, string $function): DataCollectorInterface + { + if ($name === DataCollectorName::LOGGER) { + $collector = new LoggerDataCollector($this->grabService('logger')); + $collector->collect($this->client->getRequest(), $this->client->getResponse()); + $collector->lateCollect(); + + return $collector; + } + + /** @var Profiler $profiler */ + $profiler = self::getContainer()->get('profiler'); + $profile = $profiler->collect($this->client->getRequest(), $this->client->getResponse()); + return $profile->getCollector($name->value); + } +} diff --git a/tests/MailerAssertionsTest.php b/tests/MailerAssertionsTest.php new file mode 100644 index 0000000..ce15c25 --- /dev/null +++ b/tests/MailerAssertionsTest.php @@ -0,0 +1,118 @@ +kernel = new \TestKernel('test', true); + $this->kernel->boot(); + $this->client = new KernelBrowser($this->kernel); + $this->getService('mailer.message_logger_listener')->reset(); + } + + protected function tearDown(): void + { + $this->kernel->shutdown(); + restore_exception_handler(); + parent::tearDown(); + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function getService(string $serviceId): object + { + $container = $this->kernel->getContainer(); + if ($container->has('test.service_container')) { + $container = $container->get('test.service_container'); + } + return $container->get($serviceId); + } + + public function testDontSeeEmailIsSentWithEmptyLogger(): void + { + $this->dontSeeEmailIsSent(); + } + + public function testQueuedEmailAssertions(): void + { + $queuedEmail = (new Email()) + ->from('queued@example.com') + ->to('queued@example.com'); + $envelope = new Envelope(new Address('queued@example.com'), [new Address('queued@example.com')]); + $queuedEvent = new MessageEvent($queuedEmail, $envelope, 'smtp', true); + + /** @var MessageLoggerListener $logger */ + $logger = $this->getService('mailer.message_logger_listener'); + $logger->onMessage($queuedEvent); + + $this->assertQueuedEmailCount(1); + $this->assertEmailIsQueued($queuedEvent); + $this->assertEmailCount(0); + $this->assertQueuedEmailCount(1, 'smtp', 'Queued emails can be counted by transport'); + } + + public function testMailerEventAssertionsAgainstSentEmail(): void + { + $this->client->request('GET', '/send-email'); + + $this->assertEmailCount(1); + $this->seeEmailIsSent(); + + $event = $this->getMailerEvent(); + $this->assertInstanceOf(MessageEvent::class, $event); + $this->assertEmailIsNotQueued($event); + + $email = $this->grabLastSentEmail(); + $this->assertInstanceOf(Email::class, $email); + $this->assertSame('jane_doe@example.com', $email->getTo()[0]->getAddress()); + $this->assertEmailCount(1, $event?->getTransport()); + + $emails = $this->grabSentEmails(); + $this->assertCount(1, $emails); + } + + public function testTransportSpecificMailerEvents(): void + { + /** @var MessageLoggerListener $logger */ + $logger = $this->getService('mailer.message_logger_listener'); + + $smtpEmail = (new Email()) + ->from('smtp@example.com') + ->to('smtp@example.com'); + $smtpEnvelope = new Envelope(new Address('smtp@example.com'), [new Address('smtp@example.com')]); + $smtpEvent = new MessageEvent($smtpEmail, $smtpEnvelope, 'smtp', false); + + $nullEmail = (new Email()) + ->from('null@example.com') + ->to('null@example.com'); + $nullEnvelope = new Envelope(new Address('null@example.com'), [new Address('null@example.com')]); + $nullEvent = new MessageEvent($nullEmail, $nullEnvelope, 'null', false); + + $logger->onMessage($smtpEvent); + $logger->onMessage($nullEvent); + + $this->assertEmailCount(1, 'smtp'); + $this->assertEmailCount(1, 'null'); + $this->assertEmailCount(2); + } +} diff --git a/tests/MimeAssertionsTest.php b/tests/MimeAssertionsTest.php new file mode 100644 index 0000000..294c9df --- /dev/null +++ b/tests/MimeAssertionsTest.php @@ -0,0 +1,111 @@ +kernel = new \TestKernel('test', true); + $this->kernel->boot(); + $this->client = new KernelBrowser($this->kernel); + $this->getService('mailer.message_logger_listener')->reset(); + + $this->client->request('GET', '/send-email'); + } + + protected function tearDown(): void + { + $this->kernel->shutdown(); + restore_exception_handler(); + parent::tearDown(); + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function getService(string $serviceId): object + { + $container = $this->kernel->getContainer(); + if ($container->has('test.service_container')) { + $container = $container->get('test.service_container'); + } + return $container->get($serviceId); + } + + 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 0000000..97dbb24 --- /dev/null +++ b/tests/NotifierAssertionsTest.php @@ -0,0 +1,96 @@ +kernel = new \TestKernel('test', true); + $this->kernel->boot(); + $this->client = new KernelBrowser($this->kernel); + $this->getService('notifier.notification_logger_listener')->reset(); + } + + protected function tearDown(): void + { + $this->kernel->shutdown(); + restore_exception_handler(); + parent::tearDown(); + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function getService(string $serviceId): object + { + $container = $this->kernel->getContainer(); + if ($container->has('test.service_container')) { + $container = $container->get('test.service_container'); + } + + return $container->get($serviceId); + } + + public function testNoNotificationsSent(): void + { + $this->dontSeeNotificationIsSent(); + } + + public function testQueuedAndSentNotifications(): void + { + /** @var NotifierFixture $fixture */ + $fixture = $this->getService(NotifierFixture::class); + + $sentEvent = $fixture->sendNotification('Welcome notification', 'primary'); + $queuedEvent = $fixture->sendNotification('Queued notification', 'queued', true); + + $this->assertNotificationCount(1); + $this->assertNotificationCount(1, 'primary'); + $this->assertQueuedNotificationCount(1); + $this->assertQueuedNotificationCount(1, 'queued'); + + $this->assertNotificationIsNotQueued($sentEvent); + $this->assertNotificationIsQueued($queuedEvent); + + $firstEvent = $this->getNotifierEvent(); + $this->assertInstanceOf(MessageEvent::class, $firstEvent); + $this->assertNotificationIsNotQueued($firstEvent); + } + + public function testNotificationSubjectAndTransportAssertions(): void + { + /** @var NotifierFixture $fixture */ + $fixture = $this->getService(NotifierFixture::class); + + $fixture->sendNotification('Primary alert', 'chat'); + $fixture->sendNotification('Secondary update', 'backup'); + + $lastNotification = $this->grabLastSentNotification(); + $this->assertInstanceOf(ChatMessage::class, $lastNotification); + + $this->assertNotificationSubjectContains($lastNotification, 'update'); + $this->assertNotificationSubjectNotContains($lastNotification, 'missing'); + $this->assertNotificationTransportIsEqual($lastNotification, 'backup'); + $this->assertNotificationTransportIsNotEqual($lastNotification, 'chat'); + + $notifications = $this->grabSentNotifications(); + $this->assertCount(2, $notifications); + $this->assertSame('chat', $this->getNotifierMessage(0)?->getTransport()); + } +} diff --git a/tests/ParameterAssertionsTest.php b/tests/ParameterAssertionsTest.php new file mode 100644 index 0000000..d945bc3 --- /dev/null +++ b/tests/ParameterAssertionsTest.php @@ -0,0 +1,51 @@ +client = new KernelBrowser(self::$kernel); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + public function testGrabParameter(): void + { + $this->assertSame('value', $this->grabParameter('app.param')); + } + + public function testGrabBusinessNameParameter(): void + { + $this->assertSame('Codeception', $this->grabParameter('app.business_name')); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/RouterAssertionsTest.php b/tests/RouterAssertionsTest.php new file mode 100644 index 0000000..e52a864 --- /dev/null +++ b/tests/RouterAssertionsTest.php @@ -0,0 +1,82 @@ +client = new KernelBrowser(self::$kernel); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function unpersistService(string $serviceName): void + { + // no-op for tests + } + + public function testAmOnAction(): void + { + $this->amOnAction('TestKernel::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 testSeeCurrentActionIs(): void + { + $this->client->request('GET', '/'); + + $this->seeCurrentActionIs('TestKernel::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'); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/SecurityAssertionsTest.php b/tests/SecurityAssertionsTest.php new file mode 100644 index 0000000..8ef2412 --- /dev/null +++ b/tests/SecurityAssertionsTest.php @@ -0,0 +1,117 @@ +client = new KernelBrowser(self::$kernel); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function getService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function grabSecurityService() + { + return new Security(self::getContainer()); + } + + public function testDontSeeAuthentication(): void + { + $this->client->request('GET', '/dashboard'); + + $this->dontSeeAuthentication(); + } + + public function testDontSeeRememberedAuthentication(): void + { + $user = $this->createTestUser(['ROLE_USER']); + $this->client->loginUser($user); + + $this->dontSeeRememberedAuthentication(); + } + + public function testSeeAuthentication(): void + { + $user = $this->createTestUser(['ROLE_USER']); + $this->client->loginUser($user); + + $this->seeAuthentication(); + } + + public function testSeeRememberedAuthentication(): void + { + $user = $this->createTestUser(['ROLE_USER']); + $this->client->loginUser($user); + $this->client->getCookieJar()->set(new Cookie('REMEMBERME', 'test-remember')); + + $this->seeRememberedAuthentication(); + } + + public function testSeeUserHasRole(): void + { + $user = $this->createTestUser(['ROLE_USER', 'ROLE_ADMIN']); + $this->client->loginUser($user); + + $this->seeUserHasRole('ROLE_ADMIN'); + } + + public function testSeeUserHasRoles(): void + { + $user = $this->createTestUser(['ROLE_USER', 'ROLE_CUSTOMER']); + $this->client->loginUser($user); + + $this->seeUserHasRoles(['ROLE_USER', 'ROLE_CUSTOMER']); + } + + public function testSeeUserPasswordDoesNotNeedRehash(): void + { + $user = $this->createTestUser(['ROLE_USER']); + $this->client->loginUser($user); + + $this->seeUserPasswordDoesNotNeedRehash(); + } + + private function createTestUser(array $roles): \TestUser + { + $hasher = $this->grabService('security.password_hasher'); + $hashed = $hasher->hashPassword(new \TestUser('tmp', ''), '123456'); + + return new \TestUser('john_doe@gmail.com', $hashed, $roles); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/ServicesAssertionsTest.php b/tests/ServicesAssertionsTest.php new file mode 100644 index 0000000..5278907 --- /dev/null +++ b/tests/ServicesAssertionsTest.php @@ -0,0 +1,66 @@ +client = new KernelBrowser(self::$kernel); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + public function testGrabServiceReturnsSecurityHelper(): void + { + $securityHelper = $this->grabService('security.helper'); + + $this->assertInstanceOf(Security::class, $securityHelper); + } + + public function testPersistAndUnpersistService(): void + { + $this->persistService('router'); + $this->assertArrayHasKey('router', $this->persistentServices); + + $this->persistPermanentService('router'); + $this->assertArrayHasKey('router', $this->permanentServices); + + $this->unpersistService('router'); + $this->assertArrayNotHasKey('router', $this->persistentServices); + $this->assertArrayNotHasKey('router', $this->permanentServices); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/SessionAssertionsTest.php b/tests/SessionAssertionsTest.php new file mode 100644 index 0000000..45b321f --- /dev/null +++ b/tests/SessionAssertionsTest.php @@ -0,0 +1,139 @@ +client = new KernelBrowser(self::$kernel); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + public function testAmLoggedInAsShowsDashboard(): void + { + $user = $this->getTestUser(); + + $this->amLoggedInAs($user); + $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 testAmLoggedInWithTokenShowsDashboard(): void + { + $user = $this->getTestUser(); + $token = new PostAuthenticationToken($user, 'main', $user->getRoles()); + + $this->amLoggedInWithToken($token); + $this->client->request('GET', '/dashboard'); + + $this->seeAuthentication(); + $this->assertStringContainsString('You are in the Dashboard!', $this->client->getResponse()->getContent()); + } + + public function testSessionAssertions(): void + { + $factory = self::getContainer()->get('session.factory'); + $session = $factory->createSession(); + self::getContainer()->set('session', $session); + $session->set('key1', 'value1'); + $session->set('key2', 'value2'); + $session->save(); + + $this->seeInSession('key1'); + $this->seeInSession('key1', 'value1'); + $this->dontSeeInSession('missing'); + $this->dontSeeInSession('key1', 'other'); + $this->seeSessionHasValues(['key1', 'key2']); + $this->seeSessionHasValues(['key1' => 'value1', 'key2' => 'value2']); + } + + public function testDontSeeInSessionWhenAnonymous(): void + { + $this->client->request('GET', '/'); + + $this->dontSeeInSession('_security_main'); + } + + public function testGoToLogoutPath(): void + { + $user = $this->getTestUser(); + $this->amLoggedInAs($user); + $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 testLogoutProgrammatically(): void + { + $user = $this->getTestUser(); + $this->amLoggedInAs($user); + + $this->logoutProgrammatically(); + $this->client->request('GET', '/dashboard'); + + $this->dontSeeAuthentication(); + $this->assertSame(302, $this->client->getResponse()->getStatusCode()); + $this->assertSame('/login', $this->client->getResponse()->headers->get('Location')); + } + + private function getTestUser(): User + { + /** @var UserRepository $repository */ + $repository = self::getContainer()->get(UserRepository::class); + $user = $repository->getByEmail('john_doe@gmail.com'); + $this->assertNotNull($user); + + return $user; + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/TimeAssertionsTest.php b/tests/TimeAssertionsTest.php new file mode 100644 index 0000000..a13739c --- /dev/null +++ b/tests/TimeAssertionsTest.php @@ -0,0 +1,65 @@ +client = new KernelBrowser(self::$kernel); + $this->client->request('GET', '/register'); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + public function testRequestTime(): void + { + $this->assertStringContainsString('register', $this->client->getRequest()->getPathInfo()); + $this->seeRequestTimeIsLessThan(400); + } + + protected function grabCollector(DataCollectorName $name, string $function): DataCollectorInterface + { + /** @var Profiler $profiler */ + $profiler = self::getContainer()->get('profiler'); + $profile = $profiler->collect($this->client->getRequest(), $this->client->getResponse()); + return $profile->getCollector($name->value); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/TranslationAssertionsTest.php b/tests/TranslationAssertionsTest.php new file mode 100644 index 0000000..d9b8321 --- /dev/null +++ b/tests/TranslationAssertionsTest.php @@ -0,0 +1,108 @@ + true]); + $this->client = new KernelBrowser(self::$kernel); + $this->client->enableProfiler(); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + public function testDontSeeFallbackTranslations(): void + { + $this->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); + } + + protected function grabCollector(DataCollectorName $name, string $function): DataCollectorInterface + { + /** @var Profiler $profiler */ + $profiler = self::getContainer()->get('profiler'); + $profile = $this->client->getProfile() ?? $profiler->collect($this->client->getRequest(), $this->client->getResponse()); + + return $profile->getCollector($name->value); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/TwigAssertionsTest.php b/tests/TwigAssertionsTest.php new file mode 100644 index 0000000..48a93ba --- /dev/null +++ b/tests/TwigAssertionsTest.php @@ -0,0 +1,82 @@ + true]); + $this->client = new KernelBrowser(self::$kernel); + $this->client->enableProfiler(); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + public function testDontSeeRenderedTemplate(): void + { + $this->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'); + } + + protected function grabCollector(DataCollectorName $name, string $function): DataCollectorInterface + { + /** @var Profiler $profiler */ + $profiler = self::getContainer()->get('profiler'); + $profile = $this->client->getProfile() ?? $profiler->collect($this->client->getRequest(), $this->client->getResponse()); + + return $profile->getCollector($name->value); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/ValidatorAssertionsTest.php b/tests/ValidatorAssertionsTest.php new file mode 100644 index 0000000..0d6c316 --- /dev/null +++ b/tests/ValidatorAssertionsTest.php @@ -0,0 +1,103 @@ +client = new KernelBrowser(self::$kernel); + } + + protected static function getKernelClass(): string + { + return \TestKernel::class; + } + + protected function getClient(): KernelBrowser + { + return $this->client; + } + + protected function grabService(string $serviceId): object + { + return self::getContainer()->get($serviceId); + } + + protected function _getContainer(): ContainerInterface + { + return self::getContainer(); + } + + public function testDontSeeViolatedConstraint(): void + { + $user = \ValidEntity::create('test@example.com', 'password123'); + + $this->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 = \ValidEntity::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 testSeeViolatedConstraintCount(): void + { + $user = \ValidEntity::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 testSeeViolatedConstraintMessageContains(): void + { + $user = \ValidEntity::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'); + } + + protected function tearDown(): void + { + restore_exception_handler(); + parent::tearDown(); + } +} diff --git a/tests/_app/DoctrineFixturesLoadCommand.php b/tests/_app/DoctrineFixturesLoadCommand.php new file mode 100644 index 0000000..3fd489c --- /dev/null +++ b/tests/_app/DoctrineFixturesLoadCommand.php @@ -0,0 +1,32 @@ +writeln('Fixtures loaded'); + + return Command::SUCCESS; + } +} diff --git a/tests/_app/Entity/User.php b/tests/_app/Entity/User.php new file mode 100644 index 0000000..46ee3b5 --- /dev/null +++ b/tests/_app/Entity/User.php @@ -0,0 +1,86 @@ +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 eraseCredentials(): void + { + } +} diff --git a/tests/_app/Event/NamedEvent.php b/tests/_app/Event/NamedEvent.php new file mode 100644 index 0000000..bfd0109 --- /dev/null +++ b/tests/_app/Event/NamedEvent.php @@ -0,0 +1,9 @@ +addOption( + self::OPTION_SOMETHING, + self::OPTION_SHORT_SOMETHING, + InputOption::VALUE_NONE, + 'Give some output' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if ($input->getOption(self::OPTION_SOMETHING)) { + $io->text('Bye world!'); + } else { + $io->text('Hello world!'); + } + + return Command::SUCCESS; + } +} diff --git a/tests/_app/HelloCommand.php b/tests/_app/HelloCommand.php new file mode 100644 index 0000000..26fe091 --- /dev/null +++ b/tests/_app/HelloCommand.php @@ -0,0 +1,26 @@ +setDescription('Greets the user') + ->addArgument('name', InputArgument::OPTIONAL, 'Name to greet', 'World'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $name = (string) $input->getArgument('name'); + $output->writeln('Hello ' . $name); + return Command::SUCCESS; + } +} diff --git a/tests/_app/HttpClient/MockResponseFactory.php b/tests/_app/HttpClient/MockResponseFactory.php new file mode 100644 index 0000000..c615237 --- /dev/null +++ b/tests/_app/HttpClient/MockResponseFactory.php @@ -0,0 +1,35 @@ + $options + */ + public function __invoke(string $method, string $url, array $options = []): ResponseInterface + { + $statusCode = match ($url) { + 'https://example.com/body' => 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/NamedEventListener.php b/tests/_app/Listener/NamedEventListener.php new file mode 100644 index 0000000..bfcf893 --- /dev/null +++ b/tests/_app/Listener/NamedEventListener.php @@ -0,0 +1,12 @@ +> + */ + private array $logs = []; + + public function log($level, $message, array $context = []): void + { + $priorityMap = [ + 'DEBUG' => 100, + 'INFO' => 200, + 'NOTICE' => 250, + 'WARNING' => 300, + 'ERROR' => 400, + 'CRITICAL' => 500, + 'ALERT' => 550, + 'EMERGENCY' => 600, + ]; + + $priorityName = strtoupper((string) $level); + $priority = $priorityMap[$priorityName] ?? 200; + $timestamp = microtime(true); + + $this->logs[] = [ + 'message' => (string) $message, + 'context' => $context, + 'priority' => $priority, + 'priorityName' => $priorityName, + 'channel' => 'app', + 'timestamp' => $timestamp, + 'timestamp_rfc3339' => date(DATE_RFC3339, (int) $timestamp), + 'errorCount' => 1, + ]; + } + + public function getLogs(?Request $request = null): array + { + return $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 clear(): void + { + $this->logs = []; + } +} diff --git a/tests/_app/Mailer/RegistrationMailer.php b/tests/_app/Mailer/RegistrationMailer.php new file mode 100644 index 0000000..541575f --- /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 0000000..de1a4ee --- /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/Model/UserRepositoryInterface.php b/tests/_app/Repository/Model/UserRepositoryInterface.php new file mode 100644 index 0000000..6cd17c7 --- /dev/null +++ b/tests/_app/Repository/Model/UserRepositoryInterface.php @@ -0,0 +1,12 @@ +_em->persist($user); + $this->_em->flush(); + $this->_em->clear(); + } + + public function getByEmail(string $email): ?User + { + /** @var User|null $user */ + $user = $this->findOneBy(['email' => $email]); + + return $user; + } +} diff --git a/tests/_app/Security/TestUserProvider.php b/tests/_app/Security/TestUserProvider.php new file mode 100644 index 0000000..bc9fcc2 --- /dev/null +++ b/tests/_app/Security/TestUserProvider.php @@ -0,0 +1,44 @@ +repository->getByEmail($identifier); + + if ($user === null) { + $exception = new UserNotFoundException(); + $exception->setUserIdentifier($identifier); + throw $exception; + } + + return $user; + } + + 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 0000000..a10ad02 --- /dev/null +++ b/tests/_app/TestKernel.php @@ -0,0 +1,573 @@ +extension('framework', [ + 'secret' => 'test', + 'test' => true, + 'profiler' => ['enabled' => true, 'collect' => true, 'collect_serializer_data' => true], + '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, + ]); + + $container->extension('security', [ + 'password_hashers' => [ + PasswordAuthenticatedUserInterface::class => 'auto', + ], + 'providers' => [ + 'doctrine_users' => [ + 'id' => 'security.user.provider.test', + ], + ], + 'firewalls' => [ + 'main' => [ + 'lazy' => true, + 'provider' => 'doctrine_users', + 'remember_me' => ['secret' => 'test'], + 'logout' => ['path' => 'logout'], + ], + ], + ]); + + $container->parameters()->set('app.param', 'value'); + $container->parameters()->set('app.business_name', 'Codeception'); + + $services = $container->services(); + $services->set(HelloCommand::class, HelloCommand::class) + ->tag('console.command', ['command' => 'app:hello']) + ->public(); + $services->set(ExampleCommand::class, ExampleCommand::class) + ->tag('console.command', ['command' => 'app:example-command']) + ->public(); + $services->set(DoctrineFixturesLoadCommand::class, DoctrineFixturesLoadCommand::class) + ->tag('console.command', ['command' => 'doctrine:fixtures:load']) + ->public(); + $services->set('doctrine.orm.entity_manager', EntityManagerInterface::class) + ->factory([self::class, 'createEntityManager']) + ->public() + ->share(true); + $services->alias('doctrine.orm.default_entity_manager', 'doctrine.orm.entity_manager')->public(); + $services->set('doctrine.dbal.default_connection', Connection::class) + ->factory([self::class, 'createConnection']) + ->public() + ->share(true); + $services->set('security.user.provider.test', TestUserProvider::class) + ->arg('$repository', service(UserRepository::class)) + ->tag('security.user_provider') + ->public(); + $services->set(UserRepository::class) + ->factory([self::class, 'createUserRepository']) + ->public(); + $services->alias(UserRepositoryInterface::class, UserRepository::class)->public(); + $services->set(Security::class) + ->public() + ->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') + ->public(); + $services->set('notifier.notification_logger_listener', NotificationLoggerListener::class) + ->tag('kernel.event_subscriber') + ->public(); + $services->alias('notifier.logger_notification_listener', 'notifier.notification_logger_listener')->public(); + $services->set(RegistrationMailer::class) + ->arg('$mailer', service('mailer')) + ->public(); + $services->set(NotifierFixture::class) + ->arg('$dispatcher', service('event_dispatcher')) + ->public(); + $services->set(SampleEventListener::class) + ->tag('kernel.event_listener', ['event' => SampleEvent::class]) + ->public(); + $services->set(NamedEventListener::class) + ->tag('kernel.event_listener', ['event' => 'named.event', 'method' => 'onNamedEvent']) + ->public(); + $services->set(MockResponseFactory::class) + ->public(); + $services->set('logger', ArrayLogger::class) + ->public(); + $services->alias(LoggerInterface::class, 'logger')->public(); + $services->set('app.http_client.inner', MockHttpClient::class) + ->arg('$responseFactory', service(MockResponseFactory::class)) + ->public(); + $services->set('app.http_client', TraceableHttpClient::class) + ->args([service('app.http_client.inner'), service('debug.stopwatch')->nullOnInvalid()]) + ->public(); + $services->set('app.http_client.json_client.inner', MockHttpClient::class) + ->args([service(MockResponseFactory::class), 'https://api.example.com/']) + ->public(); + $services->set('app.http_client.json_client', TraceableHttpClient::class) + ->args([service('app.http_client.json_client.inner'), service('debug.stopwatch')->nullOnInvalid()]) + ->public(); + $services->set(HttpClientDataCollector::class) + ->public() + ->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) + ->public() + ->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(); + $services->set(Profile::class) + ->public(); + $services->set(ProfilerExtension::class) + ->arg('$profile', service(Profile::class)) + ->tag('twig.extension') + ->public(); + } + + public function registerBundles(): iterable + { + return [ + new FrameworkBundle(), + new SecurityBundle(), + new TwigBundle(), + ]; + } + + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->add('index', '/') + ->controller(self::class . '::index'); + $routes->add('app_login', '/login') + ->controller(self::class . '::login'); + $routes->add('app_register', '/register') + ->controller(self::class . '::register'); + $routes->add('dashboard', '/dashboard') + ->controller(self::class . '::dashboard'); + $routes->add('sample', '/sample') + ->controller(self::class . '::sample'); + $routes->add('request_attr', '/request_attr') + ->controller(self::class . '::requestWithAttribute'); + $routes->add('response_cookie', '/response_cookie') + ->controller(self::class . '::responseWithCookie'); + $routes->add('response_json', '/response_json') + ->controller(self::class . '::responseJsonFormat'); + $routes->add('test_page', '/test_page') + ->controller(self::class . '::testPage'); + $routes->add('unprocessable_entity', '/unprocessable_entity') + ->controller(self::class . '::unprocessableEntity'); + $routes->add('redirect', '/redirect') + ->controller(self::class . '::redirect'); + $routes->add('redirect_home', '/redirect_home') + ->controller(self::class . '::redirectToHome'); + $routes->add('unprocessable', '/unprocessable') + ->controller(self::class . '::unprocessable'); + $routes->add('session', '/session') + ->controller(self::class . '::session'); + $routes->add('deprecated', '/deprecated') + ->controller(self::class . '::deprecated'); + $routes->add('send_email', '/send-email') + ->controller(self::class . '::sendEmail'); + $routes->add('translation', '/translation') + ->controller(self::class . '::translation'); + $routes->add('twig', '/twig') + ->controller(self::class . '::twig'); + $routes->add('logout', '/logout') + ->controller(self::class . '::logout'); + $routes->add('dispatch_event', '/dispatch-event') + ->controller(self::class . '::dispatchEvent'); + $routes->add('dispatch_named_event', '/dispatch-named-event') + ->controller(self::class . '::dispatchNamedEvent'); + $routes->add('dispatch_orphan_event', '/dispatch-orphan-event') + ->controller(self::class . '::dispatchOrphanEvent'); + $routes->add('form_handler', '/form') + ->controller(self::class . '::form'); + $routes->add('http_client', '/http-client') + ->controller(self::class . '::httpClientRequests'); + } + + 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 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 logout(Request $request): RedirectResponse + { + /** @var TokenStorageInterface $tokenStorage */ + $tokenStorage = $this->getContainer()->get('test.service_container')->get('security.token_storage'); + $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 dashboard(): Response + { + /** @var TokenStorageInterface $tokenStorage */ + $tokenStorage = $this->getContainer()->get('test.service_container')->get('security.token_storage'); + $token = $tokenStorage->getToken(); + if ($token === null || !is_object($token->getUser())) { + return new RedirectResponse('/login'); + } + + return new Response('You are in the Dashboard!'); + } + + public function sample(Request $request): Response + { + $request->attributes->set('foo', 'bar'); + $html = << + Test Page + + + + + +
+ +
+
Hello World
+ + +HTML; + $response = new Response($html, 200, ['X-Test' => '1']); + $response->headers->setCookie(new Cookie('response_cookie', 'yum')); + return $response; + } + + public function testPage(): Response + { + $html = << + Test Page + +

Test Page

+ + + + +HTML; + + return new Response($html); + } + + public function redirect(): RedirectResponse + { + return new RedirectResponse('/sample'); + } + + public function requestWithAttribute(Request $request): Response + { + $request->attributes->set('page', 'register'); + + return new Response('Request attribute set'); + } + + public function responseWithCookie(): Response + { + $response = new Response('TESTCOOKIE has been set.'); + $response->headers->setCookie(new Cookie('TESTCOOKIE', 'codecept')); + + return $response; + } + + public function responseJsonFormat(Request $request): JsonResponse + { + $request->setRequestFormat('json'); + + return new JsonResponse([ + 'status' => 'success', + 'message' => "Expected format: 'json'.", + ]); + } + + public function unprocessableEntity(): JsonResponse + { + return new JsonResponse([ + 'status' => 'error', + 'message' => 'The request was well-formed but could not be processed.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + public function redirectToHome(): RedirectResponse + { + return new RedirectResponse('/'); + } + + public function unprocessable(): Response + { + return new Response('Unprocessable', 422); + } + + public function session(Request $request): Response + { + $session = $request->getSession(); + $session->set('key1', 'value1'); + $session->set('key2', 'value2'); + $session->save(); + + $this->getContainer()->set('session', $session); + + return new Response('Session'); + } + + 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 sendEmail(RegistrationMailer $mailer): Response + { + $mailer->sendConfirmationEmail('jane_doe@example.com'); + + return new Response('Email sent'); + } + + 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 dispatchEvent(EventDispatcherInterface $dispatcher): Response + { + $dispatcher->dispatch(new SampleEvent()); + + return new Response('Event dispatched'); + } + + public function dispatchNamedEvent(EventDispatcherInterface $dispatcher): Response + { + $dispatcher->dispatch(new NamedEvent(), 'named.event'); + + return new Response('Named event dispatched'); + } + + public function dispatchOrphanEvent(EventDispatcherInterface $dispatcher): Response + { + $dispatcher->dispatch(new OrphanEvent()); + + return new Response('Orphan event dispatched'); + } + + 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 form(Request $request, FormFactoryInterface $formFactory): 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); + + $content = << + +
+ + + +
+ + +HTML; + + $status = $form->isSubmitted() && !$form->isValid() ? 422 : 200; + + return new Response($content, $status); + } + + public static function createEntityManager(): EntityManagerInterface + { + if (self::$entityManager !== null && self::$entityManager->isOpen()) { + return self::$entityManager; + } + + $config = ORMSetup::createAttributeMetadataConfig([__DIR__ . '/Entity'], true); + $proxyDir = sys_get_temp_dir() . '/doctrine-proxies'; + if (!is_dir($proxyDir)) { + mkdir($proxyDir, 0777, true); + } + $config->setProxyDir($proxyDir); + $config->setProxyNamespace('TestsProxies'); + $config->setAutoGenerateProxyClasses(true); + + $connection = DriverManager::getConnection([ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ]); + + $entityManager = new EntityManager($connection, $config); + + $schemaTool = new SchemaTool($entityManager); + $metadata = [$entityManager->getClassMetadata(User::class)]; + $schemaTool->dropSchema($metadata); + $schemaTool->createSchema($metadata); + + $user = User::create('john_doe@gmail.com', 'secret', ['ROLE_TEST']); + $entityManager->persist($user); + $entityManager->flush(); + $entityManager->clear(); + + self::$entityManager = $entityManager; + + return $entityManager; + } + + public static function createConnection(): Connection + { + return self::createEntityManager()->getConnection(); + } + + public static function createUserRepository(): UserRepository + { + /** @var UserRepository $repository */ + $repository = self::createEntityManager()->getRepository(User::class); + + return $repository; + } +} diff --git a/tests/_app/TestUser.php b/tests/_app/TestUser.php new file mode 100644 index 0000000..69caf2f --- /dev/null +++ b/tests/_app/TestUser.php @@ -0,0 +1,33 @@ +userIdentifier; + } + + public function getRoles(): array + { + return $this->roles; + } + + public function getPassword(): string + { + return $this->password; + } + + public function eraseCredentials(): void + { + } +} diff --git a/tests/_app/ValidEntity.php b/tests/_app/ValidEntity.php new file mode 100644 index 0000000..2066821 --- /dev/null +++ b/tests/_app/ValidEntity.php @@ -0,0 +1,50 @@ +email = $email; + $this->password = $password; + } + + public static function create(string $email, string $password): self + { + return new self($email, $password); + } + + public function getEmail(): string + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } +} diff --git a/tests/_app/templates/emails/registration.html.twig b/tests/_app/templates/emails/registration.html.twig new file mode 100644 index 0000000..69744e0 --- /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 0000000..d2f72c4 --- /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 0000000..0131177 --- /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/security/login.html.twig b/tests/_app/templates/security/login.html.twig new file mode 100644 index 0000000..db8c465 --- /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 0000000..0855e3e --- /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/translations/messages.en.yaml b/tests/_app/translations/messages.en.yaml new file mode 100644 index 0000000..9159a7e --- /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 0000000..9e0585c --- /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" diff --git a/tests/_support/.gitkeep b/tests/_support/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/_support/FunctionalTester.php b/tests/_support/FunctionalTester.php new file mode 100644 index 0000000..2b29f9b --- /dev/null +++ b/tests/_support/FunctionalTester.php @@ -0,0 +1,43 @@ +amOnPage('/register'); + + if (!$followRedirects) { + $this->stopFollowingRedirects(); + } + + $this->submitSymfonyForm('registration_form', [ + '[email]' => $email, + '[password]' => $password, + '[agreeTerms]' => true, + ]); + } +}