diff --git a/composer.json b/composer.json index 684c23c..0bdc3bd 100644 --- a/composer.json +++ b/composer.json @@ -5,6 +5,7 @@ "type": "library", "keywords": [ "codeception", + "functional testing", "symfony" ], "authors": [ @@ -37,10 +38,12 @@ "symfony/filesystem": "^5.4 | ^6.4 | ^7.0", "symfony/form": "^5.4 | ^6.4 | ^7.0", "symfony/framework-bundle": "^5.4 | ^6.4 | ^7.0", + "symfony/http-client": "^5.4 | ^6.4 | ^7.0", "symfony/http-foundation": "^5.4 | ^6.4 | ^7.0", "symfony/http-kernel": "^5.4 | ^6.4 | ^7.0", "symfony/mailer": "^5.4 | ^6.4 | ^7.0", "symfony/mime": "^5.4 | ^6.4 | ^7.0", + "symfony/notifier": "5.4 | ^6.4 | ^7.0", "symfony/options-resolver": "^5.4 | ^6.4 | ^7.0", "symfony/property-access": "^5.4 | ^6.4 | ^7.0", "symfony/property-info": "^5.4 | ^6.4 | ^7.0", diff --git a/src/Codeception/Module/Symfony.php b/src/Codeception/Module/Symfony.php index c508d5e..3469489 100644 --- a/src/Codeception/Module/Symfony.php +++ b/src/Codeception/Module/Symfony.php @@ -13,10 +13,13 @@ use Codeception\Module\Symfony\BrowserAssertionsTrait; use Codeception\Module\Symfony\ConsoleAssertionsTrait; use Codeception\Module\Symfony\DoctrineAssertionsTrait; +use Codeception\Module\Symfony\DomCrawlerAssertionsTrait; use Codeception\Module\Symfony\EventsAssertionsTrait; use Codeception\Module\Symfony\FormAssertionsTrait; +use Codeception\Module\Symfony\HttpClientAssertionsTrait; use Codeception\Module\Symfony\MailerAssertionsTrait; use Codeception\Module\Symfony\MimeAssertionsTrait; +use Codeception\Module\Symfony\NotificationAssertionsTrait; use Codeception\Module\Symfony\ParameterAssertionsTrait; use Codeception\Module\Symfony\RouterAssertionsTrait; use Codeception\Module\Symfony\SecurityAssertionsTrait; @@ -135,10 +138,13 @@ class Symfony extends Framework implements DoctrineProvider, PartedModule use BrowserAssertionsTrait; use ConsoleAssertionsTrait; use DoctrineAssertionsTrait; + use DomCrawlerAssertionsTrait; use EventsAssertionsTrait; use FormAssertionsTrait; + use HttpClientAssertionsTrait; use MailerAssertionsTrait; use MimeAssertionsTrait; + use NotificationAssertionsTrait; use ParameterAssertionsTrait; use RouterAssertionsTrait; use SecurityAssertionsTrait; diff --git a/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php b/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php index 9eb5636..d9215bc 100644 --- a/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/BrowserAssertionsTrait.php @@ -4,11 +4,191 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Constraint\Constraint; +use PHPUnit\Framework\Constraint\LogicalAnd; +use PHPUnit\Framework\Constraint\LogicalNot; +use Symfony\Component\BrowserKit\Test\Constraint\BrowserCookieValueSame; +use Symfony\Component\BrowserKit\Test\Constraint\BrowserHasCookie; +use Symfony\Component\HttpFoundation\Test\Constraint\RequestAttributeValueSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseCookieValueSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseFormatSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHasCookie; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHasHeader; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHeaderLocationSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHeaderSame; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsRedirected; use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsSuccessful; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseIsUnprocessable; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseStatusCodeSame; use function sprintf; trait BrowserAssertionsTrait { + /** + * Asserts the response format returned by the `Response::getFormat()` method is the same as the expected value. + */ + public function assertResponseFormatSame(?string $expectedFormat, string $message = ''): void + { + $this->assertThatForResponse(new ResponseFormatSame($this->getClient()->getRequest(), $expectedFormat), $message); + } + + /** + * Asserts a specific HTTP status code. + */ + public function assertResponseStatusCodeSame(int $expectedCode, string $message = '', bool $verbose = true): void + { + $this->assertThatForResponse(new ResponseStatusCodeSame($expectedCode, $verbose), $message); + } + + /** + * Asserts the response is a redirect response (optionally, you can check the target location and status code). + * The excepted location can be either an absolute or a relative path. + */ + public function assertResponseRedirects(?string $expectedLocation = null, ?int $expectedCode = null, string $message = '', bool $verbose = true): void + { + $constraint = new ResponseIsRedirected($verbose); + if ($expectedLocation) { + if (class_exists(ResponseHeaderLocationSame::class)) { + $locationConstraint = new ResponseHeaderLocationSame($this->getClient()->getRequest(), $expectedLocation); + } else { + $locationConstraint = new ResponseHeaderSame('Location', $expectedLocation); + } + + $constraint = LogicalAnd::fromConstraints($constraint, $locationConstraint); + } + if ($expectedCode) { + $constraint = LogicalAnd::fromConstraints($constraint, new ResponseStatusCodeSame($expectedCode)); + } + + $this->assertThatForResponse($constraint, $message); + } + + /** + * Asserts the given header is available on the response, e.g. assertResponseHasHeader('content-type');. + */ + public function assertResponseHasHeader(string $headerName, string $message = ''): void + { + $this->assertThatForResponse(new ResponseHasHeader($headerName), $message); + } + + /** + * Asserts the given header is not available on the response, e.g. assertResponseNotHasHeader('content-type');. + */ + public function assertResponseNotHasHeader(string $headerName, string $message = ''): void + { + $this->assertThatForResponse(new LogicalNot(new ResponseHasHeader($headerName)), $message); + } + + /** + * Asserts the given header does contain the expected value on the response, + * e.g. assertResponseHeaderSame('content-type', 'application/octet-stream');. + */ + public function assertResponseHeaderSame(string $headerName, string $expectedValue, string $message = ''): void + { + $this->assertThatForResponse(new ResponseHeaderSame($headerName, $expectedValue), $message); + } + + /** + * Asserts the given header does not contain the expected value on the response, + * e.g. assertResponseHeaderNotSame('content-type', 'application/octet-stream');. + */ + public function assertResponseHeaderNotSame(string $headerName, string $expectedValue, string $message = ''): void + { + $this->assertThatForResponse(new LogicalNot(new ResponseHeaderSame($headerName, $expectedValue)), $message); + } + + /** + * Asserts the given cookie is present in the response (optionally checking for a specific cookie path or domain). + */ + public function assertResponseHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForResponse(new ResponseHasCookie($name, $path, $domain), $message); + } + + /** + * Asserts the given cookie is not present in the response (optionally checking for a specific cookie path or domain). + */ + public function assertResponseNotHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForResponse(new LogicalNot(new ResponseHasCookie($name, $path, $domain)), $message); + } + + /** + * Asserts the given cookie is present and set to the expected value. + */ + public function assertResponseCookieValueSame(string $name, string $expectedValue, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForResponse(LogicalAnd::fromConstraints( + new ResponseHasCookie($name, $path, $domain), + new ResponseCookieValueSame($name, $expectedValue, $path, $domain) + ), $message); + } + + /** + * Asserts the response is unprocessable (HTTP status is 422) + */ + public function assertResponseIsUnprocessable(string $message = '', bool $verbose = true): void + { + $this->assertThatForResponse(new ResponseIsUnprocessable($verbose), $message); + } + + /** + * Asserts that the test Client does have the given cookie set (meaning, the cookie was set by any response in the test). + */ + public function assertBrowserHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForClient(new BrowserHasCookie($name, $path, $domain), $message); + } + + protected function assertThatForClient(Constraint $constraint, string $message = ''): void + { + $this->assertThat($this->getClient(), $constraint, $message); + } + + /** + * Asserts that the test Client does not have the given cookie set (meaning, the cookie was set by any response in the test). + */ + public function assertBrowserNotHasCookie(string $name, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForClient(new LogicalNot(new BrowserHasCookie($name, $path, $domain)), $message); + } + + /** + * Asserts the given cookie in the test Client is set to the expected value. + */ + public function assertBrowserCookieValueSame(string $name, string $expectedValue, bool $raw = false, string $path = '/', ?string $domain = null, string $message = ''): void + { + $this->assertThatForClient(LogicalAnd::fromConstraints( + new BrowserHasCookie($name, $path, $domain), + new BrowserCookieValueSame($name, $expectedValue, $raw, $path, $domain) + ), $message); + } + + /** + * Asserts the given request attribute is set to the expected value. + */ + public function assertRequestAttributeValueSame(string $name, string $expectedValue, string $message = ''): void + { + $this->assertThat($this->getClient()->getRequest(), new RequestAttributeValueSame($name, $expectedValue), $message); + } + + /** + * Asserts the request matches the given route and optionally route parameters. + */ + public function assertRouteSame(string $expectedRoute, array $parameters = [], string $message = ''): void + { + $constraint = new RequestAttributeValueSame('_route', $expectedRoute); + $constraints = []; + foreach ($parameters as $key => $value) { + $constraints[] = new RequestAttributeValueSame($key, $value); + } + if ($constraints) { + $constraint = LogicalAnd::fromConstraints($constraint, ...$constraints); + } + + $this->assertThat($this->getClient()->getRequest(), $constraint, $message); + } + /** * Reboot client's kernel. * Can be used to manually reboot kernel when 'rebootable_client' => false @@ -50,7 +230,15 @@ public function seePageIsAvailable(?string $url = null): void $this->seeInCurrentUrl($url); } - $this->assertThat($this->getClient()->getResponse(), new ResponseIsSuccessful()); + $this->assertResponseIsSuccessful(); + } + + /** + * Asserts that the response was successful (HTTP status is 2xx). + */ + public function assertResponseIsSuccessful(string $message = '', bool $verbose = true): void + { + $this->assertThatForResponse(new ResponseIsSuccessful($verbose), $message); } /** @@ -104,4 +292,9 @@ public function submitSymfonyForm(string $name, array $fields): void $this->submitForm($selector, $params, $button); } + + protected function assertThatForResponse(Constraint $constraint, string $message = ''): void + { + $this->assertThat($this->getClient()->getResponse(), $constraint, $message); + } } diff --git a/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php b/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php new file mode 100644 index 0000000..4f5627e --- /dev/null +++ b/src/Codeception/Module/Symfony/DomCrawlerAssertionsTrait.php @@ -0,0 +1,176 @@ +assertThat($this->getCrawler(), new CrawlerSelectorExists($selector), $message); + } + + /** + * Asserts that the given selector does not match at least one element in the response. + */ + public function assertSelectorNotExists(string $selector, string $message = ''): void + { + $this->assertThat($this->getCrawler(), new LogicalNot(new CrawlerSelectorExists($selector)), $message); + } + + /** + * Asserts that the expected number of selector elements are in the response. + */ + public function assertSelectorCount(int $expectedCount, string $selector, string $message = ''): void + { + $this->assertThat($this->getCrawler(), new CrawlerSelectorCount($expectedCount, $selector), $message); + } + + /** + * Asserts that any element matching the given selector does contain the expected text. + */ + public function assertAnySelectorTextContains(string $selector, string $text, string $message = ''): void + { + $this->assertThat($this->getCrawler(), LogicalAnd::fromConstraints( + new CrawlerSelectorExists($selector), + new CrawlerAnySelectorTextContains($selector, $text) + ), $message); + } + + /** + * Asserts that any element matching the given selector does equal the expected text. + */ + public function assertAnySelectorTextSame(string $selector, string $text, string $message = ''): void + { + $this->assertThat($this->getCrawler(), LogicalAnd::fromConstraints( + new CrawlerSelectorExists($selector), + new CrawlerAnySelectorTextSame($selector, $text) + ), $message); + } + + /** + * Asserts that the first element matching the given selector does not contain the expected text. + */ + public function assertSelectorTextNotContains(string $selector, string $text, string $message = ''): void + { + $this->assertThat($this->getCrawler(), LogicalAnd::fromConstraints( + new CrawlerSelectorExists($selector), + new LogicalNot(new CrawlerSelectorTextContains($selector, $text)) + ), $message); + } + + /** + * Asserts that any element matching the given selector does not contain the expected text. + */ + public function assertAnySelectorTextNotContains(string $selector, string $text, string $message = ''): void + { + $this->assertThat($this->getCrawler(), LogicalAnd::fromConstraints( + new CrawlerSelectorExists($selector), + new LogicalNot(new CrawlerAnySelectorTextContains($selector, $text)) + ), $message); + } + + /** + * Asserts that the `` element is equal to the given title. + */ + public function assertPageTitleSame(string $expectedTitle, string $message = ''): void + { + $this->assertSelectorTextSame('title', $expectedTitle, $message); + } + + /** + * Asserts that the contents of the first element matching the given selector does equal the expected text. + */ + public function assertSelectorTextSame(string $selector, string $text, string $message = ''): void + { + $this->assertThat($this->getCrawler(), LogicalAnd::fromConstraints( + new CrawlerSelectorExists($selector), + new CrawlerSelectorTextSame($selector, $text) + ), $message); + } + + /** + * Asserts that the `<title>` element contains the given title. + */ + public function assertPageTitleContains(string $expectedTitle, string $message = ''): void + { + $this->assertSelectorTextContains('title', $expectedTitle, $message); + } + + /** + * Asserts that the first element matching the given selector does contain the expected text. + */ + public function assertSelectorTextContains(string $selector, string $text, string $message = ''): void + { + $this->assertThat($this->getCrawler(), LogicalAnd::fromConstraints( + new CrawlerSelectorExists($selector), + new CrawlerSelectorTextContains($selector, $text) + ), $message); + } + + /** + * Asserts that value of the form input with the given name does equal the expected value. + */ + public function assertInputValueSame(string $fieldName, string $expectedValue, string $message = ''): void + { + $this->assertThat($this->getCrawler(), LogicalAnd::fromConstraints( + new CrawlerSelectorExists("input[name=\"$fieldName\"]"), + new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $expectedValue) + ), $message); + } + + /** + * Asserts that value of the form input with the given name does not equal the expected value. + */ + public function assertInputValueNotSame(string $fieldName, string $expectedValue, string $message = ''): void + { + $this->assertThat($this->getCrawler(), LogicalAnd::fromConstraints( + new CrawlerSelectorExists("input[name=\"$fieldName\"]"), + new LogicalNot(new CrawlerSelectorAttributeValueSame("input[name=\"$fieldName\"]", 'value', $expectedValue)) + ), $message); + } + + /** + * Asserts that the checkbox with the given name is checked. + */ + public function assertCheckboxChecked(string $fieldName, string $message = ''): void + { + $this->assertThat( + $this->getCrawler(), + new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked"), + $message + ); + } + + /** + * Asserts that the checkbox with the given name is not checked. + */ + public function assertCheckboxNotChecked(string $fieldName, string $message = ''): void + { + $this->assertThat( + $this->getCrawler(), + new LogicalNot(new CrawlerSelectorExists("input[name=\"$fieldName\"]:checked")), + $message + ); + } + + protected function getCrawler(): Crawler + { + return $this->client->getCrawler(); + } +} diff --git a/src/Codeception/Module/Symfony/FormAssertionsTrait.php b/src/Codeception/Module/Symfony/FormAssertionsTrait.php index 930969c..a581158 100644 --- a/src/Codeception/Module/Symfony/FormAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/FormAssertionsTrait.php @@ -4,6 +4,8 @@ namespace Codeception\Module\Symfony; +use DateInterval; +use DateTime; use Symfony\Component\Form\Extension\DataCollector\FormDataCollector; use function array_key_exists; use function in_array; @@ -12,6 +14,29 @@ trait FormAssertionsTrait { + /** + * Asserts that value of the field of the first form matching the given selector does equal the expected value. + */ + public function assertFormValue(string $formSelector, string $fieldName, string $value, string $message = ''): void + { + $node = $this->getCrawler()->filter($formSelector); + $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); + $values = $node->form()->getValues(); + $this->assertArrayHasKey($fieldName, $values, $message ?: sprintf('Field "%s" not found in form "%s".', $fieldName, $formSelector)); + $this->assertSame($value, $values[$fieldName]); + } + + /** + * Asserts that value of the field of the first form matching the given selector does equal the expected value. + */ + public function assertNoFormValue(string $formSelector, string $fieldName, string $message = ''): void + { + $node = $this->getCrawler()->filter($formSelector); + $this->assertNotEmpty($node, sprintf('Form "%s" not found.', $formSelector)); + $values = $node->form()->getValues(); + $this->assertArrayNotHasKey($fieldName, $values, $message ?: sprintf('Field "%s" has a value in form "%s".', $fieldName, $formSelector)); + } + /** * Verifies that there are no errors bound to the submitted form. * @@ -33,6 +58,60 @@ public function dontSeeFormErrors(): void ); } + /** + * Verifies that multiple fields on a form have errors. + * + * If you only specify the name of the fields, this method will + * verify that the field contains at least one error of any type: + * + * ```php + * <?php + * $I->seeFormErrorMessages(['telephone', 'address']); + * ``` + * + * If you want to specify the error messages, you can do so + * by sending an associative array instead, with the key being + * the name of the field and the error message the value. + * + * This method will validate that the expected error message + * is contained in the actual error message, that is, + * you can specify either the entire error message or just a part of it: + * + * ```php + * <?php + * $I->seeFormErrorMessages([ + * 'address' => 'The address is too long' + * 'telephone' => 'too short', // the full error message is 'The telephone is too short' + * ]); + * ``` + * + * If you don't want to specify the error message for some fields, + * you can pass `null` as value instead of the message string, + * or you can directly omit the value of that field. If that is the case, + * it will be validated that that field has at least one error of any type: + * + * ```php + * <?php + * $I->seeFormErrorMessages([ + * 'telephone' => 'too short', + * 'address' => null, + * 'postal code', + * ]); + * ``` + * + * @param string[] $expectedErrors + */ + public function seeFormErrorMessages(array $expectedErrors): void + { + foreach ($expectedErrors as $field => $message) { + if (is_int($field)) { + $this->seeFormErrorMessage($message); + } else { + $this->seeFormErrorMessage($field, $message); + } + } + } + /** * Verifies that a form field has an error. * You can specify the expected error message as second parameter. @@ -42,8 +121,6 @@ public function dontSeeFormErrors(): void * $I->seeFormErrorMessage('username'); * $I->seeFormErrorMessage('username', 'Username is empty'); * ``` - * - * @param string|null $message */ public function seeFormErrorMessage(string $field, ?string $message = null): void { @@ -93,60 +170,6 @@ public function seeFormErrorMessage(string $field, ?string $message = null): voi ); } - /** - * Verifies that multiple fields on a form have errors. - * - * If you only specify the name of the fields, this method will - * verify that the field contains at least one error of any type: - * - * ```php - * <?php - * $I->seeFormErrorMessages(['telephone', 'address']); - * ``` - * - * If you want to specify the error messages, you can do so - * by sending an associative array instead, with the key being - * the name of the field and the error message the value. - * - * This method will validate that the expected error message - * is contained in the actual error message, that is, - * you can specify either the entire error message or just a part of it: - * - * ```php - * <?php - * $I->seeFormErrorMessages([ - * 'address' => 'The address is too long' - * 'telephone' => 'too short', // the full error message is 'The telephone is too short' - * ]); - * ``` - * - * If you don't want to specify the error message for some fields, - * you can pass `null` as value instead of the message string, - * or you can directly omit the value of that field. If that is the case, - * it will be validated that that field has at least one error of any type: - * - * ```php - * <?php - * $I->seeFormErrorMessages([ - * 'telephone' => 'too short', - * 'address' => null, - * 'postal code', - * ]); - * ``` - * - * @param string[] $expectedErrors - */ - public function seeFormErrorMessages(array $expectedErrors): void - { - foreach ($expectedErrors as $field => $message) { - if (is_int($field)) { - $this->seeFormErrorMessage($message); - } else { - $this->seeFormErrorMessage($field, $message); - } - } - } - /** * Verifies that there are one or more errors bound to the submitted form. * diff --git a/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php b/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php new file mode 100644 index 0000000..cc53274 --- /dev/null +++ b/src/Codeception/Module/Symfony/HttpClientAssertionsTrait.php @@ -0,0 +1,117 @@ +<?php + +declare(strict_types=1); + +namespace Codeception\Module\Symfony; + +use Symfony\Component\HttpClient\DataCollector\HttpClientDataCollector; +use function array_key_exists; +use function is_string; + +trait HttpClientAssertionsTrait +{ + /** + * Asserts that the given URL has been called using, if specified, the given method body and headers. + * By default, it will check on the HttpClient, but you can also pass a specific HttpClient ID. (It will succeed if the request has been called multiple times.) + */ + public function assertHttpClientRequest(string $expectedUrl, string $expectedMethod = 'GET', string|array|null $expectedBody = null, array $expectedHeaders = [], string $httpClientId = 'http_client'): void + { + $httpClientDataCollector = $this->getHttpClientDataCollector(__FUNCTION__); + $expectedRequestHasBeenFound = false; + + if (!array_key_exists($httpClientId, $httpClientDataCollector->getClients())) { + $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); + } + + foreach ($httpClientDataCollector->getClients()[$httpClientId]['traces'] as $trace) { + if (($expectedUrl !== $trace['info']['url'] && $expectedUrl !== $trace['url']) + || $expectedMethod !== $trace['method'] + ) { + continue; + } + + if (null !== $expectedBody) { + $actualBody = null; + + if (null !== $trace['options']['body'] && null === $trace['options']['json']) { + $actualBody = is_string($trace['options']['body']) ? $trace['options']['body'] : $trace['options']['body']->getValue(true); + } + + if (null === $trace['options']['body'] && null !== $trace['options']['json']) { + $actualBody = $trace['options']['json']->getValue(true); + } + + if (!$actualBody) { + continue; + } + + if ($expectedBody === $actualBody) { + $expectedRequestHasBeenFound = true; + + if (!$expectedHeaders) { + break; + } + } + } + + if ($expectedHeaders) { + $actualHeaders = $trace['options']['headers'] ?? []; + + foreach ($actualHeaders as $headerKey => $actualHeader) { + if (array_key_exists($headerKey, $expectedHeaders) + && $expectedHeaders[$headerKey] === $actualHeader->getValue(true) + ) { + $expectedRequestHasBeenFound = true; + break 2; + } + } + } + + $expectedRequestHasBeenFound = true; + break; + } + + $this->assertTrue($expectedRequestHasBeenFound, 'The expected request has not been called: "'.$expectedMethod.'" - "'.$expectedUrl.'"'); + } + + /** + * Asserts that the given URL has not been called using GET or the specified method. + * By default, it will check on the HttpClient, but a HttpClient id can be specified. + */ + public function assertNotHttpClientRequest(string $unexpectedUrl, string $expectedMethod = 'GET', string $httpClientId = 'http_client'): void + { + $httpClientDataCollector = $this->getHttpClientDataCollector(__FUNCTION__); + $unexpectedUrlHasBeenFound = false; + + if (!array_key_exists($httpClientId, $httpClientDataCollector->getClients())) { + $this->fail(sprintf('HttpClient "%s" is not registered.', $httpClientId)); + } + + foreach ($httpClientDataCollector->getClients()[$httpClientId]['traces'] as $trace) { + if (($unexpectedUrl === $trace['info']['url'] || $unexpectedUrl === $trace['url']) + && $expectedMethod === $trace['method'] + ) { + $unexpectedUrlHasBeenFound = true; + break; + } + } + + $this->assertFalse($unexpectedUrlHasBeenFound, sprintf('Unexpected URL called: "%s" - "%s"', $expectedMethod, $unexpectedUrl)); + } + + /** + * Asserts that the given number of requests has been made on the HttpClient. + * By default, it will check on the HttpClient, but you can also pass a specific HttpClient ID. + */ + public function assertHttpClientRequestCount(int $count, string $httpClientId = 'http_client'): void + { + $httpClientDataCollector = $this->getHttpClientDataCollector(__FUNCTION__); + + $this->assertCount($count, $httpClientDataCollector->getClients()[$httpClientId]['traces']); + } + + protected function getHttpClientDataCollector(string $function): HttpClientDataCollector + { + return $this->grabCollector('http_client', $function); + } +} diff --git a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php index f8bb977..753a871 100644 --- a/src/Codeception/Module/Symfony/MailerAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/MailerAssertionsTrait.php @@ -4,6 +4,8 @@ namespace Codeception\Module\Symfony; +use PHPUnit\Framework\Constraint\LogicalNot; +use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mailer\Event\MessageEvents; use Symfony\Component\Mailer\EventListener\MessageLoggerListener; use Symfony\Component\Mailer\Test\Constraint as MailerConstraint; @@ -11,6 +13,40 @@ trait MailerAssertionsTrait { + /** + * Asserts that the expected number of emails was sent. + */ + public function assertEmailCount(int $count, ?string $transport = null, string $message = ''): void + { + $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($count, $transport), $message); + } + + /** + * Asserts that the expected number of emails was queued (e.g. using the Messenger component). + */ + public function assertQueuedEmailCount(int $count, ?string $transport = null, string $message = ''): void + { + $this->assertThat($this->getMessageMailerEvents(), new MailerConstraint\EmailCount($count, $transport, true), $message); + } + + /** + * Asserts that the given mailer event is queued. + * Use `getMailerEvent(int $index = 0, ?string $transport = null)` to retrieve a mailer event by index. + */ + public function assertEmailIsQueued(MessageEvent $event, string $message = ''): void + { + $this->assertThat($event, new MailerConstraint\EmailIsQueued(), $message); + } + + /** + * Asserts that the given mailer event is not queued. + * Use `getMailerEvent(int $index = 0, ?string $transport = null)` to retrieve a mailer event by index. + */ + public function assertEmailIsNotQueued(MessageEvent $event, string $message = ''): void + { + $this->assertThat($event, new LogicalNot(new MailerConstraint\EmailIsQueued()), $message); + } + /** * Checks that no email was sent. * The check is based on `\Symfony\Component\Mailer\EventListener\MessageLoggerListener`, which means: diff --git a/src/Codeception/Module/Symfony/MimeAssertionsTrait.php b/src/Codeception/Module/Symfony/MimeAssertionsTrait.php index d20ea30..5365fe1 100644 --- a/src/Codeception/Module/Symfony/MimeAssertionsTrait.php +++ b/src/Codeception/Module/Symfony/MimeAssertionsTrait.php @@ -6,10 +6,27 @@ use PHPUnit\Framework\Constraint\LogicalNot; use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\RawMessage; use Symfony\Component\Mime\Test\Constraint as MimeConstraint; trait MimeAssertionsTrait { + /** + * Asserts that the subject of the given email does contain the expected subject. + */ + public function assertEmailSubjectContains(RawMessage $email, string $expectedValue, string $message = ''): void + { + $this->assertThat($email, new MimeConstraint\EmailSubjectContains($expectedValue), $message); + } + + /** + * Asserts that the subject of the given email does not contain the expected subject. + */ + public function assertEmailSubjectNotContains(RawMessage $email, string $expectedValue, string $message = ''): void + { + $this->assertThat($email, new LogicalNot(new MimeConstraint\EmailSubjectContains($expectedValue)), $message); + } + /** * Verify that an email contains addresses with a [header](https://datatracker.ietf.org/doc/html/rfc4021) * `$headerName` and its expected value `$expectedValue`. diff --git a/src/Codeception/Module/Symfony/NotificationAssertionsTrait.php b/src/Codeception/Module/Symfony/NotificationAssertionsTrait.php new file mode 100644 index 0000000..cfab668 --- /dev/null +++ b/src/Codeception/Module/Symfony/NotificationAssertionsTrait.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +namespace Codeception\Module\Symfony; + +use PHPUnit\Framework\Constraint\LogicalNot; +use Symfony\Component\Notifier\Event\MessageEvent; +use Symfony\Component\Notifier\Event\NotificationEvents; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Test\Constraint\NotificationCount; +use Symfony\Component\Notifier\Test\Constraint\NotificationIsQueued; +use Symfony\Component\Notifier\Test\Constraint\NotificationSubjectContains; +use Symfony\Component\Notifier\Test\Constraint\NotificationTransportIsEqual; + +trait NotificationAssertionsTrait +{ + /** + * Asserts that the given number of notifications has been created (in total or for the given transport). + */ + public function assertNotificationCount(int $count, ?string $transportName = null, string $message = ''): void + { + $this->assertThat($this->getNotificationEvents(), new NotificationCount($count, $transportName), $message); + } + + /** + * Asserts that the given number of notifications are queued (in total or for the given transport). + */ + public function assertQueuedNotificationCount(int $count, ?string $transportName = null, string $message = ''): void + { + $this->assertThat($this->getNotificationEvents(), new NotificationCount($count, $transportName, true), $message); + } + + /** + * Asserts that the given notification is queued. + */ + public function assertNotificationIsQueued(MessageEvent $event, string $message = ''): void + { + $this->assertThat($event, new NotificationIsQueued(), $message); + } + + /** + * Asserts that the given notification is not queued. + */ + public function assertNotificationIsNotQueued(MessageEvent $event, string $message = ''): void + { + $this->assertThat($event, new LogicalNot(new NotificationIsQueued()), $message); + } + + /** + * Asserts that the given text is included in the subject of the given notification. + */ + public function assertNotificationSubjectContains(MessageInterface $notification, string $text, string $message = ''): void + { + $this->assertThat($notification, new NotificationSubjectContains($text), $message); + } + + /** + * Asserts that the given text is not included in the subject of the given notification. + */ + public function assertNotificationSubjectNotContains(MessageInterface $notification, string $text, string $message = ''): void + { + $this->assertThat($notification, new LogicalNot(new NotificationSubjectContains($text)), $message); + } + + /** + * Asserts that the name of the transport for the given notification is the same as the given text. + */ + public function assertNotificationTransportIsEqual(MessageInterface $notification, ?string $transportName = null, string $message = ''): void + { + $this->assertThat($notification, new NotificationTransportIsEqual($transportName), $message); + } + + /** + * Asserts that the name of the transport for the given notification is not the same as the given text. + */ + public function assertNotificationTransportIsNotEqual(MessageInterface $notification, ?string $transportName = null, string $message = ''): void + { + $this->assertThat($notification, new LogicalNot(new NotificationTransportIsEqual($transportName)), $message); + } + + protected function getNotificationEvents(): NotificationEvents + { + $notificationLogger = $this->getService('notifier.notification_logger_listener'); + if ($notificationLogger) { + return $notificationLogger->getEvents(); + } + + $this->fail('A client must have Notifier enabled to make notifications assertions. Did you forget to require symfony/notifier?'); + } +}