chore: further cleanup and refactor

This commit is contained in:
Björn Fromme
2026-04-19 15:45:02 +02:00
parent 5a0bf11c81
commit 80969d1da0
21 changed files with 606 additions and 321 deletions
+2
View File
@@ -15,6 +15,8 @@ use App\BusProNet\Constants;
*/
class Booking
{
public const string STATUS_CANCELED = 'S';
public ?int $id = null;
public ?int $agencyId = null;
public ?int $bookingNumber = null;
+6 -2
View File
@@ -15,6 +15,10 @@ namespace App\BusProNet\Model;
*/
class BookingResponse
{
public const string BOOKING_POSSIBLE = 'möglich';
public const string BOOKING_IMPOSSIBLE = 'nicht möglich';
public const string BOOKING_PROCESSED = 'erfolgt';
/**
* @param string $status Booking status (möglich|erfolgt)
* @param int|null $bookingNumber Booking number (BPN XML: vorgang)
@@ -37,7 +41,7 @@ class BookingResponse
*/
public function isInquiryValid(): bool
{
return 'möglich' === $this->status;
return self::BOOKING_POSSIBLE === $this->status;
}
/**
@@ -45,7 +49,7 @@ class BookingResponse
*/
public function isBookingSuccessful(): bool
{
return 'erfolgt' === $this->status;
return self::BOOKING_PROCESSED === $this->status;
}
/**
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Shared base controller for booking creation steps.
*/
abstract class AbstractBookingCreateController extends AbstractController
{
protected function loadBookingCreateDto(BookingSessionManager $bookingSessionService, Request $request): BookingDto
{
return $bookingSessionService->getOrCreateBookingCreateDto($request);
}
protected function createBookingCreateFailureResponse(\Throwable $exception, bool $htmx): Response
{
if (true === $htmx) {
return $this->createBookingCreateHtmxFailureResponse();
}
return $this->createBookingCreateRedirectFailureResponse($exception);
}
protected function validateStepAccess(BookingDto $bookingCreateDto, int $expectedStep): ?RedirectResponse
{
// Allow access to current step or any previous step
if ($expectedStep > $bookingCreateDto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
return $this->redirectToCurrentStep($bookingCreateDto);
}
return null;
}
/**
* Handles API errors by logging and adding a flash message.
*
* @param array<string, mixed> $context
*/
protected function handleApiError(
LoggerInterface $logger,
string $logMessage,
array $context,
string $flashMessage,
): void {
$logger->error($logMessage, $context);
$this->addFlash('error', $flashMessage);
}
private function createBookingCreateRedirectFailureResponse(\Throwable $exception): Response
{
$message = match (true) {
$exception instanceof BookingSessionNotFoundException => 'Deine Buchungssitzung ist abgelaufen. Bitte starte eine neue Buchung.',
$exception instanceof TravelNotFoundException => 'Reisedaten sind nicht (mehr) verfügbar.',
$exception instanceof HotelNotInTravelException => 'Das gewählte Hotel ist für diese Reise nicht verfügbar.',
default => 'Die Buchung konnte nicht geladen werden.',
};
$this->addFlash('error', $message);
return $this->redirectToRoute('app_booking_create_error');
}
private function createBookingCreateHtmxFailureResponse(): Response
{
return new Response('', 400);
}
private function redirectToCurrentStep(BookingDto $bookingCreateDto): RedirectResponse
{
$route = match ($bookingCreateDto->currentStep) {
2 => 'app_booking_create_step_2',
3 => 'app_booking_create_step_3',
4 => 'app_booking_create_step_4',
default => 'app_booking_create_step_1',
};
return $this->redirectToRoute($route);
}
}
@@ -4,8 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep1Type;
use App\Form\Model\BookingDto;
use App\Htmx\HxTrait;
@@ -13,7 +14,6 @@ use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\RoomPricingCalculator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -24,10 +24,8 @@ use Symfony\Component\Routing\Attribute\Route;
* This controller manages room selection functionality where users
* choose the types and quantities of rooms for their booking.
*/
class Step1Controller extends AbstractController
class Step1Controller extends AbstractBookingCreateController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
@@ -43,11 +41,11 @@ class Step1Controller extends AbstractController
#[Route('/bookings/create/rooms', name: 'app_booking_create_step_1')]
public function index(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, false);
}
$bookingCreateDto = $result;
// Get or create baseline snapshot for change detection
$oldRoomSelectionSnapshot = $this->bookingSessionService->getOrCreateBaselineSnapshot($request, $bookingCreateDto);
@@ -98,11 +96,11 @@ class Step1Controller extends AbstractController
#[Route('/bookings/create/refresh', name: 'app_booking_create_step_1_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDtoForHtmx($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, true);
}
$bookingCreateDto = $result;
// Process form data without validation to capture current state
$form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [
@@ -5,19 +5,20 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create;
use App\BusProNet\Model\Travel;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Entity\User;
use App\Form\BookingCreateStep2Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\RoomSelectionDto;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\ParticipantDataPrefiller;
use App\Service\RoomAssigner;
use App\Service\TravelDataProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -28,11 +29,8 @@ use Symfony\Component\Routing\Attribute\Route;
* This controller uses a card overview and lazy-loaded individual participant forms
* for better performance and UX with large groups (50+ participants).
*/
class Step2Controller extends AbstractController
class Step2Controller extends AbstractBookingCreateController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
public function __construct(
private readonly BookingConfigurator $bookingService,
private readonly BookingSessionManager $bookingSessionService,
@@ -49,15 +47,14 @@ class Step2Controller extends AbstractController
#[Route('/bookings/create/participants', name: 'app_booking_create_step_2')]
public function index(Request $request): Response
{
// Load or create booking DTO
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, false);
}
$bookingCreateDto = $result;
// Validate step access
if ($redirect = $this->validateStepAccess($bookingCreateDto, 2)) {
if (null !== $redirect = $this->validateStepAccess($bookingCreateDto, 2)) {
return $redirect;
}
@@ -132,7 +129,9 @@ class Step2Controller extends AbstractController
}
}
/** @param array<int, \App\Form\Model\RoomSelectionDto> $roomSelections */
/**
* @param array<int, RoomSelectionDto> $roomSelections
*/
private function calculateParticipantsCount(array $roomSelections, Travel $travelData): int
{
$participantsCount = 0;
@@ -8,8 +8,9 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep3Type;
use App\Form\Model\BookingDto;
use App\Htmx\HxTrait;
@@ -20,7 +21,6 @@ use App\Service\BookingPriceMismatchAnalyzer;
use App\Service\BookingSessionManager;
use App\Service\RoomPricingCalculator;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -29,10 +29,8 @@ use Symfony\Component\Routing\Attribute\Route;
/**
* Handles the third step of the booking creation process (payment method selection).
*/
class Step3Controller extends AbstractController
class Step3Controller extends AbstractBookingCreateController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
@@ -52,14 +50,14 @@ class Step3Controller extends AbstractController
#[Route('/bookings/create/payment', name: 'app_booking_create_step_3')]
public function index(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, false);
}
$bookingCreateDto = $result;
// Validate step access
if ($redirect = $this->validateStepAccess($bookingCreateDto, 3)) {
if (null !== $redirect = $this->validateStepAccess($bookingCreateDto, 3)) {
return $redirect;
}
@@ -203,11 +201,11 @@ class Step3Controller extends AbstractController
#[Route('/bookings/create/payment/refresh', name: 'app_booking_create_step_3_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, true);
}
$bookingCreateDto = $result;
$form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto, [
'validation_groups' => false,
@@ -248,7 +246,7 @@ class Step3Controller extends AbstractController
private function shouldFallbackToInquiryMode(BookingResponse $response): bool
{
// Check for "nicht möglich" status + inquiry suggestion in message
if ('nicht möglich' !== $response->status) {
if (BookingResponse::BOOKING_IMPOSSIBLE !== $response->status) {
return false;
}
@@ -7,10 +7,11 @@ namespace App\Controller\Booking\Create;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits\BookingCreateTrait;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
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\Htmx\HxTrait;
@@ -20,7 +21,6 @@ use App\Service\BookingSessionManager;
use App\Service\MailjetApiClient;
use App\Service\NewsletterManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -30,10 +30,8 @@ use Symfony\Contracts\Cache\CacheInterface;
/**
* Handles the fourth step of the booking creation process (confirmation).
*/
class Step4Controller extends AbstractController
class Step4Controller extends AbstractBookingCreateController
{
use BookingCreateTrait;
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
@@ -54,14 +52,14 @@ class Step4Controller extends AbstractController
#[Route('/bookings/create/confirmation', name: 'app_booking_create_step_4')]
public function index(Request $request): Response
{
$result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request);
if ($result instanceof Response) {
return $result;
try {
$bookingCreateDto = $this->loadBookingCreateDto($this->bookingSessionService, $request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $exception) {
return $this->createBookingCreateFailureResponse($exception, false);
}
$bookingCreateDto = $result;
// Validate step access
if ($redirect = $this->validateStepAccess($bookingCreateDto, 4)) {
if (null !== $redirect = $this->validateStepAccess($bookingCreateDto, 4)) {
return $redirect;
}
+58 -23
View File
@@ -5,11 +5,11 @@ declare(strict_types=1);
namespace App\Controller\Booking\Edit;
use App\BusProNet\Model\Notification;
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Entity\User;
use App\Exception\TravelNotFoundException;
use App\Form\BookingEditType;
use App\Form\Model\BookingDto;
use App\Model\BookingEditSubmissionResult;
use App\Htmx\HxTrait;
use App\Service\BookingChangeTracker;
use App\Service\BookingEditContextFactory;
@@ -34,7 +34,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
*/
class IndexController extends AbstractController
{
use BookingExceptionHandlerTrait;
use HxTrait;
public function __construct(
@@ -43,7 +42,7 @@ class IndexController extends AbstractController
private readonly BookingSessionManager $bookingSessionService,
private readonly BookingChangeTracker $fingerprintService,
private readonly BookingEditContextFactory $editContextFactory,
private readonly BookingEditSubmitter $submitService,
private readonly BookingEditSubmitter $formSubmitter,
) {
}
@@ -53,32 +52,32 @@ class IndexController extends AbstractController
* Clears any existing session data and API cache to ensure fresh data
* is loaded, then redirects to the main edit page.
*/
#[Route('/bookings/{id}/edit/start', name: 'app_booking_edit_start', requirements: ['id' => '\d+'])]
#[Route('/bookings/{bookingId}/edit/start', name: 'app_booking_edit_start', requirements: ['bookingId' => '\d+'])]
#[IsGranted('ROLE_USER')]
public function start(int $id, Request $request): Response
public function start(int $bookingId, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
$this->dataLoader->invalidateBookingCache($id, $user);
$this->dataLoader->invalidateBookingCache($bookingId, $user);
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
/**
* Display participant cards overview.
*/
#[Route('/bookings/{id}/edit', name: 'app_booking_edit', requirements: ['id' => '\d+'])]
#[Route('/bookings/{bookingId}/edit', name: 'app_booking_edit', requirements: ['bookingId' => '\d+'])]
#[IsGranted('ROLE_USER')]
public function index(int $id, Request $request): Response
public function index(int $bookingId, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
// Load form data from session (or API on first load)
try {
$bookingDto = $this->dataLoader->loadFormData($request, $id, $user);
$bookingDto = $this->dataLoader->loadFormData($request, $bookingId, $user);
} catch (TravelNotFoundException) {
$this->addFlash('error', 'Reisedaten sind nicht (mehr) verfügbar');
@@ -92,12 +91,12 @@ class IndexController extends AbstractController
}
// Show flash message if draft was restored
if (true === $this->dataLoader->wasDraftRestored()) {
if (true === $this->dataLoader->isDraftRestored()) {
$this->addFlash('info', 'Dein zuvor gespeicherter Entwurf wurde wiederhergestellt.');
}
// Fetch booking data for display (surcharges, canceled status, etc.)
$bookingData = $this->dataLoader->fetchBookingData($id, $user);
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (null === $bookingData || $bookingData instanceof Notification) {
$this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar');
@@ -110,7 +109,10 @@ class IndexController extends AbstractController
// Handle form submission (clicking "Buchung aktualisieren")
if ($form->isSubmitted() && $form->isValid()) {
return $this->submitService->handleSubmission($request, $bookingDto, $id, $user);
$submissionResult = $this->formSubmitter->handleSubmission($request, $bookingDto, $bookingId, $user);
$this->applySubmissionResultFlashes($submissionResult);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$bookingEditContext = $this->editContextFactory->createOverviewContext(
@@ -129,6 +131,39 @@ class IndexController extends AbstractController
return $this->render('booking/edit/index.html.twig', $templateData);
}
private function applySubmissionResultFlashes(BookingEditSubmissionResult $result): void
{
if (true === $result->immutableChangesReverted) {
$this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
}
switch ($result->status) {
case BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED:
$this->addFlash('error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
break;
case BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR:
$this->addFlash('error', $result->message ?? 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
break;
case BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO:
if (null !== $result->message && '' !== trim($result->message)) {
$this->addFlash('info', $result->message);
}
break;
case BookingEditSubmissionResult::STATUS_SUCCESS:
$this->addFlash('success', 'Buchung erfolgreich aktualisiert');
break;
case BookingEditSubmissionResult::STATUS_UNSUCCESSFUL:
$this->addFlash('error', $result->message ?? 'Buchung konnte nicht aktualisiert werden');
break;
case BookingEditSubmissionResult::STATUS_TIMEOUT:
$this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut.');
break;
case BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR:
$this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
break;
}
}
/**
* Reloads booking data from API, discarding all session changes.
*
@@ -136,12 +171,12 @@ class IndexController extends AbstractController
* POST: Clears session and redirects to reload the booking
*/
#[Route(
path: '/bookings/{id}/edit/reload',
path: '/bookings/{bookingId}/edit/reload',
name: 'app_booking_edit_reload',
requirements: ['id' => '\d+']
requirements: ['bookingId' => '\d+']
)]
#[IsGranted('ROLE_USER')]
public function reloadFromApi(int $id, Request $request): Response
public function reloadFromApi(int $bookingId, Request $request): Response
{
if (Request::METHOD_POST === $request->getMethod()) {
// Clear session to discard all changes
@@ -150,15 +185,15 @@ class IndexController extends AbstractController
// Delete draft since user explicitly chose to discard changes
/** @var User $user */
$user = $this->getUser();
$this->draftService->deleteDraft($user, $id);
$this->draftService->deleteDraft($user, $bookingId);
$this->addFlash('success', 'Änderungen verworfen, Daten neu geladen');
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
return $this->render('booking/edit/modal_reload.html.twig', [
'bookingId' => $id,
'bookingId' => $bookingId,
]);
}
@@ -169,12 +204,12 @@ class IndexController extends AbstractController
* Shows a flash message informing about the saved draft.
*/
#[Route(
path: '/bookings/{id}/edit/cancel',
path: '/bookings/{bookingId}/edit/cancel',
name: 'app_booking_edit_cancel',
requirements: ['id' => '\d+']
requirements: ['bookingId' => '\d+']
)]
#[IsGranted('ROLE_USER')]
public function cancelEdit(int $id, Request $request): Response
public function cancelEdit(int $bookingId, Request $request): Response
{
// Clear session state
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
@@ -182,7 +217,7 @@ class IndexController extends AbstractController
// Check if a draft exists to show appropriate message
/** @var User $user */
$user = $this->getUser();
$draft = $this->draftService->findDraft($user, $id);
$draft = $this->draftService->findDraft($user, $bookingId);
if (null !== $draft) {
$this->addFlash('info', 'Deine Änderungen wurden als Entwurf gespeichert.');
@@ -41,12 +41,12 @@ class ParticipantController extends AbstractController
* Edit single participant form.
*/
#[Route(
path: '/bookings/{id}/edit/participants/{index}',
path: '/bookings/{bookingId}/edit/participants/{index}',
name: 'app_booking_edit_participant',
requirements: ['id' => '\d+', 'index' => '\d+']
requirements: ['bookingId' => '\d+', 'index' => '\d+']
)]
#[IsGranted('ROLE_USER')]
public function editParticipant(int $id, int $index, Request $request): Response
public function editParticipant(int $bookingId, int $index, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
@@ -56,12 +56,12 @@ class ParticipantController extends AbstractController
if (null === $bookingDto) {
$this->addFlash('error', 'Sitzung abgelaufen. Bitte neu laden.');
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$this->participantFormSupportService->ensureParticipantExists($bookingDto, $index);
$bookingData = $this->dataLoader->fetchBookingData($id, $user);
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (null === $bookingData || $bookingData instanceof Notification) {
$this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar');
@@ -71,7 +71,7 @@ class ParticipantController extends AbstractController
if ($this->isParticipantCanceled($bookingData, $index)) {
$this->addFlash('warning', 'Stornierte Teilnehmer können nicht bearbeitet werden');
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$this->editContextFactory->prepareBookingDto($bookingDto);
@@ -90,10 +90,10 @@ class ParticipantController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
$this->draftService->saveDraft($user, $id, $bookingDto);
$this->draftService->saveDraft($user, $bookingId, $bookingDto);
$this->addNotificationsAsFlashMessages($notifications);
return $this->redirectToRoute('app_booking_edit', ['id' => $id]);
return $this->redirectToRoute('app_booking_edit', ['bookingId' => $bookingId]);
}
$context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData);
@@ -106,9 +106,9 @@ class ParticipantController extends AbstractController
'summaryData' => $context->summaryData,
'mutableData' => $context->mutableData,
'refreshRouteName' => 'app_booking_edit_participant_refresh',
'refreshRouteParams' => ['id' => $id, 'index' => $index],
'refreshRouteParams' => ['bookingId' => $bookingId, 'index' => $index],
'cancelRouteName' => 'app_booking_edit',
'cancelRouteParams' => ['id' => $id],
'cancelRouteParams' => ['bookingId' => $bookingId],
]);
}
@@ -116,13 +116,13 @@ class ParticipantController extends AbstractController
* HTMX endpoint for refreshing participant form without validation.
*/
#[Route(
path: '/bookings/{id}/edit/participants/{index}/refresh',
path: '/bookings/{bookingId}/edit/participants/{index}/refresh',
name: 'app_booking_edit_participant_refresh',
requirements: ['id' => '\d+', 'index' => '\d+'],
requirements: ['bookingId' => '\d+', 'index' => '\d+'],
methods: ['POST']
)]
#[IsGranted('ROLE_USER')]
public function refreshParticipantForm(int $id, int $index, Request $request): Response
public function refreshParticipantForm(int $bookingId, int $index, Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
@@ -136,7 +136,7 @@ class ParticipantController extends AbstractController
$this->participantFormSupportService->ensureParticipantExists($bookingDto, $index);
$this->editContextFactory->prepareBookingDto($bookingDto);
$bookingData = $this->dataLoader->fetchBookingData($id, $user);
$bookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
$wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index);
@@ -165,9 +165,9 @@ class ParticipantController extends AbstractController
'summaryData' => $context->summaryData,
'mutableData' => $context->mutableData,
'refreshRouteName' => 'app_booking_edit_participant_refresh',
'refreshRouteParams' => ['id' => $id, 'index' => $index],
'refreshRouteParams' => ['bookingId' => $bookingId, 'index' => $index],
'cancelRouteName' => 'app_booking_edit',
'cancelRouteParams' => ['id' => $id],
'cancelRouteParams' => ['bookingId' => $bookingId],
]
);
@@ -192,6 +192,6 @@ class ParticipantController extends AbstractController
private function isParticipantCanceled(Booking $bookingData, int $index): bool
{
return 'S' === ($bookingData->participantsStatus[$index] ?? null);
return Booking::STATUS_CANCELED === ($bookingData->participantsStatus[$index] ?? null);
}
}
@@ -1,67 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Traits;
use App\Form\Model\BookingDto;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Provides common functionality for booking creation controllers.
*
* This trait contains shared validation and redirect logic used across
* all booking creation steps to ensure consistent behavior and reduce
* code duplication.
*/
trait BookingCreateTrait
{
/**
* Validates step access and returns redirect response if necessary.
*
* @return RedirectResponse|null Returns redirect response if validation fails, null if access is allowed
*/
private function validateStepAccess(BookingDto $bookingCreateDto, int $expectedStep): ?RedirectResponse
{
// Allow access to current step or any previous step
if ($expectedStep > $bookingCreateDto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
return $this->redirectToCurrentStep($bookingCreateDto);
}
return null;
}
/**
* Redirects to the current step based on the DTO's currentStep.
*/
private function redirectToCurrentStep(BookingDto $bookingCreateDto): RedirectResponse
{
$route = match ($bookingCreateDto->currentStep) {
2 => 'app_booking_create_step_2',
3 => 'app_booking_create_step_3',
4 => 'app_booking_create_step_4',
default => 'app_booking_create_step_1',
};
return $this->redirectToRoute($route);
}
/**
* Handles API errors by logging and adding a flash message.
*/
/**
* @param array<string, mixed> $context
*/
private function handleApiError(
LoggerInterface $logger,
string $logMessage,
array $context,
string $flashMessage,
): void {
$logger->error($logMessage, $context);
$this->addFlash('error', $flashMessage);
}
}
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Booking\Traits;
use App\Exception\BookingSessionNotFoundException;
use App\Exception\HotelNotInTravelException;
use App\Exception\TravelNotFoundException;
use App\Form\Model\BookingDto;
use App\Service\BookingSessionManager;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Provides centralized exception handling for booking controllers.
*
* This trait contains common exception handling logic for booking operations
* including user-friendly error messages and appropriate redirects.
*/
trait BookingExceptionHandlerTrait
{
/**
* Safely retrieves booking DTO with centralized exception handling.
*
* Handles all booking-related exceptions and provides appropriate user feedback
* by redirecting to the error page with flash messages.
*/
protected function getOrCreateBookingCreateDto(BookingSessionManager $bookingSessionService, Request $request): BookingDto|RedirectResponse
{
try {
return $bookingSessionService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $e) {
$this->addFlash('error', 'Deine Buchungssitzung ist abgelaufen. Bitte starte eine neue Buchung.');
return $this->redirectToRoute('app_booking_create_error');
}
}
/**
* Safely retrieves booking DTO for HTMX requests with lightweight error responses.
*
* Returns empty 400 responses for HTMX requests when exceptions occur,
* allowing the frontend to handle errors appropriately.
*/
protected function getOrCreateBookingCreateDtoForHtmx(BookingSessionManager $bookingSessionService, Request $request): mixed
{
try {
return $bookingSessionService->getOrCreateBookingCreateDto($request);
} catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotInTravelException $e) {
return new Response('', 400);
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Model;
final readonly class BookingEditSubmissionResult
{
public const string STATUS_BOOKING_DATA_RELOAD_FAILED = 'booking_data_reload_failed';
public const string STATUS_NOTIFICATION_ERROR = 'notification_error';
public const string STATUS_NOTIFICATION_INFO = 'notification_info';
public const string STATUS_SUCCESS = 'success';
public const string STATUS_UNSUCCESSFUL = 'unsuccessful';
public const string STATUS_TIMEOUT = 'timeout';
public const string STATUS_API_CLIENT_ERROR = 'api_client_error';
public function __construct(
public string $status,
public ?string $message = null,
public bool $immutableChangesReverted = false,
) {
}
}
+5 -5
View File
@@ -27,7 +27,7 @@ class BookingEditDataLoader
{
public const string CACHE_TAG_USER_PREFIX = 'user_bookings_';
private bool $draftWasRestored = false;
private bool $draftRestored = false;
public function __construct(
private readonly ApiClient $apiClient,
@@ -48,9 +48,9 @@ class BookingEditDataLoader
* This flag is reset on each call to loadFormData() and can be used
* by the controller to show a flash message to the user.
*/
public function wasDraftRestored(): bool
public function isDraftRestored(): bool
{
return $this->draftWasRestored;
return $this->draftRestored;
}
/**
@@ -71,7 +71,7 @@ class BookingEditDataLoader
*/
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
{
$this->draftWasRestored = false;
$this->draftRestored = false;
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
@@ -154,7 +154,7 @@ class BookingEditDataLoader
if (null !== $draft) {
$applied = $this->draftService->applyDraftToDto($draft, $formData, $travelData);
if (true === $applied) {
$this->draftWasRestored = true;
$this->draftRestored = true;
}
}
+43 -42
View File
@@ -9,12 +9,10 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Entity\User;
use App\Model\BookingEditSubmissionResult;
use App\Form\Model\BookingDto;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Handles the final booking edit submission workflow.
@@ -28,12 +26,11 @@ class BookingEditSubmitter
private readonly TravelDataProvider $travelDataService,
private readonly BookingEditSubmitGuard $submitGuard,
private readonly BookingSessionManager $bookingSessionService,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger,
) {
}
public function handleSubmission(Request $request, BookingDto $bookingDto, int $bookingId, User $user): RedirectResponse
public function handleSubmission(Request $request, BookingDto $bookingDto, int $bookingId, User $user): BookingEditSubmissionResult
{
$email = $user->getEmail();
@@ -45,14 +42,15 @@ class BookingEditSubmitter
$this->dataLoader->invalidateBookingCache($bookingId, $user);
$freshBookingData = $this->dataLoader->fetchBookingData($bookingId, $user);
if (null === $freshBookingData || $freshBookingData instanceof Notification) {
$this->addFlash($request, 'error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden');
$this->logger->warning('Failed to refresh booking before update submission', [
'email' => $email,
'booking_id' => $bookingId,
'has_notification' => $freshBookingData instanceof Notification,
]);
return $this->redirectToEdit($bookingId);
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED,
);
}
$bookingDto->booking = $freshBookingData;
@@ -68,66 +66,69 @@ class BookingEditSubmitter
$immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData);
if (true === $immutableChangesReverted) {
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
$this->addFlash($request, 'info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.');
}
try {
$response = $this->apiClient->updateBooking($bookingDto, true);
if ($response instanceof Notification) {
if (true === $response->isError()) {
$this->addFlash($request, 'error', $response->message);
} else {
$this->addFlash($request, 'info', $response->message);
}
$this->logger->error('Booking update not successful', [
'email' => $email,
'booking_id' => $bookingId,
'message' => $response->message,
]);
} elseif (true === $response->success) {
return new BookingEditSubmissionResult(
true === $response->isError()
? BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR
: BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
$response->message,
$immutableChangesReverted,
);
}
if (true === $response->success) {
$this->dataLoader->invalidateBookingCache($bookingId, $user);
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
$this->draftService->deleteDraft($user, $bookingId);
$this->addFlash($request, 'success', 'Buchung erfolgreich aktualisiert');
$this->logger->info('Booking update successful', [
'email' => $email,
'booking_id' => $bookingId,
]);
return $this->redirectToEdit($bookingId);
} else {
$this->addFlash($request, 'error', $response->status ?? 'Buchung konnte nicht aktualisiert werden');
$this->logger->warning('Booking update returned unsuccessful status', [
'email' => $email,
'booking_id' => $bookingId,
'status' => $response->status,
'valid' => $response->valid,
]);
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_SUCCESS,
null,
$immutableChangesReverted,
);
}
$this->logger->warning('Booking update returned unsuccessful status', [
'email' => $email,
'booking_id' => $bookingId,
'status' => $response->status,
'valid' => $response->valid,
]);
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_UNSUCCESSFUL,
$response->status ?? 'Buchung konnte nicht aktualisiert werden',
$immutableChangesReverted,
);
} catch (TimeoutException) {
$this->addFlash($request, 'error', 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.');
$this->logger->error('Booking update timeout', [
'email' => $email,
'booking_id' => $bookingId,
]);
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_TIMEOUT,
null,
$immutableChangesReverted,
);
} catch (ApiClientException) {
$this->addFlash($request, 'error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
return new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR,
null,
$immutableChangesReverted,
);
}
return $this->redirectToEdit($bookingId);
}
private function addFlash(Request $request, string $type, string $message): void
{
$session = $request->getSession();
if ($session instanceof FlashBagAwareSessionInterface) {
$session->getFlashBag()->add($type, $message);
}
}
private function redirectToEdit(int $bookingId): RedirectResponse
{
return new RedirectResponse($this->urlGenerator->generate('app_booking_edit', ['id' => $bookingId]));
}
}
@@ -10,7 +10,7 @@
{# Build edit URL for linking #}
{% if mode == 'edit' %}
{% set editUrl = path('app_booking_edit_participant', {id: bookingId, index: index}) %}
{% set editUrl = path('app_booking_edit_participant', {bookingId: bookingId, index: index}) %}
{% else %}
{% set editUrl = path('app_booking_create_step_2_participant', {index: index}) %}
{% endif %}
+2 -2
View File
@@ -32,7 +32,7 @@
</h1>
{% if bookingEditContext.isDirty %}
<button type="button"
hx-get="{{ path('app_booking_edit_reload', {id: bookingEditContext.bookingData.id}) }}"
hx-get="{{ path('app_booking_edit_reload', {bookingId: bookingEditContext.bookingData.id}) }}"
hx-target="body"
hx-swap="beforeend"
class="button button--small button--secondary">
@@ -85,7 +85,7 @@
{# Fixed footer #}
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between" hx-disinherit="*">
<a href="{{ bookingEditContext.isDirty ? path('app_booking_edit_cancel', {id: bookingEditContext.bookingData.id}) : path('app_bookings') }}"
<a href="{{ bookingEditContext.isDirty ? path('app_booking_edit_cancel', {bookingId: bookingEditContext.bookingData.id}) : path('app_bookings') }}"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-8 h-8 md:w-10 md:h-10">
<svg class="w-6 h-6 md:w-8 md:h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</a>
@@ -8,7 +8,7 @@
</div>
<div class="flex justify-between">
<button type="button"
hx-post="{{ path('app_booking_edit_reload', {id: bookingId}) }}"
hx-post="{{ path('app_booking_edit_reload', {bookingId: bookingId}) }}"
hx-target="body"
class="button button--small button--primary">
Ja
+1 -1
View File
@@ -43,7 +43,7 @@
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex justify-between">
<a href="{{ path('app_booking_edit', {id: bookingEditContext.bookingData.id}) }}"
<a href="{{ path('app_booking_edit', {bookingId: bookingEditContext.bookingData.id}) }}"
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-8 h-8 md:w-10 md:h-10"
{{ qa_attribute('btn-cancel') }}>
<svg class="w-6 h-6 md:w-8 md:h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
+1 -1
View File
@@ -73,7 +73,7 @@
</table>
<div class="flex space-x-2">
{% if booking.editable %}
<a href="{{ path('app_booking_edit_start', { 'id': booking.id }) }}"
<a href="{{ path('app_booking_edit_start', { bookingId: booking.id }) }}"
class="button button--primary button--small"
title="Buchung bearbeiten">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M92.69,216H48a8,8,0,0,1-8-8V163.31a8,8,0,0,1,2.34-5.65L165.66,34.34a8,8,0,0,1,11.31,0L221.66,79a8,8,0,0,1,0,11.31L98.34,213.66A8,8,0,0,1,92.69,216Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="136" y1="64" x2="192" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="164" y1="92" x2="68" y2="188" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="95.49" y1="215.49" x2="40.51" y2="160.51" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Booking\Edit;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Travel;
use App\Controller\Booking\Edit\IndexController;
use App\Entity\User;
use App\Form\Model\BookingDto;
use App\Model\BookingEditSubmissionResult;
use App\Service\BookingChangeTracker;
use App\Service\BookingEditContextFactory;
use App\Service\BookingEditDataLoader;
use App\Service\BookingEditDraftManager;
use App\Service\BookingEditSubmitter;
use App\Service\BookingSessionManager;
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\Security\Core\User\UserInterface;
class IndexControllerTest extends TestCase
{
public function testIndexAppliesSubmissionResultFlashesAndRedirectsBackToEditPage(): void
{
$this->assertSubmissionResultFlashes(
new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_SUCCESS,
null,
true
),
[
['info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.'],
['success', 'Buchung erfolgreich aktualisiert'],
],
);
}
/**
* @dataProvider submissionResultProvider
*
* @param array<int, array{0: string, 1: string}> $expectedFlashes
* @phpstan-param array<int, array{0: string, 1: string}> $expectedFlashes
*/
public function testIndexAppliesErrorSubmissionResultFlashesAndRedirectsBackToEditPage(
BookingEditSubmissionResult $submissionResult,
array $expectedFlashes,
): void {
$this->assertSubmissionResultFlashes($submissionResult, $expectedFlashes);
}
/**
* @return array<string, array{0: BookingEditSubmissionResult, 1: array<int, array{0: string, 1: string}>}>
*/
public static function submissionResultProvider(): array
{
return [
'notification error' => [
new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR,
'BPN-FAIL',
false
),
[
['error', 'BPN-FAIL'],
],
],
'timeout' => [
new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_TIMEOUT,
null,
false
),
[
['error', 'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut.'],
],
],
'reload failed' => [
new BookingEditSubmissionResult(
BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED,
null,
false
),
[
['error', 'Buchungsdaten konnten vor dem Speichern nicht neu geladen werden'],
],
],
];
}
private function createUser(): User
{
return new User('[email protected]');
}
private function createBookingDto(): BookingDto
{
$travel = new Travel();
$travel->id = 1234;
$bookingDto = new BookingDto($travel, 77);
$bookingDto->booking = new Booking();
$bookingDto->booking->id = 42;
return $bookingDto;
}
private function createBooking(): Booking
{
$booking = new Booking();
$booking->id = 42;
$booking->dateId = 1234;
return $booking;
}
/**
* @param array<int, array{0: string, 1: string}> $expectedFlashes
*/
private function assertSubmissionResultFlashes(
BookingEditSubmissionResult $submissionResult,
array $expectedFlashes,
): void {
$request = Request::create('/bookings/42/edit', 'POST');
$user = $this->createUser();
$bookingDto = $this->createBookingDto();
$bookingData = $this->createBooking();
$form = $this->createMock(FormInterface::class);
$form->expects($this->once())
->method('handleRequest')
->with($request)
->willReturnSelf();
$form->expects($this->once())
->method('isSubmitted')
->willReturn(true);
$form->expects($this->once())
->method('isValid')
->willReturn(true);
$dataLoader = $this->createMock(BookingEditDataLoader::class);
$dataLoader->expects($this->once())
->method('loadFormData')
->with($request, 42, $user)
->willReturn($bookingDto);
$dataLoader->expects($this->once())
->method('fetchBookingData')
->with(42, $user)
->willReturn($bookingData);
$dataLoader->expects($this->once())
->method('isDraftRestored');
$submitter = $this->createMock(BookingEditSubmitter::class);
$submitter->expects($this->once())
->method('handleSubmission')
->with($request, $bookingDto, 42, $user)
->willReturn($submissionResult);
$controller = new TestableIndexController(
$dataLoader,
$this->createMock(BookingEditDraftManager::class),
$this->createMock(BookingSessionManager::class),
$this->createMock(BookingChangeTracker::class),
$this->createMock(BookingEditContextFactory::class),
$submitter,
$user,
$form,
);
$response = $controller->index(42, $request);
$this->assertInstanceOf(RedirectResponse::class, $response);
$this->assertSame('/bookings/42/edit', $response->headers->get('Location'));
$this->assertSame($expectedFlashes, $controller->flashes);
}
}
final class TestableIndexController extends IndexController
{
/**
* @var array<int, array{string, mixed}>
*/
public array $flashes = [];
/**
* @var FormInterface<mixed>
*/
private readonly FormInterface $form;
/**
* @param FormInterface<mixed> $form
*/
public function __construct(
BookingEditDataLoader $dataLoader,
BookingEditDraftManager $draftService,
BookingSessionManager $bookingSessionService,
BookingChangeTracker $fingerprintService,
BookingEditContextFactory $editContextFactory,
BookingEditSubmitter $formSubmitter,
private readonly User $user,
FormInterface $form,
) {
$this->form = $form;
parent::__construct(
$dataLoader,
$draftService,
$bookingSessionService,
$fingerprintService,
$editContextFactory,
$formSubmitter,
);
}
protected function getUser(): UserInterface
{
return $this->user;
}
/**
* @return FormInterface<mixed>
*/
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
{
return $this->form;
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = [$type, $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
{
$location = match ($route) {
'app_booking_edit' => '/bookings/'.$parameters['bookingId'].'/edit',
default => '/'.$route,
};
return new RedirectResponse($location, $status);
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
throw new \LogicException('Render should not be called in this test.');
}
}
+47 -49
View File
@@ -12,6 +12,7 @@ use App\BusProNet\Model\Notification;
use App\BusProNet\Model\Travel;
use App\Entity\User;
use App\Form\Model\BookingDto;
use App\Model\BookingEditSubmissionResult;
use App\Service\BookingEditDataLoader;
use App\Service\BookingEditDraftManager;
use App\Service\BookingEditSubmitGuard;
@@ -23,11 +24,10 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class BookingEditSubmitterTest extends TestCase
{
public function testHandleSubmissionReturnsRedirectWhenFreshBookingDataCannotBeLoaded(): void
public function testHandleSubmissionReturnsResultWhenFreshBookingDataCannotBeLoaded(): void
{
$request = $this->createRequestWithSession();
$user = $this->createUser();
@@ -46,22 +46,18 @@ class BookingEditSubmitterTest extends TestCase
dataLoader: $dataLoader,
);
$response = $service->handleSubmission($request, $bookingDto, 42, $user);
$result = $service->handleSubmission($request, $bookingDto, 42, $user);
$this->assertSame('/bookings/42/edit', $response->headers->get('Location'));
$this->assertSame(
['Buchungsdaten konnten vor dem Speichern nicht neu geladen werden'],
$request->getSession()->getFlashBag()->get('error')
);
$this->assertSame(BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED, $result->status);
$this->assertFalse($result->immutableChangesReverted);
}
/**
* @dataProvider updateFailureProvider
*/
public function testHandleSubmissionReturnsRedirectWhenUpdateThrows(
public function testHandleSubmissionReturnsResultWhenUpdateThrows(
\Throwable $exception,
string $expectedFlashType,
string $expectedFlashMessage,
string $expectedStatus,
): void {
$request = $this->createRequestWithSession();
$user = $this->createUser();
@@ -104,13 +100,14 @@ class BookingEditSubmitterTest extends TestCase
submitGuard: $submitGuard,
);
$response = $service->handleSubmission($request, $bookingDto, 42, $user);
$result = $service->handleSubmission($request, $bookingDto, 42, $user);
$this->assertSame('/bookings/42/edit', $response->headers->get('Location'));
$this->assertSame([$expectedFlashMessage], $request->getSession()->getFlashBag()->get($expectedFlashType));
$this->assertSame($expectedStatus, $result->status);
$this->assertNull($result->message);
$this->assertFalse($result->immutableChangesReverted);
}
public function testHandleSubmissionReturnsRedirectWhenUpdateIsUnsuccessful(): void
public function testHandleSubmissionReturnsResultWhenUpdateIsUnsuccessful(): void
{
$request = $this->createRequestWithSession();
$user = $this->createUser();
@@ -156,20 +153,21 @@ class BookingEditSubmitterTest extends TestCase
submitGuard: $submitGuard,
);
$response = $service->handleSubmission($request, $bookingDto, 42, $user);
$result = $service->handleSubmission($request, $bookingDto, 42, $user);
$this->assertSame('/bookings/42/edit', $response->headers->get('Location'));
$this->assertSame(['BPN-FAIL'], $request->getSession()->getFlashBag()->get('error'));
$this->assertSame(BookingEditSubmissionResult::STATUS_UNSUCCESSFUL, $result->status);
$this->assertSame('BPN-FAIL', $result->message);
$this->assertFalse($result->immutableChangesReverted);
}
public function testHandleSubmissionStoresInfoForNonErrorNotification(): void
{
$this->assertNotificationFlash(new Notification(650, 'Alles gut'), 'info');
$this->assertNotificationResult(new Notification(650, 'Alles gut'), 'info');
}
public function testHandleSubmissionStoresErrorForErrorNotification(): void
{
$this->assertNotificationFlash(new Notification(500, 'Kaputt'), 'error');
$this->assertNotificationResult(new Notification(500, 'Kaputt'), 'error');
}
public function testHandleSubmissionClearsSessionAndDraftOnSuccessfulUpdate(): void
@@ -231,10 +229,10 @@ class BookingEditSubmitterTest extends TestCase
bookingSessionService: $bookingSessionService,
);
$response = $service->handleSubmission($request, $bookingDto, 42, $user);
$result = $service->handleSubmission($request, $bookingDto, 42, $user);
$this->assertSame('/bookings/42/edit', $response->headers->get('Location'));
$this->assertSame(['Buchung erfolgreich aktualisiert'], $request->getSession()->getFlashBag()->get('success'));
$this->assertSame(BookingEditSubmissionResult::STATUS_SUCCESS, $result->status);
$this->assertFalse($result->immutableChangesReverted);
$this->assertSame($freshBookingData, $bookingDto->booking);
}
@@ -298,32 +296,31 @@ class BookingEditSubmitterTest extends TestCase
bookingSessionService: $bookingSessionService,
);
$response = $service->handleSubmission($request, $bookingDto, 42, $user);
$result = $service->handleSubmission($request, $bookingDto, 42, $user);
$this->assertSame('/bookings/42/edit', $response->headers->get('Location'));
$this->assertSame(['Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.'], $request->getSession()->getFlashBag()->get('info'));
$this->assertSame(['Buchung erfolgreich aktualisiert'], $request->getSession()->getFlashBag()->get('success'));
$this->assertSame(BookingEditSubmissionResult::STATUS_SUCCESS, $result->status);
$this->assertTrue($result->immutableChangesReverted);
}
public function testHandleSubmissionReturnsRedirectOnTimeout(): void
public function testHandleSubmissionReturnsResultOnTimeout(): void
{
$this->assertExceptionFlash(new TimeoutException('slow'), 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.');
$this->assertExceptionResult(new TimeoutException('slow'), BookingEditSubmissionResult::STATUS_TIMEOUT);
}
public function testHandleSubmissionReturnsRedirectOnApiClientException(): void
public function testHandleSubmissionReturnsResultOnApiClientException(): void
{
$this->assertExceptionFlash(new ApiClientException('boom'), 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten');
$this->assertExceptionResult(new ApiClientException('boom'), BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR);
}
public static function updateFailureProvider(): array
{
return [
'timeout' => [new TimeoutException('slow'), 'error', 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.'],
'api-client' => [new ApiClientException('boom'), 'error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten'],
'timeout' => [new TimeoutException('slow'), BookingEditSubmissionResult::STATUS_TIMEOUT],
'api-client' => [new ApiClientException('boom'), BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR],
];
}
private function assertNotificationFlash(Notification $notification, string $expectedType): void
private function assertNotificationResult(Notification $notification, string $expectedType): void
{
$request = $this->createRequestWithSession();
$user = $this->createUser();
@@ -366,13 +363,14 @@ class BookingEditSubmitterTest extends TestCase
submitGuard: $submitGuard,
);
$response = $service->handleSubmission($request, $bookingDto, 42, $user);
$result = $service->handleSubmission($request, $bookingDto, 42, $user);
$this->assertSame('/bookings/42/edit', $response->headers->get('Location'));
$this->assertSame([$notification->message], $request->getSession()->getFlashBag()->get($expectedType));
$this->assertSame($this->resolveExpectedStatus($expectedType), $result->status);
$this->assertSame($notification->message, $result->message);
$this->assertFalse($result->immutableChangesReverted);
}
private function assertExceptionFlash(\Throwable $exception, string $expectedMessage): void
private function assertExceptionResult(\Throwable $exception, string $expectedStatus): void
{
$request = $this->createRequestWithSession();
$user = $this->createUser();
@@ -415,10 +413,11 @@ class BookingEditSubmitterTest extends TestCase
submitGuard: $submitGuard,
);
$response = $service->handleSubmission($request, $bookingDto, 42, $user);
$result = $service->handleSubmission($request, $bookingDto, 42, $user);
$this->assertSame('/bookings/42/edit', $response->headers->get('Location'));
$this->assertSame([$expectedMessage], $request->getSession()->getFlashBag()->get('error'));
$this->assertSame($expectedStatus, $result->status);
$this->assertNull($result->message);
$this->assertFalse($result->immutableChangesReverted);
}
private function createService(
@@ -436,7 +435,6 @@ class BookingEditSubmitterTest extends TestCase
$travelDataService ?? $this->createMock(TravelDataProvider::class),
$submitGuard ?? $this->createMock(BookingEditSubmitGuard::class),
$bookingSessionService ?? $this->createMock(BookingSessionManager::class),
$this->createUrlGenerator(),
$this->createMock(LoggerInterface::class),
);
}
@@ -470,13 +468,13 @@ class BookingEditSubmitterTest extends TestCase
return $request;
}
private function createUrlGenerator(): UrlGeneratorInterface
private function resolveExpectedStatus(string $expectedType): string
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator->method('generate')
->with('app_booking_edit', ['id' => 42])
->willReturn('/bookings/42/edit');
return $urlGenerator;
return match ($expectedType) {
'error' => BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR,
'info' => BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO,
default => $expectedType,
};
}
}