diff --git a/docs/service-simplification-plan.md b/docs/service-simplification-plan.md new file mode 100644 index 0000000..aa1ac15 --- /dev/null +++ b/docs/service-simplification-plan.md @@ -0,0 +1,125 @@ +# Service Simplification Plan + +Status: draft +Last updated: 2026-04-05 + +## Purpose + +This document tracks the next pass of service simplification work. The goal is to reduce orchestration density, make the booking flow easier to follow for a human reader, and keep responsibilities aligned with the actual boundaries in the code. + +The emphasis is not on deleting services for its own sake. The emphasis is on: +- keeping one clear owner for each meaningful boundary +- removing thin wrappers and pass-through helpers +- avoiding services that mostly shuffle data between layers +- keeping presentation concerns out of calculation and orchestration code + +## Current Read + +The codebase is already in a better place than it was at the start of the refactor, but a few services still carry more than one responsibility: + +- `BookingService` no longer owns session lifecycle, baseline snapshot handling, or return URL management. That work now lives in `BookingSessionService`, which keeps the booking orchestration boundary narrower. +- `BookingService` still covers hydration, booking bootstrap, room grouping, participant counting, service preselection, and booking status rules. +- `BookingPriceCalculatorService` is focused on pricing, but it still sits close to display-oriented behavior in adjacent code paths. +- `TravelDataService` remains broad and is likely the next larger boundary after booking orchestration is reduced. + +One registry stands out as a real orchestration boundary and should be left alone for now: +- `ParticipantFieldHandlerRegistry` + +It is not just a lookup table. It owns execution order, edit-mode mutability gating, and synchronization of submitted form data back into the DTO state. + +## Next Pass + +### 1. Reduce `BookingService` + +Primary goal: make the booking create/edit flow easier to read by splitting unrelated concerns. + +Concrete next steps: +- keep booking session lifecycle in one place +- extract baseline room snapshot handling into a narrower helper or dedicated service +- separate return URL handling if it stays conceptually unrelated +- keep `startFreshBooking()` focused on booking bootstrap rather than general session utilities +- keep hydration behavior obvious and local to the booking session path + +Decision rule: +- if a method only forwards to DTO/session behavior, prefer removing the wrapper +- if a method is a genuine workflow owner, keep it and narrow the surrounding API instead of splitting it into generic helpers + +### 2. Keep pricing calculation focused + +Primary goal: keep pricing code about pricing, not rendering. + +Concrete next steps: +- keep `BookingPriceCalculatorService` as the pricing boundary +- continue removing display formatting from pricing code paths +- keep any remaining view-specific formatting in the presentation layer or a dedicated UI helper +- avoid introducing another service that only formats values already known to the view + +Decision rule: +- if a value is only needed for display, prefer exposing the raw numeric/domain value and formatting it as close to the UI as possible + +### 3. Leave the field-handler registry in place + +Primary goal: avoid unnecessary churn in a class that is already a meaningful orchestration layer. + +Concrete next steps: +- do not refactor `ParticipantFieldHandlerRegistry` in this pass +- revisit only if a later change can split ordering, mutability, and synchronization into clear collaborators without making the flow harder to trace + +Decision rule: +- if a registry owns actual workflow behavior, treat it as a boundary rather than a smell + +## Follow-Up Queue + +After the booking service pass, the next likely candidates are: + +### `TravelDataService` + +This is the largest broad service still in the codebase. It likely needs a later pass if the application should become easier to follow end to end. + +Likely directions: +- separate runtime travel loading from cache/snapshot maintenance if the public API still feels too wide +- keep the read path explicit and avoid hiding maintenance work behind one large method surface + +### `BookingPriceCalculatorService` + +This service should stay focused on pricing logic, but it may still have room for further internal cleanup if more display or transport aggregation concerns surface. + +Likely directions: +- keep calculation responsibilities together +- avoid dragging presentation behavior back into the calculator +- split only if a sub-boundary becomes obvious and reusable + +### `ParticipantFieldHandlerRegistry` + +This stays on the list only as a future optional refactor, not as an immediate target. + +Likely directions, only if justified later: +- isolate ordering/sorting logic if it becomes independently meaningful +- split synchronization code if a clearer DTO/form boundary emerges +- otherwise leave it as the central orchestration point for participant field processing + +## Progress Tracker + +| Item | Status | Notes | +|------|--------|-------| +| Participant card DTO cleanup | Done | Card data now uses typed DTOs instead of nested array payloads | +| Room label formatting cleanup | Done | Pricing labels now have a dedicated presentation helper | +| Booking service split | In progress | Session lifecycle, baseline snapshot, and return URL handling moved to `BookingSessionService` | +| Pricing service review | Pending | Keep focused on calculation, not rendering | +| Travel data service review | Pending | Broad boundary, likely later pass | +| Participant field registry review | Deferred | Real orchestration boundary, intentionally left alone for now | + +## Acceptance Criteria + +The next booking-service pass is only worth keeping if it: +- reduces the number of unrelated responsibilities in `BookingService` +- makes the booking flow easier to trace from controller to session/DTO state +- preserves existing booking behavior and test coverage +- does not replace one large service with several generic “manager” classes + +## Working Agreement + +- Update this document as decisions are made. +- Record rejected simplification ideas here with a short reason. +- Keep the plan aligned with actual code, not with an abstract architecture ideal. +- If a future simplification does not clearly reduce cognitive load, do not add it. diff --git a/docs/travel-snapshot-extended-availability-plan.md b/docs/travel-snapshot-extended-availability-plan.md deleted file mode 100644 index fdfebca..0000000 --- a/docs/travel-snapshot-extended-availability-plan.md +++ /dev/null @@ -1,79 +0,0 @@ -# Travel Snapshot Persistence (DB-Primary + Extended Availability) - -## Scope -Persist travel payloads in DB and use them as primary local source, with enrichment from BusPro `VERFUEGBARKEIT2` (internal naming: **extended/ext**). - -## Current Implementation Status - -### Completed -- [x] Added snapshot persistence entity and repository: - - `src/Entity/TravelSnapshot.php` - - `src/Repository/TravelSnapshotRepository.php` -- [x] Added snapshot application service: - - `src/Service/TravelSnapshotService.php` - - Uses Symfony Serializer JSON payloads for `Travel` - - Supports upsert/hash comparison, load, mapping, product lookup, refresh, purge -- [x] Added extended availability integration: - - `ApiClient::TYPE_AVAILABILITY_EXTENDED = 'VERFUEGBARKEIT2'` - - `ApiClient::getAvailabilitiesExtended()` - - parser dispatch in `ApiResponseParser` - - `ExtendedAvailabilitiesParser` - - models `ExtendedAvailability` and `ExtendedServiceAvailabilityResponse` -- [x] Added DB-primary read path in `TravelDataService`: - - tries snapshot first - - falls back to XML parse + enrichment + snapshot upsert - - snapshot mapping merged into `generateFilesMap()` - - product lookup includes snapshot metadata fallback -- [x] Added refresh command: - - `app:travel:snapshot-refresh` - - supports batch processing, force mode, optional purge -- [x] Added migration for snapshot table: - - `migrations/Version20260321120000.php` -- [x] Added serializer type metadata/docblocks in relevant model classes to support stable snapshot deserialization. -- [x] Removed unified travel cache from `TravelDataService::getTravelData()`: - - cache and snapshot-version-token logic removed; travel is now loaded directly from snapshot DB (fast indexed lookup) or XML fallback on every call - - `TravelSnapshotService::getCacheVersionToken()` removed along with it -- [x] Fixed hotel-specific lookup correctness: - - when `hotelId` is provided, snapshot lookup no longer falls back to another hotel of same date. - -### Confirmed Behaviors -- [x] Snapshot data survives XML deletion and remains loadable. -- [x] Extended refresh updates service-level fields including `uhrzeit_von` -> `Service::timeFrom`. -- [x] Runtime availability overlay (`VERFUEGBARKEIT`) remains in place. - -## Design Decisions (Final) -- Internal naming uses `extended/ext`; external request type string stays `VERFUEGBARKEIT2`. -- DB snapshots are the primary local source for travel loading. -- Serializer format is JSON via Symfony Serializer, not PHP `serialize()`. -- No application-level cache wraps `getTravelData()`; snapshot DB is the fast path, XML is the fallback. - -## Operational Commands -- Refresh snapshots: -```bash -ddev php bin/console app:travel:snapshot-refresh -``` -- Force refresh: -```bash -ddev php bin/console app:travel:snapshot-refresh --force -``` -- Force refresh with limit: -```bash -ddev php bin/console app:travel:snapshot-refresh --force --limit=500 -``` -- Refresh + purge: -```bash -ddev php bin/console app:travel:snapshot-refresh --purge -``` - -## Open TODOs -- [ ] Revisit DB indexes on `travel_snapshot` and remove unused ones if desired (`date_code`, `product_id` currently appear non-critical for active query paths). -- [x] Added automated tests: - - `TravelSnapshotServiceTest` — upsert, load, exists, generateMapping - - `TravelDataServiceTest` — DB-primary + XML fallback paths, insurance rehydration, exception propagation -- [ ] Add/expand automated tests: - - extended parser coverage - - snapshot refresh command behavior -- [ ] Define production cron schedule for refresh and cleanup cadence. - -## Notes -- If refreshed snapshot data is not visible immediately, ensure the request path is not serving an older in-memory/session DTO. There is no application-level cache on `getTravelData()`; each call reads the snapshot DB directly, so data is current on the next request after a refresh. diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index f2d3048..4bec2e2 100644 --- a/src/Controller/Booking/Create/IndexController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -12,6 +12,7 @@ use App\Exception\TravelNotFoundException; use App\Htmx\HxTrait; use App\Model\BookingQueryParams; use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -32,6 +33,7 @@ class IndexController extends AbstractController public function __construct( private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly AgencyLoader $agencyLoader, private readonly BookingSummaryDataService $summaryDataService, ) { @@ -84,13 +86,13 @@ class IndexController extends AbstractController try { // Clear any existing booking session to ensure fresh start - $this->bookingService->clearBookingSession($request); + $this->bookingSessionService->clearBookingSession($request); // Determine agency ID from optional query parameter $agencyId = $this->resolveAgencyId($params->agency); // Store optional return URL in session (defaults to main EP site) - $this->bookingService->storeReturnUrl($request, $params->returnUrl); + $this->bookingSessionService->storeReturnUrl($request, $params->returnUrl); // Create fresh booking session with the provided parameters $bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId); @@ -154,10 +156,10 @@ class IndexController extends AbstractController { if (Request::METHOD_POST === $request->getMethod()) { // Get return URL before clearing session (for guest users) - $returnUrl = $this->bookingService->getReturnUrl($request); + $returnUrl = $this->bookingSessionService->getReturnUrl($request); // Clear the booking session - $this->bookingService->clearBookingSession($request); + $this->bookingSessionService->clearBookingSession($request); // Clear the security target path to prevent redirect loop after login // Without this, logging in after cancel would redirect back to a stale booking URL @@ -188,7 +190,7 @@ class IndexController extends AbstractController public function error(Request $request): Response { return $this->render('booking/create/error.html.twig', [ - 'returnUrl' => $this->bookingService->getReturnUrl($request), + 'returnUrl' => $this->bookingSessionService->getReturnUrl($request), ]); } } diff --git a/src/Controller/Booking/Create/Step1Controller.php b/src/Controller/Booking/Create/Step1Controller.php index eda214c..a0d0ed7 100644 --- a/src/Controller/Booking/Create/Step1Controller.php +++ b/src/Controller/Booking/Create/Step1Controller.php @@ -10,6 +10,7 @@ use App\Form\BookingCreateStep1Type; use App\Form\Model\BookingDto; use App\Htmx\HxTrait; use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use App\Service\RoomPricingCalculator; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -31,6 +32,7 @@ class Step1Controller extends AbstractController public function __construct( private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly BookingSummaryDataService $summaryDataService, ) { } @@ -41,14 +43,14 @@ class Step1Controller extends AbstractController #[Route('/bookings/create/rooms', name: 'app_booking_create_step_1')] public function index(Request $request): Response { - $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request); + $result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request); if ($result instanceof Response) { return $result; } $bookingCreateDto = $result; // Get or create baseline snapshot for change detection - $oldRoomSelectionSnapshot = $this->bookingService->getOrCreateBaselineSnapshot($request, $bookingCreateDto); + $oldRoomSelectionSnapshot = $this->bookingSessionService->getOrCreateBaselineSnapshot($request, $bookingCreateDto); // Validate step access - allow step 1 or redirect to current step if ($redirect = $this->validateStepAccess($bookingCreateDto, 1)) { @@ -70,10 +72,10 @@ class Step1Controller extends AbstractController $this->bookingService->updateBookingStatusFromRoomSelection($bookingCreateDto); $bookingCreateDto->currentStep = 2; - $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); // Clear baseline snapshot when moving to step 2 - $this->bookingService->clearBaselineSnapshot($request); + $this->bookingSessionService->clearBaselineSnapshot($request); return $this->redirectToRoute('app_booking_create_step_2'); } diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index 3a32028..bea6155 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -9,6 +9,7 @@ use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Form\BookingCreateStep2Type; use App\Form\Model\BookingDto; use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use App\Service\ParticipantCardDataService; use App\Service\ParticipantPrepopulationService; @@ -32,6 +33,7 @@ class Step2Controller extends AbstractController public function __construct( private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly BookingSummaryDataService $summaryDataService, private readonly TravelDataService $travelDataService, private readonly RoomAssignmentService $roomAssignmentService, @@ -47,7 +49,7 @@ class Step2Controller extends AbstractController public function index(Request $request): Response { // Load or create booking DTO - $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request); + $result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request); if ($result instanceof Response) { return $result; } @@ -79,7 +81,7 @@ class Step2Controller extends AbstractController $this->bookingService->applyCreateBookingStatusRules($bookingCreateDto); // Save BookingDto to session - $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); // Create validation form $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto); @@ -89,7 +91,7 @@ class Step2Controller extends AbstractController if (true === $form->isSubmitted() && true === $form->isValid()) { // All participants validated successfully, update current step $bookingCreateDto->currentStep = 3; - $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); // Proceed to Step 3 return $this->redirectToRoute('app_booking_create_step_3'); diff --git a/src/Controller/Booking/Create/Step2ParticipantController.php b/src/Controller/Booking/Create/Step2ParticipantController.php index 11285cd..deb1c11 100644 --- a/src/Controller/Booking/Create/Step2ParticipantController.php +++ b/src/Controller/Booking/Create/Step2ParticipantController.php @@ -9,6 +9,7 @@ use App\Form\Model\BookingDto; use App\Form\Service\DummyDataFillService; use App\Htmx\HxTrait; use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use App\Service\ParticipantFormSupportService; use App\Service\TravelDataService; @@ -27,6 +28,7 @@ class Step2ParticipantController extends AbstractController public function __construct( private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly BookingSummaryDataService $summaryDataService, private readonly TravelDataService $travelDataService, private readonly DummyDataFillService $dummyDataFillService, @@ -68,7 +70,7 @@ class Step2ParticipantController extends AbstractController $this->bookingService->preselectDefaultServices($bookingDto); $this->bookingService->applyCreateBookingStatusRules($bookingDto); - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); $form = $this->createParticipantForm($bookingDto, $index); @@ -83,7 +85,7 @@ class Step2ParticipantController extends AbstractController $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); if (true === $isSubmitted && true === $form->isValid()) { - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); $this->addNotificationsAsFlashMessages($notifications); return $this->redirectToRoute('app_booking_create_step_2'); @@ -137,7 +139,7 @@ class Step2ParticipantController extends AbstractController private function loadBookingDtoOrRedirect(Request $request): BookingDto|Response { - $bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_CREATE); + $bookingDto = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE); if (null === $bookingDto) { $this->addFlash('info', 'Deine Sitzung ist abgelaufen. Bitte starte eine neue Buchung.'); @@ -186,7 +188,7 @@ class Step2ParticipantController extends AbstractController $notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto); - $this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode()); + $this->bookingSessionService->saveBookingDto($request, $bookingDto, $bookingDto->getMode()); $summaryData = $this->summaryDataService->getSummaryData($bookingDto); diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index f187bce..5bf2f56 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -16,6 +16,7 @@ use App\Htmx\HxTrait; use App\Service\BookingPriceCalculatorService; use App\Service\BookingPriceMismatchDiagnosticsService; use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -35,6 +36,7 @@ class Step3Controller extends AbstractController public function __construct( private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly BookingSummaryDataService $summaryDataService, private readonly BookingPriceCalculatorService $priceCalculator, private readonly BookingPriceMismatchDiagnosticsService $priceMismatchDiagnostics, @@ -49,7 +51,7 @@ class Step3Controller extends AbstractController #[Route('/bookings/create/payment', name: 'app_booking_create_step_3')] public function index(Request $request): Response { - $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request); + $result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request); if ($result instanceof Response) { return $result; } @@ -91,7 +93,7 @@ class Step3Controller extends AbstractController // Auto-switch to inquiry mode $bookingCreateDto->bookingStatus = 'A'; $bookingCreateDto->currentStep = 4; - $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); $message = 'Buchung konnte nicht validiert werden.'; if (null !== $inquiryResponse->message && '' !== trim($inquiryResponse->message)) { @@ -156,7 +158,7 @@ class Step3Controller extends AbstractController // Validation successful - proceed to confirmation step $bookingCreateDto->currentStep = 4; - $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); if ($inquiryResponse->message) { $this->addFlash('info', $inquiryResponse->message); @@ -195,7 +197,7 @@ 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->bookingService, $request); + $result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request); if ($result instanceof Response) { return $result; } diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index 2ef0536..4eead07 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -16,6 +16,7 @@ use App\Htmx\HxTrait; use App\Entity\User; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use App\Service\Newsletter\MailjetNewsletterService; use App\Service\Newsletter\NewsletterDoubleOptInService; @@ -38,6 +39,7 @@ class Step4Controller extends AbstractController public function __construct( private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly BookingSummaryDataService $summaryDataService, private readonly BookingPriceCalculatorService $priceCalculator, private readonly ApiClient $apiClient, @@ -54,7 +56,7 @@ class Step4Controller extends AbstractController #[Route('/bookings/create/confirmation', name: 'app_booking_create_step_4')] public function index(Request $request): Response { - $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request); + $result = $this->getOrCreateBookingCreateDto($this->bookingSessionService, $request); if ($result instanceof Response) { return $result; } @@ -137,7 +139,7 @@ class Step4Controller extends AbstractController $this->addFlash('booking_total', $summaryData->payableAmount); $this->addFlash('booking_travel_name', $bookingCreateDto->travel->label); $this->clearTravelDataCache($bookingCreateDto); - $this->bookingService->clearBookingDto($request, BookingDto::MODE_CREATE); + $this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_CREATE); $this->logger->info('Booking successfully created.', [ 'date_id' => $bookingCreateDto->travel->id, diff --git a/src/Controller/Booking/Create/SuccessController.php b/src/Controller/Booking/Create/SuccessController.php index 080f865..8ccfef5 100644 --- a/src/Controller/Booking/Create/SuccessController.php +++ b/src/Controller/Booking/Create/SuccessController.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace App\Controller\Booking\Create; -use App\Service\BookingService; +use App\Service\BookingSessionService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route; class SuccessController extends AbstractController { public function __construct( - private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, ) { } @@ -27,7 +27,7 @@ class SuccessController extends AbstractController $bookingNumber = $flashBag->get('booking_number')[0] ?? null; $bookingTotal = $flashBag->get('booking_total')[0] ?? null; $travelName = $flashBag->get('booking_travel_name')[0] ?? null; - $returnUrl = $this->bookingService->getReturnUrl($request); + $returnUrl = $this->bookingSessionService->getReturnUrl($request); // Redirect to return URL if no booking number (direct access or refresh) if (null === $bookingNumber) { diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index a4a9d72..48f1dd9 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -17,8 +17,8 @@ use App\Htmx\HxTrait; use App\Service\BookingEditDataLoaderService; use App\Service\BookingEditDraftService; use App\Service\BookingFingerprintService; -use App\Service\BookingService; use App\Service\BookingEditSubmitGuardService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use App\Service\ParticipantCardDataService; use App\Service\TravelDataService; @@ -48,7 +48,7 @@ class IndexController extends AbstractController private readonly BookingEditDataLoaderService $dataLoader, private readonly BookingEditDraftService $draftService, private readonly TravelDataService $travelDataService, - private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly BookingEditSubmitGuardService $submitGuard, private readonly BookingFingerprintService $fingerprintService, private readonly BookingSummaryDataService $summaryDataService, @@ -70,7 +70,7 @@ class IndexController extends AbstractController /** @var User $user */ $user = $this->getUser(); - $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + $this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT); $this->dataLoader->invalidateBookingCache($id, $user); return $this->redirectToRoute('app_booking_edit', ['id' => $id]); @@ -163,7 +163,7 @@ class IndexController extends AbstractController { if (Request::METHOD_POST === $request->getMethod()) { // Clear session to discard all changes - $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + $this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT); // Delete draft since user explicitly chose to discard changes /** @var User $user */ @@ -195,7 +195,7 @@ class IndexController extends AbstractController public function cancelEdit(int $id, Request $request): Response { // Clear session state - $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + $this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT); // Check if a draft exists to show appropriate message /** @var User $user */ @@ -246,7 +246,7 @@ class IndexController extends AbstractController $immutableChangesReverted = $this->submitGuard->reconcileImmutableCategories($bookingDto, $freshBookingData); if (true === $immutableChangesReverted) { - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); $this->addFlash('info', 'Einige Änderungen wurden verworfen, da sie aktuell nicht mehr änderbar sind.'); } @@ -266,7 +266,7 @@ class IndexController extends AbstractController } elseif (true === $response->success) { // Invalidate cache and clear session on success $this->dataLoader->invalidateBookingCache($id, $user); - $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + $this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT); // Delete draft on successful submission $this->draftService->deleteDraft($user, $id); diff --git a/src/Controller/Booking/Edit/ParticipantController.php b/src/Controller/Booking/Edit/ParticipantController.php index 1586da8..90665d0 100644 --- a/src/Controller/Booking/Edit/ParticipantController.php +++ b/src/Controller/Booking/Edit/ParticipantController.php @@ -9,6 +9,7 @@ use App\Entity\User; use App\Form\BookingParticipantType; use App\Htmx\HxTrait; use App\Service\BookingEditParticipantFormService; +use App\Service\BookingSessionService; use App\Service\ParticipantFormSupportService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -26,6 +27,7 @@ class ParticipantController extends AbstractController public function __construct( private readonly ParticipantFormSupportService $participantFormSupportService, private readonly BookingEditParticipantFormService $participantFormService, + private readonly BookingSessionService $bookingSessionService, ) { } diff --git a/src/Controller/Booking/Traits/BookingExceptionHandlerTrait.php b/src/Controller/Booking/Traits/BookingExceptionHandlerTrait.php index 40f3e0a..3a85503 100644 --- a/src/Controller/Booking/Traits/BookingExceptionHandlerTrait.php +++ b/src/Controller/Booking/Traits/BookingExceptionHandlerTrait.php @@ -10,7 +10,7 @@ use App\Exception\HotelNotInTravelException; use App\Exception\NoRoomsAvailableException; use App\Exception\TravelNotFoundException; use App\Form\Model\BookingDto; -use App\Service\BookingService; +use App\Service\BookingSessionService; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -29,10 +29,10 @@ trait BookingExceptionHandlerTrait * Handles all booking-related exceptions and provides appropriate user feedback * by redirecting to the error page with flash messages. */ - protected function getOrCreateBookingCreateDto(BookingService $bookingService, Request $request): BookingDto|RedirectResponse + protected function getOrCreateBookingCreateDto(BookingSessionService $bookingSessionService, Request $request): BookingDto|RedirectResponse { try { - return $bookingService->getOrCreateBookingCreateDto($request); + return $bookingSessionService->getOrCreateBookingCreateDto($request); } catch (BookingSessionNotFoundException $e) { $this->addFlash('error', 'Deine Buchungssitzung ist abgelaufen. Bitte starte eine neue Buchung.'); @@ -62,10 +62,10 @@ trait BookingExceptionHandlerTrait * Returns empty 400 responses for HTMX requests when exceptions occur, * allowing the frontend to handle errors appropriately. */ - protected function getOrCreateBookingCreateDtoForHtmx(BookingService $bookingService, Request $request): mixed + protected function getOrCreateBookingCreateDtoForHtmx(BookingSessionService $bookingSessionService, Request $request): mixed { try { - return $bookingService->getOrCreateBookingCreateDto($request); + return $bookingSessionService->getOrCreateBookingCreateDto($request); } catch (BookingSessionNotFoundException|TravelNotFoundException|HotelNotFoundException|HotelNotInTravelException|NoRoomsAvailableException $e) { return new Response('', 400); } diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 36b5f6f..e24bb58 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -2,7 +2,7 @@ namespace App\Controller; -use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -18,11 +18,11 @@ class SecurityController extends AbstractController public function login( AuthenticationUtils $authenticationUtils, Request $request, - BookingService $bookingService, + BookingSessionService $bookingSessionService, BookingSummaryDataService $summaryDataService, ): Response { // Check if this is a booking flow (BookingDto exists in session) - $bookingDto = $bookingService->getBookingDto($request, BookingService::BOOKING_CREATE_KEY); + $bookingDto = $bookingSessionService->getBookingDto($request, BookingSessionService::BOOKING_CREATE_KEY); $isBookingFlow = null !== $bookingDto; // If authenticated and in booking flow, proceed to Step 1 diff --git a/src/Service/BookingEditDataLoaderService.php b/src/Service/BookingEditDataLoaderService.php index ce4634d..8b2a655 100644 --- a/src/Service/BookingEditDataLoaderService.php +++ b/src/Service/BookingEditDataLoaderService.php @@ -32,7 +32,7 @@ class BookingEditDataLoaderService public function __construct( private readonly ApiClient $apiClient, private readonly BookingDataProcessor $bookingDataProcessor, - private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly BookingFingerprintService $fingerprintService, private readonly TravelDataService $travelDataService, private readonly BookingEditDraftService $draftService, @@ -73,11 +73,11 @@ class BookingEditDataLoaderService { $this->draftWasRestored = false; - $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + $formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT); // Validate session data matches requested booking - clear stale data if mismatched if (null !== $formData && $formData->booking?->id !== $bookingId) { - $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + $this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT); $formData = null; } @@ -158,7 +158,7 @@ class BookingEditDataLoaderService } } - $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); + $this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); return $formData; } diff --git a/src/Service/BookingEditParticipantFormService.php b/src/Service/BookingEditParticipantFormService.php index 6fb7790..4a7d212 100644 --- a/src/Service/BookingEditParticipantFormService.php +++ b/src/Service/BookingEditParticipantFormService.php @@ -24,7 +24,7 @@ class BookingEditParticipantFormService public function __construct( private readonly BookingEditDataLoaderService $dataLoader, private readonly BookingEditDraftService $draftService, - private readonly BookingService $bookingService, + private readonly BookingSessionService $bookingSessionService, private readonly BookingSummaryDataService $summaryDataService, private readonly TravelDataService $travelDataService, ) { @@ -32,7 +32,7 @@ class BookingEditParticipantFormService public function loadBookingDto(Request $request): ?BookingDto { - return $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + return $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT); } public function fetchBookingData(int $bookingId, User $user): Booking|Notification|null @@ -52,7 +52,7 @@ class BookingEditParticipantFormService public function saveBookingDto(Request $request, BookingDto $bookingDto): void { - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); } public function saveDraft(User $user, int $bookingId, BookingDto $bookingDto): void diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index f41bfca..d1a42c2 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -10,7 +10,6 @@ use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; use App\BusProNet\Service\BookingStatusRuleRegistry; use App\BusProNet\XmlLoader\AgencyLoader; -use App\Exception\BookingSessionNotFoundException; use App\Exception\NoRoomsAvailableException; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; @@ -22,13 +21,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class BookingService { - public const BOOKING_CREATE_KEY = 'booking_create'; - public const BOOKING_CREATE_BASELINE_KEY = 'booking_create_baseline_snapshot'; - public const BOOKING_EDIT_KEY = 'booking_edit'; - public const RETURN_URL_KEY = 'booking_return_url'; - public const DEFAULT_RETURN_URL = 'https://www.ep-reisen.de'; - public function __construct( + private readonly BookingSessionService $bookingSessionService, private readonly TravelDataService $travelDataService, private readonly BookingPriceCalculatorService $priceCalculator, private readonly ParticipantEligibilityService $participantEligibilityService, @@ -39,195 +33,6 @@ class BookingService ) { } - /** - * Gets or creates the baseline room selection snapshot for change detection. - * - * The baseline snapshot captures the initial room selection state when step 1 - * is first loaded, before any HTMX modifications. This ensures accurate change - * detection for room assignment resets. - */ - public function getOrCreateBaselineSnapshot(Request $request, BookingDto $bookingCreateDto): array - { - $baselineKey = self::BOOKING_CREATE_BASELINE_KEY; - - if (!$request->getSession()->has($baselineKey) || $request->query->has('reset_baseline')) { - $baseline = $bookingCreateDto->createRoomSelectionSnapshot(); - $request->getSession()->set($baselineKey, $baseline); - - return $baseline; - } - - return $request->getSession()->get($baselineKey); - } - - /** - * Clears the baseline snapshot from the session. - * - * Should be called when moving to the next step or when the baseline - * needs to be refreshed. - */ - public function clearBaselineSnapshot(Request $request): void - { - $request->getSession()->remove('booking_create_baseline_snapshot'); - } - - /** - * Retrieves the booking creation DTO from the session. - * - * This method enforces the secure booking flow by only returning existing - * session data. Users must go through the proper initialization flow via - * CreateInitController to create new booking sessions. - * - * @param Request $request The HTTP request containing session data - * - * @return BookingDto The booking DTO from session - * - * @throws BookingSessionNotFoundException When no valid booking session exists - */ - public function getOrCreateBookingCreateDto(Request $request): BookingDto - { - $bookingDto = $this->getBookingDto($request, BookingDto::MODE_CREATE); - - if (null !== $bookingDto) { - return $bookingDto; - } - - throw new BookingSessionNotFoundException(); - } - - /** - * Saves the booking DTO to the session. - * - * @param Request $request The HTTP request with session - * @param BookingDto $bookingDto The booking DTO to persist - * @param string $mode The booking mode (create/edit) - */ - public function saveBookingDto(Request $request, BookingDto $bookingDto, string $mode): void - { - $sessionKey = $this->getSessionKey($mode); - $request->getSession()->set($sessionKey, $bookingDto); - } - - /** - * Retrieves the booking DTO from the session and restores the Travel object. - * - * After deserialization the DTO contains only a Travel skeleton with the ID. - * This method replaces it with the full Travel via hydrate(). - * - * @param Request $request The HTTP request containing session data - * @param string $mode The booking mode (create/edit) - * - * @return BookingDto|null The booking DTO or null if not found - */ - public function getBookingDto(Request $request, string $mode): ?BookingDto - { - $sessionKey = $this->getSessionKey($mode); - $session = $request->getSession(); - - if (false === $session->has($sessionKey)) { - return null; - } - - $bookingDto = $session->get($sessionKey); - $this->hydrate($bookingDto); - - return $bookingDto; - } - - /** - * Clears the booking DTO from the session. - * - * @param Request $request The HTTP request with session - * @param string $mode The booking mode (create/edit) - */ - public function clearBookingDto(Request $request, string $mode): void - { - $sessionKey = $this->getSessionKey($mode); - $request->getSession()->remove($sessionKey); - } - - /** - * Generates session key based on mode. - * - * @param string $mode 'create' or 'edit' - * - * @return string The session key - */ - private function getSessionKey(string $mode): string - { - return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY; - } - - /** - * Restores the full Travel object after session deserialization. - * - * BookingDto::__serialize() replaces Travel with just its ID to keep session - * payloads small. This method fetches the complete Travel (from DB snapshot or - * XML fallback) and sets it on both the DTO and the Booking reference. - */ - private function hydrate(BookingDto $bookingDto): void - { - $travel = $this->travelDataService->getTravelData( - $bookingDto->travel->id, - $bookingDto->hotelId - ); - - if (null === $travel) { - return; - } - - $bookingDto->travel = $travel; - - if (null !== $bookingDto->booking) { - $bookingDto->booking->travelData = $travel; - } - } - - /** - * Clears all booking-related session data. - * - * This method removes all booking session data including the main DTO - * and any cached snapshots to ensure a completely fresh start. - * Note: RETURN_URL_KEY is intentionally preserved so it remains available - * for redirects after cancel or error flows. - */ - public function clearBookingSession(Request $request): void - { - $session = $request->getSession(); - $session->remove(self::BOOKING_CREATE_KEY); - $session->remove(self::BOOKING_CREATE_BASELINE_KEY); - } - - /** - * Stores the return URL in the session. - * - * Validates that the URL is a valid absolute URL with http/https scheme. - * Falls back to the default return URL if null or invalid. - */ - public function storeReturnUrl(Request $request, ?string $returnUrl): void - { - $url = self::DEFAULT_RETURN_URL; - - if (null !== $returnUrl && '' !== trim($returnUrl)) { - if (false !== filter_var($returnUrl, \FILTER_VALIDATE_URL) - && 1 === preg_match('#^https?://#i', $returnUrl)) { - $url = $returnUrl; - } - } - - $request->getSession()->set(self::RETURN_URL_KEY, $url); - } - - /** - * Retrieves the return URL from the session. - * - * Returns the default URL if not set in session. - */ - public function getReturnUrl(Request $request): string - { - return $request->getSession()->get(self::RETURN_URL_KEY, self::DEFAULT_RETURN_URL); - } - /** * Creates a fresh booking session with the provided travel parameters. * @@ -280,7 +85,7 @@ class BookingService : null; $bookingCreateDto->bookingStatus = $bookingStatus; - $this->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + $this->bookingSessionService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); return $bookingCreateDto; } diff --git a/src/Service/BookingSessionService.php b/src/Service/BookingSessionService.php new file mode 100644 index 0000000..e0d1690 --- /dev/null +++ b/src/Service/BookingSessionService.php @@ -0,0 +1,168 @@ +getBookingDto($request, BookingDto::MODE_CREATE); + + if (null !== $bookingDto) { + return $bookingDto; + } + + throw new BookingSessionNotFoundException(); + } + + /** + * Gets or creates the baseline room selection snapshot for change detection. + */ + public function getOrCreateBaselineSnapshot(Request $request, BookingDto $bookingCreateDto): array + { + $baselineKey = self::BOOKING_CREATE_BASELINE_KEY; + + if (!$request->getSession()->has($baselineKey) || $request->query->has('reset_baseline')) { + $baseline = $bookingCreateDto->createRoomSelectionSnapshot(); + $request->getSession()->set($baselineKey, $baseline); + + return $baseline; + } + + return $request->getSession()->get($baselineKey); + } + + /** + * Clears the baseline snapshot from the session. + */ + public function clearBaselineSnapshot(Request $request): void + { + $request->getSession()->remove(self::BOOKING_CREATE_BASELINE_KEY); + } + + /** + * Saves the booking DTO to the session. + */ + public function saveBookingDto(Request $request, BookingDto $bookingDto, string $mode): void + { + $sessionKey = $this->getSessionKey($mode); + $request->getSession()->set($sessionKey, $bookingDto); + } + + /** + * Retrieves the booking DTO from the session and restores the Travel object. + */ + public function getBookingDto(Request $request, string $mode): ?BookingDto + { + $sessionKey = $this->getSessionKey($mode); + $session = $request->getSession(); + + if (false === $session->has($sessionKey)) { + return null; + } + + $bookingDto = $session->get($sessionKey); + $this->hydrate($bookingDto); + + return $bookingDto; + } + + /** + * Clears the booking DTO from the session. + */ + public function clearBookingDto(Request $request, string $mode): void + { + $sessionKey = $this->getSessionKey($mode); + $request->getSession()->remove($sessionKey); + } + + /** + * Clears all booking-related session data except the return URL. + */ + public function clearBookingSession(Request $request): void + { + $session = $request->getSession(); + $session->remove(self::BOOKING_CREATE_KEY); + $session->remove(self::BOOKING_CREATE_BASELINE_KEY); + } + + /** + * Stores the return URL in the session. + */ + public function storeReturnUrl(Request $request, ?string $returnUrl): void + { + $url = self::DEFAULT_RETURN_URL; + + if (null !== $returnUrl && '' !== trim($returnUrl)) { + if (false !== filter_var($returnUrl, \FILTER_VALIDATE_URL) + && 1 === preg_match('#^https?://#i', $returnUrl)) { + $url = $returnUrl; + } + } + + $request->getSession()->set(self::RETURN_URL_KEY, $url); + } + + /** + * Retrieves the return URL from the session. + */ + public function getReturnUrl(Request $request): string + { + return $request->getSession()->get(self::RETURN_URL_KEY, self::DEFAULT_RETURN_URL); + } + + private function getSessionKey(string $mode): string + { + return BookingDto::MODE_EDIT === $mode ? self::BOOKING_EDIT_KEY : self::BOOKING_CREATE_KEY; + } + + /** + * Restores the full Travel object after session deserialization. + */ + private function hydrate(BookingDto $bookingDto): void + { + $travel = $this->travelDataService->getTravelData( + $bookingDto->travel->id, + $bookingDto->hotelId + ); + + if (null === $travel) { + return; + } + + $bookingDto->travel = $travel; + + if (null !== $bookingDto->booking) { + $bookingDto->booking->travelData = $travel; + } + } +} diff --git a/tests/Service/BookingEditParticipantFormServiceTest.php b/tests/Service/BookingEditParticipantFormServiceTest.php index 35ada5f..c8ac3ff 100644 --- a/tests/Service/BookingEditParticipantFormServiceTest.php +++ b/tests/Service/BookingEditParticipantFormServiceTest.php @@ -11,7 +11,7 @@ use App\Form\Model\BookingDto; use App\Service\BookingEditDataLoaderService; use App\Service\BookingEditDraftService; use App\Service\BookingEditParticipantFormService; -use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\BookingSummaryDataService; use App\Service\TravelDataService; use PHPUnit\Framework\TestCase; @@ -23,13 +23,13 @@ class BookingEditParticipantFormServiceTest extends TestCase { $request = new Request(); $bookingDto = $this->createBookingDto(); - $bookingService = $this->createMock(BookingService::class); - $bookingService->expects($this->once()) + $bookingSessionService = $this->createMock(BookingSessionService::class); + $bookingSessionService->expects($this->once()) ->method('getBookingDto') ->with($request, BookingDto::MODE_EDIT) ->willReturn($bookingDto); - $service = $this->createService(bookingService: $bookingService); + $service = $this->createService(bookingSessionService: $bookingSessionService); $this->assertSame($bookingDto, $service->loadBookingDto($request)); } @@ -56,7 +56,7 @@ class BookingEditParticipantFormServiceTest extends TestCase $service = new BookingEditParticipantFormService( $this->createMock(BookingEditDataLoaderService::class), $this->createMock(BookingEditDraftService::class), - $this->createMock(BookingService::class), + $this->createMock(BookingSessionService::class), $this->createMock(BookingSummaryDataService::class), $travelDataService, ); @@ -67,14 +67,14 @@ class BookingEditParticipantFormServiceTest extends TestCase private function createService( ?BookingEditDataLoaderService $dataLoader = null, ?BookingEditDraftService $draftService = null, - ?BookingService $bookingService = null, + ?BookingSessionService $bookingSessionService = null, ?BookingSummaryDataService $summaryDataService = null, ?TravelDataService $travelDataService = null, ): BookingEditParticipantFormService { return new BookingEditParticipantFormService( $dataLoader ?? $this->createMock(BookingEditDataLoaderService::class), $draftService ?? $this->createMock(BookingEditDraftService::class), - $bookingService ?? $this->createMock(BookingService::class), + $bookingSessionService ?? $this->createMock(BookingSessionService::class), $summaryDataService ?? $this->createMock(BookingSummaryDataService::class), $travelDataService ?? $this->createMock(TravelDataService::class), ); diff --git a/tests/Service/BookingServiceBabyTest.php b/tests/Service/BookingServiceBabyTest.php index 4ac9b67..5d4c1f3 100644 --- a/tests/Service/BookingServiceBabyTest.php +++ b/tests/Service/BookingServiceBabyTest.php @@ -13,6 +13,7 @@ use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\ParticipantEligibilityService; use App\Service\TravelDataService; use PHPUnit\Framework\TestCase; @@ -32,6 +33,7 @@ class BookingServiceBabyTest extends TestCase $agencyLoader = $this->createMock(AgencyLoader::class); $this->bookingService = new BookingService( + $this->createMock(BookingSessionService::class), $travelDataService, $priceCalculator, $this->participantEligibilityService, diff --git a/tests/Service/BookingServiceStatusTest.php b/tests/Service/BookingServiceStatusTest.php index 91ca037..3d6a186 100644 --- a/tests/Service/BookingServiceStatusTest.php +++ b/tests/Service/BookingServiceStatusTest.php @@ -13,6 +13,7 @@ use App\BusProNet\XmlLoader\AgencyLoader; use App\Exception\NoRoomsAvailableException; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; +use App\Service\BookingSessionService; use App\Service\ParticipantEligibilityService; use App\Service\TravelDataService; use PHPUnit\Framework\TestCase; @@ -27,6 +28,7 @@ class BookingServiceStatusTest extends TestCase protected function setUp(): void { + $bookingSessionService = $this->createMock(BookingSessionService::class); $this->travelDataService = $this->createMock(TravelDataService::class); $priceCalculator = $this->createMock(BookingPriceCalculatorService::class); $participantEligibility = $this->createMock(ParticipantEligibilityService::class); @@ -35,6 +37,7 @@ class BookingServiceStatusTest extends TestCase $agencyLoader = $this->createMock(AgencyLoader::class); $this->bookingService = new BookingService( + $bookingSessionService, $this->travelDataService, $priceCalculator, $participantEligibility, @@ -178,6 +181,7 @@ class BookingServiceStatusTest extends TestCase $bookingStatusRuleRegistry->method('evaluateStatus')->willReturn('O'); $bookingService = new BookingService( + $this->createMock(BookingSessionService::class), $this->travelDataService, $this->createMock(BookingPriceCalculatorService::class), $this->createMock(ParticipantEligibilityService::class), @@ -209,6 +213,7 @@ class BookingServiceStatusTest extends TestCase $bookingStatusRuleRegistry->expects($this->never())->method('evaluateStatus'); $bookingService = new BookingService( + $this->createMock(BookingSessionService::class), $this->travelDataService, $this->createMock(BookingPriceCalculatorService::class), $this->createMock(ParticipantEligibilityService::class), diff --git a/tests/Service/BookingSessionServiceTest.php b/tests/Service/BookingSessionServiceTest.php new file mode 100644 index 0000000..b1aaa8d --- /dev/null +++ b/tests/Service/BookingSessionServiceTest.php @@ -0,0 +1,115 @@ +travelDataService = $this->createMock(TravelDataService::class); + $this->service = new BookingSessionService($this->travelDataService); + } + + public function testGetOrCreateBookingCreateDtoThrowsWhenSessionMissing(): void + { + $request = $this->createRequestWithSession(); + + $this->expectException(\App\Exception\BookingSessionNotFoundException::class); + + $this->service->getOrCreateBookingCreateDto($request); + } + + public function testSaveAndLoadBookingDtoHydratesTravel(): void + { + $request = $this->createRequestWithSession(); + + $storedTravel = new Travel(); + $storedTravel->id = 12; + $storedTravel->hotelId = 34; + + $bookingDto = new BookingDto($storedTravel, 34); + $bookingDto->booking = new Booking(); + $bookingDto->booking->travelData = $storedTravel; + + $hydratedTravel = new Travel(); + $hydratedTravel->id = 12; + $hydratedTravel->hotelId = 34; + $hydratedTravel->label = 'Hydrated travel'; + + $this->travelDataService + ->expects($this->once()) + ->method('getTravelData') + ->with(12, 34) + ->willReturn($hydratedTravel); + + $this->service->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + $loadedDto = $this->service->getBookingDto($request, BookingDto::MODE_CREATE); + + $this->assertNotNull($loadedDto); + $this->assertSame($hydratedTravel, $loadedDto?->travel); + $this->assertSame($hydratedTravel, $loadedDto?->booking?->travelData); + } + + public function testBaselineSnapshotIsCreatedAndReused(): void + { + $request = $this->createRequestWithSession(); + $bookingDto = new BookingDto(new Travel(), 99); + $selection = new RoomSelectionDto(); + $selection->id = 1; + $selection->quantity = 2; + $bookingDto->roomSelections = [$selection]; + + $first = $this->service->getOrCreateBaselineSnapshot($request, $bookingDto); + $this->assertSame([[1, 2]], $first); + + $selection->quantity = 5; + $second = $this->service->getOrCreateBaselineSnapshot($request, $bookingDto); + + $this->assertSame($first, $second); + $this->assertSame([[1, 2]], $second); + } + + public function testStoreReturnUrlFallsBackToDefaultForInvalidInput(): void + { + $request = $this->createRequestWithSession(); + + $this->service->storeReturnUrl($request, 'javascript:alert(1)'); + + $this->assertSame(BookingSessionService::DEFAULT_RETURN_URL, $this->service->getReturnUrl($request)); + } + + public function testClearBookingSessionPreservesReturnUrl(): void + { + $request = $this->createRequestWithSession(); + + $this->service->storeReturnUrl($request, 'https://example.test/after-booking'); + $this->service->clearBookingSession($request); + + $this->assertSame('https://example.test/after-booking', $this->service->getReturnUrl($request)); + } + + private function createRequestWithSession(): Request + { + $session = new Session(new MockArraySessionStorage()); + $request = new Request(); + $request->setSession($session); + + return $request; + } +}