From 876e69e62c662b6b644649a54beebd6a97133041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sat, 26 Sep 2026 13:45:43 +0200 Subject: [PATCH] feat: offer a newsletter opt-in for the other participants addresses #869etn6c5 --- .../Booking/Create/Step4Controller.php | 84 +++----- src/Enum/NewsletterOptInContext.php | 32 +++ src/Form/BookingCreateStep4Type.php | 13 ++ src/Service/BookingNewsletterOptIn.php | 164 ++++++++++++++++ src/Service/NewsletterManager.php | 24 ++- templates/booking/create/step_4.html.twig | 5 + .../newsletter_opt_in_companion.html.twig | 33 ++++ tests/Form/BookingCreateStep4TypeTest.php | 58 ++++++ tests/Service/BookingNewsletterOptInTest.php | 183 ++++++++++++++++++ tests/Service/NewsletterManagerTest.php | 73 ++++++- 10 files changed, 598 insertions(+), 71 deletions(-) create mode 100644 src/Enum/NewsletterOptInContext.php create mode 100644 src/Service/BookingNewsletterOptIn.php create mode 100644 templates/email/newsletter_opt_in_companion.html.twig create mode 100644 tests/Form/BookingCreateStep4TypeTest.php create mode 100644 tests/Service/BookingNewsletterOptInTest.php diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index ab527782..7b19aead 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -10,16 +10,14 @@ use App\BusProNet\Model\Notification; use App\Entity\User; use App\Exception\BookingSessionNotFoundException; use App\Exception\HotelNotInTravelException; -use App\Exception\NewsletterProviderException; use App\Exception\TravelNotFoundException; use App\Form\BookingCreateStep4Type; use App\Form\Model\BookingDto; -use App\Form\Model\ParticipantDto; use App\Htmx\HxTrait; use App\Service\BookingConfigurator; use App\Service\BookingCreateContextFactory; +use App\Service\BookingNewsletterOptIn; use App\Service\BookingSessionManager; -use App\Service\NewsletterManager; use App\Service\ParticipantFormSupport; use Psr\Log\LoggerInterface; use Symfony\Component\Form\FormInterface; @@ -41,7 +39,7 @@ class Step4Controller extends AbstractBookingCreateController private readonly BookingCreateContextFactory $createContextFactory, private readonly ApiClient $apiClient, private readonly CacheInterface $cache, - private readonly NewsletterManager $doubleOptInService, + private readonly BookingNewsletterOptIn $bookingNewsletterOptIn, private readonly ParticipantFormSupport $participantFormSupportService, private readonly LoggerInterface $logger, ) { @@ -64,11 +62,15 @@ class Step4Controller extends AbstractBookingCreateController return $redirect; } - $newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto); + $user = $this->getUser(); + $newsletterTargetEmail = $this->bookingNewsletterOptIn->resolveApplicantEmail($bookingCreateDto, $user instanceof User ? $user : null); $newsletterOptInVisible = null !== $newsletterTargetEmail; + $companions = $this->bookingNewsletterOptIn->resolveCompanions($bookingCreateDto, $newsletterTargetEmail); + $companionNewsletterOptInVisible = [] !== $companions; $form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [ 'show_newsletter_opt_in' => $newsletterOptInVisible, + 'show_companion_newsletter_opt_in' => $companionNewsletterOptInVisible, 'newsletter_target_email' => $newsletterTargetEmail, ]); $form->handleRequest($request); @@ -94,7 +96,7 @@ class Step4Controller extends AbstractBookingCreateController $bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.', ); - return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); + return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible); } if (false === $bookingResponse->isBookingSuccessful()) { @@ -110,7 +112,7 @@ class Step4Controller extends AbstractBookingCreateController $errorMessage, ); - return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); + return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible); } $newsletterOptInSelected = $newsletterOptInVisible @@ -118,19 +120,21 @@ class Step4Controller extends AbstractBookingCreateController && true === $form->get('newsletterOptIn')->getData(); if (true === $newsletterOptInSelected) { - try { - $targetParticipant = $this->resolveNewsletterTargetParticipant($bookingCreateDto); - $this->doubleOptInService->requestConfirmation( - $newsletterTargetEmail, - $targetParticipant?->firstName, - $targetParticipant?->lastName, - ); - } catch (NewsletterProviderException|\InvalidArgumentException $e) { - $this->logger->warning('Newsletter confirmation request failed after booking', [ - 'email' => $newsletterTargetEmail, - 'error' => $e->getMessage(), - ]); - } + $this->bookingNewsletterOptIn->subscribeApplicant( + $newsletterTargetEmail, + $this->bookingNewsletterOptIn->resolveApplicantParticipant($bookingCreateDto), + ); + } + + $companionOptInSelected = $companionNewsletterOptInVisible + && $form->has('companionNewsletterOptIn') + && true === $form->get('companionNewsletterOptIn')->getData(); + + if (true === $companionOptInSelected) { + $this->bookingNewsletterOptIn->subscribeCompanions( + $companions, + $this->bookingNewsletterOptIn->resolveRequestedByName($bookingCreateDto), + ); } // Success: Store booking data in flash for conversion tracking @@ -171,7 +175,7 @@ class Step4Controller extends AbstractBookingCreateController } } - return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); + return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail, $companionNewsletterOptInVisible); } /** @@ -185,6 +189,7 @@ class Step4Controller extends AbstractBookingCreateController FormInterface $form, bool $newsletterOptInVisible, ?string $newsletterTargetEmail, + bool $companionNewsletterOptInVisible, ): Response { $bookingCreateContext = $this->createContextFactory->createWithParticipantPrices($bookingCreateDto); @@ -193,45 +198,10 @@ class Step4Controller extends AbstractBookingCreateController 'form' => $form, 'newsletterOptInVisible' => $newsletterOptInVisible, 'newsletterTargetEmail' => $newsletterTargetEmail, + 'companionNewsletterOptInVisible' => $companionNewsletterOptInVisible, ]); } - private function resolveNewsletterTargetEmail(BookingDto $bookingDto): ?string - { - $currentUser = $this->getUser(); - $email = null; - - if ($currentUser instanceof User) { - $email = $currentUser->getEmail(); - } - - if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) { - $email = $bookingDto->participants[0]->email; - } - - if (null === $email) { - return null; - } - - $normalizedEmail = mb_strtolower(trim($email)); - - return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null; - } - - private function resolveNewsletterTargetParticipant(BookingDto $bookingDto): ?ParticipantDto - { - $participant = $bookingDto->participants[0] ?? null; - if (null === $participant) { - return null; - } - - if (null === $participant->firstName && null === $participant->lastName) { - return null; - } - - return $participant; - } - /** * Clears travel data and availability cache after successful booking. */ diff --git a/src/Enum/NewsletterOptInContext.php b/src/Enum/NewsletterOptInContext.php new file mode 100644 index 00000000..c6c8c9e5 --- /dev/null +++ b/src/Enum/NewsletterOptInContext.php @@ -0,0 +1,32 @@ + 'Deine Anmeldung zum E&P Newsletter', + self::Companion => 'Möchtest du den E&P Newsletter erhalten?', + }; + } + + public function template(): string + { + return match ($this) { + self::Own => 'email/newsletter_opt_in.html.twig', + self::Companion => 'email/newsletter_opt_in_companion.html.twig', + }; + } +} diff --git a/src/Form/BookingCreateStep4Type.php b/src/Form/BookingCreateStep4Type.php index ac03e51e..1284c28f 100644 --- a/src/Form/BookingCreateStep4Type.php +++ b/src/Form/BookingCreateStep4Type.php @@ -44,6 +44,17 @@ class BookingCreateStep4Type extends AbstractType ]); } + if (true === $options['show_companion_newsletter_opt_in']) { + // Deliberately no companion addresses in the label: the applicant already knows them, + // and rendering them would put other people's addresses on a shared screen. + $builder->add('companionNewsletterOptIn', CheckboxType::class, [ + 'label' => 'Ich möchte auch meine Mitfahrer:innen für den E&P Newsletter anmelden.', + 'help' => 'Alle erhalten eine eigene Bestätigungs-E-Mail und werden erst nach ihrer eigenen Bestätigung angemeldet.', + 'mapped' => false, + 'required' => false, + ]); + } + $builder ->add('confirmationAccepted', CheckboxType::class, [ 'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.', @@ -72,10 +83,12 @@ class BookingCreateStep4Type extends AbstractType $resolver->setDefaults([ 'data_class' => BookingDto::class, 'show_newsletter_opt_in' => false, + 'show_companion_newsletter_opt_in' => false, 'newsletter_target_email' => null, ]); $resolver->setAllowedTypes('show_newsletter_opt_in', 'bool'); + $resolver->setAllowedTypes('show_companion_newsletter_opt_in', 'bool'); $resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']); } } diff --git a/src/Service/BookingNewsletterOptIn.php b/src/Service/BookingNewsletterOptIn.php new file mode 100644 index 00000000..af63f60b --- /dev/null +++ b/src/Service/BookingNewsletterOptIn.php @@ -0,0 +1,164 @@ +getEmail(); + } + + if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) { + $email = $bookingDto->participants[0]->email; + } + + if (null === $email) { + return null; + } + + $normalizedEmail = mb_strtolower(trim($email)); + + return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null; + } + + public function resolveApplicantParticipant(BookingDto $bookingDto): ?ParticipantDto + { + $participant = $bookingDto->participants[0] ?? null; + if (null === $participant) { + return null; + } + + if (null === $participant->firstName && null === $participant->lastName) { + return null; + } + + return $participant; + } + + /** + * Companions eligible for a third-party opt-in offer. + * + * Children are excluded because they are allowed to share an adult's address, so subscribing + * them would mail somebody else and would rest on a minor's consent. Deliberately no consent + * lookups here: whether the checkbox appears must not disclose who is already subscribed. + * + * @return list + */ + public function resolveCompanions(BookingDto $bookingDto, ?string $applicantEmail): array + { + $companions = []; + $seenEmails = []; + + foreach ($bookingDto->participants as $participant) { + if (true === $participant->isApplicant() || true === $participant->isChild()) { + continue; + } + + if (null === $participant->email) { + continue; + } + + $normalizedEmail = mb_strtolower(trim($participant->email)); + if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) { + continue; + } + + if ($normalizedEmail === $applicantEmail || true === isset($seenEmails[$normalizedEmail])) { + continue; + } + + $seenEmails[$normalizedEmail] = true; + $companions[] = $participant; + } + + return $companions; + } + + public function subscribeApplicant(string $email, ?ParticipantDto $applicant): void + { + try { + $this->newsletterManager->requestConfirmation( + $email, + $applicant?->firstName, + $applicant?->lastName, + ); + } catch (NewsletterProviderException|\InvalidArgumentException $exception) { + $this->logger->warning('Newsletter confirmation request failed after booking', [ + 'email' => $email, + 'error' => $exception->getMessage(), + ]); + } + } + + /** + * @param list $companions + */ + public function subscribeCompanions(array $companions, ?string $requestedByName): void + { + foreach ($companions as $companion) { + $email = mb_strtolower(trim((string) $companion->email)); + + // Skipping a pending request avoids replacing a token the companion asked for + // themselves, which would silently break the link they already hold. + if (true === $this->newsletterManager->hasConfirmedOptIn($email) + || true === $this->newsletterManager->hasPendingConfirmation($email)) { + continue; + } + + // The booking already succeeded, so one unusable address must not stop the others + // and must never surface as a booking failure. + try { + $this->newsletterManager->requestConfirmation( + $email, + $companion->firstName, + $companion->lastName, + NewsletterOptInContext::Companion, + $requestedByName, + ); + } catch (NewsletterProviderException|\InvalidArgumentException $exception) { + $this->logger->warning('Companion newsletter confirmation request failed after booking', [ + 'email' => $email, + 'error' => $exception->getMessage(), + ]); + } + } + } + + public function resolveRequestedByName(BookingDto $bookingDto): ?string + { + $applicant = $this->resolveApplicantParticipant($bookingDto); + if (null === $applicant) { + return null; + } + + $name = trim(sprintf('%s %s', $applicant->firstName ?? '', $applicant->lastName ?? '')); + + return '' !== $name ? $name : null; + } +} diff --git a/src/Service/NewsletterManager.php b/src/Service/NewsletterManager.php index a297d214..b0752597 100644 --- a/src/Service/NewsletterManager.php +++ b/src/Service/NewsletterManager.php @@ -7,6 +7,7 @@ namespace App\Service; use App\Email\Mailer; use App\Entity\NewsletterConsent; use App\Entity\NewsletterOptInRequest; +use App\Enum\NewsletterOptInContext; use App\Exception\NewsletterListNotAllowedException; use App\Exception\NewsletterProviderException; use App\Mailjet\ApiClient; @@ -37,8 +38,13 @@ class NewsletterManager ) { } - public function requestConfirmation(string $email, ?string $firstName = null, ?string $lastName = null): void - { + public function requestConfirmation( + string $email, + ?string $firstName = null, + ?string $lastName = null, + NewsletterOptInContext $optInContext = NewsletterOptInContext::Own, + ?string $requestedByName = null, + ): void { $normalizedEmail = $this->normalizeEmail($email); if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) { @@ -51,6 +57,8 @@ class NewsletterManager mailjetListIds: [$this->defaultMailjetListId()], firstName: $this->normalizeName($firstName), lastName: $this->normalizeName($lastName), + optInContext: $optInContext, + requestedByName: $this->normalizeName($requestedByName), ); } @@ -210,7 +218,7 @@ class NewsletterManager /** * @param list $mailjetListIds */ - private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void + private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null, NewsletterOptInContext $optInContext = NewsletterOptInContext::Own, ?string $requestedByName = null): void { $token = $this->generateToken(); $tokenHash = $this->hashToken($token); @@ -255,11 +263,13 @@ class NewsletterManager $context = [ 'token' => $token, 'newsletterLists' => $this->createListIdMapping($mailjetListIds), + 'recipientFirstName' => $firstName, + 'requestedByName' => $requestedByName, ]; $options = [ 'to' => $normalizedEmail, - 'subject' => 'Deine Anmeldung zum E&P Newsletter', - 'template' => 'email/newsletter_opt_in.html.twig', + 'subject' => $optInContext->subject(), + 'template' => $optInContext->template(), ]; $this->mailer->createAndSendEmail($context, $options); } catch (\Throwable $exception) { @@ -275,9 +285,13 @@ class NewsletterManager throw new NewsletterProviderException('Could not send newsletter confirmation email.', previous: $exception); } + // The log line is the provenance record for third-party sign-ups: there is no column + // saying who requested a companion's opt-in, so it has to be recoverable from here. $this->logger->info('Newsletter confirmation requested', [ 'email' => $normalizedEmail, 'expires_at' => $expiresAt->format(DATE_ATOM), + 'context' => $optInContext->value, + 'requested_by' => $requestedByName, ]); } diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index 4598a1fb..aeb66e5d 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -486,6 +486,11 @@ 'label_attr': {'class': 'text-base font-semibold'} }) }} {% endif %} + {% if form.companionNewsletterOptIn is defined and companionNewsletterOptInVisible %} + {{ form_row(form.companionNewsletterOptIn, { + 'label_attr': {'class': 'text-base font-semibold'} + }) }} + {% endif %} {{ form_row(form.confirmationAccepted, { 'label_attr': {'class': 'text-base font-semibold'} }) }} diff --git a/templates/email/newsletter_opt_in_companion.html.twig b/templates/email/newsletter_opt_in_companion.html.twig new file mode 100644 index 00000000..4c6ffee0 --- /dev/null +++ b/templates/email/newsletter_opt_in_companion.html.twig @@ -0,0 +1,33 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

