From dc6de1734a7f6bde3aad5a35b2773e1ed48acb3a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?=
Date: Fri, 24 Oct 2025 17:07:08 +0200
Subject: [PATCH] feat: authenticated booking
---
.../DataProcessor/BookingDataProcessor.php | 16 +
.../Booking/Create/IndexController.php | 21 +-
.../Booking/Create/Step2Controller.php | 21 ++
src/Controller/SecurityController.php | 22 +-
src/Form/BookingParticipantType.php | 55 +++-
src/Form/Model/ParticipantDto.php | 1 +
...AuthenticatedUserPersonalDataCondition.php | 76 +++++
src/Form/Service/CreateFieldStateProvider.php | 64 +++-
.../ParticipantPrepopulationService.php | 101 ++++++
templates/booking/_participant_form.html.twig | 107 +++++-
.../booking/create/authenticate.html.twig | 103 ++++++
templates/booking/create/success.html.twig | 22 +-
.../ParticipantPrepopulationServiceTest.php | 307 ++++++++++++++++++
13 files changed, 866 insertions(+), 50 deletions(-)
create mode 100644 src/Form/Service/Condition/AuthenticatedUserPersonalDataCondition.php
create mode 100644 src/Service/ParticipantPrepopulationService.php
create mode 100644 templates/booking/create/authenticate.html.twig
create mode 100644 tests/Service/ParticipantPrepopulationServiceTest.php
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 @@
+ {% else %}
+
+
Adresse
+ {% 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 %}
+
+
+
+
+ {# 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 #}
+
+ {% 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;
+ }
+}