diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 104257e..e167d72 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -760,6 +760,14 @@ class BookingDataProcessor 'nationalitaet' => $firstParticipant->nationality ?? '', ]; + // Include BPN IDs for linking to existing records (authenticated users) + if (null !== $firstParticipant->addressId) { + $payload['anmelder']['idadresse'] = $firstParticipant->addressId; + } + if (null !== $firstParticipant->personId) { + $payload['anmelder']['idadresseperson'] = $firstParticipant->personId; + } + if (null !== $firstParticipant->dateOfBirth) { $payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y'); } @@ -790,6 +798,14 @@ class BookingDataProcessor 'nationalitaet' => $participant->nationality ?? '', ]; + // Include BPN IDs for linking to existing records (authenticated users) + if (null !== $participant->addressId) { + $participantData['idadresse'] = $participant->addressId; + } + if (null !== $participant->personId) { + $participantData['idadresseperson'] = $participant->personId; + } + if (null !== $participant->dateOfBirth) { $participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y'); } diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index cdbafbe..86fd613 100644 --- a/src/Controller/Booking/Create/IndexController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -32,11 +32,12 @@ class IndexController extends AbstractController } /** - * Initializes a fresh booking session and redirects to step 1. + * Initializes a fresh booking session and redirects to the login page. * * This endpoint provides a clean way to start the booking flow with just - * dateId and hotelId parameters. It clears any existing booking session - * and creates a fresh BookingCreateDto before redirecting to step 1. + * dateId and hotelId parameters. It clears any existing booking session, + * creates a fresh BookingDto, and redirects to the login page where users + * can authenticate (for prepopulation) or continue as guest. * * Optionally accepts an agency code parameter. If provided and valid, the * corresponding agency ID is stored in the booking. If not provided or invalid, @@ -59,17 +60,17 @@ class IndexController extends AbstractController // Create fresh booking session with the provided parameters $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId); - // Redirect to step 1 of the booking flow - return $this->redirectToRoute('app_booking_create_step_1'); - } catch (TravelNotFoundException $e) { + // Redirect to login page (optional authentication before Step 1) + return $this->redirectToRoute('app_login'); + } catch (TravelNotFoundException) { throw $this->createNotFoundException(sprintf('Travel not found for date ID %d', $dateId)); - } catch (HotelNotFoundException $e) { + } catch (HotelNotFoundException) { throw $this->createNotFoundException(sprintf('Hotel not found for hotel ID %d', $hotelId)); - } catch (HotelNotInTravelException $e) { + } catch (HotelNotInTravelException) { throw $this->createNotFoundException(sprintf('Hotel ID %d is not available for travel ID %d', $hotelId, $dateId)); - } catch (NoRoomsAvailableException $e) { + } catch (NoRoomsAvailableException) { throw $this->createNotFoundException('No rooms available for this travel.'); - } catch (BookingNotPossibleException $e) { + } catch (BookingNotPossibleException) { throw $this->createNotFoundException('Booking is not possible for this travel (Buchungsstop).'); } } diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index a72fa01..45dd677 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -15,6 +15,7 @@ use App\Htmx\HxTrait; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; use App\Service\ParticipantCardDataService; +use App\Service\ParticipantPrepopulationService; use App\Service\RoomAssignmentService; use App\Service\TravelDataService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -42,6 +43,7 @@ class Step2Controller extends AbstractController private readonly RoomAssignmentService $roomAssignmentService, private readonly ParticipantCardDataService $participantCardService, private readonly ParticipantFieldOptionsProvider $fieldOptionsProvider, + private readonly ParticipantPrepopulationService $prepopulationService, ) { } @@ -227,10 +229,29 @@ class Step2Controller extends AbstractController for ($i = 0; $i < $participantsCount; ++$i) { $participant = $participants[$i] ?? new ParticipantDto(); $participant->index = $i; + + // Prepopulate applicant from authenticated user (index 0 only) + if (0 === $i && $this->getUser() && $this->shouldPrepopulate($participant)) { + $participant = $this->prepopulationService->prepopulateApplicantFromUser( + $this->getUser(), + $participant + ); + } + $bookingCreateDto->participants[$i] = $participant; } } + /** + * Determines if a participant should be prepopulated. + * + * Only prepopulates if the participant is "fresh" (no name set yet). + */ + private function shouldPrepopulate(ParticipantDto $participant): bool + { + return null === $participant->firstName || '' === $participant->firstName; + } + /** * Enriches travel data with cached availability information from BusProNet API. */ diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 8733111..72ec7a8 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Service\BookingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -13,8 +14,17 @@ class SecurityController extends AbstractController { #[Route('/', name: 'app_login')] #[IsGranted('PUBLIC_ACCESS')] - public function login(AuthenticationUtils $authenticationUtils, Request $request): Response + public function login(AuthenticationUtils $authenticationUtils, Request $request, BookingService $bookingService): Response { + // Check if this is a booking flow (BookingDto exists in session) + $isBookingFlow = null !== $bookingService->getBookingDto($request, BookingService::BOOKING_CREATE_KEY); + + // If authenticated and in booking flow, proceed to Step 1 + if (null !== $this->getUser() && true === $isBookingFlow) { + return $this->redirectToRoute('app_booking_create_step_1'); + } + + // If authenticated but not in booking flow, go to personal data if (null !== $this->getUser()) { return $this->redirectToRoute('app_personal_data'); } @@ -31,7 +41,15 @@ class SecurityController extends AbstractController $session->set('_oauth2', true); } - return $this->render('security/login.html.twig', [ + // For booking flow, set target path to Step 1 (after successful auth, redirect there) + if (true === $isBookingFlow) { + $session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1')); + } + + // Render booking login template if in booking flow, otherwise standard login + $template = true === $isBookingFlow ? 'booking/create/authenticate.html.twig' : 'security/login.html.twig'; + + return $this->render($template, [ 'last_username' => $lastUsername, 'error' => $error, ]); diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index b86a063..7899805 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -155,25 +155,35 @@ class BookingParticipantType extends AbstractType // Helper to get field state or empty array $getFieldState = fn (string $fieldName) => $allFieldStates[$fieldName] ?? []; - $form - ->add('firstName', TextType::class, $this->mergeFieldState([ + // Add personal data fields with conditional inclusion for authenticated users + if ($this->fieldStateProvider->shouldIncludeField('firstName', $bookingDto, $participantIndex)) { + $form->add('firstName', TextType::class, $this->mergeFieldState([ 'label' => 'Vorname', 'sanitize_html' => true, 'property_path' => 'participant.firstName', - ], $getFieldState('firstName'))) - ->add('lastName', TextType::class, $this->mergeFieldState([ + ], $getFieldState('firstName'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('lastName', $bookingDto, $participantIndex)) { + $form->add('lastName', TextType::class, $this->mergeFieldState([ 'label' => 'Nachname', 'sanitize_html' => true, 'property_path' => 'participant.lastName', - ], $getFieldState('lastName'))) - ->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([ + ], $getFieldState('lastName'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('dateOfBirth', $bookingDto, $participantIndex)) { + $form->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([ 'label' => 'Geburtsdatum', 'widget' => 'text', 'input' => 'datetime_immutable', 'html5' => false, 'property_path' => 'participant.dateOfBirth', - ], $getFieldState('dateOfBirth'))) - ->add('gender', ChoiceType::class, $this->mergeFieldState([ + ], $getFieldState('dateOfBirth'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('gender', $bookingDto, $participantIndex)) { + $form->add('gender', ChoiceType::class, $this->mergeFieldState([ 'label' => 'Geschlecht', 'required' => false, 'placeholder' => 'keine Angabe', @@ -183,28 +193,41 @@ class BookingParticipantType extends AbstractType 'divers' => 'D', ], 'property_path' => 'participant.gender', - ], $getFieldState('gender'))) - ->add('nationality', CountryType::class, $this->mergeFieldState([ + ], $getFieldState('gender'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('nationality', $bookingDto, $participantIndex)) { + $form->add('nationality', CountryType::class, $this->mergeFieldState([ 'label' => 'Nationalität', 'property' => 'nationality', 'preferred_choices' => ['D', 'A', 'CH'], 'property_path' => 'participant.nationality', - ], $getFieldState('nationality'))) - ->add('email', EmailType::class, $this->mergeFieldState([ + ], $getFieldState('nationality'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('email', $bookingDto, $participantIndex)) { + $form->add('email', EmailType::class, $this->mergeFieldState([ 'label' => 'E-Mail', 'property_path' => 'participant.email', - ], $getFieldState('email'))) - ->add('mobile', TextType::class, $this->mergeFieldState([ + ], $getFieldState('email'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('mobile', $bookingDto, $participantIndex)) { + $form->add('mobile', TextType::class, $this->mergeFieldState([ 'label' => 'Telefon (mobil)', 'required' => false, 'sanitize_html' => true, 'property_path' => 'participant.mobile', - ], $getFieldState('mobile'))) - ->add('address', AddressType::class, $this->mergeFieldState([ + ], $getFieldState('mobile'))); + } + + if ($this->fieldStateProvider->shouldIncludeField('address', $bookingDto, $participantIndex)) { + $form->add('address', AddressType::class, $this->mergeFieldState([ 'label' => 'Adresse', 'required' => false, 'property_path' => 'participant.address', ], $getFieldState('address'))); + } // Add body dimensions with state handling - use shouldIncludeField method if ($this->fieldStateProvider->shouldIncludeField('bodyDimensions', $bookingDto, $participantIndex)) { diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 753d149..146c205 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -128,6 +128,7 @@ class ParticipantDto $instance->mutable = $personalData->mutable; $instance->firstName = $personalData->firstName; $instance->lastName = $personalData->name; + $instance->title = $personalData->title; $instance->gender = $personalData->gender; $instance->nationality = $personalData->nationality; $instance->email = $personalData->communication?->email; diff --git a/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php b/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php new file mode 100644 index 0000000..2dfd373 --- /dev/null +++ b/src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php @@ -0,0 +1,76 @@ + $formData Current form data (unused) + * + * @return bool True if fields should be hidden (participant linked to BPN account) + */ + public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool + { + $participant = $bookingDto->getParticipant($participantIndex); + if (null === $participant) { + return false; + } + + // Hide personal data fields if participant has BPN account IDs + // (indicates prepopulation from authenticated user) + return null !== $participant->addressId && null !== $participant->personId; + } + + /** + * Returns field names that this condition depends on. + * + * This condition is based on addressId/personId which are set during prepopulation + * and don't change during the form interaction, so no field dependencies. + * + * @return string[] Empty array - no field dependencies + */ + public function getDependentFields(): array + { + return []; + } + + /** + * Returns a human-readable description of this condition. + * + * @return string Description of the authenticated user personal data protection logic + */ + public function getDescription(): string + { + return 'Personal data is not editable when participant is linked to BPN account (prevents duplicate records)'; + } +} diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 07348cc..3a5cf8f 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -7,6 +7,7 @@ namespace App\Form\Service; use App\BusProNet\Utility\DirectionMapper; use App\Form\Service\Abstract\AbstractFieldStateProvider; use App\Form\Service\Condition\ApplicantCondition; +use App\Form\Service\Condition\AuthenticatedUserPersonalDataCondition; use App\Form\Service\Condition\BookingEligibilityCondition; use App\Form\Service\Condition\BulkInsuranceBookingCondition; use App\Form\Service\Condition\CompositeCondition; @@ -73,6 +74,64 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider $rentalCondition = new RentalSelectionCondition(); $skiPassCondition = new SkiPassSelectionCondition(); + // Authenticated user personal data protection + // Hide personal data fields for participants linked to BPN accounts (prevents duplicate records) + $authenticatedUserCondition = new AuthenticatedUserPersonalDataCondition(); + + $this->fieldStateConditions['firstName'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['lastName'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['gender'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['nationality'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['dateOfBirth'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['email'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + // Mobile field: hidden for authenticated users, required for guest applicants + $this->fieldStateConditions['mobile'] = [ + 'hidden' => $authenticatedUserCondition, + 'required' => new ApplicantCondition(), + ]; + + // Address subfields - must be hidden to prevent creating duplicate BPN records + $this->fieldStateConditions['address.street'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['address.postCode'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['address.city'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['address.country'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + $this->fieldStateConditions['address.district'] = [ + 'hidden' => $authenticatedUserCondition, + ]; + + // Note: Body dimensions (height, weight, shoeSize) are NOT hidden for authenticated users + // These are preferences/measurements that can be updated without creating duplicate records + // Hide body dimensions section unless rental services are selected $this->fieldStateConditions['bodyDimensions'] = [ 'hidden' => CompositeCondition::not($rentalCondition), @@ -195,11 +254,6 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider 'hidden' => FieldValueCondition::equals('parking', false), ]; - // Make mobile field required for applicant (participant index 0) - $this->fieldStateConditions['mobile'] = [ - 'required' => new ApplicantCondition(), - ]; - // Make address field required for applicant (participant index 0) $this->fieldStateConditions['address'] = [ 'required' => new ApplicantCondition(), diff --git a/src/Service/ParticipantPrepopulationService.php b/src/Service/ParticipantPrepopulationService.php new file mode 100644 index 0000000..3ea25a1 --- /dev/null +++ b/src/Service/ParticipantPrepopulationService.php @@ -0,0 +1,101 @@ +crypt->decrypt($user->getPassword()); + $email = $user->getEmail(); + + // Fetch personal data from API + $result = $this->apiClient->getPersonalData($email, $password); + + // Handle API error response + if ($result instanceof Notification) { + $this->logger->warning('Failed to fetch personal data for prepopulation', [ + 'email' => $email, + 'code' => $result->code, + 'message' => $result->message, + ]); + + return $applicant; + } + + // Create a temporary participant from personal data + $prepopulated = ParticipantDto::fromPersonalData($result); + + // Copy personal data fields to the existing applicant + // Preserve booking-specific fields (roomId, services, etc.) + $applicant->addressId = $prepopulated->addressId; + $applicant->personId = $prepopulated->personId; + $applicant->firstName = $prepopulated->firstName; + $applicant->lastName = $prepopulated->lastName; + $applicant->title = $prepopulated->title; + $applicant->gender = $prepopulated->gender; + $applicant->nationality = $prepopulated->nationality; + $applicant->dateOfBirth = $prepopulated->dateOfBirth; + $applicant->email = $prepopulated->email; + $applicant->mobile = $prepopulated->mobile; + $applicant->address = $prepopulated->address; + $applicant->height = $prepopulated->height; + $applicant->weight = $prepopulated->weight; + $applicant->shoeSize = $prepopulated->shoeSize; + $applicant->remarksRoom = $prepopulated->remarksRoom; + $applicant->licensePlate = $prepopulated->licensePlate; + + $this->logger->info('Prepopulated applicant data from user profile', [ + 'email' => $email, + ]); + + return $applicant; + } catch (\Exception $e) { + // Catch any unexpected exceptions (decryption failure, API errors, etc.) + $this->logger->error('Exception during applicant prepopulation', [ + 'error' => $e->getMessage(), + 'email' => $user->getEmail(), + ]); + + return $applicant; + } + } +} diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 2930ba5..f3a9da5 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -46,24 +46,88 @@
{# Personal data section #}
- {{ form_row(form.firstName) }} - {{ form_row(form.lastName) }} - {{ form_row(form.dateOfBirth, { - 'attr': { - 'hx-trigger': 'change', - 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), - 'hx-target': '#main-content', - 'hx-swap': 'innerHTML' - } - }) }} - {{ form_row(form.gender) }} - {{ form_row(form.nationality) }} + {% if form.firstName is defined %} + {{ form_row(form.firstName) }} + {% else %} +
+ +
{{ form.vars.data.participant.firstName }}
+
+ {% endif %} + + {% if form.lastName is defined %} + {{ form_row(form.lastName) }} + {% else %} +
+ +
{{ form.vars.data.participant.lastName }}
+
+ {% endif %} + + {% if form.dateOfBirth is defined %} + {{ form_row(form.dateOfBirth, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', + 'hx-swap': 'innerHTML' + } + }) }} + {% else %} +
+ +
{{ form.vars.data.participant.dateOfBirth ? form.vars.data.participant.dateOfBirth|date('d.m.Y') : '-' }}
+
+ {% endif %} + + {% if form.gender is defined %} + {{ form_row(form.gender) }} + {% else %} +
+ +
+ {% if form.vars.data.participant.gender == 'M' %} + männlich + {% elseif form.vars.data.participant.gender == 'W' %} + weiblich + {% elseif form.vars.data.participant.gender == 'D' %} + divers + {% else %} + - + {% endif %} +
+
+ {% endif %} + + {% if form.nationality is defined %} + {{ form_row(form.nationality) }} + {% else %} +
+ +
{{ form.vars.data.participant.nationality ?: '-' }}
+
+ {% endif %}
{# Contact information #}
- {{ form_row(form.email) }} - {{ form_row(form.mobile) }} + {% if form.email is defined %} + {{ form_row(form.email) }} + {% else %} +
+ +
{{ form.vars.data.participant.email }}
+
+ {% endif %} + + {% if form.mobile is defined %} + {{ form_row(form.mobile) }} + {% else %} +
+ +
{{ form.vars.data.participant.mobile ?: '-' }}
+
+ {% endif %}
{# Address #} @@ -74,6 +138,21 @@ {{ form_row(form.address.city) }} {{ form_row(form.address.country) }}
+ {% else %} +
+ + {% if form.vars.data.participant.address %} +
+ {% if form.vars.data.participant.address.street %}{{ form.vars.data.participant.address.street }}
{% endif %} + {% if form.vars.data.participant.address.postCode or form.vars.data.participant.address.city %} + {{ form.vars.data.participant.address.postCode }} {{ form.vars.data.participant.address.city }}
+ {% endif %} + {% if form.vars.data.participant.address.country %}{{ form.vars.data.participant.address.country }}{% endif %} +
+ {% else %} +
-
+ {% endif %} +
{% endif %} {# Room assignment #} diff --git a/templates/booking/create/authenticate.html.twig b/templates/booking/create/authenticate.html.twig new file mode 100644 index 0000000..ac615f3 --- /dev/null +++ b/templates/booking/create/authenticate.html.twig @@ -0,0 +1,103 @@ +{% extends 'layout.html.twig' %} + +{% block content %} + {% include '_partials/_flashes.html.twig' %} + +

+ Neue Buchung +

+ +
+

+ Optional: Anmelden für schnelleres Buchen +

+

+ Melde dich an, um deine persönlichen Daten automatisch in die Buchung zu übernehmen. + Dies ist vollständig optional – du kannst auch ohne Anmeldung als Gast fortfahren. +

+
+ +
+ {# Login Form #} +
+

+ Mit bestehendem Account anmelden +

+ + {% if error %} + {% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %} + {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + + Passwort vergessen? + +
+ + +
+
+ + {# Continue as Guest #} +
+

+ Als Gast fortfahren +

+ +

+ Du kannst auch ohne Anmeldung buchen. Deine persönlichen Daten gibst du dann im nächsten Schritt ein. +

+ + + Als Gast fortfahren + + +
+

+ Noch kein Account? +

+

+ Nach Abschluss deiner Buchung wird automatisch ein Account für dich erstellt. + Du erhältst dann Zugangsdaten per E-Mail und kannst deine Buchungen jederzeit verwalten. +

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/booking/create/success.html.twig b/templates/booking/create/success.html.twig index 8721bfe..ce7527c 100644 --- a/templates/booking/create/success.html.twig +++ b/templates/booking/create/success.html.twig @@ -16,8 +16,24 @@

- - Zurück zur Startseite - + {% if app.user %} + {# Authenticated user - show account-related actions #} +
+ + Zu meinen persönlichen Daten + + + Meine Buchungen anzeigen + + + Abmelden + +
+ {% else %} + {# Guest user - show simple homepage link #} + + Zurück zur Startseite + + {% endif %} {% endblock %} diff --git a/tests/Service/ParticipantPrepopulationServiceTest.php b/tests/Service/ParticipantPrepopulationServiceTest.php new file mode 100644 index 0000000..2b23131 --- /dev/null +++ b/tests/Service/ParticipantPrepopulationServiceTest.php @@ -0,0 +1,307 @@ +apiClient = $this->createMock(ApiClient::class); + $this->crypt = $this->createMock(Crypt::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new ParticipantPrepopulationService( + $this->apiClient, + $this->crypt, + $this->logger + ); + } + + public function testPrepopulateApplicantWithCompletePersonalData(): void + { + $user = $this->createUser('test@example.com', 'encrypted_password'); + $applicant = new ParticipantDto(); + $applicant->index = 0; + + $personalData = $this->createCompletePersonalData(); + + $this->crypt->expects($this->once()) + ->method('decrypt') + ->with('encrypted_password') + ->willReturn('decrypted_password'); + + $this->apiClient->expects($this->once()) + ->method('getPersonalData') + ->with('test@example.com', 'decrypted_password') + ->willReturn($personalData); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Prepopulated applicant data from user profile', ['email' => 'test@example.com']); + + $result = $this->service->prepopulateApplicantFromUser($user, $applicant); + + // BPN API IDs for linking to existing records + $this->assertSame(12345, $result->addressId); + $this->assertSame(67890, $result->personId); + + $this->assertSame('John', $result->firstName); + $this->assertSame('Doe', $result->lastName); + $this->assertSame('Dr.', $result->title); + $this->assertSame('M', $result->gender); + $this->assertSame('DE', $result->nationality); + $this->assertSame('test@example.com', $result->email); + $this->assertSame('+49123456789', $result->mobile); + $this->assertSame('180', $result->height); + $this->assertSame('75', $result->weight); + $this->assertSame('42', $result->shoeSize); + $this->assertSame('No smoking room please', $result->remarksRoom); + $this->assertSame('AB-CD-1234', $result->licensePlate); + + $this->assertNotNull($result->dateOfBirth); + $this->assertSame('1990-05-15', $result->dateOfBirth->format('Y-m-d')); + + $this->assertNotNull($result->address); + $this->assertSame('Main Street 123', $result->address->street); + $this->assertSame('12345', $result->address->postCode); + $this->assertSame('Berlin', $result->address->city); + $this->assertSame('DE', $result->address->country); + $this->assertSame('Mitte', $result->address->district); + } + + public function testPrepopulateApplicantWithPartialPersonalData(): void + { + $user = $this->createUser('test@example.com', 'encrypted_password'); + $applicant = new ParticipantDto(); + $applicant->index = 0; + + $personalData = $this->createPartialPersonalData(); + + $this->crypt->expects($this->once()) + ->method('decrypt') + ->willReturn('decrypted_password'); + + $this->apiClient->expects($this->once()) + ->method('getPersonalData') + ->willReturn($personalData); + + $this->logger->expects($this->once()) + ->method('info'); + + $result = $this->service->prepopulateApplicantFromUser($user, $applicant); + + // Required fields should be populated + $this->assertSame('Jane', $result->firstName); + $this->assertSame('Smith', $result->lastName); + $this->assertSame('jane@example.com', $result->email); + + // Optional fields should be null + $this->assertNull($result->title); + $this->assertNull($result->gender); + $this->assertNull($result->nationality); + $this->assertNull($result->mobile); + $this->assertNull($result->height); + $this->assertNull($result->weight); + $this->assertNull($result->shoeSize); + $this->assertNull($result->remarksRoom); + $this->assertNull($result->licensePlate); + } + + public function testPrepopulateApplicantWithApiNotificationError(): void + { + $user = $this->createUser('test@example.com', 'encrypted_password'); + $applicant = new ParticipantDto(); + $applicant->index = 0; + + $notification = new Notification(401, 'Authentication failed'); + + $this->crypt->expects($this->once()) + ->method('decrypt') + ->willReturn('decrypted_password'); + + $this->apiClient->expects($this->once()) + ->method('getPersonalData') + ->willReturn($notification); + + $this->logger->expects($this->once()) + ->method('warning') + ->with('Failed to fetch personal data for prepopulation', [ + 'email' => 'test@example.com', + 'code' => 401, + 'message' => 'Authentication failed', + ]); + + $this->logger->expects($this->never()) + ->method('info'); + + $result = $this->service->prepopulateApplicantFromUser($user, $applicant); + + // Should return unchanged applicant + $this->assertNull($result->firstName); + $this->assertNull($result->lastName); + $this->assertNull($result->email); + } + + public function testPrepopulateApplicantWithDecryptionException(): void + { + $user = $this->createUser('test@example.com', 'encrypted_password'); + $applicant = new ParticipantDto(); + $applicant->index = 0; + + $this->crypt->expects($this->once()) + ->method('decrypt') + ->willThrowException(new \RuntimeException('Decryption failed')); + + $this->apiClient->expects($this->never()) + ->method('getPersonalData'); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Exception during applicant prepopulation', [ + 'error' => 'Decryption failed', + 'email' => 'test@example.com', + ]); + + $result = $this->service->prepopulateApplicantFromUser($user, $applicant); + + // Should return unchanged applicant + $this->assertNull($result->firstName); + $this->assertNull($result->lastName); + $this->assertNull($result->email); + } + + public function testPrepopulateApplicantWithApiException(): void + { + $user = $this->createUser('test@example.com', 'encrypted_password'); + $applicant = new ParticipantDto(); + $applicant->index = 0; + + $this->crypt->expects($this->once()) + ->method('decrypt') + ->willReturn('decrypted_password'); + + $this->apiClient->expects($this->once()) + ->method('getPersonalData') + ->willThrowException(new \RuntimeException('API connection failed')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Exception during applicant prepopulation', [ + 'error' => 'API connection failed', + 'email' => 'test@example.com', + ]); + + $result = $this->service->prepopulateApplicantFromUser($user, $applicant); + + // Should return unchanged applicant + $this->assertNull($result->firstName); + $this->assertNull($result->lastName); + $this->assertNull($result->email); + } + + public function testPrepopulatePreservesBookingSpecificFields(): void + { + $user = $this->createUser('test@example.com', 'encrypted_password'); + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->assignedRoomId = 42; + $applicant->bulkInsuranceBooking = true; + + $personalData = $this->createCompletePersonalData(); + + $this->crypt->expects($this->once()) + ->method('decrypt') + ->willReturn('decrypted_password'); + + $this->apiClient->expects($this->once()) + ->method('getPersonalData') + ->willReturn($personalData); + + $result = $this->service->prepopulateApplicantFromUser($user, $applicant); + + // Personal data should be updated + $this->assertSame('John', $result->firstName); + $this->assertSame('Doe', $result->lastName); + + // Booking-specific fields should be preserved + $this->assertSame(42, $result->assignedRoomId); + $this->assertTrue($result->bulkInsuranceBooking); + } + + private function createUser(string $email, string $encryptedPassword): User + { + $user = new User($email); + $user->setPassword($encryptedPassword); + + return $user; + } + + private function createCompletePersonalData(): PersonalData + { + $personalData = new PersonalData(); + $personalData->addressId = 12345; + $personalData->personId = 67890; + $personalData->firstName = 'John'; + $personalData->name = 'Doe'; + $personalData->title = 'Dr.'; + $personalData->gender = 'M'; + $personalData->nationality = 'DE'; + $personalData->dateOfBirth = new \DateTimeImmutable('1990-05-15'); + $personalData->height = '180'; + $personalData->weight = '75'; + $personalData->shoeSize = '42'; + $personalData->remarksRoom = 'No smoking room please'; + $personalData->licensePlate = 'AB-CD-1234'; + + $personalData->communication = new Communication(); + $personalData->communication->email = 'test@example.com'; + $personalData->communication->mobile = '+49123456789'; + + $personalData->address = new Address(); + $personalData->address->street = 'Main Street 123'; + $personalData->address->postCode = '12345'; + $personalData->address->city = 'Berlin'; + $personalData->address->country = 'DE'; + $personalData->address->district = 'Mitte'; + + return $personalData; + } + + private function createPartialPersonalData(): PersonalData + { + $personalData = new PersonalData(); + $personalData->firstName = 'Jane'; + $personalData->name = 'Smith'; + $personalData->dateOfBirth = new \DateTimeImmutable('1995-03-20'); + + $personalData->communication = new Communication(); + $personalData->communication->email = 'jane@example.com'; + + $personalData->address = new Address(); + $personalData->address->street = 'Second Street 45'; + $personalData->address->postCode = '54321'; + $personalData->address->city = 'Munich'; + $personalData->address->country = 'DE'; + + return $personalData; + } +}