feat: offer a newsletter opt-in for the other participants
addresses #869etn6c5
This commit is contained in:
1 parent
96b7b3f451
commit
876e69e62c
10 files changed
+598
-71
No files matched your search
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Who asked for a double opt-in mail — the recipient themselves, or the applicant of a
|
||||
* booking they are travelling on. Only the wording of the confirmation mail depends on
|
||||
* this: a third-party sign-up has to name who requested it and make clear that nothing
|
||||
* is subscribed without the recipient's own click. Confirmation itself stays
|
||||
* context-agnostic, so the value never has to be persisted.
|
||||
*/
|
||||
enum NewsletterOptInContext: string
|
||||
{
|
||||
case Own = 'own';
|
||||
case Companion = 'companion';
|
||||
|
||||
public function subject(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Own => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Enum\NewsletterOptInContext;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Decides who a booking may enrol in the newsletter and starts the double opt-in cycles.
|
||||
*
|
||||
* The applicant opts in for themselves; everyone else is a third-party sign-up, which is why
|
||||
* companions are restricted to adults with an address of their own and always get the
|
||||
* companion wording naming who requested it.
|
||||
*/
|
||||
class BookingNewsletterOptIn
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NewsletterManager $newsletterManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveApplicantEmail(BookingDto $bookingDto, ?User $currentUser): ?string
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
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<ParticipantDto>
|
||||
*/
|
||||
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<ParticipantDto> $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;
|
||||
}
|
||||
}
|
||||
@@ -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<int|string> $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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'}
|
||||
}) }}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends 'email/layout.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h1>
|
||||
Hallo{% if recipientFirstName %} {{ recipientFirstName }}{% endif %}!
|
||||
</h1>
|
||||
<p>
|
||||
{% if requestedByName %}{{ requestedByName }} hat{% else %}Die anmeldende Person hat{% endif %}
|
||||
bei der Buchung eurer gemeinsamen Reise angegeben, dass du unseren Newsletter erhalten möchtest.
|
||||
</p>
|
||||
{% if newsletterLists is defined and newsletterLists is not empty %}
|
||||
<p>
|
||||
Bitte bestätige selbst, dass du die folgende Newsletter erhalten möchtest:
|
||||
{% for newsletterList in newsletterLists %}
|
||||
<br>
|
||||
- {{ newsletterList.label }}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p>
|
||||
<a href="{{ url('app_newsletter_confirm', { 'token': token }) }}" class="button">
|
||||
Newsletter-Anmeldung bestätigen
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
Wenn du dich nicht zum Newsletter anmelden möchtest, kannst du diese E-Mail einfach ignorieren und löschen.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ url('app_login') }}" class="button">
|
||||
Zu MyE&P
|
||||
</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form;
|
||||
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\BookingCreateStep4Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\Forms;
|
||||
use Symfony\Component\Form\PreloadedExtension;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
|
||||
class BookingCreateStep4TypeTest extends TestCase
|
||||
{
|
||||
public function testCompanionOptInIsAbsentByDefault(): void
|
||||
{
|
||||
$form = $this->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<string, mixed> $options
|
||||
*
|
||||
* @return FormInterface<mixed>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Enum\NewsletterOptInContext;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\BookingNewsletterOptIn;
|
||||
use App\Service\NewsletterManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
class BookingNewsletterOptInTest extends TestCase
|
||||
{
|
||||
public function testChildrenAreNotOfferedACompanionOptIn(): void
|
||||
{
|
||||
$bookingDto = $this->createBookingDto([
|
||||
['[email protected]', 30],
|
||||
['[email protected]', 12],
|
||||
]);
|
||||
|
||||
$companions = $this->createService($this->createStub(NewsletterManager::class))
|
||||
->resolveCompanions($bookingDto, '[email protected]');
|
||||
|
||||
self::assertSame([], $companions);
|
||||
}
|
||||
|
||||
public function testApplicantAddressIsNotOfferedAsACompanion(): void
|
||||
{
|
||||
$bookingDto = $this->createBookingDto([
|
||||
['[email protected]', 30],
|
||||
['[email protected] ', 25],
|
||||
]);
|
||||
|
||||
$companions = $this->createService($this->createStub(NewsletterManager::class))
|
||||
->resolveCompanions($bookingDto, '[email protected]');
|
||||
|
||||
self::assertSame([], $companions);
|
||||
}
|
||||
|
||||
public function testDuplicateAndInvalidCompanionAddressesAreCollapsed(): void
|
||||
{
|
||||
$bookingDto = $this->createBookingDto([
|
||||
['[email protected]', 30],
|
||||
['[email protected]', 25],
|
||||
['[email protected]', 26],
|
||||
['not-an-email', 27],
|
||||
[null, 28],
|
||||
]);
|
||||
|
||||
$companions = $this->createService($this->createStub(NewsletterManager::class))
|
||||
->resolveCompanions($bookingDto, '[email protected]');
|
||||
|
||||
self::assertCount(1, $companions);
|
||||
self::assertSame('[email protected]', $companions[0]->email);
|
||||
}
|
||||
|
||||
public function testConfirmedAndPendingCompanionsAreSkipped(): void
|
||||
{
|
||||
$companions = $this->createCompanions([
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
]);
|
||||
|
||||
$newsletterManager = $this->createMock(NewsletterManager::class);
|
||||
$newsletterManager
|
||||
->method('hasConfirmedOptIn')
|
||||
->willReturnCallback(static fn (string $email): bool => '[email protected]' === $email);
|
||||
$newsletterManager
|
||||
->method('hasPendingConfirmation')
|
||||
->willReturnCallback(static fn (string $email): bool => '[email protected]' === $email);
|
||||
$newsletterManager
|
||||
->expects(self::once())
|
||||
->method('requestConfirmation')
|
||||
->with('[email protected]', 'Tim', 'Teiler', NewsletterOptInContext::Companion, 'Mia Muster');
|
||||
|
||||
$this->createService($newsletterManager)->subscribeCompanions($companions, 'Mia Muster');
|
||||
}
|
||||
|
||||
public function testOneFailingCompanionDoesNotStopTheOthers(): void
|
||||
{
|
||||
$companions = $this->createCompanions([
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
]);
|
||||
|
||||
$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 ('[email protected]' === $email) {
|
||||
throw new NewsletterProviderException('Mailer down.');
|
||||
}
|
||||
});
|
||||
|
||||
$this->createService($newsletterManager)->subscribeCompanions($companions, 'Mia Muster');
|
||||
|
||||
self::assertSame(['[email protected]', '[email protected]'], $requestedEmails);
|
||||
}
|
||||
|
||||
public function testApplicantOptInUsesTheOwnWording(): void
|
||||
{
|
||||
$bookingDto = $this->createBookingDto([['[email protected]', 30]]);
|
||||
$applicant = $bookingDto->participants[0];
|
||||
|
||||
$newsletterManager = $this->createMock(NewsletterManager::class);
|
||||
$newsletterManager
|
||||
->expects(self::once())
|
||||
->method('requestConfirmation')
|
||||
->with('[email protected]', 'Mia', 'Muster');
|
||||
|
||||
$this->createService($newsletterManager)->subscribeApplicant('[email protected]', $applicant);
|
||||
}
|
||||
|
||||
public function testRequestedByNameIsTheApplicantsFullName(): void
|
||||
{
|
||||
$bookingDto = $this->createBookingDto([['[email protected]', 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<array{0: string|null, 1: int}> $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<string> $emails
|
||||
*
|
||||
* @return list<ParticipantDto>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<int, string> $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('[email protected]', '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('[email protected]', 'Tim', 'Teiler', NewsletterOptInContext::Companion, 'Mia Muster');
|
||||
}
|
||||
|
||||
private function createManager(
|
||||
NewsletterOptInRequestRepository $repository,
|
||||
NewsletterConsentRepository $consents,
|
||||
|
||||
Reference in new issue
Block a user