teamerRepository = $this->createMock(TeamerRepository::class); $this->mailer = $this->createMock(Mailer::class); // The real renderer, not a mock: what the mailing puts in front of a teamer is // Markdown turned into HTML, and getting that wrong is the whole risk here. $this->service = new TeamerMailingService( $this->teamerRepository, $this->mailer, new MailBodyRenderer(), $this->createMock(LoggerInterface::class), 'mailing', ); } public function testRenderReplacesAllNamePlaceholders(): void { $recipient = $this->createRecipient('Anna', 'Berg', 'anna@example.org'); $rendered = $this->service->render( 'Hallo {{vorname}} {{nachname}}, alias {{name}}!', $this->service->placeholderValuesFor($recipient) ); $this->assertSame('Hallo Anna Berg, alias Anna Berg!', $rendered); } public function testRenderLeavesUnknownPlaceholdersUntouched(): void { $recipient = $this->createRecipient('Anna', 'Berg', 'anna@example.org'); $rendered = $this->service->render( 'Hallo {{vorname}}, {{unbekannt}} bleibt stehen', $this->service->placeholderValuesFor($recipient) ); $this->assertSame('Hallo Anna, {{unbekannt}} bleibt stehen', $rendered); } public function testRecipientIsBuiltFromARepositoryRow(): void { $deletedAt = new \DateTimeImmutable('2026-01-02 03:04:05'); $recipient = TeamerMailingRecipient::fromRow([ 'id' => 42, 'firstName' => 'Anna', 'lastName' => 'Berg', 'email' => 'anna@example.org', 'deletedAt' => $deletedAt, 'disabledAt' => null, ]); $this->assertSame(42, $recipient->getId()); $this->assertSame('Anna Berg', $recipient->getFullName()); $this->assertSame('anna@example.org', $recipient->getEmail()); $this->assertTrue($recipient->isDeleted()); $this->assertFalse($recipient->isDisabled()); } public function testResolveRecipientsPartitionsBySkipReason(): void { $eligible = $this->createRecipient('Anna', 'Berg', 'anna@example.org'); $deleted = $this->createRecipient('Bea', 'Ohm', 'bea@example.org', deleted: true); $disabled = $this->createRecipient('Cem', 'Dal', 'cem@example.org', disabled: true); $noEmail = $this->createRecipient('Dana', 'Elf', null); $blankEmail = $this->createRecipient('Emil', 'Fux', ' '); $this->teamerRepository ->method('getMailingRecipients') ->willReturn([$eligible, $deleted, $disabled, $noEmail, $blankEmail]) ; $recipients = $this->service->resolveRecipients(new TeamerFilterDto()); $this->assertSame([$eligible], $recipients->getEligible()); $this->assertSame(1, $recipients->getSkippedDeleted()); $this->assertSame(1, $recipients->getSkippedDisabled()); $this->assertSame(2, $recipients->getSkippedNoEmail()); $this->assertSame(5, $recipients->getTotal()); } public function testSendMailingPersonalisesSubjectAndMessagePerRecipient(): void { $sent = []; $this->mailer ->expects($this->exactly(2)) ->method('createAndSendEmail') ->willReturnCallback(function (array $context, array $options) use (&$sent): void { $sent[] = [$options['to'], $options['subject'], $context['bodyHtml']]; }) ; $this->service->sendMailing($this->createMailing( 'Hallo {{vorname}}', 'Servus {{name}}', $this->createRecipient('Anna', 'Berg', 'anna@example.org'), $this->createRecipient('Bea', 'Ohm', 'bea@example.org') )); $this->assertSame([ ['anna@example.org', 'Hallo Anna', '
Servus Anna Berg
'], ['bea@example.org', 'Hallo Bea', 'Servus Bea Ohm
'], ], $sent); } /** * Without both transports a mailing silently leaves over the webhoster's relay on the * shared queue - it still arrives, so nothing about it looks broken, which is exactly * why it is pinned here. */ public function testSendMailingGoesOutOverTheMailingTransports(): void { $this->mailer ->expects($this->once()) ->method('createAndSendEmail') ->willReturnCallback(function (array $context, array $options): void { $this->assertSame('mailing', $options['transport']); $this->assertSame('mailing', $options['bus_transport']); }) ; $this->service->sendMailing($this->createMailing( 'Betreff', 'Nachricht', $this->createRecipient('Anna', 'Berg', 'anna@example.org') )); } public function testSendMailingWithoutRecipientsSendsNothing(): void { $this->mailer ->expects($this->never()) ->method('createAndSendEmail') ; $this->service->sendMailing($this->createMailing('Betreff', 'Nachricht')); } /** * A preview has to stay on the default relay and the default queue, or composing a * mailing would wait on the same worker the mailing itself is queued behind. */ public function testPreviewLeavesTheTransportsAlone(): void { $this->mailer ->expects($this->once()) ->method('createAndSendEmail') ->willReturnCallback(function (array $context, array $options): void { $this->assertNull($options['transport']); $this->assertNull($options['bus_transport']); }) ; $mailingDto = (new TeamerMailingDto()) ->setSubject('Hallo {{vorname}}') ->setMessage('Servus {{name}}') ; $this->service->sendPreview($mailingDto, (new User())->setEmail('admin@example.org')); } public function testMailingSnapshotSkipsRecipientsWithoutAnAddress(): void { $recipients = (new TeamerMailingRecipientsDto()) ->addEligible($this->createRecipient('Anna', 'Berg', 'anna@example.org')) ->addEligible($this->createRecipient('Dana', 'Elf', null)) ; $mailing = SendTeamerMailing::fromRecipients('Betreff', 'Nachricht', $recipients); $this->assertSame(1, $mailing->getRecipientCount()); $this->assertSame('anna@example.org', $mailing->getRecipients()[0]['email']); $this->assertSame('Anna Berg', $mailing->getRecipients()[0]['fullName']); } public function testSendPreviewUsesAdminOwnNameAndMarksTheSubject(): void { $admin = (new User()) ->setEmail('admin@example.org') ->setFirstName('Rita') ->setLastName('Kern') ; $this->mailer ->expects($this->once()) ->method('createAndSendEmail') ->with( ['bodyHtml' => 'Servus Rita Kern
'], $this->callback(function (array $options): bool { $this->assertSame('admin@example.org', $options['to']); $this->assertSame('[Vorschau] Hallo Rita', $options['subject']); return true; }) ) ; $mailingDto = (new TeamerMailingDto()) ->setSubject('Hallo {{vorname}}') ->setMessage('Servus {{name}}') ; $this->service->sendPreview($mailingDto, $admin); } public function testSendPreviewPrefersTheAdminsOwnTeamerRecord(): void { $teamer = (new Teamer())->setFirstName('Rita')->setLastName('Kern-Teamer'); $admin = (new User()) ->setEmail('admin@example.org') ->setFirstName('Rita') ->setLastName('Kern-User') ->setTeamer($teamer) ; $this->assertSame( 'Kern-Teamer', $this->service->placeholderValuesForUser($admin)[TeamerMailingService::PLACEHOLDER_LAST_NAME] ); } public function testPreviewFallsBackToTheAddressWhenTheAdminHasNoName(): void { $admin = (new User())->setEmail('rita.kern@example.org'); $values = $this->service->placeholderValuesForUser($admin); $this->assertSame('rita.kern', $values[TeamerMailingService::PLACEHOLDER_FIRST_NAME]); $this->assertSame('rita.kern', $values[TeamerMailingService::PLACEHOLDER_FULL_NAME]); } private function createMailing(string $subject, string $message, TeamerMailingRecipient ...$recipients): SendTeamerMailing { $dto = new TeamerMailingRecipientsDto(); foreach ($recipients as $recipient) { $dto->addEligible($recipient); } return SendTeamerMailing::fromRecipients($subject, $message, $dto); } private function createRecipient( string $firstName, string $lastName, ?string $email, bool $deleted = false, bool $disabled = false, ): TeamerMailingRecipient { $now = new \DateTimeImmutable(); return new TeamerMailingRecipient( self::$nextId++, $firstName, $lastName, $email, $deleted ? $now : null, $disabled ? $now : null ); } }