feat: offer contact validation flow
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups\Offer;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Htmx\HxTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SymfonyAbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
abstract class AbstractController extends SymfonyAbstractController
|
||||
{
|
||||
use HxTrait;
|
||||
|
||||
/**
|
||||
* A draft and an inquiry the office has not worked through yet are not ready to be
|
||||
* shown, and a discarded booking must not be viewable any more — in all three cases
|
||||
* the link behaves as if it had expired.
|
||||
*/
|
||||
protected function isCustomerVisible(AccommodationBooking $booking): bool
|
||||
{
|
||||
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the browser to a full reload of the (non-modal) offer page — this is
|
||||
* triggered from an htmx-loaded modal, so a plain render/redirect here would
|
||||
* get appended as an inert HTML fragment instead of actually navigating.
|
||||
*/
|
||||
protected function redirectToOfferPage(Request $request, string $uuid): Response
|
||||
{
|
||||
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
|
||||
|
||||
return $this->htmxRedirect($request, $url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups\Offer;
|
||||
|
||||
use App\Form\Model\OfferAcceptDto;
|
||||
use App\Form\OfferAcceptConfirmationType;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationTermsUrlProvider;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ConfirmController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
|
||||
public function index(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
// Offen is the one status that means an offer is out and awaiting acceptance, so it
|
||||
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
|
||||
if (!$booking->isOpen()) {
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
// Checked for POST as well, so the modal cannot be submitted around the contact page.
|
||||
if (!$this->bookingService->hasCompleteContactData($booking)) {
|
||||
return $this->htmxRedirect($request, $this->generateUrl('app_groups_offer_contact', ['uuid' => $uuid]));
|
||||
}
|
||||
|
||||
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
|
||||
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
|
||||
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
|
||||
]);
|
||||
$confirmationForm->handleRequest($request);
|
||||
|
||||
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
||||
// The textarea is prefilled, so the submitted value is the complete remark —
|
||||
// an emptied field arrives as null and must clear the stored one, not keep it.
|
||||
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
|
||||
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
|
||||
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
|
||||
'booking' => $booking,
|
||||
'confirmationForm' => $confirmationForm,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups\Offer;
|
||||
|
||||
use App\Form\AccommodationContactType;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ContactController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Full page rather than modal: the form is as large as step 3 of a self-service booking,
|
||||
* whose fields and constraints it shares. Once saved, the customer is back on the offer
|
||||
* and accepts it from there as usual.
|
||||
*/
|
||||
#[Route(path: '/groups/booking/offer/{uuid}/contact', name: 'app_groups_offer_contact', methods: ['GET', 'POST'])]
|
||||
public function index(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
if (!$booking->isOpen()) {
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
$dto = $this->bookingService->contactDataFromBooking($booking);
|
||||
$form = $this->createForm(AccommodationContactType::class, $dto, [
|
||||
'email_readonly' => true,
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->bookingService->applyContactData($booking, $dto);
|
||||
$this->addFlash('success', 'Deine Kontaktdaten wurden gespeichert. Du kannst die Buchung jetzt abschließen.');
|
||||
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
|
||||
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
|
||||
|
||||
return $this->render('groups/offer/contact.html.twig', [
|
||||
'booking' => $booking,
|
||||
'priceBreakdown' => $ctx->priceBreakdown,
|
||||
'ctx' => $ctx,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups\Offer;
|
||||
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Form\Model\OfferAcceptDto;
|
||||
use App\Form\OfferAcceptConfirmationType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationTermsUrlProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -22,14 +13,9 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
private readonly AccommodationTermsUrlProvider $termsUrlProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -39,7 +25,7 @@ class IndexController extends AbstractController
|
||||
* (session-gated) offer page — nothing downstream needs the signature again.
|
||||
*/
|
||||
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_offer', methods: ['GET'])]
|
||||
public function access(string $uuid, Request $request): Response
|
||||
public function index(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
@@ -55,91 +41,4 @@ class IndexController extends AbstractController
|
||||
|
||||
return new RedirectResponse($this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]));
|
||||
}
|
||||
|
||||
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
|
||||
public function view(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||
return $this->render('groups/offer/unavailable.html.twig');
|
||||
}
|
||||
|
||||
if (!$this->isCustomerVisible($booking)) {
|
||||
return $this->render('groups/offer/unavailable.html.twig');
|
||||
}
|
||||
|
||||
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
|
||||
$priceBreakdown = $this->breakdownCalculator->compute($booking);
|
||||
|
||||
$ctx = new AccommodationBookingContext(
|
||||
accommodation: $accommodation,
|
||||
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
|
||||
priceBreakdown: $priceBreakdown,
|
||||
);
|
||||
|
||||
return $this->render('groups/offer/view.html.twig', [
|
||||
'booking' => $booking,
|
||||
'priceBreakdown' => $priceBreakdown,
|
||||
'ctx' => $ctx,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_offer_confirm', methods: ['GET', 'POST'])]
|
||||
public function confirm(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
// Offen is the one status that means an offer is out and awaiting acceptance, so it
|
||||
// is the whole guard — mirroring acceptBooking(), which no-ops on anything else.
|
||||
if (!$booking->isOpen()) {
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
$dto = new OfferAcceptDto(remarks: $booking->getRemarks());
|
||||
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, $dto, [
|
||||
'terms_url' => $this->termsUrlProvider->forAccommodation($booking->getAccommodation()),
|
||||
]);
|
||||
$confirmationForm->handleRequest($request);
|
||||
|
||||
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
|
||||
// The textarea is prefilled, so the submitted value is the complete remark —
|
||||
// an emptied field arrives as null and must clear the stored one, not keep it.
|
||||
$this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
|
||||
$this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
|
||||
|
||||
return $this->redirectToOfferPage($request, $uuid);
|
||||
}
|
||||
|
||||
return $this->render('groups/offer/modal_accept_confirmation.html.twig', [
|
||||
'booking' => $booking,
|
||||
'confirmationForm' => $confirmationForm,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft and an inquiry the office has not worked through yet are not ready to be
|
||||
* shown, and a discarded booking must not be viewable any more — in all three cases
|
||||
* the link behaves as if it had expired.
|
||||
*/
|
||||
private function isCustomerVisible(AccommodationBooking $booking): bool
|
||||
{
|
||||
return $booking->isCustomerAccessible() && !$booking->isRequested() && !$booking->isDiscarded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the browser to a full reload of the (non-modal) offer page — this is
|
||||
* triggered from an htmx-loaded modal, so a plain render/redirect here would
|
||||
* get appended as an inert HTML fragment instead of actually navigating.
|
||||
*/
|
||||
private function redirectToOfferPage(Request $request, string $uuid): Response
|
||||
{
|
||||
$url = $this->generateUrl('app_groups_offer_view', ['uuid' => $uuid]);
|
||||
|
||||
return $this->htmxRedirect($request, $url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Groups\Offer;
|
||||
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
class ViewController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingRepository $bookingRepository,
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_offer_view', methods: ['GET'])]
|
||||
public function index(string $uuid, Request $request): Response
|
||||
{
|
||||
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
|
||||
|
||||
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
|
||||
return $this->render('groups/offer/unavailable.html.twig');
|
||||
}
|
||||
|
||||
if (!$this->isCustomerVisible($booking)) {
|
||||
return $this->render('groups/offer/unavailable.html.twig');
|
||||
}
|
||||
|
||||
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
|
||||
$ctx = $this->bookingService->createOfferContext($booking, $accommodation);
|
||||
|
||||
return $this->render('groups/offer/view.html.twig', [
|
||||
'booking' => $booking,
|
||||
'priceBreakdown' => $ctx->priceBreakdown,
|
||||
'ctx' => $ctx,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* The contact data of a group booking — shared by step 3 of the self-service flow and by
|
||||
* the page that completes an office-made offer before it can be accepted, so both ask
|
||||
* for the same fields under the same constraints.
|
||||
*
|
||||
* @extends AbstractType<AccommodationBookingDto>
|
||||
*/
|
||||
class AccommodationContactType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('groupName', TextType::class, [
|
||||
'label' => 'Name der Gruppe',
|
||||
])
|
||||
->add('salutation', ChoiceType::class, [
|
||||
'label' => 'Anrede',
|
||||
'choices' => [
|
||||
'Herr' => 'Herr',
|
||||
'Frau' => 'Frau',
|
||||
'divers' => 'divers',
|
||||
],
|
||||
'expanded' => false,
|
||||
'multiple' => false,
|
||||
'placeholder' => 'Bitte wählen',
|
||||
])
|
||||
->add('firstName', TextType::class, [
|
||||
'label' => 'Vorname',
|
||||
])
|
||||
->add('lastName', TextType::class, [
|
||||
'label' => 'Nachname',
|
||||
])
|
||||
->add('email', EmailType::class, [
|
||||
'label' => 'E-Mail-Adresse',
|
||||
// An offer's access link and every notification already go to this address.
|
||||
'disabled' => $options['email_readonly'],
|
||||
])
|
||||
->add('phone', TextType::class, [
|
||||
'label' => 'Telefon',
|
||||
])
|
||||
->add('street', TextType::class, [
|
||||
'label' => 'Straße und Hausnummer',
|
||||
])
|
||||
->add('zip', TextType::class, [
|
||||
'label' => 'Postleitzahl',
|
||||
])
|
||||
->add('city', TextType::class, [
|
||||
'label' => 'Ort',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AccommodationBookingDto::class,
|
||||
'validation_groups' => ['contact'],
|
||||
'email_readonly' => false,
|
||||
]);
|
||||
$resolver->setAllowedTypes('email_readonly', 'bool');
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,7 @@ namespace App\Form;
|
||||
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -21,41 +18,6 @@ class AccommodationStep3Type extends AbstractType
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('groupName', TextType::class, [
|
||||
'label' => 'Name der Gruppe',
|
||||
])
|
||||
->add('salutation', ChoiceType::class, [
|
||||
'label' => 'Anrede',
|
||||
'choices' => [
|
||||
'Herr' => 'Herr',
|
||||
'Frau' => 'Frau',
|
||||
'divers' => 'divers',
|
||||
],
|
||||
'expanded' => false,
|
||||
'multiple' => false,
|
||||
'placeholder' => 'Bitte wählen',
|
||||
])
|
||||
->add('firstName', TextType::class, [
|
||||
'label' => 'Vorname',
|
||||
])
|
||||
->add('lastName', TextType::class, [
|
||||
'label' => 'Nachname',
|
||||
])
|
||||
->add('email', EmailType::class, [
|
||||
'label' => 'E-Mail-Adresse',
|
||||
])
|
||||
->add('phone', TextType::class, [
|
||||
'label' => 'Telefon',
|
||||
])
|
||||
->add('street', TextType::class, [
|
||||
'label' => 'Straße und Hausnummer',
|
||||
])
|
||||
->add('zip', TextType::class, [
|
||||
'label' => 'Postleitzahl',
|
||||
])
|
||||
->add('city', TextType::class, [
|
||||
'label' => 'Ort',
|
||||
])
|
||||
->add('remarks', TextareaType::class, [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
@@ -71,4 +33,9 @@ class AccommodationStep3Type extends AbstractType
|
||||
'validation_groups' => ['step_3'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return AccommodationContactType::class;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,33 +43,35 @@ class AccommodationBookingDto
|
||||
/** @var array<string, mixed> */
|
||||
public array $priceBreakdown = [];
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
// `contact` is the subset an office-made offer is checked against before the customer
|
||||
// may accept it — the same constraints the customer meets in step 3 of a self-service booking.
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
public ?string $groupName = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3'])]
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
#[Assert\Choice(choices: ['Herr', 'Frau', 'divers'], groups: ['step_3', 'contact'])]
|
||||
public ?string $salutation = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
public ?string $firstName = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
public ?string $lastName = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
#[Assert\Email(message: 'invalid', groups: ['step_3'])]
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
#[Assert\Email(message: 'invalid', groups: ['step_3', 'contact'])]
|
||||
public ?string $email = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
public ?string $phone = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
public ?string $street = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
public ?string $zip = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3'])]
|
||||
#[Assert\NotBlank(message: 'required', groups: ['step_3', 'contact'])]
|
||||
public ?string $city = null;
|
||||
|
||||
public ?string $remarks = null;
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Entity\Groups\BoardService;
|
||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Model\AccommodationBookingQueryParams;
|
||||
use App\Model\CmsHotelData;
|
||||
use App\Model\InquiryStatus;
|
||||
@@ -23,6 +24,7 @@ use App\Repository\Groups\AdditionalServiceRepository;
|
||||
use App\Repository\Groups\BoardServiceRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
class AccommodationBookingService
|
||||
{
|
||||
@@ -41,6 +43,7 @@ class AccommodationBookingService
|
||||
private readonly AccommodationBookingLinkSigner $linkSigner,
|
||||
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
private readonly AccommodationBookingPdfGenerator $pdfGenerator,
|
||||
private readonly ValidatorInterface $validator,
|
||||
private readonly string $accommodationEmail,
|
||||
) {
|
||||
}
|
||||
@@ -115,6 +118,19 @@ class AccommodationBookingService
|
||||
return $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* What the customer-facing offer pages show around a stored booking: the hotel's CMS
|
||||
* content and the price breakdown computed from the booking's frozen snapshot.
|
||||
*/
|
||||
public function createOfferContext(AccommodationBooking $booking, Accommodation $accommodation): AccommodationBookingContext
|
||||
{
|
||||
return new AccommodationBookingContext(
|
||||
accommodation: $accommodation,
|
||||
hotelCmsData: $this->loadHotelCmsData($accommodation),
|
||||
priceBreakdown: $this->breakdownCalculator->compute($booking),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AccommodationPrice[]
|
||||
*/
|
||||
@@ -262,7 +278,6 @@ class AccommodationBookingService
|
||||
): AccommodationBooking {
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setAccommodation($accommodation);
|
||||
$booking->setGroupName($dto->groupName);
|
||||
$booking->setDateFrom($dto->dateFrom);
|
||||
$booking->setDateTo($dto->dateTo);
|
||||
$booking->setPaxCount($dto->paxCount);
|
||||
@@ -305,15 +320,7 @@ class AccommodationBookingService
|
||||
);
|
||||
}
|
||||
|
||||
// Personal data
|
||||
$booking->setSalutation($dto->salutation);
|
||||
$booking->setFirstName($dto->firstName);
|
||||
$booking->setLastName($dto->lastName);
|
||||
$booking->setEmail($dto->email);
|
||||
$booking->setPhone($dto->phone);
|
||||
$booking->setStreet($dto->street);
|
||||
$booking->setZip($dto->zip);
|
||||
$booking->setCity($dto->city);
|
||||
$this->copyContactData($dto, $booking);
|
||||
$booking->setRemarks($dto->remarks);
|
||||
|
||||
$this->refreshPriceSnapshot($booking);
|
||||
@@ -627,6 +634,51 @@ class AccommodationBookingService
|
||||
$this->sendOfferAcceptedNotificationEmail($booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers are put together by the office, which only has to supply name and email, so
|
||||
* the rest of the contact data is often missing. A customer may accept an offer once it
|
||||
* meets the constraints a self-service booking meets in step 3.
|
||||
*/
|
||||
public function hasCompleteContactData(AccommodationBooking $booking): bool
|
||||
{
|
||||
return 0 === $this->validator->validate($this->contactDataFromBooking($booking), null, ['contact'])->count();
|
||||
}
|
||||
|
||||
public function contactDataFromBooking(AccommodationBooking $booking): AccommodationBookingDto
|
||||
{
|
||||
$dto = new AccommodationBookingDto();
|
||||
$dto->groupName = $booking->getGroupName();
|
||||
$dto->salutation = $booking->getSalutation();
|
||||
$dto->firstName = $booking->getFirstName();
|
||||
$dto->lastName = $booking->getLastName();
|
||||
$dto->email = $booking->getEmail();
|
||||
$dto->phone = $booking->getPhone();
|
||||
$dto->street = $booking->getStreet();
|
||||
$dto->zip = $booking->getZip();
|
||||
$dto->city = $booking->getCity();
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
public function applyContactData(AccommodationBooking $booking, AccommodationBookingDto $dto): void
|
||||
{
|
||||
$this->copyContactData($dto, $booking);
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
private function copyContactData(AccommodationBookingDto $dto, AccommodationBooking $booking): void
|
||||
{
|
||||
$booking->setGroupName($dto->groupName);
|
||||
$booking->setSalutation($dto->salutation);
|
||||
$booking->setFirstName($dto->firstName);
|
||||
$booking->setLastName($dto->lastName);
|
||||
$booking->setEmail($dto->email);
|
||||
$booking->setPhone($dto->phone);
|
||||
$booking->setStreet($dto->street);
|
||||
$booking->setZip($dto->zip);
|
||||
$booking->setCity($dto->city);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit office action after the requested services and capacities have been validated:
|
||||
* this is the moment the booking becomes binding for the customer, and the only place the
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends 'layout_booking.html.twig' %}
|
||||
|
||||
{% block title %}Kontaktdaten vervollständigen{% endblock %}
|
||||
|
||||
{% block background %}bg-outer bg-outer--summer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% form_theme form 'booking/_form_theme.html.twig' %}
|
||||
|
||||
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
|
||||
|
||||
{{ form_start(form, {
|
||||
'attr': {
|
||||
'class': 'flex-1 flex flex-col min-h-0',
|
||||
'novalidate': 'novalidate',
|
||||
}
|
||||
}) }}
|
||||
|
||||
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
|
||||
|
||||
{# Sidebar summary #}
|
||||
<div class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
|
||||
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
|
||||
{% include 'groups/offer/_summary.html.twig' with {
|
||||
booking: booking,
|
||||
ctx: ctx,
|
||||
currency: currency,
|
||||
} %}
|
||||
</div>
|
||||
|
||||
{# Contact data form #}
|
||||
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
|
||||
<div class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
|
||||
<hgroup class="pb-4">
|
||||
<h1 class="text-2xl lg:text-4xl font-bold mb-2">
|
||||
Kontaktdaten vervollständigen
|
||||
</h1>
|
||||
<p>
|
||||
Bevor du das Angebot für {{ ctx.accommodation.name }} buchen kannst, benötigen wir
|
||||
noch deine vollständigen Kontaktdaten.
|
||||
</p>
|
||||
</hgroup>
|
||||
|
||||
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||
{{ validation_alert(form, 'Bitte fülle alle Pflichtfelder aus.') }}
|
||||
|
||||
<div class="grid lg:grid-cols-2 lg:gap-x-8">
|
||||
{{ form_row(form.groupName) }}
|
||||
{{ form_row(form.salutation) }}
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 lg:gap-x-8">
|
||||
{{ form_row(form.firstName) }}
|
||||
{{ form_row(form.lastName) }}
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-2 lg:gap-x-8">
|
||||
{{ form_row(form.email) }}
|
||||
{{ form_row(form.phone) }}
|
||||
</div>
|
||||
|
||||
<h3 class="pt-4 pb-2">Adresse</h3>
|
||||
|
||||
{{ form_row(form.street) }}
|
||||
|
||||
<div class="grid lg:grid-cols-2 lg:gap-x-8">
|
||||
{{ form_row(form.zip) }}
|
||||
{{ form_row(form.city) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
|
||||
<div class="flex items-center justify-between">
|
||||
<a href="{{ path('app_groups_offer_view', { uuid: booking.uuid }) }}" class="button button--secondary">
|
||||
Zurück zum Angebot
|
||||
</a>
|
||||
<button type="submit" class="button button--primary">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Groups\Offer;
|
||||
|
||||
use App\Controller\Groups\Offer\ConfirmController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Form\Model\OfferAcceptDto;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationTermsUrlProvider;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ConfirmControllerTest extends TestCase
|
||||
{
|
||||
public function testGetRendersModalWithFreshForm(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
$booking->setRemarks("Bitte Zimmer im EG\nDanke!");
|
||||
|
||||
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
|
||||
|
||||
$confirmationForm = $this->createStub(FormInterface::class);
|
||||
$confirmationForm->method('handleRequest')->willReturnSelf();
|
||||
$confirmationForm->method('isSubmitted')->willReturn(false);
|
||||
|
||||
$bookingService = $this->serviceWithCompleteContactData();
|
||||
$bookingService->expects(self::never())->method('acceptBooking');
|
||||
|
||||
$controller = new TestableOfferConfirmController(
|
||||
$bookingRepository,
|
||||
$this->authorizedLinkSigner(),
|
||||
$bookingService,
|
||||
$confirmationForm,
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
|
||||
self::assertSame($booking, $controller->renderedParameters['booking']);
|
||||
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
|
||||
|
||||
self::assertInstanceOf(OfferAcceptDto::class, $controller->formData);
|
||||
self::assertSame("Bitte Zimmer im EG\nDanke!", $controller->formData->remarks, 'the stored remark is prefilled into the modal');
|
||||
self::assertFalse($controller->formData->termsAccepted);
|
||||
}
|
||||
|
||||
public function testPostValidAcceptsBookingAndRedirects(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
$booking->setRemarks('Bitte Zimmer im EG');
|
||||
|
||||
$bookingService = $this->serviceWithCompleteContactData();
|
||||
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, 'Bitte Zimmer im EG');
|
||||
|
||||
$controller = new TestableOfferConfirmController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->authorizedLinkSigner(),
|
||||
$bookingService,
|
||||
$this->submittedForm(valid: true),
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
|
||||
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.']], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testPostValidViaHtmxReturnsHxRedirect(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
|
||||
$bookingService = $this->serviceWithCompleteContactData();
|
||||
// No remark on the booking and none submitted: the empty string clears rather than keeps.
|
||||
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, '');
|
||||
|
||||
$controller = new TestableOfferConfirmController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->authorizedLinkSigner(),
|
||||
$bookingService,
|
||||
$this->submittedForm(valid: true),
|
||||
);
|
||||
|
||||
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
|
||||
$request->headers->set('HX-Request', 'true');
|
||||
$response = $controller->index($booking->getUuid(), $request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||
}
|
||||
|
||||
public function testPostInvalidReRendersModalWithoutAccepting(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
|
||||
$bookingService = $this->serviceWithCompleteContactData();
|
||||
$bookingService->expects(self::never())->method('acceptBooking');
|
||||
|
||||
$controller = new TestableOfferConfirmController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->authorizedLinkSigner(),
|
||||
$bookingService,
|
||||
$this->submittedForm(valid: false),
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertFalse($response->headers->has('HX-Redirect'));
|
||||
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
#[DataProvider('methods')]
|
||||
public function testSendsToContactPageWhenContactDataIsIncomplete(string $method): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->method('hasCompleteContactData')->with($booking)->willReturn(false);
|
||||
$bookingService->expects(self::never())->method('acceptBooking');
|
||||
|
||||
$controller = new TestableOfferConfirmController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->authorizedLinkSigner(),
|
||||
$bookingService,
|
||||
);
|
||||
|
||||
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', $method);
|
||||
$request->headers->set('HX-Request', 'true');
|
||||
$response = $controller->index($booking->getUuid(), $request);
|
||||
|
||||
self::assertSame('/app_groups_offer_contact?uuid='.$booking->getUuid(), $response->headers->get('HX-Redirect'));
|
||||
self::assertNull($controller->renderedView);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function methods(): iterable
|
||||
{
|
||||
yield 'opening the modal' => ['GET'];
|
||||
yield 'submitting it anyway' => ['POST'];
|
||||
}
|
||||
|
||||
public function testRedirectsWhenAlreadyAccepted(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||
|
||||
$controller = new TestableOfferConfirmController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->authorizedLinkSigner(),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
}
|
||||
|
||||
public function testRedirectsWhenSessionNotAuthorized(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(false);
|
||||
|
||||
$controller = new TestableOfferConfirmController(
|
||||
$this->repositoryReturning($booking),
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
}
|
||||
|
||||
public function testRedirectsForUnknownUuid(): void
|
||||
{
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn(null);
|
||||
|
||||
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->expects(self::never())->method('isSessionAuthorized');
|
||||
|
||||
$controller = new TestableOfferConfirmController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
}
|
||||
|
||||
private function repositoryReturning(AccommodationBooking $booking): AccommodationBookingRepository
|
||||
{
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
return $bookingRepository;
|
||||
}
|
||||
|
||||
private function authorizedLinkSigner(): AccommodationBookingLinkSigner
|
||||
{
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
return $linkSigner;
|
||||
}
|
||||
|
||||
private function serviceWithCompleteContactData(): AccommodationBookingService&MockObject
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->method('hasCompleteContactData')->willReturn(true);
|
||||
|
||||
return $bookingService;
|
||||
}
|
||||
|
||||
private function submittedForm(bool $valid): FormInterface
|
||||
{
|
||||
$form = $this->createStub(FormInterface::class);
|
||||
$form->method('handleRequest')->willReturnSelf();
|
||||
$form->method('isSubmitted')->willReturn(true);
|
||||
$form->method('isValid')->willReturn($valid);
|
||||
|
||||
return $form;
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableOfferConfirmController extends ConfirmController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $renderedParameters = [];
|
||||
|
||||
public mixed $formData = null;
|
||||
|
||||
/** @var array<int, array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
public function __construct(
|
||||
AccommodationBookingRepository $bookingRepository,
|
||||
AccommodationBookingLinkSigner $linkSigner,
|
||||
AccommodationBookingService $bookingService,
|
||||
private readonly ?FormInterface $form = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$bookingService,
|
||||
new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
$this->formData = $data;
|
||||
|
||||
return $this->form ?? throw new \LogicException('No form mock configured for this test.');
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
$this->renderedParameters = $parameters;
|
||||
|
||||
return $response ?? new Response('<html></html>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Groups\Offer;
|
||||
|
||||
use App\Controller\Groups\Offer\ContactController;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ContactControllerTest extends TestCase
|
||||
{
|
||||
public function testGetRendersPagePrefilledFromBooking(): void
|
||||
{
|
||||
$booking = $this->openBooking();
|
||||
|
||||
$dto = new AccommodationBookingDto();
|
||||
$ctx = new AccommodationBookingContext(accommodation: $booking->getAccommodation(), priceBreakdown: ['total' => 1000]);
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->method('contactDataFromBooking')->with($booking)->willReturn($dto);
|
||||
$bookingService->method('createOfferContext')->with($booking, $booking->getAccommodation())->willReturn($ctx);
|
||||
$bookingService->expects(self::never())->method('applyContactData');
|
||||
|
||||
$form = $this->createStub(FormInterface::class);
|
||||
$form->method('handleRequest')->willReturnSelf();
|
||||
$form->method('isSubmitted')->willReturn(false);
|
||||
|
||||
$controller = new TestableOfferContactController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->linkSigner(authorized: true),
|
||||
$bookingService,
|
||||
$form,
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/contact.html.twig', $controller->renderedView);
|
||||
self::assertSame($form, $controller->renderedParameters['form']);
|
||||
self::assertSame($ctx, $controller->renderedParameters['ctx']);
|
||||
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
|
||||
self::assertSame($dto, $controller->formData);
|
||||
self::assertSame(['email_readonly' => true], $controller->formOptions);
|
||||
}
|
||||
|
||||
public function testPostValidSavesAndReturnsToOffer(): void
|
||||
{
|
||||
$booking = $this->openBooking();
|
||||
|
||||
$dto = new AccommodationBookingDto();
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->method('contactDataFromBooking')->willReturn($dto);
|
||||
$bookingService->expects(self::once())->method('applyContactData')->with($booking, $dto);
|
||||
$bookingService->expects(self::never())->method('acceptBooking');
|
||||
|
||||
$controller = new TestableOfferContactController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->linkSigner(authorized: true),
|
||||
$bookingService,
|
||||
$this->submittedForm(valid: true),
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
|
||||
self::assertSame([['type' => 'success', 'message' => 'Deine Kontaktdaten wurden gespeichert. Du kannst die Buchung jetzt abschließen.']], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testPostInvalidReRendersWithoutSaving(): void
|
||||
{
|
||||
$booking = $this->openBooking();
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->method('contactDataFromBooking')->willReturn(new AccommodationBookingDto());
|
||||
$bookingService->method('createOfferContext')->willReturn(new AccommodationBookingContext(accommodation: $booking->getAccommodation()));
|
||||
$bookingService->expects(self::never())->method('applyContactData');
|
||||
|
||||
$controller = new TestableOfferContactController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->linkSigner(authorized: true),
|
||||
$bookingService,
|
||||
$this->submittedForm(valid: false),
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/contact.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
#[DataProvider('guards')]
|
||||
public function testRedirectsToOfferWhenNotEditable(AccommodationBookingStatus $status, bool $authorized): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus($status);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('applyContactData');
|
||||
|
||||
$controller = new TestableOfferContactController(
|
||||
$this->repositoryReturning($booking),
|
||||
$this->linkSigner($authorized),
|
||||
$bookingService,
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/contact', 'POST'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{AccommodationBookingStatus, bool}>
|
||||
*/
|
||||
public static function guards(): iterable
|
||||
{
|
||||
yield 'unauthorized session' => [AccommodationBookingStatus::Open, false];
|
||||
yield 'already accepted' => [AccommodationBookingStatus::Received, true];
|
||||
yield 'discarded' => [AccommodationBookingStatus::Discarded, true];
|
||||
}
|
||||
|
||||
private function openBooking(): AccommodationBooking
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
$booking->setAccommodation(new Accommodation());
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
private function repositoryReturning(AccommodationBooking $booking): AccommodationBookingRepository
|
||||
{
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
return $bookingRepository;
|
||||
}
|
||||
|
||||
private function linkSigner(bool $authorized): AccommodationBookingLinkSigner
|
||||
{
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn($authorized);
|
||||
|
||||
return $linkSigner;
|
||||
}
|
||||
|
||||
private function submittedForm(bool $valid): FormInterface
|
||||
{
|
||||
$form = $this->createStub(FormInterface::class);
|
||||
$form->method('handleRequest')->willReturnSelf();
|
||||
$form->method('isSubmitted')->willReturn(true);
|
||||
$form->method('isValid')->willReturn($valid);
|
||||
|
||||
return $form;
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableOfferContactController extends ContactController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $renderedParameters = [];
|
||||
|
||||
public mixed $formData = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $formOptions = [];
|
||||
|
||||
/** @var array<int, array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
public function __construct(
|
||||
AccommodationBookingRepository $bookingRepository,
|
||||
AccommodationBookingLinkSigner $linkSigner,
|
||||
AccommodationBookingService $bookingService,
|
||||
private readonly ?FormInterface $form = null,
|
||||
) {
|
||||
parent::__construct($bookingRepository, $linkSigner, $bookingService);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
$this->formData = $data;
|
||||
$this->formOptions = $options;
|
||||
|
||||
return $this->form ?? throw new \LogicException('No form mock configured for this test.');
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
$this->renderedParameters = $parameters;
|
||||
|
||||
return $response ?? new Response('<html></html>');
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,19 @@ declare(strict_types=1);
|
||||
namespace App\Tests\Controller\Groups\Offer;
|
||||
|
||||
use App\Controller\Groups\Offer\IndexController;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Form\Model\OfferAcceptDto;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingBreakdownCalculator;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use App\Service\AccommodationTermsUrlProvider;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
|
||||
class IndexControllerTest extends TestCase
|
||||
{
|
||||
public function testAccessAuthorizesSessionAndRedirectsToView(): void
|
||||
public function testAuthorizesSessionAndRedirectsToView(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
@@ -37,20 +29,15 @@ class IndexControllerTest extends TestCase
|
||||
$linkSigner->method('isValidLinkRequest')->willReturn(true);
|
||||
$linkSigner->expects(self::once())->method('authorizeSession')->with(self::anything(), $booking);
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
$controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
|
||||
|
||||
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
|
||||
}
|
||||
|
||||
public function testAccessRendersUnavailableForInvalidLink(): void
|
||||
public function testRendersUnavailableForInvalidLink(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
@@ -61,20 +48,15 @@ class IndexControllerTest extends TestCase
|
||||
$linkSigner->method('isValidLinkRequest')->willReturn(false);
|
||||
$linkSigner->expects(self::never())->method('authorizeSession');
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
$controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
|
||||
|
||||
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
public function testAccessRendersUnavailableForUnknownUuid(): void
|
||||
public function testRendersUnavailableForUnknownUuid(): void
|
||||
{
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn(null);
|
||||
@@ -82,21 +64,16 @@ class IndexControllerTest extends TestCase
|
||||
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->expects(self::never())->method('isValidLinkRequest');
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
$controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
|
||||
|
||||
$response = $controller->access('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid'));
|
||||
$response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
#[DataProvider('statusesTheCustomerMustNotSee')]
|
||||
public function testAccessRendersUnavailableForABookingThatIsNotOfferedYet(AccommodationBookingStatus $status): void
|
||||
public function testRendersUnavailableForABookingThatIsNotOfferedYet(AccommodationBookingStatus $status): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus($status);
|
||||
@@ -108,14 +85,9 @@ class IndexControllerTest extends TestCase
|
||||
$linkSigner->method('isValidLinkRequest')->willReturn(true);
|
||||
$linkSigner->expects(self::never())->method('authorizeSession');
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
$controller = new TestableOfferIndexController($bookingRepository, $linkSigner);
|
||||
|
||||
$response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid()));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
|
||||
@@ -132,353 +104,12 @@ class IndexControllerTest extends TestCase
|
||||
yield 'requested' => [AccommodationBookingStatus::Requested];
|
||||
yield 'discarded' => [AccommodationBookingStatus::Discarded];
|
||||
}
|
||||
|
||||
/**
|
||||
* The status is re-read on every view, so an authorized session is no free pass: a
|
||||
* booking pushed back into Entwurf stops showing the offer from that moment on.
|
||||
*/
|
||||
#[DataProvider('statusesTheCustomerMustNotSee')]
|
||||
public function testViewRendersUnavailableForABookingTheCustomerMustNotSee(AccommodationBookingStatus $status): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus($status);
|
||||
$booking->setAccommodation(new Accommodation());
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||
$breakdownCalculator->expects(self::never())->method('compute');
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$breakdownCalculator,
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$response = $controller->view($booking->getUuid(), $request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
public function testViewRendersOfferWhenSessionAuthorized(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
$booking->setAccommodation(new Accommodation());
|
||||
|
||||
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||
$breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('acceptBooking');
|
||||
$bookingService->method('loadHotelCmsData')->willReturn(null);
|
||||
|
||||
$controller = new TestableOfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService);
|
||||
|
||||
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$response = $controller->view($booking->getUuid(), $request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('<html>offer</html>', $response->getContent());
|
||||
self::assertSame('groups/offer/view.html.twig', $controller->renderedView);
|
||||
self::assertSame($booking, $controller->renderedParameters['booking']);
|
||||
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
|
||||
self::assertSame($booking->getAccommodation(), $controller->renderedParameters['ctx']->accommodation);
|
||||
}
|
||||
|
||||
public function testViewRendersUnavailableWhenSessionNotAuthorized(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(false);
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$response = $controller->view($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
public function testConfirmGetRendersModalWithFreshForm(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
$booking->setRemarks("Bitte Zimmer im EG\nDanke!");
|
||||
|
||||
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$confirmationForm = $this->createStub(FormInterface::class);
|
||||
$confirmationForm->method('handleRequest')->willReturnSelf();
|
||||
$confirmationForm->method('isSubmitted')->willReturn(false);
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
$confirmationForm,
|
||||
);
|
||||
|
||||
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
|
||||
self::assertSame($booking, $controller->renderedParameters['booking']);
|
||||
self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']);
|
||||
|
||||
self::assertInstanceOf(OfferAcceptDto::class, $controller->formData);
|
||||
self::assertSame("Bitte Zimmer im EG\nDanke!", $controller->formData->remarks, 'the stored remark is prefilled into the modal');
|
||||
self::assertFalse($controller->formData->termsAccepted);
|
||||
}
|
||||
|
||||
public function testConfirmPostValidAcceptsBookingAndRedirects(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
$booking->setRemarks('Bitte Zimmer im EG');
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, 'Bitte Zimmer im EG');
|
||||
|
||||
$confirmationForm = $this->createStub(FormInterface::class);
|
||||
$confirmationForm->method('handleRequest')->willReturnSelf();
|
||||
$confirmationForm->method('isSubmitted')->willReturn(true);
|
||||
$confirmationForm->method('isValid')->willReturn(true);
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$bookingService,
|
||||
$confirmationForm,
|
||||
);
|
||||
|
||||
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
|
||||
$response = $controller->confirm($booking->getUuid(), $request);
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_groups_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
|
||||
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.']], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
// No remark on the booking and none submitted: the empty string clears rather than keeps.
|
||||
$bookingService->expects(self::once())->method('acceptBooking')->with($booking, '');
|
||||
|
||||
$confirmationForm = $this->createStub(FormInterface::class);
|
||||
$confirmationForm->method('handleRequest')->willReturnSelf();
|
||||
$confirmationForm->method('isSubmitted')->willReturn(true);
|
||||
$confirmationForm->method('isValid')->willReturn(true);
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$bookingService,
|
||||
$confirmationForm,
|
||||
);
|
||||
|
||||
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST');
|
||||
$request->headers->set('HX-Request', 'true');
|
||||
$response = $controller->confirm($booking->getUuid(), $request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||
}
|
||||
|
||||
public function testConfirmPostInvalidReRendersModalWithoutAccepting(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('acceptBooking');
|
||||
|
||||
$confirmationForm = $this->createStub(FormInterface::class);
|
||||
$confirmationForm->method('handleRequest')->willReturnSelf();
|
||||
$confirmationForm->method('isSubmitted')->willReturn(true);
|
||||
$confirmationForm->method('isValid')->willReturn(false);
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$bookingService,
|
||||
$confirmationForm,
|
||||
);
|
||||
|
||||
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertFalse($response->headers->has('HX-Redirect'));
|
||||
self::assertSame('groups/offer/modal_accept_confirmation.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
public function testConfirmRedirectsWhenAlreadyAccepted(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Received);
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
}
|
||||
|
||||
public function testConfirmRedirectsWhenSessionNotAuthorized(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(false);
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
}
|
||||
|
||||
public function testConfirmRedirectsForUnknownUuid(): void
|
||||
{
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn(null);
|
||||
|
||||
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->expects(self::never())->method('isSessionAuthorized');
|
||||
|
||||
$controller = new TestableOfferController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$response = $controller->confirm('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm'));
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableOfferController extends IndexController
|
||||
final class TestableOfferIndexController extends IndexController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $renderedParameters = [];
|
||||
|
||||
public mixed $formData = null;
|
||||
|
||||
public function __construct(
|
||||
AccommodationBookingRepository $bookingRepository,
|
||||
AccommodationBookingLinkSigner $linkSigner,
|
||||
AccommodationBookingBreakdownCalculator $breakdownCalculator,
|
||||
AccommodationBookingService $bookingService,
|
||||
private readonly ?FormInterface $confirmationForm = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$breakdownCalculator,
|
||||
$bookingService,
|
||||
new AccommodationTermsUrlProvider(['AT' => '', 'CH' => '', 'IT' => ''], 'https://example.test/agb/'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
$this->formData = $data;
|
||||
|
||||
return $this->confirmationForm ?? throw new \LogicException('No confirmation form mock configured for this test.');
|
||||
}
|
||||
|
||||
protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null
|
||||
{
|
||||
return 'https://example.test/agb/';
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array<int, array{type: string, message: mixed}>
|
||||
*/
|
||||
public array $flashes = [];
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
@@ -490,17 +121,7 @@ final class TestableOfferController extends IndexController
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
$this->renderedParameters = $parameters;
|
||||
|
||||
if ('groups/offer/view.html.twig' === $view) {
|
||||
$content = null !== $parameters['booking']->getAcceptedAt() ? '<html>confirmed</html>' : '<html>offer</html>';
|
||||
} else {
|
||||
$content = '<html>unavailable</html>';
|
||||
}
|
||||
|
||||
$response ??= new Response();
|
||||
$response->setContent($content);
|
||||
|
||||
return $response;
|
||||
return $response ?? new Response('<html>unavailable</html>');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Groups\Offer;
|
||||
|
||||
use App\Controller\Groups\Offer\ViewController;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Model\AccommodationBookingContext;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Service\AccommodationBookingLinkSigner;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
|
||||
class ViewControllerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* The status is re-read on every view, so an authorized session is no free pass: a
|
||||
* booking pushed back into Entwurf stops showing the offer from that moment on.
|
||||
*/
|
||||
#[DataProvider('statusesTheCustomerMustNotSee')]
|
||||
public function testRendersUnavailableForABookingTheCustomerMustNotSee(AccommodationBookingStatus $status): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus($status);
|
||||
$booking->setAccommodation(new Accommodation());
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('createOfferContext');
|
||||
|
||||
$controller = new TestableOfferViewController($bookingRepository, $linkSigner, $bookingService);
|
||||
|
||||
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$response = $controller->index($booking->getUuid(), $request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{AccommodationBookingStatus}>
|
||||
*/
|
||||
public static function statusesTheCustomerMustNotSee(): iterable
|
||||
{
|
||||
yield 'draft' => [AccommodationBookingStatus::Draft];
|
||||
yield 'requested' => [AccommodationBookingStatus::Requested];
|
||||
yield 'discarded' => [AccommodationBookingStatus::Discarded];
|
||||
}
|
||||
|
||||
public function testRendersOfferWhenSessionAuthorized(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
$booking->setAccommodation(new Accommodation());
|
||||
|
||||
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(true);
|
||||
|
||||
$ctx = new AccommodationBookingContext(accommodation: $booking->getAccommodation(), priceBreakdown: ['total' => 1000]);
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->method('createOfferContext')->with($booking, $booking->getAccommodation())->willReturn($ctx);
|
||||
$bookingService->expects(self::never())->method('acceptBooking');
|
||||
|
||||
$controller = new TestableOfferViewController($bookingRepository, $linkSigner, $bookingService);
|
||||
|
||||
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$response = $controller->index($booking->getUuid(), $request);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/view.html.twig', $controller->renderedView);
|
||||
self::assertSame($booking, $controller->renderedParameters['booking']);
|
||||
self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']);
|
||||
self::assertSame($ctx, $controller->renderedParameters['ctx']);
|
||||
}
|
||||
|
||||
public function testRendersUnavailableWhenSessionNotAuthorized(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
|
||||
$bookingRepository = $this->createStub(AccommodationBookingRepository::class);
|
||||
$bookingRepository->method('findOneBy')->willReturn($booking);
|
||||
|
||||
$linkSigner = $this->createStub(AccommodationBookingLinkSigner::class);
|
||||
$linkSigner->method('isSessionAuthorized')->willReturn(false);
|
||||
|
||||
$controller = new TestableOfferViewController(
|
||||
$bookingRepository,
|
||||
$linkSigner,
|
||||
$this->createStub(AccommodationBookingService::class),
|
||||
);
|
||||
|
||||
$response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('groups/offer/unavailable.html.twig', $controller->renderedView);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableOfferViewController extends ViewController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $renderedParameters = [];
|
||||
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
$this->renderedParameters = $parameters;
|
||||
|
||||
return $response ?? new Response('<html></html>');
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
|
||||
class AccommodationBookingServiceTest extends TestCase
|
||||
{
|
||||
@@ -1010,6 +1011,90 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
return $booking;
|
||||
}
|
||||
|
||||
public function testHasCompleteContactDataAcceptsAFullyFilledBooking(): void
|
||||
{
|
||||
$service = $this->createServiceWithAccommodation();
|
||||
|
||||
self::assertTrue($service->hasCompleteContactData($this->createBookingWithContactData()));
|
||||
}
|
||||
|
||||
/**
|
||||
* An offer the office saved with only the fields its own form requires.
|
||||
*/
|
||||
public function testHasCompleteContactDataRejectsWhatTheOfficeMayLeaveOut(): void
|
||||
{
|
||||
$service = $this->createServiceWithAccommodation();
|
||||
|
||||
$booking = $this->createBookingWithContactData();
|
||||
$booking->setPhone(null);
|
||||
|
||||
self::assertFalse($service->hasCompleteContactData($booking));
|
||||
}
|
||||
|
||||
public function testHasCompleteContactDataAppliesTheStep3Constraints(): void
|
||||
{
|
||||
$service = $this->createServiceWithAccommodation();
|
||||
|
||||
$booking = $this->createBookingWithContactData();
|
||||
$booking->setSalutation('Dr.');
|
||||
|
||||
self::assertFalse($service->hasCompleteContactData($booking));
|
||||
}
|
||||
|
||||
public function testApplyContactDataRoundTripsAndFlushes(): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||
|
||||
$dto = $service->contactDataFromBooking($this->createBookingWithContactData());
|
||||
$dto->phone = '0171 999999';
|
||||
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setRemarks('bleibt');
|
||||
$service->applyContactData($booking, $dto);
|
||||
|
||||
self::assertSame('Skiclub Nord', $booking->getGroupName());
|
||||
self::assertSame('Frau', $booking->getSalutation());
|
||||
self::assertSame('Erika', $booking->getFirstName());
|
||||
self::assertSame('Mustermann', $booking->getLastName());
|
||||
self::assertSame('[email protected]', $booking->getEmail());
|
||||
self::assertSame('0171 999999', $booking->getPhone());
|
||||
self::assertSame('Hauptstraße 1', $booking->getStreet());
|
||||
self::assertSame('12345', $booking->getZip());
|
||||
self::assertSame('Musterstadt', $booking->getCity());
|
||||
self::assertSame('bleibt', $booking->getRemarks(), 'remarks are not contact data');
|
||||
}
|
||||
|
||||
public function testCreateOfferContextCombinesCmsDataAndStoredBreakdown(): void
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$accommodation = (new Accommodation())->setCalendarCode('HOTEL');
|
||||
|
||||
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||
$breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]);
|
||||
$service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator);
|
||||
|
||||
$ctx = $service->createOfferContext($booking, $accommodation);
|
||||
|
||||
self::assertSame($accommodation, $ctx->accommodation);
|
||||
self::assertSame(['total' => 1000], $ctx->priceBreakdown);
|
||||
}
|
||||
|
||||
private function createBookingWithContactData(): AccommodationBooking
|
||||
{
|
||||
return (new AccommodationBooking())
|
||||
->setGroupName('Skiclub Nord')
|
||||
->setSalutation('Frau')
|
||||
->setFirstName('Erika')
|
||||
->setLastName('Mustermann')
|
||||
->setEmail('[email protected]')
|
||||
->setPhone('0171 123456')
|
||||
->setStreet('Hauptstraße 1')
|
||||
->setZip('12345')
|
||||
->setCity('Musterstadt');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccommodationPrice[] $prices
|
||||
*/
|
||||
@@ -1051,6 +1136,7 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
$linkSigner ?? $this->createStub(AccommodationBookingLinkSigner::class),
|
||||
$breakdownCalculator ?? $this->createStub(AccommodationBookingBreakdownCalculator::class),
|
||||
$pdfGenerator ?? $this->createStub(AccommodationBookingPdfGenerator::class),
|
||||
Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator(),
|
||||
'[email protected]',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user