+ Hallo{% if recipientFirstName %} {{ recipientFirstName }}{% endif %}! +

+

+ {% if requestedByName %}{{ requestedByName }} hat{% else %}Die anmeldende Person hat{% endif %} + bei der Buchung eurer gemeinsamen Reise angegeben, dass du unseren Newsletter erhalten möchtest. +

+ {% if newsletterLists is defined and newsletterLists is not empty %} +

+ Bitte bestätige selbst, dass du die folgende Newsletter erhalten möchtest: + {% for newsletterList in newsletterLists %} +
+ - {{ newsletterList.label }} + {% endfor %} +

+ {% endif %} +

+ + Newsletter-Anmeldung bestätigen + +

+

+ Wenn du dich nicht zum Newsletter anmelden möchtest, kannst du diese E-Mail einfach ignorieren und löschen. +

+

+ + Zu MyE&P + +

+{% endblock %} diff --git a/tests/Form/BookingCreateStep4TypeTest.php b/tests/Form/BookingCreateStep4TypeTest.php new file mode 100644 index 00000000..5dd81310 --- /dev/null +++ b/tests/Form/BookingCreateStep4TypeTest.php @@ -0,0 +1,58 @@ +createForm(); + + self::assertFalse($form->has('companionNewsletterOptIn')); + } + + public function testCompanionOptInIsAddedWhenTheOptionIsOn(): void + { + $form = $this->createForm(['show_companion_newsletter_opt_in' => true]); + + self::assertTrue($form->has('companionNewsletterOptIn')); + self::assertFalse($form->get('companionNewsletterOptIn')->isRequired()); + } + + public function testCompanionOptInIsIndependentOfTheApplicantOptIn(): void + { + $form = $this->createForm(['show_companion_newsletter_opt_in' => true]); + + self::assertFalse($form->has('newsletterOptIn')); + self::assertTrue($form->has('companionNewsletterOptIn')); + } + + /** + * @param array $options + * + * @return FormInterface + */ + private function createForm(array $options = []): FormInterface + { + $travel = new Travel(); + $travel->id = 123; + + return Forms::createFormFactoryBuilder() + ->addExtension(new ValidatorExtension(Validation::createValidator())) + ->addExtension(new PreloadedExtension([new BookingCreateStep4Type('https://example.com/agb')], [])) + ->getFormFactory() + ->create(BookingCreateStep4Type::class, new BookingDto($travel, 77), $options); + } +} diff --git a/tests/Service/BookingNewsletterOptInTest.php b/tests/Service/BookingNewsletterOptInTest.php new file mode 100644 index 00000000..392e662c --- /dev/null +++ b/tests/Service/BookingNewsletterOptInTest.php @@ -0,0 +1,183 @@ +createBookingDto([ + ['applicant@example.com', 30], + ['child@example.com', 12], + ]); + + $companions = $this->createService($this->createStub(NewsletterManager::class)) + ->resolveCompanions($bookingDto, 'applicant@example.com'); + + self::assertSame([], $companions); + } + + public function testApplicantAddressIsNotOfferedAsACompanion(): void + { + $bookingDto = $this->createBookingDto([ + ['applicant@example.com', 30], + ['Applicant@Example.com ', 25], + ]); + + $companions = $this->createService($this->createStub(NewsletterManager::class)) + ->resolveCompanions($bookingDto, 'applicant@example.com'); + + self::assertSame([], $companions); + } + + public function testDuplicateAndInvalidCompanionAddressesAreCollapsed(): void + { + $bookingDto = $this->createBookingDto([ + ['applicant@example.com', 30], + ['companion@example.com', 25], + ['COMPANION@example.com', 26], + ['not-an-email', 27], + [null, 28], + ]); + + $companions = $this->createService($this->createStub(NewsletterManager::class)) + ->resolveCompanions($bookingDto, 'applicant@example.com'); + + self::assertCount(1, $companions); + self::assertSame('companion@example.com', $companions[0]->email); + } + + public function testConfirmedAndPendingCompanionsAreSkipped(): void + { + $companions = $this->createCompanions([ + 'confirmed@example.com', + 'pending@example.com', + 'fresh@example.com', + ]); + + $newsletterManager = $this->createMock(NewsletterManager::class); + $newsletterManager + ->method('hasConfirmedOptIn') + ->willReturnCallback(static fn (string $email): bool => 'confirmed@example.com' === $email); + $newsletterManager + ->method('hasPendingConfirmation') + ->willReturnCallback(static fn (string $email): bool => 'pending@example.com' === $email); + $newsletterManager + ->expects(self::once()) + ->method('requestConfirmation') + ->with('fresh@example.com', 'Tim', 'Teiler', NewsletterOptInContext::Companion, 'Mia Muster'); + + $this->createService($newsletterManager)->subscribeCompanions($companions, 'Mia Muster'); + } + + public function testOneFailingCompanionDoesNotStopTheOthers(): void + { + $companions = $this->createCompanions([ + 'broken@example.com', + 'fine@example.com', + ]); + + $newsletterManager = $this->createMock(NewsletterManager::class); + $newsletterManager->method('hasConfirmedOptIn')->willReturn(false); + $newsletterManager->method('hasPendingConfirmation')->willReturn(false); + + $requestedEmails = []; + $newsletterManager + ->expects(self::exactly(2)) + ->method('requestConfirmation') + ->willReturnCallback(static function (string $email) use (&$requestedEmails): void { + $requestedEmails[] = $email; + + if ('broken@example.com' === $email) { + throw new NewsletterProviderException('Mailer down.'); + } + }); + + $this->createService($newsletterManager)->subscribeCompanions($companions, 'Mia Muster'); + + self::assertSame(['broken@example.com', 'fine@example.com'], $requestedEmails); + } + + public function testApplicantOptInUsesTheOwnWording(): void + { + $bookingDto = $this->createBookingDto([['applicant@example.com', 30]]); + $applicant = $bookingDto->participants[0]; + + $newsletterManager = $this->createMock(NewsletterManager::class); + $newsletterManager + ->expects(self::once()) + ->method('requestConfirmation') + ->with('applicant@example.com', 'Mia', 'Muster'); + + $this->createService($newsletterManager)->subscribeApplicant('applicant@example.com', $applicant); + } + + public function testRequestedByNameIsTheApplicantsFullName(): void + { + $bookingDto = $this->createBookingDto([['applicant@example.com', 30]]); + + self::assertSame( + 'Mia Muster', + $this->createService($this->createStub(NewsletterManager::class))->resolveRequestedByName($bookingDto) + ); + } + + private function createService(NewsletterManager $newsletterManager): BookingNewsletterOptIn + { + return new BookingNewsletterOptIn($newsletterManager, new NullLogger()); + } + + /** + * @param list $participants email and age, applicant first + */ + private function createBookingDto(array $participants): BookingDto + { + $bookingDto = new BookingDto(new Travel(), 1); + + foreach ($participants as $index => [$email, $age]) { + $participant = new ParticipantDto(); + $participant->index = $index; + $participant->firstName = 0 === $index ? 'Mia' : 'Tim'; + $participant->lastName = 0 === $index ? 'Muster' : 'Teiler'; + $participant->email = $email; + $participant->dateOfBirth = new \DateTimeImmutable(sprintf('-%d years', $age)); + $bookingDto->participants[] = $participant; + } + + return $bookingDto; + } + + /** + * @param list $emails + * + * @return list + */ + private function createCompanions(array $emails): array + { + $companions = []; + + foreach ($emails as $offset => $email) { + $participant = new ParticipantDto(); + $participant->index = $offset + 1; + $participant->firstName = 'Tim'; + $participant->lastName = 'Teiler'; + $participant->email = $email; + $participant->dateOfBirth = new \DateTimeImmutable('-25 years'); + $companions[] = $participant; + } + + return $companions; + } +} diff --git a/tests/Service/NewsletterManagerTest.php b/tests/Service/NewsletterManagerTest.php index 9b11e77c..870d0429 100644 --- a/tests/Service/NewsletterManagerTest.php +++ b/tests/Service/NewsletterManagerTest.php @@ -7,6 +7,7 @@ namespace App\Tests\Service; use App\Email\Mailer; use App\Entity\NewsletterConsent; use App\Entity\NewsletterOptInRequest; +use App\Enum\NewsletterOptInContext; use App\Exception\NewsletterListNotAllowedException; use App\Mailjet\ApiClient; use App\Model\NewsletterConfirmationResult; @@ -16,7 +17,6 @@ use App\Repository\NewsletterOptInRequestRepository; use App\Service\NewsletterManager; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; @@ -439,14 +439,69 @@ class NewsletterManagerTest extends TestCase yield 'boolean' => [true]; } - /** - * @param MockObject&NewsletterOptInRequestRepository $repository - * @param MockObject&NewsletterConsentRepository $consents - * @param MockObject&EntityManagerInterface $entityManager - * @param MockObject&ApiClient $mailjet - * @param MockObject&Mailer $mailer - * @param array $mailjetLists - */ + public function testRequestConfirmationUsesOwnWordingByDefault(): void + { + $repository = $this->createStub(NewsletterOptInRequestRepository::class); + $consents = $this->createStub(NewsletterConsentRepository::class); + $entityManager = $this->createStub(EntityManagerInterface::class); + $mailjet = $this->createStub(ApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $repository->method('deleteExpiredPendingByEmail')->willReturn(0); + $repository->method('findPendingByEmail')->willReturn(null); + $mailer + ->expects(self::once()) + ->method('createAndSendEmail') + ->with( + self::callback(static function (array $context): bool { + self::assertNull($context['requestedByName']); + + return true; + }), + self::callback(static function (array $options): bool { + self::assertSame('Deine Anmeldung zum E&P Newsletter', $options['subject']); + self::assertSame('email/newsletter_opt_in.html.twig', $options['template']); + + return true; + }), + ); + + $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestConfirmation('customer@example.com', 'Mia', 'Muster'); + } + + public function testRequestConfirmationUsesCompanionWordingAndNamesTheRequester(): void + { + $repository = $this->createStub(NewsletterOptInRequestRepository::class); + $consents = $this->createStub(NewsletterConsentRepository::class); + $entityManager = $this->createStub(EntityManagerInterface::class); + $mailjet = $this->createStub(ApiClient::class); + $mailer = $this->createMock(Mailer::class); + + $repository->method('deleteExpiredPendingByEmail')->willReturn(0); + $repository->method('findPendingByEmail')->willReturn(null); + $mailer + ->expects(self::once()) + ->method('createAndSendEmail') + ->with( + self::callback(static function (array $context): bool { + self::assertSame('Tim', $context['recipientFirstName']); + self::assertSame('Mia Muster', $context['requestedByName']); + + return true; + }), + self::callback(static function (array $options): bool { + self::assertSame('Möchtest du den E&P Newsletter erhalten?', $options['subject']); + self::assertSame('email/newsletter_opt_in_companion.html.twig', $options['template']); + + return true; + }), + ); + + $this->createManager($repository, $consents, $entityManager, $mailjet, $mailer) + ->requestConfirmation('companion@example.com', 'Tim', 'Teiler', NewsletterOptInContext::Companion, 'Mia Muster'); + } + private function createManager( NewsletterOptInRequestRepository $repository, NewsletterConsentRepository $consents,