From cb5d683530ebaec2341bdfc00ebac925277fabcb Mon Sep 17 00:00:00 2001 From: Wolfgang Klinger Date: Wed, 30 Sep 2026 12:07:25 +0200 Subject: [PATCH 1/3] Add nth email, sender and recipient assertions Every "last email" action gets an "nth email" counterpart. Positions start at 1 and count in the order the emails were received; the ...To variants only count emails sent to the given address. Add seeInLastEmailSender, seeInNthEmailSender, seeInLastEmailRecipient and seeInNthEmailRecipient, backed by Email::getSender(). The sender is an optional trailing constructor argument to keep existing callers working. Fix the message sort tie-breaker, which compared created_at and id as a concatenated string and therefore ordered id 10 before id 9 within the same second. Supersedes #102. Resolves: #12, #98 --- README.md | 52 +++++ src/Module/MailCatcher.php | 268 +++++++++++++++++++++----- src/Util/Email.php | 42 ++-- tests/_support/Helper/Acceptance.php | 5 +- tests/acceptance/MailcatcherCest.php | 103 ++++++++++ tests/unit/Module/MailCatcherTest.php | 180 ++++++++++++++--- tests/unit/Util/EmailTest.php | 10 + 7 files changed, 556 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 6a4078f..64d49a0 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,58 @@ Example: * Param $email * Param $regex +### seeInLastEmailSender / seeInLastEmailRecipient + +Checks that the sender (or one of the recipients) of the last email contains a value. + +Example: + + seeInLastEmailSender('noreply@example.com'); + $I->seeInLastEmailRecipient('user@example.com'); + ?> + +* Param $text + +### Nth email actions + +Every "last email" action has an "nth email" counterpart that takes the +position as its first argument. Positions start at 1 and count in the order +the emails were received, so `1` is the first email and `2` the second. +For the `...To` variants, only emails sent to the given address are counted. + +| Last email | Nth email | +| --- | --- | +| `seeInLastEmail($text)` | `seeInNthEmail($nth, $text)` | +| `dontSeeInLastEmail($text)` | `dontSeeInNthEmail($nth, $text)` | +| `seeInLastEmailSubject($text)` | `seeInNthEmailSubject($nth, $text)` | +| `dontSeeInLastEmailSubject($text)` | `dontSeeInNthEmailSubject($nth, $text)` | +| `seeInLastEmailSender($text)` | `seeInNthEmailSender($nth, $text)` | +| `seeInLastEmailRecipient($text)` | `seeInNthEmailRecipient($nth, $text)` | +| `seeInLastEmailTo($email, $text)` | `seeInNthEmailTo($nth, $email, $text)` | +| `dontSeeInLastEmailTo($email, $text)` | `dontSeeInNthEmailTo($nth, $email, $text)` | +| `seeInLastEmailSubjectTo($email, $text)` | `seeInNthEmailSubjectTo($nth, $email, $text)` | +| `dontSeeInLastEmailSubjectTo($email, $text)` | `dontSeeInNthEmailSubjectTo($nth, $email, $text)` | +| `grabMatchesFromLastEmail($regex)` | `grabMatchesFromNthEmail($nth, $regex)` | +| `grabFromLastEmail($regex)` | `grabFromNthEmail($nth, $regex)` | +| `grabMatchesFromLastEmailTo($email, $regex)` | `grabMatchesFromNthEmailTo($nth, $email, $regex)` | +| `grabFromLastEmailTo($email, $regex)` | `grabFromNthEmailTo($nth, $email, $regex)` | +| `grabUrlsFromLastEmail()` | `grabUrlsFromNthEmail($nth)` | +| `grabAttachmentsFromLastEmail()` | `grabAttachmentsFromNthEmail($nth)` | +| `seeAttachmentInLastEmail($filename)` | `seeAttachmentInNthEmail($nth, $filename)` | +| `seeEmailAttachmentCount($count)` | `seeNthEmailAttachmentCount($nth, $count)` | +| `lastMessage()` | `nthMessage($nth)` | +| `lastMessageTo($email)` | `nthMessageTo($nth, $email)` | + +Example: + + seeInNthEmail(1, 'Welcome!'); + $I->seeInNthEmailSubject(2, 'Please confirm your address'); + $I->seeInNthEmailTo(2, 'admin@example.com', 'A new user has signed up'); + ?> + ### seeEmailCount Asserts that a certain number of emails have been sent since the last time diff --git a/src/Module/MailCatcher.php b/src/Module/MailCatcher.php index 64a657f..61fc6d7 100644 --- a/src/Module/MailCatcher.php +++ b/src/Module/MailCatcher.php @@ -60,6 +60,11 @@ public function seeInLastEmail(string $expected): void $this->seeInEmail($email, $expected); } + public function seeInNthEmail(int $nth, string $expected): void + { + $this->seeInEmail($this->nthMessage($nth), $expected); + } + /** * See In Last Email subject * @@ -73,6 +78,11 @@ public function seeInLastEmailSubject(string $expected): void $this->seeInEmailSubject($email, $expected); } + public function seeInNthEmailSubject(int $nth, string $expected): void + { + $this->seeInEmailSubject($this->nthMessage($nth), $expected); + } + /** * Don't See In Last Email subject * @@ -84,6 +94,11 @@ public function dontSeeInLastEmailSubject(string $expected): void $this->dontSeeInEmailSubject($email, $expected); } + public function dontSeeInNthEmailSubject(int $nth, string $unexpected): void + { + $this->dontSeeInEmailSubject($this->nthMessage($nth), $unexpected); + } + /** * Don't See In Last Email * @@ -95,6 +110,31 @@ public function dontSeeInLastEmail(string $unexpected): void $this->dontSeeInEmail($email, $unexpected); } + public function dontSeeInNthEmail(int $nth, string $unexpected): void + { + $this->dontSeeInEmail($this->nthMessage($nth), $unexpected); + } + + public function seeInLastEmailSender(string $expected): void + { + $this->seeInEmailSender($this->lastMessage(), $expected); + } + + public function seeInNthEmailSender(int $nth, string $expected): void + { + $this->seeInEmailSender($this->nthMessage($nth), $expected); + } + + public function seeInLastEmailRecipient(string $expected): void + { + $this->seeInEmailRecipients($this->lastMessage(), $expected); + } + + public function seeInNthEmailRecipient(int $nth, string $expected): void + { + $this->seeInEmailRecipients($this->nthMessage($nth), $expected); + } + /** * See In Last Email To * @@ -108,6 +148,11 @@ public function seeInLastEmailTo(string $address, string $expected): void $this->seeInEmail($email, $expected); } + public function seeInNthEmailTo(int $nth, string $address, string $expected): void + { + $this->seeInEmail($this->nthMessageTo($nth, $address), $expected); + } + /** * Don't See In Last Email To * @@ -119,6 +164,11 @@ public function dontSeeInLastEmailTo(string $address, string $unexpected): void $this->dontSeeInEmail($email, $unexpected); } + public function dontSeeInNthEmailTo(int $nth, string $address, string $unexpected): void + { + $this->dontSeeInEmail($this->nthMessageTo($nth, $address), $unexpected); + } + /** * See In Last Email Subject To * @@ -132,6 +182,11 @@ public function seeInLastEmailSubjectTo(string $address, string $expected): void $this->seeInEmailSubject($email, $expected); } + public function seeInNthEmailSubjectTo(int $nth, string $address, string $expected): void + { + $this->seeInEmailSubject($this->nthMessageTo($nth, $address), $expected); + } + /** * Don't See In Last Email Subject To * @@ -143,6 +198,11 @@ public function dontSeeInLastEmailSubjectTo(string $address, string $unexpected) $this->dontSeeInEmailSubject($email, $unexpected); } + public function dontSeeInNthEmailSubjectTo(int $nth, string $address, string $unexpected): void + { + $this->dontSeeInEmailSubject($this->nthMessageTo($nth, $address), $unexpected); + } + public function lastMessage(): \Codeception\Util\Email { $messages = $this->messages(); @@ -155,6 +215,11 @@ public function lastMessage(): \Codeception\Util\Email return $this->emailFromId($last['id']); } + public function nthMessage(int $nth): \Codeception\Util\Email + { + return $this->emailAtPosition($this->messagesInReceivedOrder(), $nth, "No message found at position {$nth}"); + } + public function lastMessageTo(string $address): \Codeception\Util\Email { $ids = []; @@ -164,10 +229,8 @@ public function lastMessageTo(string $address): \Codeception\Util\Email } foreach ($messages as $message) { - foreach ($message['recipients'] as $recipient) { - if (strpos($recipient, $address) !== false) { - $ids[] = $message['id']; - } + if ($this->isSentTo($message, $address)) { + $ids[] = $message['id']; } } @@ -178,6 +241,19 @@ public function lastMessageTo(string $address): \Codeception\Util\Email return $this->emailFromId(max($ids)); } + public function nthMessageTo(int $nth, string $address): \Codeception\Util\Email + { + $messages = array_filter( + $this->messagesInReceivedOrder(), + fn (array $message): bool => $this->isSentTo($message, $address) + ); + if (empty($messages)) { + $this->fail("No messages sent to {$address}"); + } + + return $this->emailAtPosition($messages, $nth, "No message found at position {$nth} sent to {$address}"); + } + public function lastMessageFrom(string $address): \Codeception\Util\Email { $ids = []; @@ -221,6 +297,14 @@ public function grabMatchesFromLastEmail(string $regex): array return $this->grabMatchesFromEmail($email, $regex); } + /** + * @return mixed[] + */ + public function grabMatchesFromNthEmail(int $nth, string $regex): array + { + return $this->grabMatchesFromEmail($this->nthMessage($nth), $regex); + } + /** * Grab From Last Email * @@ -234,6 +318,11 @@ public function grabFromLastEmail(string $regex): string return $matches[0]; } + public function grabFromNthEmail(int $nth, string $regex): string + { + return $this->grabMatchesFromNthEmail($nth, $regex)[0]; + } + /** * Grab Matches From Last Email To * @@ -249,6 +338,14 @@ public function grabMatchesFromLastEmailTo(string $address, string $regex): arra return $this->grabMatchesFromEmail($email, $regex); } + /** + * @return mixed[] + */ + public function grabMatchesFromNthEmailTo(int $nth, string $address, string $regex): array + { + return $this->grabMatchesFromEmail($this->nthMessageTo($nth, $address), $regex); + } + /** * Grab From Last Email To * @@ -263,6 +360,11 @@ public function grabFromLastEmailTo(string $address, string $regex): string return $matches[0]; } + public function grabFromNthEmailTo(int $nth, string $address, string $regex): string + { + return $this->grabMatchesFromNthEmailTo($nth, $address, $regex)[0]; + } + /** * Grab Urls From Email * @@ -273,18 +375,15 @@ public function grabFromLastEmailTo(string $address, string $regex): string */ public function grabUrlsFromLastEmail(): array { - $regex = '#\bhttps?://[^,\s()<>]+(?:\([\w\d]+\)|([^,[:punct:]\s]|/))#'; - $email = $this->lastMessage(); - - $message = Message::from($email->getSource(), false); - - $text = $message->getTextContent(); - preg_match_all($regex, $text, $text_matches); - - $html = $message->getHtmlContent(); - preg_match_all($regex, $html, $html_matches); + return $this->grabUrlsFromEmail($this->lastMessage()); + } - return array_merge($text_matches[0], $html_matches[0]); + /** + * @return string[] + */ + public function grabUrlsFromNthEmail(int $nth): array + { + return $this->grabUrlsFromEmail($this->nthMessage($nth)); } /** @@ -297,19 +396,15 @@ public function grabUrlsFromLastEmail(): array */ public function grabAttachmentsFromLastEmail(): array { - $email = $this->lastMessage(); - - $message = Message::from($email->getSource(), false); - - $attachments = []; - - foreach ($message->getAllAttachmentParts() as $attachmentPart) { - $filename = $attachmentPart->getFilename(); - $content = $attachmentPart->getContent(); - $attachments[$filename] = $content; - } + return $this->grabAttachmentsFromEmail($this->lastMessage()); + } - return $attachments; + /** + * @return array + */ + public function grabAttachmentsFromNthEmail(int $nth): array + { + return $this->grabAttachmentsFromEmail($this->nthMessage($nth)); } /** @@ -321,15 +416,12 @@ public function grabAttachmentsFromLastEmail(): array **/ public function seeAttachmentInLastEmail(string $expectedFilename): void { - $email = $this->lastMessage(); - $message = Message::from($email->getSource(), false); + $this->seeAttachmentInEmail($this->lastMessage(), $expectedFilename); + } - foreach ($message->getAllAttachmentParts() as $attachmentPart) { - if ($attachmentPart->getFilename() === $expectedFilename) { - return; - } - } - $this->fail("Filename not found in attachments."); + public function seeAttachmentInNthEmail(int $nth, string $expectedFilename): void + { + $this->seeAttachmentInEmail($this->nthMessage($nth), $expectedFilename); } /** @@ -351,9 +443,12 @@ public function seeEmailCount(int $expected): void **/ public function seeEmailAttachmentCount(int $expectedCount): void { - $email = $this->lastMessage(); - $message = Message::from($email->getSource(), false); - $this->assertEquals($expectedCount, $message->getAttachmentCount()); + $this->seeAttachmentCountInEmail($this->lastMessage(), $expectedCount); + } + + public function seeNthEmailAttachmentCount(int $nth, int $expectedCount): void + { + $this->seeAttachmentCountInEmail($this->nthMessage($nth), $expectedCount); } // ----------- HELPER METHODS BELOW HERE -----------------------// @@ -370,14 +465,49 @@ protected function messages(): array $messages = json_decode($response->getBody(), true); // Ensure messages are shown in the order they were recieved // https://github.com/sj26/mailcatcher/pull/184 - usort($messages, function ($messageA, $messageB): int { - $sortKeyA = $messageA['created_at'] . $messageA['id']; - $sortKeyB = $messageB['created_at'] . $messageB['id']; - return ($sortKeyA > $sortKeyB) ? -1 : 1; - }); + usort( + $messages, + static fn (array $messageA, array $messageB): int + => [$messageB['created_at'], $messageB['id']] <=> [$messageA['created_at'], $messageA['id']] + ); return $messages; } + protected function messagesInReceivedOrder(): array + { + $messages = $this->messages(); + if (empty($messages)) { + $this->fail("No messages received"); + } + + return array_reverse($messages); + } + + protected function emailAtPosition(array $messages, int $nth, string $notFoundMessage): \Codeception\Util\Email + { + if ($nth < 1) { + $this->fail("Position must be 1 or greater, {$nth} given"); + } + + $message = array_values($messages)[$nth - 1] ?? null; + if ($message === null) { + $this->fail($notFoundMessage); + } + + return $this->emailFromId($message['id']); + } + + protected function isSentTo(array $message, string $address): bool + { + foreach ($message['recipients'] as $recipient) { + if (str_contains($recipient, $address)) { + return true; + } + } + + return false; + } + /** * @param int|string $id */ @@ -411,10 +541,62 @@ protected function dontSeeInEmail(Email $email, string $unexpected): void $this->assertStringNotContainsString($unexpected, $email->getSourceQuotedPrintableDecoded(), "Email Does Not Contain"); } + protected function seeInEmailSender(Email $email, string $expected): void + { + $this->assertStringContainsString($expected, $email->getSender(), "Email Sender Contains"); + } + + protected function seeInEmailRecipients(Email $email, string $expected): void + { + $this->assertStringContainsString($expected, implode(', ', $email->getRecipients()), "Email Recipients Contain"); + } + protected function grabMatchesFromEmail(Email $email, string $regex): array { preg_match($regex, $email->getSourceQuotedPrintableDecoded(), $matches); $this->assertNotEmpty($matches, "No matches found for $regex"); return $matches; } + + /** + * @return string[] + */ + protected function grabUrlsFromEmail(Email $email): array + { + $regex = '#\bhttps?://[^,\s()<>]+(?:\([\w\d]+\)|([^,[:punct:]\s]|/))#'; + $message = Message::from($email->getSource(), false); + + preg_match_all($regex, $message->getTextContent() ?? '', $textMatches); + preg_match_all($regex, $message->getHtmlContent() ?? '', $htmlMatches); + + return array_merge($textMatches[0], $htmlMatches[0]); + } + + /** + * @return array + */ + protected function grabAttachmentsFromEmail(Email $email): array + { + $attachments = []; + foreach (Message::from($email->getSource(), false)->getAllAttachmentParts() as $attachmentPart) { + $attachments[$attachmentPart->getFilename()] = $attachmentPart->getContent(); + } + + return $attachments; + } + + protected function seeAttachmentInEmail(Email $email, string $expectedFilename): void + { + foreach (Message::from($email->getSource(), false)->getAllAttachmentParts() as $attachmentPart) { + if ($attachmentPart->getFilename() === $expectedFilename) { + return; + } + } + $this->fail("Filename not found in attachments."); + } + + protected function seeAttachmentCountInEmail(Email $email, int $expectedCount): void + { + $this->assertEquals($expectedCount, Message::from($email->getSource(), false)->getAttachmentCount()); + } } diff --git a/src/Util/Email.php b/src/Util/Email.php index a9699db..70da9a3 100644 --- a/src/Util/Email.php +++ b/src/Util/Email.php @@ -4,35 +4,16 @@ class Email { - /** - * @var int - */ - private $id; - - /** - * @var string[] - */ - private $recipients; - - /** - * @var string - */ - private $subject; - - /** - * @var string - */ - private $source; - /** * @param string[] $recipients */ - public function __construct(int $id, array $recipients, ?string $subject, string $source) - { - $this->id = $id; - $this->recipients = $recipients; - $this->subject = $subject; - $this->source = $source; + public function __construct( + private readonly int $id, + private readonly array $recipients, + private readonly ?string $subject, + private readonly string $source, + private readonly string $sender = '', + ) { } public function getId(): int @@ -40,6 +21,11 @@ public function getId(): int return $this->id; } + public function getSender(): string + { + return $this->sender; + } + /** * @return string[] */ @@ -65,6 +51,6 @@ public function getSourceQuotedPrintableDecoded(): string public static function createFromMailcatcherData(array $data): \Codeception\Util\Email { - return new self($data['id'], $data['recipients'], $data['subject'], $data['source']); + return new self($data['id'], $data['recipients'], $data['subject'], $data['source'], $data['sender'] ?? ''); } -} \ No newline at end of file +} diff --git a/tests/_support/Helper/Acceptance.php b/tests/_support/Helper/Acceptance.php index 8a9275f..01a4ea1 100644 --- a/tests/_support/Helper/Acceptance.php +++ b/tests/_support/Helper/Acceptance.php @@ -15,7 +15,8 @@ public function sendEmail( string $body, bool $isHtml = false, ?string $encoding = null, - array $attachments = [] + array $attachments = [], + string $from = 'sender@example.com' ): void { $phpmailer = new PHPMailer(); $phpmailer->isSMTP(); @@ -29,7 +30,7 @@ public function sendEmail( } $phpmailer->addAddress($to); - $phpmailer->setFrom("sender@example.com"); + $phpmailer->setFrom($from); $phpmailer->Subject = $subject; $phpmailer->Body = $body; $phpmailer->isHTML($isHtml); diff --git a/tests/acceptance/MailcatcherCest.php b/tests/acceptance/MailcatcherCest.php index 67c6c74..5d3edc6 100644 --- a/tests/acceptance/MailcatcherCest.php +++ b/tests/acceptance/MailcatcherCest.php @@ -284,4 +284,107 @@ public function test_fail_attachment_count_in_mail(AcceptanceTester $I) $I->seeEmailAttachmentCount(3); }); } + + public function test_see_in_nth_email(AcceptanceTester $I) + { + $I->sendEmail('user@example.com', 'Subject Line', "First"); + $I->sendEmail('user@example.com', 'Subject Line', "Second"); + $I->sendEmail('user@example.com', 'Subject Line', "Third"); + $I->seeInNthEmail(2, "Second"); + $I->dontSeeInNthEmail(2, "Third"); + } + + public function test_see_in_nth_email_subject(AcceptanceTester $I) + { + $I->sendEmail('user@example.com', 'First Subject', "Hello World!"); + $I->sendEmail('user@example.com', 'Second Subject', "Hello World!"); + $I->sendEmail('user@example.com', 'Third Subject', "Hello World!"); + $I->seeInNthEmailSubject(2, 'Second Subject'); + $I->dontSeeInNthEmailSubject(2, 'Third Subject'); + } + + public function test_fail_nth_email_beyond_received_emails(AcceptanceTester $I) + { + $I->sendEmail('user@example.com', 'Subject Line', "Hello World!"); + $I->expectThrowable(new Exception("No message found at position 2"), function() use ($I) { + $I->seeInNthEmail(2, "Hello World!"); + }); + } + + public function test_see_in_email_sender(AcceptanceTester $I) + { + $I->sendEmail('user@example.com', 'Subject Line', "Hello World!", false, null, [], 'first@example.com'); + $I->sendEmail('user@example.com', 'Subject Line', "Hello World!", false, null, [], 'second@example.com'); + $I->sendEmail('user@example.com', 'Subject Line', "Hello World!", false, null, [], 'third@example.com'); + $I->seeInLastEmailSender('third@example.com'); + $I->seeInNthEmailSender(2, 'second@example.com'); + } + + public function test_see_in_email_recipient(AcceptanceTester $I) + { + $I->sendEmail('userA@example.com', 'Subject Line', "Hello World!"); + $I->sendEmail('userB@example.com', 'Subject Line', "Hello World!"); + $I->sendEmail('userC@example.com', 'Subject Line', "Hello World!"); + $I->seeInLastEmailRecipient('userC@example.com'); + $I->seeInNthEmailRecipient(2, 'userB@example.com'); + } + + public function test_see_in_nth_email_to(AcceptanceTester $I) + { + $user = "userA@example.com"; + $I->sendEmail($user, 'First Subject', "First"); + $I->sendEmail('userB@example.com', 'Other Subject', "Other"); + $I->sendEmail($user, 'Second Subject', "Second"); + $I->sendEmail($user, 'Third Subject', "Third"); + $I->seeInNthEmailTo(2, $user, "Second"); + $I->dontSeeInNthEmailTo(2, $user, "Other"); + $I->seeInNthEmailSubjectTo(2, $user, 'Second Subject'); + $I->dontSeeInNthEmailSubjectTo(2, $user, 'Other Subject'); + } + + public function test_grab_from_nth_email(AcceptanceTester $I) + { + $I->sendEmail("user@example.com", 'Subject Line', "Hello World!"); + $I->sendEmail("user@example.com", 'Subject Line', "Hello Codeception!"); + $I->sendEmail("user@example.com", 'Subject Line', "Hello Again!"); + $I->assertEquals(['Hello Codeception', 'Codeception'], $I->grabMatchesFromNthEmail(2, "/Hello (Codeception)/")); + $I->assertEquals('Hello Codeception', $I->grabFromNthEmail(2, "/Hello (Codeception)/")); + } + + public function test_grab_from_nth_email_to(AcceptanceTester $I) + { + $user = "user@example.com"; + $I->sendEmail($user, 'Subject Line', "Hello World!"); + $I->sendEmail("userB@example.com", 'Subject Line', "Hello Other!"); + $I->sendEmail($user, 'Subject Line', "Hello Codeception!"); + $I->assertEquals(['Hello Codeception', 'Codeception'], $I->grabMatchesFromNthEmailTo(2, $user, "/Hello (\w+)/")); + $I->assertEquals('Hello Codeception', $I->grabFromNthEmailTo(2, $user, "/Hello (\w+)/")); + } + + public function test_grab_urls_from_nth_email(AcceptanceTester $I) + { + $url = "http://example.com/list.php?page=56"; + $I->sendEmail("user@example.com", 'Subject Line', "I have no URLs."); + $I->sendEmail("user@example.com", 'Subject Line', "I'm in $url ."); + $I->sendEmail("user@example.com", 'Subject Line', "I'm in https://example.org ."); + $I->assertEquals([$url], $I->grabUrlsFromNthEmail(2)); + } + + public function test_attachments_in_nth_email(AcceptanceTester $I) + { + $user = "user@example.com"; + $attachments = [ + "image.jpg" => codecept_data_dir('image.jpg'), + "lorem.txt" => codecept_data_dir('lorem.txt'), + ]; + + $I->sendEmail($user, 'Email without attachments', "I have no attachments."); + $I->sendEmail($user, 'Email with attachments', "I have attachments.", false, null, $attachments); + $I->sendEmail($user, 'Email without attachments', "I have no attachments."); + + $I->assertEquals(['image.jpg', 'lorem.txt'], array_keys($I->grabAttachmentsFromNthEmail(2))); + $I->seeAttachmentInNthEmail(2, 'lorem.txt'); + $I->seeNthEmailAttachmentCount(2, 2); + $I->seeNthEmailAttachmentCount(3, 0); + } } diff --git a/tests/unit/Module/MailCatcherTest.php b/tests/unit/Module/MailCatcherTest.php index f27377c..da67304 100644 --- a/tests/unit/Module/MailCatcherTest.php +++ b/tests/unit/Module/MailCatcherTest.php @@ -146,39 +146,18 @@ public function testLastMessageFromNoMessages() * * @return void */ - public function lastMessageFrom() + public function testLastMessageFrom() { - $handler = new MockHandler([ - new Response(200, [], json_encode([ - [ - 'id' => 1, - 'created_at' => date('c'), - 'sender' => 'sender@example.com', - 'recipients' => ['user@example.com'], - ], - [ - 'id' => 2, - 'created_at' => date('c'), - 'sender' => 'sender2@example.com', - 'recipients' => ['user2@example.com'], - ], - [ - 'id' => 3, - 'created_at' => date('c'), - 'sender' => 'sender3@example.com', - 'recipients' => ['user3@example.com'], - ] - ])) - ]); - $client = new Client(['handler' => $handler]); - - $mailcatcher = new MailCatcherTest_TestClass(); - $mailcatcher->setClient($client); - - $this->assertEquals( - $mailcatcher->getLastMessageFrom('sender2@example.com'), - 2 + $mailcatcher = $this->mailcatcherWithMessages( + [ + $this->message(1, 'sender@example.com', ['user@example.com']), + $this->message(2, 'sender2@example.com', ['user2@example.com']), + $this->message(3, 'sender3@example.com', ['user3@example.com']), + ], + new Email(2, [], '', ''), ); + + $this->assertEquals(2, $mailcatcher->lastMessageFrom('sender2@example.com')->getId()); } public function testSeeInLastEmailTo() @@ -262,6 +241,128 @@ public function testSeeEmailCountFail() $mailcatcher->seeEmailCount(3); } + + public function testLastMessageUsesIdAsTieBreakerForSameTimestamp() + { + $mailcatcher = $this->mailcatcherWithMessages( + [$this->message(9), $this->message(10)], + new Email(9, [], '', 'ninth'), + new Email(10, [], '', 'tenth'), + ); + + $this->assertEquals(10, $mailcatcher->lastMessage()->getId()); + } + + public function testNthMessageCountsFromTheFirstReceivedEmail() + { + $mailcatcher = $this->mailcatcherWithMessages( + [$this->message(3), $this->message(1), $this->message(2)], + new Email(2, [], '', 'second'), + ); + + $this->assertEquals(2, $mailcatcher->nthMessage(2)->getId()); + } + + public function testNthMessageNoMessages() + { + $mailcatcher = $this->mailcatcherWithMessages([]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('No messages received'); + + $mailcatcher->nthMessage(1); + } + + public function testNthMessageBeyondReceivedEmails() + { + $mailcatcher = $this->mailcatcherWithMessages([$this->message(1)]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('No message found at position 2'); + + $mailcatcher->nthMessage(2); + } + + public function testNthMessageRejectsPositionBelowOne() + { + $mailcatcher = $this->mailcatcherWithMessages([$this->message(1)]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Position must be 1 or greater, 0 given'); + + $mailcatcher->nthMessage(0); + } + + public function testNthMessageToCountsOnlyEmailsSentToAddress() + { + $mailcatcher = $this->mailcatcherWithMessages( + [ + $this->message(1, recipients: ['', '']), + $this->message(2, recipients: ['']), + $this->message(3, recipients: ['']), + ], + new Email(3, [], '', 'second to userA'), + ); + + $this->assertEquals(3, $mailcatcher->nthMessageTo(2, 'userA@example.com')->getId()); + } + + public function testSeeInLastEmailSender() + { + $mailcatcher = new MailCatcherTest_TestClass(); + $mailcatcher->setLastMessage(new Email(1, [], '', '', '')); + + $mailcatcher->seeInLastEmailSender('sender@example.com'); + } + + public function testSeeInLastEmailSenderFail() + { + $mailcatcher = new MailCatcherTest_TestClass(); + $mailcatcher->setLastMessage(new Email(1, [], '', '', '')); + + $this->expectException(AssertionFailedError::class); + + $mailcatcher->seeInLastEmailSender('other@example.com'); + } + + public function testSeeInLastEmailRecipientMatchesAnyRecipient() + { + $mailcatcher = new MailCatcherTest_TestClass(); + $mailcatcher->setLastMessage(new Email(1, ['', ''], '', '')); + + $mailcatcher->seeInLastEmailRecipient('userB@example.com'); + } + + public function testSeeInLastEmailRecipientFail() + { + $mailcatcher = new MailCatcherTest_TestClass(); + $mailcatcher->setLastMessage(new Email(1, [''], '', '')); + + $this->expectException(AssertionFailedError::class); + + $mailcatcher->seeInLastEmailRecipient('userB@example.com'); + } + + private function mailcatcherWithMessages(array $messages, Email ...$emails): MailCatcherTest_TestClass + { + $mailcatcher = new MailCatcherTest_TestClass(); + $mailcatcher->setClient(new Client(['handler' => new MockHandler([ + new Response(200, [], json_encode($messages)), + ])])); + $mailcatcher->setEmails(...$emails); + + return $mailcatcher; + } + + private function message(int $id, string $sender = '', array $recipients = ['']): array + { + return [ + 'id' => $id, + 'created_at' => '2026-09-30T10:00:00+00:00', + 'sender' => $sender, + 'recipients' => $recipients, + ]; + } } class MailCatcherTest_TestClass extends MailCatcher @@ -270,11 +371,28 @@ class MailCatcherTest_TestClass extends MailCatcher private $lastMessageTo; private $lastMessageFrom; + /** + * @var array + */ + private array $emails = []; + public function __construct() { } + public function setEmails(Email ...$emails) + { + foreach ($emails as $email) { + $this->emails[$email->getId()] = $email; + } + } + + protected function emailFromId($id): Email + { + return $this->emails[$id] ?? parent::emailFromId($id); + } + public function getClient() { return $this->mailcatcher; diff --git a/tests/unit/Util/EmailTest.php b/tests/unit/Util/EmailTest.php index ce3bf75..7341ed5 100644 --- a/tests/unit/Util/EmailTest.php +++ b/tests/unit/Util/EmailTest.php @@ -12,18 +12,28 @@ public function testGetters() $this->assertEquals(['some@email.com'], $email->getRecipients()); $this->assertEquals('Some subject', $email->getSubject()); $this->assertEquals('Source body', $email->getSource()); + $this->assertEquals('', $email->getSender()); + } + + public function testGetSender() + { + $email = new Email(1, ['some@email.com'], 'Some subject', 'Source body', ''); + + $this->assertEquals('', $email->getSender()); } public function testCreateFromMailcatcherData() { $email = Email::createFromMailcatcherData([ 'id' => 1, + 'sender' => '', 'recipients' => ['some@email.com'], 'subject' => 'Some subject', 'source' => 'Source body' ]); $this->assertEquals(1, $email->getId()); + $this->assertEquals('', $email->getSender()); $this->assertEquals(['some@email.com'], $email->getRecipients()); $this->assertEquals('Some subject', $email->getSubject()); $this->assertEquals('Source body', $email->getSource()); From e54a249a80447f1dca90c7a68735fa5ede0f7e8e Mon Sep 17 00:00:00 2001 From: Wolfgang Klinger Date: Thu, 1 Oct 2026 11:07:02 +0200 Subject: [PATCH 2/3] Check each recipient separately in recipient assertions Joining the recipients into one string let an expectation match across recipient boundaries, so seeInLastEmailRecipient and seeInNthEmailRecipient passed for values no single recipient contains. --- src/Module/MailCatcher.php | 11 ++++++++++- tests/_support/Helper/Acceptance.php | 6 ++++-- tests/acceptance/MailcatcherCest.php | 9 +++++++++ tests/unit/Module/MailCatcherTest.php | 10 ++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Module/MailCatcher.php b/src/Module/MailCatcher.php index 61fc6d7..02167e6 100644 --- a/src/Module/MailCatcher.php +++ b/src/Module/MailCatcher.php @@ -548,7 +548,16 @@ protected function seeInEmailSender(Email $email, string $expected): void protected function seeInEmailRecipients(Email $email, string $expected): void { - $this->assertStringContainsString($expected, implode(', ', $email->getRecipients()), "Email Recipients Contain"); + $recipients = $email->getRecipients(); + $matchingRecipients = array_filter( + $recipients, + static fn (string $recipient): bool => str_contains($recipient, $expected) + ); + + $this->assertNotEmpty( + $matchingRecipients, + sprintf('Failed asserting that one of the email recipients [%s] contains "%s".', implode(', ', $recipients), $expected) + ); } protected function grabMatchesFromEmail(Email $email, string $regex): array diff --git a/tests/_support/Helper/Acceptance.php b/tests/_support/Helper/Acceptance.php index 01a4ea1..02d7a89 100644 --- a/tests/_support/Helper/Acceptance.php +++ b/tests/_support/Helper/Acceptance.php @@ -10,7 +10,7 @@ class Acceptance extends \Codeception\Module { public function sendEmail( - string $to, + string|array $to, string $subject, string $body, bool $isHtml = false, @@ -29,7 +29,9 @@ public function sendEmail( $phpmailer->addAttachment($attachment, $attachmentName); } - $phpmailer->addAddress($to); + foreach ((array) $to as $address) { + $phpmailer->addAddress($address); + } $phpmailer->setFrom($from); $phpmailer->Subject = $subject; $phpmailer->Body = $body; diff --git a/tests/acceptance/MailcatcherCest.php b/tests/acceptance/MailcatcherCest.php index 5d3edc6..aec5cf9 100644 --- a/tests/acceptance/MailcatcherCest.php +++ b/tests/acceptance/MailcatcherCest.php @@ -329,6 +329,15 @@ public function test_see_in_email_recipient(AcceptanceTester $I) $I->seeInNthEmailRecipient(2, 'userB@example.com'); } + public function test_see_in_email_recipient_checks_each_recipient(AcceptanceTester $I) + { + $I->sendEmail(['alice@example.com', 'bob@example.com'], 'Subject Line', "Hello World!"); + $I->seeInLastEmailRecipient('bob@example.com'); + $I->expectThrowable(\PHPUnit\Framework\AssertionFailedError::class, function() use ($I) { + $I->seeInNthEmailRecipient(1, 'alice@example.com>, seeInLastEmailRecipient('userB@example.com'); } + public function testSeeInLastEmailRecipientDoesNotMatchAcrossRecipients() + { + $mailcatcher = new MailCatcherTest_TestClass(); + $mailcatcher->setLastMessage(new Email(1, ['', ''], '', '')); + + $this->expectException(AssertionFailedError::class); + + $mailcatcher->seeInLastEmailRecipient(', '); + } + public function testSeeInLastEmailRecipientFail() { $mailcatcher = new MailCatcherTest_TestClass(); From 1019fa45c000ab501625060526c382ddc675092a Mon Sep 17 00:00:00 2001 From: Wolfgang Klinger Date: Thu, 1 Oct 2026 11:10:11 +0200 Subject: [PATCH 3/3] Cover received-time ordering and document substring address matching Test that nth positions follow created_at before the id tie-breaker, including the first and last position. Document that ...To addresses match by substring, as the existing ...To actions always have. --- README.md | 2 ++ tests/unit/Module/MailCatcherTest.php | 24 +++++++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 64d49a0..3b02dc5 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,8 @@ Every "last email" action has an "nth email" counterpart that takes the position as its first argument. Positions start at 1 and count in the order the emails were received, so `1` is the first email and `2` the second. For the `...To` variants, only emails sent to the given address are counted. +Like the existing `...To` actions, addresses match by substring, so +`user@example.com` also matches `superuser@example.com`. | Last email | Nth email | | --- | --- | diff --git a/tests/unit/Module/MailCatcherTest.php b/tests/unit/Module/MailCatcherTest.php index de14f85..496d286 100644 --- a/tests/unit/Module/MailCatcherTest.php +++ b/tests/unit/Module/MailCatcherTest.php @@ -263,6 +263,20 @@ public function testNthMessageCountsFromTheFirstReceivedEmail() $this->assertEquals(2, $mailcatcher->nthMessage(2)->getId()); } + public function testNthMessageOrdersByReceivedTimeBeforeId() + { + $messages = [ + $this->message(1, createdAt: '2026-09-30T10:00:02+00:00'), + $this->message(2, createdAt: '2026-09-30T10:00:01+00:00'), + $this->message(3, createdAt: '2026-09-30T10:00:00+00:00'), + ]; + $emails = [new Email(1, [], '', 'newest'), new Email(3, [], '', 'oldest')]; + + $this->assertEquals(3, $this->mailcatcherWithMessages($messages, ...$emails)->nthMessage(1)->getId()); + $this->assertEquals(1, $this->mailcatcherWithMessages($messages, ...$emails)->nthMessage(3)->getId()); + $this->assertEquals(1, $this->mailcatcherWithMessages($messages, ...$emails)->lastMessage()->getId()); + } + public function testNthMessageNoMessages() { $mailcatcher = $this->mailcatcherWithMessages([]); @@ -364,11 +378,15 @@ private function mailcatcherWithMessages(array $messages, Email ...$emails): Mai return $mailcatcher; } - private function message(int $id, string $sender = '', array $recipients = ['']): array - { + private function message( + int $id, + string $sender = '', + array $recipients = [''], + string $createdAt = '2026-09-30T10:00:00+00:00' + ): array { return [ 'id' => $id, - 'created_at' => '2026-09-30T10:00:00+00:00', + 'created_at' => $createdAt, 'sender' => $sender, 'recipients' => $recipients, ];