diff --git a/behat.yml.dist b/behat.yml.dist index eb9bcce0..7b77794c 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -114,6 +114,7 @@ drupal8: - Drupal\DrupalExtension\Context\MinkContext - Drupal\DrupalExtension\Context\MarkupContext - Drupal\DrupalExtension\Context\MessageContext + - Drupal\DrupalExtension\Context\MailContext filters: tags: "@d8&&~@d8wip" extensions: diff --git a/features/mail.feature b/features/mail.feature new file mode 100644 index 00000000..4890ee30 --- /dev/null +++ b/features/mail.feature @@ -0,0 +1,91 @@ +@api @d8 +Feature: MailContext + In order to prove the Mail context is working properly + As a developer + I need to use the step definitions of this context + + Scenario: Mail is sent + When Drupal sends an email: + | to | fred@example.com | + | subject | test | + | body | test body | + And Drupal sends a mail: + | to | jane@example.com | + | subject | test | + | body | test body 2 | + Then mails have been sent: + | to | subject | body | + | fred | | test body | + | jane | test | body 2 | + When Drupal sends a mail: + | to | jack@example.com | + | subject | for jack | + | body | test body with many words | + Then new email is sent: + | to | body | body | + | jack | test | many words | + And a mail has been sent to "jane@example.com" + And a mail has been sent to "jane@example.com": + | subject | + | test | + And an email has been sent with the subject "test" + And emails have been sent with the subject "test": + | to | + | fred | + | jane | + And a mail has been sent to "fred" with the subject "test" + And emails have been sent to "fred" with the subject "test": + | body | + | test body | + + Scenario: New mail is sent to someone + When Drupal sends a mail: + | to | fred@example.com | + | subject | test 1 | + And Drupal sends a mail: + | to | jane@example.com | + | subject | test 2 | + Then new mail is sent to fred: + | subject | + | test 1 | + + + Scenario: No mail is sent + Then no mail has been sent + + Scenario: Count sent mail + When Drupal sends an email: + | to | fred@example.com | + | subject | test | + And Drupal sends a mail: + | to | jane@example.com | + | subject | test | + And Drupal sends a mail: + | to | jane@example.com | + | subject | something else | + Then 2 new emails are sent with the subject "test" + And 1 mail has been sent to "jane" with the subject "something else" + And no new emails are sent + And no mail has been sent to "hans" + + Scenario: I follow link in mail + When Drupal sends a mail: + | to | fred@example.com | + | subject | test link | + | body | A link to Google: http://www.Google.com | + And I follow the link to "google" from the mail with the subject "test link" + Then I should see "Search" + + Scenario: We try to be order insensitive + When Drupal sends an email: + | to | fred@example.com | + | subject | test | + | body | test body | + And Drupal sends a mail: + | to | jane@example.com | + | subject | test | + | body | test body 2 | + Then mails have been sent: + | to | subject | body | + | jane | test | body 2 | + | fred | | test body | diff --git a/src/Drupal/DrupalExtension/Context/MailContext.php b/src/Drupal/DrupalExtension/Context/MailContext.php new file mode 100644 index 00000000..247eea64 --- /dev/null +++ b/src/Drupal/DrupalExtension/Context/MailContext.php @@ -0,0 +1,170 @@ +getFeature()->getTags(), $event->getScenario()->getTags()); + if (!in_array('sendmail', $tags) && !in_array('sendemail', $tags)) { + $this->getMailManager()->disableMail(); + // Always reset mail count, in case the default mail manager is being used + // which enables mail collecting automatically when mail is disabled, making + //the use of the @mail tag optional in this case. + $this->mailCount = []; + } + } + + /** + * Restore mail sending. + * + * @AfterScenario + */ + public function enableMail($event) { + $tags = array_merge($event->getFeature()->getTags(), $event->getScenario()->getTags()); + if (!in_array('sendmail', $tags) && !in_array('sendemail', $tags)) { + $this->getMailManager()->enableMail(); + } + } + + /** + * Allow opting in to mail collection. When using the default mail manager + * service, it is not necessary to use this tag. + * + * @BeforeScenario @mail @email + */ + public function collectMail() { + $this->getMailManager()->startCollectingMail(); + } + + /** + * Stop collecting mail at scenario end. + * + * @AfterScenario @mail @email + */ + public function stopCollectingMail() { + $this->getMailManager()->stopCollectingMail(); + } + + /** + * This is mainly useful for testing this context. + * + * @When Drupal sends a/an (e)mail: + */ + public function DrupalSendsMail(TableNode $fields) { + $mail = [ + 'body' => $this->getRandom()->name(255), + 'subject' => $this->getRandom()->name(20), + 'to' => $this->getRandom()->name(10) . '@anonexample.com', + 'langcode' => '', + ]; + foreach ($fields->getRowsHash() as $field => $value) { + $mail[$field] = $value; + } + $this->getDriver()->sendMail($mail['body'], $mail['subject'], $mail['to'], $mail['langcode']); + } + + /** + * Check all mail sent during the scenario. + * + * @Then (a )(an )(e)mail(s) has/have been sent: + * @Then (a )(an )(e)mail(s) has/have been sent to :to: + * @Then (a )(an )(e)mail(s) has/have been sent with the subject :subject: + * @Then (a )(an )(e)mail(s) has/have been sent to :to with the subject :subject: + */ + public function mailHasBeenSent(TableNode $expectedMailTable, $to = '', $subject = '') { + $expectedMail = $expectedMailTable->getHash(); + $actualMail = $this->getMail(['to' => $to, 'subject' => $subject], FALSE); + $this->compareMail($actualMail, $expectedMail); + } + + /** + * Check mail sent since the last step that checked mail. + * + * @Then (a )(an )new (e)mail(s) is/are sent: + * @Then (a )(an )new (e)mail(s) is/are sent to :to: + * @Then (a )(an )new (e)mail(s) is/are sent with the subject :subject: + * @Then (a )(an )new (e)mail(s) is/are sent to :to with the subject :subject: + */ + public function newMailIsSent(TableNode $expectedMailTable, $to = '', $subject = '') { + $expectedMail = $expectedMailTable->getHash(); + $actualMail = $this->getMail(['to' => $to, 'subject' => $subject], TRUE); + $this->compareMail($actualMail, $expectedMail); + } + + /** + * Check all mail sent during the scenario. + * + * @Then :count (e)mail(s) has/have been sent + * @Then :count (e)mail(s) has/have been sent to :to + * @Then :count (e)mail(s) has/have been sent with the subject :subject + * @Then :count (e)mail(s) has/have been sent to :to with the subject :subject + */ + public function noMailHasBeenSent($count, $to = '', $subject = '') { + $actualMail = $this->getMail(['to' => $to, 'subject' => $subject], FALSE); + $count = $count === 'no' ? 0 : $count; + $count = $count === 'a' ? NULL : $count; + $count = $count === 'an' ? NULL : $count; + $this->assertMailCount($actualMail, $count); + } + + /** + * Check mail sent since the last step that checked mail. + * + * @Then :count new (e)mail(s) is/are sent + * @Then :count new (e)mail(s) is/are sent to :to + * @Then :count new (e)mail(s) is/are sent with the subject :subject + * @Then :count new (e)mail(s) is/are sent to :to with the subject :subject + */ + public function noNewMailIsSent($count, $to = '', $subject = '') { + $actualMail = $this->getMail(['to' => $to, 'subject' => $subject], TRUE); + $count = $count === 'no' ? 0 : $count; + $count = $count === 'a' ? 1 : $count; + $count = $count === 'an' ? 1 : $count; + $this->assertMailCount($actualMail, $count); + } + + /** + * @When I follow the link to :urlFragment from the (e)mail + * @When I follow the link to :urlFragment from the (e)mail to :to + * @When I follow the link to :urlFragment from the (e)mail with the subject :subject + * @When I follow the link to :urlFragment from the (e)mail to :to with the subject :subject + */ + public function followLinkInMail($urlFragment, $to = '', $subject = '') { + // Get the mail + $matches = ['to' => $to, 'subject' => $subject]; + $mail = $this->getMail($matches, FALSE, -1); + if (count($mail) == 0) { + throw new \Exception('No such mail found.'); + } + $body = $mail['body']; + + // Find web URLs in the mail + $urlPattern = '`.*?((http|https)://[\w#$&+,\/:;=?@.-]+)[^\w#$&+,\/:;=?@.-]*?`i'; + if (preg_match_all($urlPattern, $body, $urls)) { + // Visit the first url that contains the desired fragment. + foreach ($urls[1] as $url) { + $match = (strpos(strtolower($url), strtolower($urlFragment)) !== FALSE); + if ($match) { + $this->getMinkContext()->visitPath($url); + return; + } + } + throw new \Exception(sprintf('No URL in mail body contained "%s".', $urlFragment)); + } + else { + throw new \Exception('No URL found in mail body.'); + } + } + +} diff --git a/src/Drupal/DrupalExtension/Context/RawMailContext.php b/src/Drupal/DrupalExtension/Context/RawMailContext.php new file mode 100644 index 00000000..d5e778d9 --- /dev/null +++ b/src/Drupal/DrupalExtension/Context/RawMailContext.php @@ -0,0 +1,241 @@ +mailManager)) { + $this->mailManager = new DrupalMailManager($this->getDriver()); + } + return $this->mailManager; + } + + /** + * Get collected mail, matching certain specifications. + * + * @param array $matches + * Associative array of mail fields and the values to filter by. + * @param bool $new + * Whether to ignore previously seen mail. + * @param null|int $index + * A particular mail to return, e.g. 0 for first or -1 for last. + * @param string $store + * The name of the mail store to get mail from. + * + * @return \stdClass[] + * An array of mail, each formatted as a Drupal 8 + * \Drupal\Core\Mail\MailInterface::mail $message array. + */ + protected function getMail($matches = [], $new = FALSE, $index = NULL, $store = 'default') { + $mail = $this->getMailManager()->getMail($store); + $previousMailCount = $this->getMailCount($store); + $this->mailCount[$store] = count($mail); + + // Ignore previously seen mail. + if ($new) { + $mail = array_slice($mail, $previousMailCount); + } + + // Filter mail based on $matches; keep only mail where each field mentioned + // in $matches contains the value specified for that field. + $mail = array_values(array_filter($mail, function ($singleMail) use ($matches) { + return ($this->matchesMail($singleMail, $matches)); + })); + + // Return an individual mail if specified by an index. + if (is_null($index) || count($mail) === 0) { + return $mail; + } + else { + return array_slice($mail, $index, 1)[0]; + } + } + + /** + * Get the number of mails received in a particular mail store. + * + * @return int + * The number of mails received during this scenario. + */ + protected function getMailCount($store) { + if (array_key_exists($store, $this->mailCount)) { + $count = $this->mailCount[$store]; + } + else { + $count = 0; + } + return $count; + } + + /** + * Determine if a mail meets criteria. + * + * @param array $mail + * The mail, as an array of mail fields. + * @param array $matches + * The criteria: an associative array of mail fields and desired values. + * + * @return bool + * Whether the mail matches the criteria. + */ + protected function matchesMail($mail = [], $matches = []) { + // Discard criteria that are just zero-length strings. + $matches = array_filter($matches, 'strlen'); + // For each criteria, check the specified mail field contains the value. + foreach($matches as $field => $value) { + // Case insensitive. + if (stripos($mail[$field], $value) === FALSE) { + return FALSE; + } + } + return TRUE; + } + + /** + * Compare actual mail with expected mail. + * + * @param array $actualMail + * An array of actual mail. + * @param array $expectedMail + * An array of expected mail. + */ + protected function compareMail($actualMail, $expectedMail) { + // Make sure there is the same number of actual and expected. + $expectedCount = count($expectedMail); + $this->assertMailCount($actualMail, $expectedCount); + + // For each row of expected mail, check the corresponding actual mail. + // Make the comparison insensitive to the order mails were sent. + $actualMail = $this->sortMail($actualMail); + $expectedMail = $this->sortMail($expectedMail); + foreach ($expectedMail as $index => $expectedMailItem) { + // For each column of the expected, check the field of the actual mail. + foreach ($expectedMailItem as $fieldName => $fieldValue) { + $expectedField = [$fieldName => $fieldValue]; + $match = $this->matchesMail($actualMail[$index], $expectedField); + if (!$match) { + throw new \Exception(sprintf("The #%s mail did not have '%s' in its %s field. It had:\n'%s'", $index, $fieldValue, $fieldName, mb_strimwidth($actualMail[$index][$fieldName],0, 30, "..."))); + } + } + } + } + + /** + * Assert there is the expected number of mails, or that there are some mails + * if the exact number expected is not specified. + * + * @param array $actualMail + * An array of actual mail. + * @param int $expectedCount + * Optional. The number of mails expected. + */ + protected function assertMailCount($actualMail, $expectedCount = NULL) { + $actualCount = count($actualMail); + if (is_null($expectedCount)) { + // If number to expect is not specified, expect more than zero. + if ($actualCount === 0) { + throw new \Exception("Expected some mail, but none found."); + } + } + else { + if ($expectedCount != $actualCount) { + // Prepare a simple list of actual mail. + $prettyActualMail = []; + foreach ($actualMail as $singleActualMail) { + $prettyActualMail[] = [ + 'to' => $singleActualMail['to'], + 'subject' => $singleActualMail['subject'], + ]; + } + throw new \Exception(sprintf("Expected %s mail, but %s found:\n\n%s", $expectedCount, $actualCount, print_r($prettyActualMail, TRUE))); + } + } + } + + /** + * Sort mail by to, subject and body. + * + * @param array $mail + * An array of mail to sort. + * + * @return array + * The same mail, but sorted. + */ + protected function sortMail($mail) { + // Can't sort an empty array. + if (count($mail) === 0) { + return []; + } + + // To, subject and body keys must be present. + // Empty strings are ignored when matching so adding them is harmless. + foreach ($mail as $key => $row) { + if (!array_key_exists('to',$row)) { + $mail[$key]['to'] = ''; + } + if (!array_key_exists('subject',$row)) { + $mail[$key]['subject'] = ''; + } + if (!array_key_exists('body',$row)) { + $mail[$key]['body'] = ''; + } + } + + // Obtain a list of columns. + foreach ($mail as $key => $row) { + if (array_key_exists('to',$row)) { + $to[$key] = $row['to']; + } + if (array_key_exists('subject',$row)) { + $subject[$key] = $row['subject']; + } + if (array_key_exists('body',$row)) { + $body[$key] = $row['body']; + } + } + + // Add $mail as the last parameter, to sort by the common key. + array_multisort($to, SORT_ASC, $subject, SORT_ASC, $body, SORT_ASC, $mail); + return $mail; + } + + /** + * Get the mink context, so we can visit pages using the mink session. + */ + protected function getMinkContext() { + $minkContext = $this->getContext('\Behat\MinkExtension\Context\RawMinkContext'); + if ($minkContext === FALSE) { + throw new \Exception(sprintf('No mink context found.')); + } + return $minkContext; + } + +} diff --git a/src/Drupal/DrupalMailManager.php b/src/Drupal/DrupalMailManager.php new file mode 100644 index 00000000..a865990f --- /dev/null +++ b/src/Drupal/DrupalMailManager.php @@ -0,0 +1,70 @@ +driver = $driver; + } + + /** + * {@inheritdoc} + */ + public function startCollectingMail() { + $this->driver->startCollectingMail(); + $this->clearMail(); + } + + /** + * {@inheritdoc} + */ + public function stopCollectingMail() { + $this->driver->stopCollectingMail(); + } + + /** + * {@inheritdoc} + */ + public function enableMail() { + $this->stopCollectingMail(); + } + + /** + * {@inheritdoc} + */ + public function disableMail() { + $this->startCollectingMail(); + } + + /** + * {@inheritdoc} + */ + public function getMail($store = 'default') { + return $this->driver->getMail(); + } + + /** + * {@inheritdoc} + */ + public function clearMail($store = 'default') { + $this->driver->clearMail(); + } + +} diff --git a/src/Drupal/DrupalMailManagerInterface.php b/src/Drupal/DrupalMailManagerInterface.php new file mode 100644 index 00000000..d9ac7d27 --- /dev/null +++ b/src/Drupal/DrupalMailManagerInterface.php @@ -0,0 +1,47 @@ +