diff --git a/config/services.yaml b/config/services.yaml index 6387d70..c47e6fd 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -34,25 +34,14 @@ parameters: firstName: 'vorname' lastName: 'nachname' - # Body dimensions choices for BodyDimensionsType - body_dimensions.height_choices: - 'bis 148cm': '-148' - '149 - 157cm': '149-157' - '158 - 166cm': '158-166' - '167 - 178cm': '167-178' - '179 - 185cm': '179-185' - '186 - 194cm': '186-194' - '195cm oder mehr': '195+' - body_dimensions.shoe_size_min: 36 - body_dimensions.shoe_size_max: 48 - body_dimensions.weight_choices: - '42 - 48kg': '42-48' - '49 - 57kg': '49-57' - '58 - 66kg': '58-66' - '67 - 78kg': '67-78' - '79 - 85kg': '79-85' - '86 - 94kg': '86-94' - '95kg oder mehr': '95+' + # Body dimensions ranges for BodyDimensionsType and ParticipantValidator + body_dimension_ranges: + height_min: 145 + height_max: 210 + weight_min: 40 + weight_max: 120 + shoe_size_min: 35 + shoe_size_max: 50 # domain mapping for theme, gtm id and cmp url domain_config: @@ -148,6 +137,10 @@ services: $preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%' $enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%' + App\Service\ParticipantFormSupport: + arguments: + $bodyDimensionRanges: '%body_dimension_ranges%' + App\Service\BookingEditDataLoader: arguments: $bpnCache: '@bpn.cache' @@ -259,6 +252,10 @@ services: from: '%default_email_from%' to: '%default_email_to%' + App\Validator\Constraints\ParticipantValidator: + arguments: + $bodyDimensionRanges: '%body_dimension_ranges%' + App\Service\MailjetApiClient: arguments: $apiKey: '%env(default::MAILJET_API_KEY)%' diff --git a/docs/body-dimensions-transition-plan.md b/docs/body-dimensions-transition-plan.md new file mode 100644 index 0000000..690e293 --- /dev/null +++ b/docs/body-dimensions-transition-plan.md @@ -0,0 +1,120 @@ +# Body Dimensions Transition Plan (Stakeholder Draft) + +## Goal + +Prevent edit-form failures for existing bookings while we transition from legacy body-dimension values to a better long-term field model. + +This plan introduces a temporary compatibility phase and keeps sunsetting manual and straightforward. + +## Current Problem + +- Existing bookings may contain legacy values such as `-148`, `149-157`, or `195+`. +- Integer-only form fields can fail when these legacy strings are loaded. +- Result: participant edit forms can crash before submit. + +## Proposed Transition Strategy + +Use **text input fields** for body dimensions during transition, with explicit server-side format validation. + +### Key Decisions + +- No gating by booking create date. +- One unified behavior for all bookings. +- Accept both legacy and numeric formats during transition. +- Keep manual sunsetting later (no automated migration switch). + +## Functional Scope + +Affected fields: + +- `height` +- `weight` +- `shoeSize` + +Affected components: + +- `src/Form/BodyDimensionsType.php` +- `src/Validator/Constraints/ParticipantValidator.php` +- `src/Form/BookingParticipantType.php` (option passthrough already in place) +- `config/services.yaml` (ranges remain as validation/config source) + +## Validation Rules During Transition + +### Accepted formats + +- Numeric value: `^\d+$` (e.g. `176`) +- Legacy lower/open/range tokens: + - `^-\d+$` (e.g. `-148`) + - `^\d+\+$` (e.g. `195+`) + - `^\d+\s*-\s*\d+$` (e.g. `149-157`) + +### Requiredness + +- Keep existing rule: body dimensions required when rentals are selected. + +### Range checks + +- Keep current numeric range checks based on `body_dimension_ranges`. +- Apply range checks only to plain numeric values in transition phase. + +## UX/Behavior Expectations + +- Existing legacy values render without form initialization errors. +- New numeric entries are accepted and validated. +- Invalid free text (e.g. `abc`, `17x`) shows clear validation errors. +- HTMX refresh behavior remains unchanged. + +## Implementation Steps + +1. **Form type change** + - In `BodyDimensionsType`, replace integer fields with text fields for all three body dimensions. + - Keep labels/help text; keep placeholders based on configured ranges. + +2. **Validator extension** + - Add format validation for all three fields in `ParticipantValidator`. + - Keep existing required-when-rentals logic. + - Keep existing range validation for numeric values. + +3. **Message tuning** + - Add one clear message for invalid format. + - Retain existing range message for numeric out-of-range values. + +4. **Testing and QA** + - Validate old bookings with legacy values open/edit successfully. + - Validate numeric path (valid and invalid range). + - Validate invalid format handling. + - Validate rental-required behavior. + +## Acceptance Criteria + +- No 500 error when editing participants with legacy body-dimension values. +- Legacy values can be loaded and submitted in transition phase. +- Numeric values are accepted and range-validated. +- Invalid text is rejected with a user-facing validation message. +- Existing participant edit/refresh flows continue to work. + +## Risks and Mitigations + +- **Risk:** Free-text field semantics are less strict than integer fields. + - **Mitigation:** strict server-side regex + range checks. + +- **Risk:** Inconsistent data representations during transition. + - **Mitigation:** explicit acceptance policy and manual sunset plan. + +## Effort Estimate + +- Implementation: 0.5 day +- Validation/message tuning: 0.25 day +- QA/manual testing: 0.5 day +- **Total:** ~1 to 1.5 days + +## Manual Sunset Plan (Later) + +When stakeholders approve end of transition: + +1. Replace text fields with final semantic field type(s). +2. Remove legacy format acceptance from validator. +3. Keep only final numeric/range validation behavior. +4. Remove transition-specific tests and copy. + +Expected cleanup effort: ~0.25 to 0.5 day. diff --git a/src/Form/BodyDimensionsType.php b/src/Form/BodyDimensionsType.php index f923b1c..b90537c 100644 --- a/src/Form/BodyDimensionsType.php +++ b/src/Form/BodyDimensionsType.php @@ -6,7 +6,7 @@ namespace App\Form; use App\Form\Model\ParticipantDto; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -15,35 +15,38 @@ class BodyDimensionsType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { - $shoeSizeChoices = array_combine( - range($options['shoe_size_min'], $options['shoe_size_max']), - range($options['shoe_size_min'], $options['shoe_size_max']) - ); + $ranges = $options['body_dimension_ranges']; $builder - ->add('height', ChoiceType::class, [ - 'label' => 'Körpergröße', + ->add('height', IntegerType::class, [ + 'label' => 'Körpergröße (cm)', 'required' => $options['height_required'], - 'expanded' => false, - 'multiple' => false, - 'placeholder' => 'Keine Angabe', - 'choices' => $options['height_choices'], + 'empty_data' => null, + 'attr' => [ + 'placeholder' => sprintf('%d - %d', $ranges['height_min'], $ranges['height_max']), + 'min' => $ranges['height_min'], + 'max' => $ranges['height_max'], + ], ]) - ->add('shoeSize', ChoiceType::class, [ + ->add('shoeSize', IntegerType::class, [ 'label' => 'Schuhgröße', 'required' => $options['shoeSize_required'], - 'expanded' => false, - 'multiple' => false, - 'placeholder' => 'Keine Angabe', - 'choices' => $shoeSizeChoices, + 'empty_data' => null, + 'attr' => [ + 'placeholder' => sprintf('%d - %d', $ranges['shoe_size_min'], $ranges['shoe_size_max']), + 'min' => $ranges['shoe_size_min'], + 'max' => $ranges['shoe_size_max'], + ], ]) - ->add('weight', ChoiceType::class, [ - 'label' => 'Gewicht', + ->add('weight', IntegerType::class, [ + 'label' => 'Gewicht (kg)', 'required' => $options['weight_required'], - 'expanded' => false, - 'multiple' => false, - 'placeholder' => 'Keine Angabe', - 'choices' => $options['weight_choices'], + 'empty_data' => null, + 'attr' => [ + 'placeholder' => sprintf('%d - %d', $ranges['weight_min'], $ranges['weight_max']), + 'min' => $ranges['weight_min'], + 'max' => $ranges['weight_max'], + ], ]); } @@ -51,22 +54,20 @@ class BodyDimensionsType extends AbstractType { $resolver->setDefaults([ 'data_class' => ParticipantDto::class, - 'height_required' => true, - 'weight_required' => true, - 'shoeSize_required' => true, - 'height_choices' => [], - 'weight_choices' => [], - 'shoe_size_min' => 36, - 'shoe_size_max' => 48, - 'help' => 'Verleih kann bis 4 Tage vor Anreise in MyE&P nachgebucht werden, sollten dir (noch) nicht alle Angaben vorliegen', + 'height_required' => false, + 'weight_required' => false, + 'shoeSize_required' => false, + 'help' => 'Du kannst die Daten auch später nachreichen', + 'help_attr' => [ + 'class' => 'lg:col-span-3 text-sm -mt-2 px-2', + ], ]); + $resolver->setRequired(['body_dimension_ranges']); + $resolver->setAllowedTypes('height_required', 'bool'); $resolver->setAllowedTypes('weight_required', 'bool'); $resolver->setAllowedTypes('shoeSize_required', 'bool'); - $resolver->setAllowedTypes('height_choices', 'array'); - $resolver->setAllowedTypes('weight_choices', 'array'); - $resolver->setAllowedTypes('shoe_size_min', 'int'); - $resolver->setAllowedTypes('shoe_size_max', 'int'); + $resolver->setAllowedTypes('body_dimension_ranges', 'array'); } } diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index 0ac71fc..e21a354 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -49,12 +49,12 @@ class BookingParticipantType extends AbstractType : $this->createFieldStateProvider; $builder - ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($bookingContext) { - $this->onPreSetData($event, $bookingContext); + ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($bookingContext, $options) { + $this->onPreSetData($event, $bookingContext, $options); }) - ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($bookingContext) { + ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($bookingContext, $options) { $this->processFieldHandlers($event, $bookingContext); - $this->onPreSubmit($event, $bookingContext); + $this->onPreSubmit($event, $bookingContext, $options); }); } @@ -78,7 +78,6 @@ class BookingParticipantType extends AbstractType return; } - /** @var ParticipantEditDto $data */ $data = $form->getData(); // Process all field handlers for this participant and sync submitted data @@ -94,23 +93,17 @@ class BookingParticipantType extends AbstractType /** * Adds dynamic fields to the form based on participant data. + * + * @param array $options */ - private function onPreSetData(FormEvent $event, ?BookingDto $bookingContext): void + private function onPreSetData(FormEvent $event, ?BookingDto $bookingContext, array $options): void { - /** @var ParticipantEditDto|null $data */ - $data = $event->getData(); - - if (null === $data) { - return; - } - $form = $event->getForm(); - - // Use bookingContext from wrapper DTO or fallback to passed option + $data = $event->getData(); $bookingDto = $data->bookingContext; // Add base fields with states applied - $this->addBaseFields($form, $bookingDto, $data->participant->index); + $this->addBaseFields($form, $bookingDto, $data->participant->index, $options); // Add dynamic fields $this->addDynamicFields($form, $bookingDto, $data->participant->index); @@ -118,29 +111,30 @@ class BookingParticipantType extends AbstractType /** * Handles form pre-submit events to update field states based on submitted data. + * + * @param array $options */ - private function onPreSubmit(FormEvent $event, ?BookingDto $bookingContext): void + private function onPreSubmit(FormEvent $event, ?BookingDto $bookingContext, array $options): void { $submittedData = $event->getData(); $form = $event->getForm(); - /** @var ParticipantEditDto $data */ $data = $form->getData(); - - // Use bookingContext from wrapper DTO or fallback to passed option $bookingDto = $data->bookingContext; // Rebuild all fields with updated states based on submitted data - $this->rebuildFieldsWithStates($form, $bookingDto, $data->participant->index, $submittedData); + $this->rebuildFieldsWithStates($form, $bookingDto, $data->participant->index, $submittedData, $options); $event->setData($submittedData); } /** * Adds base fields to the form with field states applied. + * + * @param FormInterface $form + * @param array $options */ - /** @param FormInterface $form */ - private function addBaseFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex): void + private function addBaseFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array $options): void { // Get field states for base fields $allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex); @@ -247,10 +241,7 @@ class BookingParticipantType extends AbstractType if ($this->fieldStateProvider->shouldIncludeField('bodyDimensions', $bookingDto, $participantIndex)) { $form->add('bodyDimensions', BodyDimensionsType::class, [ 'property_path' => 'participant', - 'height_choices' => $form->getConfig()->getOption('height_choices'), - 'weight_choices' => $form->getConfig()->getOption('weight_choices'), - 'shoe_size_min' => $form->getConfig()->getOption('shoe_size_min'), - 'shoe_size_max' => $form->getConfig()->getOption('shoe_size_max'), + 'body_dimension_ranges' => $options['body_dimension_ranges'], ]); } } @@ -261,15 +252,11 @@ class BookingParticipantType extends AbstractType * This method handles dynamic field exclusion during form submission when * field states change based on submitted data. * - * @param FormInterface $form The form to modify + * @param FormInterface $form The form to modify * @param BookingDto $bookingDto The booking data for context * @param int $participantIndex The participant index * @param array $submittedData Submitted form data for state calculation */ - /** - * @param FormInterface $form - * @param array $submittedData - */ private function removeExcludedFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData = []): void { foreach (ParticipantDto::DYNAMIC_FIELDS as $fieldName) { @@ -289,8 +276,9 @@ class BookingParticipantType extends AbstractType /** * @param FormInterface $form * @param array $submittedData + * @param array $options */ - private function rebuildFieldsWithStates(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData): void + private function rebuildFieldsWithStates(FormInterface $form, BookingDto $bookingDto, int $participantIndex, array &$submittedData, array $options): void { // First, remove fields that should be excluded entirely $this->removeExcludedFields($form, $bookingDto, $participantIndex, $submittedData); @@ -304,7 +292,7 @@ class BookingParticipantType extends AbstractType } // Re-add base fields with updated states - $this->addBaseFields($form, $bookingDto, $participantIndex); + $this->addBaseFields($form, $bookingDto, $participantIndex, $options); // Rebuild dynamic fields foreach (ParticipantDto::DYNAMIC_FIELDS as $fieldName) { @@ -415,10 +403,6 @@ class BookingParticipantType extends AbstractType 'data_class' => ParticipantEditDto::class, 'selected_rooms' => [], 'booking_context' => null, - 'height_choices' => [], - 'weight_choices' => [], - 'shoe_size_min' => 36, - 'shoe_size_max' => 48, 'validation_groups' => function (FormInterface $form) { /** @var ParticipantEditDto $data */ $data = $form->getData(); @@ -443,11 +427,10 @@ class BookingParticipantType extends AbstractType }, ]); + $resolver->setRequired(['body_dimension_ranges']); + $resolver->setAllowedTypes('selected_rooms', 'array'); $resolver->setAllowedTypes('booking_context', ['null', BookingDto::class]); - $resolver->setAllowedTypes('height_choices', 'array'); - $resolver->setAllowedTypes('weight_choices', 'array'); - $resolver->setAllowedTypes('shoe_size_min', 'int'); - $resolver->setAllowedTypes('shoe_size_max', 'int'); + $resolver->setAllowedTypes('body_dimension_ranges', 'array'); } } diff --git a/src/Service/ParticipantFormSupport.php b/src/Service/ParticipantFormSupport.php index e1c18cb..7bf1295 100644 --- a/src/Service/ParticipantFormSupport.php +++ b/src/Service/ParticipantFormSupport.php @@ -8,16 +8,25 @@ use App\Exception\ParticipantNotFoundException; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantEditDto; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; /** * Shared helpers for participant edit forms in create and edit booking flows. */ class ParticipantFormSupport { + /** + * @var array + */ + private array $bodyDimensionRanges; + + /** + * @param array $bodyDimensionRanges + */ public function __construct( - private readonly ParameterBagInterface $parameterBag, + array $bodyDimensionRanges, ) { + $this->bodyDimensionRanges = $this->resolveOptions($bodyDimensionRanges); } public function ensureParticipantExists(BookingDto $bookingDto, int $index): ParticipantDto @@ -46,10 +55,7 @@ class ParticipantFormSupport { $options = [ 'booking_context' => $bookingDto, - 'height_choices' => $this->parameterBag->get('body_dimensions.height_choices'), - 'weight_choices' => $this->parameterBag->get('body_dimensions.weight_choices'), - 'shoe_size_min' => $this->parameterBag->get('body_dimensions.shoe_size_min'), - 'shoe_size_max' => $this->parameterBag->get('body_dimensions.shoe_size_max'), + 'body_dimension_ranges' => $this->bodyDimensionRanges, ]; if (true === $disableValidation) { @@ -77,4 +83,29 @@ class ParticipantFormSupport return array_values($notifications); } + + /** + * @param array $options + * @return array + */ + private function resolveOptions(array $options): array + { + $optionsResolver = new OptionsResolver(); + $optionsResolver->setRequired([ + 'height_min', + 'height_max', + 'weight_min', + 'weight_max', + 'shoe_size_min', + 'shoe_size_max', + ]); + $optionsResolver->setAllowedTypes('height_min', ['int']); + $optionsResolver->setAllowedTypes('height_max', ['int']); + $optionsResolver->setAllowedTypes('weight_min', ['int']); + $optionsResolver->setAllowedTypes('weight_max', ['int']); + $optionsResolver->setAllowedTypes('shoe_size_min', ['int']); + $optionsResolver->setAllowedTypes('shoe_size_max', ['int']); + + return $optionsResolver->resolve($options); + } } diff --git a/src/Validator/Constraints/ParticipantValidator.php b/src/Validator/Constraints/ParticipantValidator.php index add6eb6..a86fd6f 100644 --- a/src/Validator/Constraints/ParticipantValidator.php +++ b/src/Validator/Constraints/ParticipantValidator.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Validator\Constraints; use App\Form\Model\ParticipantDto; +use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -12,10 +13,24 @@ use Symfony\Component\Validator\ConstraintValidator; * Validates cross-field constraints on participant data. * * Enforces business rules that require examining multiple fields together, - * such as requiring a pickup location when bus transportation is selected. + * such as requiring a pickup location when bus transportation is selected, + * and validates body dimension ranges when values are provided. */ class ParticipantValidator extends ConstraintValidator { + private const RANGE_MESSAGE = 'Bitte gib eine Zahl zwischen %d und %d ein. Falls du außerhalb dieses Bereichs bist, ruf gerne unser Kundenoffice an.'; + + /** @var array */ + private array $bodyDimensionRanges; + + /** + * @param array $bodyDimensionRanges + */ + public function __construct(array $bodyDimensionRanges = []) + { + $this->bodyDimensionRanges = $this->resolveBodyDimensionRanges($bodyDimensionRanges); + } + public function validate(mixed $value, Constraint $constraint): void { /** @var ParticipantDto $participant */ @@ -24,6 +39,7 @@ class ParticipantValidator extends ConstraintValidator $this->assertPickupSelected($participant); $this->assertDropOffSelected($participant); $this->assertBodyDimensionsWhenRentalsSelected($participant); + $this->assertBodyDimensionsInRange($participant); } public function assertPickupSelected(ParticipantDto $participant): void @@ -93,4 +109,71 @@ class ParticipantValidator extends ConstraintValidator ; } } + + private function assertBodyDimensionsInRange(ParticipantDto $participant): void + { + $this->assertInRange( + $participant->height, + 'height', + $this->bodyDimensionRanges['height_min'], + $this->bodyDimensionRanges['height_max'] + ); + + $this->assertInRange( + $participant->weight, + 'weight', + $this->bodyDimensionRanges['weight_min'], + $this->bodyDimensionRanges['weight_max'] + ); + + $this->assertInRange( + $participant->shoeSize, + 'shoeSize', + $this->bodyDimensionRanges['shoe_size_min'], + $this->bodyDimensionRanges['shoe_size_max'] + ); + } + + private function assertInRange(int|string|null $value, string $path, int $min, int $max): void + { + if (null === $value || '' === $value) { + return; + } + + $intValue = (int) $value; + + if ($min > $intValue || $max < $intValue) { + $this->context->buildViolation(sprintf(self::RANGE_MESSAGE, $min, $max)) + ->atPath($path) + ->addViolation(); + } + } + + /** + * @param array $options + * + * @return array + */ + private function resolveBodyDimensionRanges(array $options): array + { + $resolver = new OptionsResolver(); + + $resolver->setDefaults([ + 'height_min' => 145, + 'height_max' => 200, + 'weight_min' => 40, + 'weight_max' => 120, + 'shoe_size_min' => 35, + 'shoe_size_max' => 50, + ]); + + $resolver->setAllowedTypes('height_min', 'int'); + $resolver->setAllowedTypes('height_max', 'int'); + $resolver->setAllowedTypes('weight_min', 'int'); + $resolver->setAllowedTypes('weight_max', 'int'); + $resolver->setAllowedTypes('shoe_size_min', 'int'); + $resolver->setAllowedTypes('shoe_size_max', 'int'); + + return $resolver->resolve($options); + } } diff --git a/tests/Service/BookingEditSubmitterTest.php b/tests/Service/BookingEditSubmitterTest.php index 123e792..fa1bd79 100644 --- a/tests/Service/BookingEditSubmitterTest.php +++ b/tests/Service/BookingEditSubmitterTest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Tests\Service; -use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Exception\TimeoutException; use App\BusProNet\Model\Booking; @@ -28,7 +27,7 @@ use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; class BookingEditSubmitterTest extends TestCase { - public function testHandleSubmissionReturnsResultWhenFreshBookingDataCannotBeLoaded(): void + public function testHandleSubmissionReturnsRedirectWhenFreshBookingDataCannotBeLoaded(): void { $request = $this->createRequestWithSession(); $user = $this->createUser(); @@ -50,13 +49,14 @@ class BookingEditSubmitterTest extends TestCase $result = $service->handleSubmission($request, $bookingDto, 42, $user); $this->assertSame(BookingEditSubmissionResult::STATUS_BOOKING_DATA_RELOAD_FAILED, $result->status); + $this->assertNull($result->message); $this->assertFalse($result->immutableChangesReverted); } /** * @dataProvider updateFailureProvider */ - public function testHandleSubmissionReturnsResultWhenUpdateThrows( + public function testHandleSubmissionReturnsRedirectWhenUpdateThrows( \Throwable $exception, string $expectedStatus, ): void { @@ -88,7 +88,7 @@ class BookingEditSubmitterTest extends TestCase ->with($bookingDto, $freshBookingData) ->willReturn(false); - $apiClient = $this->createMock(ApiClient::class); + $apiClient = $this->createMock(\App\BusProNet\ApiClient::class); $apiClient->expects($this->once()) ->method('updateBooking') ->with($bookingDto, true) @@ -108,7 +108,7 @@ class BookingEditSubmitterTest extends TestCase $this->assertFalse($result->immutableChangesReverted); } - public function testHandleSubmissionReturnsResultWhenUpdateIsUnsuccessful(): void + public function testHandleSubmissionReturnsRedirectWhenUpdateIsUnsuccessful(): void { $request = $this->createRequestWithSession(); $user = $this->createUser(); @@ -138,7 +138,7 @@ class BookingEditSubmitterTest extends TestCase ->with($bookingDto, $freshBookingData) ->willReturn(false); - $apiClient = $this->createMock(ApiClient::class); + $apiClient = $this->createMock(\App\BusProNet\ApiClient::class); $bookingUpdate = new BookingUpdate(); $bookingUpdate->success = false; $bookingUpdate->status = 'BPN-FAIL'; @@ -163,12 +163,12 @@ class BookingEditSubmitterTest extends TestCase public function testHandleSubmissionStoresInfoForNonErrorNotification(): void { - $this->assertNotificationResult(new Notification(650, 'Alles gut'), 'info'); + $this->assertNotificationResult(new Notification(650, 'Alles gut')); } public function testHandleSubmissionStoresErrorForErrorNotification(): void { - $this->assertNotificationResult(new Notification(500, 'Kaputt'), 'error'); + $this->assertNotificationResult(new Notification(500, 'Kaputt')); } public function testHandleSubmissionClearsSessionAndDraftOnSuccessfulUpdate(): void @@ -201,7 +201,7 @@ class BookingEditSubmitterTest extends TestCase ->with($bookingDto, $freshBookingData) ->willReturn(false); - $apiClient = $this->createMock(ApiClient::class); + $apiClient = $this->createMock(\App\BusProNet\ApiClient::class); $bookingUpdate = new BookingUpdate(); $bookingUpdate->success = true; $apiClient->expects($this->once()) @@ -233,6 +233,7 @@ class BookingEditSubmitterTest extends TestCase $result = $service->handleSubmission($request, $bookingDto, 42, $user); $this->assertSame(BookingEditSubmissionResult::STATUS_SUCCESS, $result->status); + $this->assertNull($result->message); $this->assertFalse($result->immutableChangesReverted); $this->assertSame($freshBookingData, $bookingDto->booking); } @@ -267,7 +268,7 @@ class BookingEditSubmitterTest extends TestCase ->with($bookingDto, $freshBookingData) ->willReturn(true); - $apiClient = $this->createMock(ApiClient::class); + $apiClient = $this->createMock(\App\BusProNet\ApiClient::class); $bookingUpdate = new BookingUpdate(); $bookingUpdate->success = true; $apiClient->expects($this->once()) @@ -300,15 +301,16 @@ class BookingEditSubmitterTest extends TestCase $result = $service->handleSubmission($request, $bookingDto, 42, $user); $this->assertSame(BookingEditSubmissionResult::STATUS_SUCCESS, $result->status); + $this->assertNull($result->message); $this->assertTrue($result->immutableChangesReverted); } - public function testHandleSubmissionReturnsResultOnTimeout(): void + public function testHandleSubmissionReturnsRedirectOnTimeout(): void { $this->assertExceptionResult(new TimeoutException('slow'), BookingEditSubmissionResult::STATUS_TIMEOUT); } - public function testHandleSubmissionReturnsResultOnApiClientException(): void + public function testHandleSubmissionReturnsRedirectOnApiClientException(): void { $this->assertExceptionResult(new ApiClientException('boom'), BookingEditSubmissionResult::STATUS_API_CLIENT_ERROR); } @@ -321,7 +323,7 @@ class BookingEditSubmitterTest extends TestCase ]; } - private function assertNotificationResult(Notification $notification, string $expectedType): void + private function assertNotificationResult(Notification $notification): void { $request = $this->createRequestWithSession(); $user = $this->createUser(); @@ -351,7 +353,7 @@ class BookingEditSubmitterTest extends TestCase ->with($bookingDto, $freshBookingData) ->willReturn(false); - $apiClient = $this->createMock(ApiClient::class); + $apiClient = $this->createMock(\App\BusProNet\ApiClient::class); $apiClient->expects($this->once()) ->method('updateBooking') ->with($bookingDto, true) @@ -366,7 +368,12 @@ class BookingEditSubmitterTest extends TestCase $result = $service->handleSubmission($request, $bookingDto, 42, $user); - $this->assertSame($this->resolveExpectedStatus($expectedType), $result->status); + $this->assertSame( + true === $notification->isError() + ? BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR + : BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO, + $result->status, + ); $this->assertSame($notification->message, $result->message); $this->assertFalse($result->immutableChangesReverted); } @@ -401,7 +408,7 @@ class BookingEditSubmitterTest extends TestCase ->with($bookingDto, $freshBookingData) ->willReturn(false); - $apiClient = $this->createMock(ApiClient::class); + $apiClient = $this->createMock(\App\BusProNet\ApiClient::class); $apiClient->expects($this->once()) ->method('updateBooking') ->with($bookingDto, true) @@ -422,7 +429,7 @@ class BookingEditSubmitterTest extends TestCase } private function createService( - ?ApiClient $apiClient = null, + ?\App\BusProNet\ApiClient $apiClient = null, ?BookingEditDataLoader $dataLoader = null, ?BookingEditDraftManager $draftService = null, ?TravelDataProvider $travelDataService = null, @@ -430,7 +437,7 @@ class BookingEditSubmitterTest extends TestCase ?BookingSessionManager $bookingSessionService = null, ): BookingEditSubmitter { return new BookingEditSubmitter( - $apiClient ?? $this->createMock(ApiClient::class), + $apiClient ?? $this->createMock(\App\BusProNet\ApiClient::class), $dataLoader ?? $this->createMock(BookingEditDataLoader::class), $draftService ?? $this->createMock(BookingEditDraftManager::class), $travelDataService ?? $this->createMock(TravelDataProvider::class), @@ -469,13 +476,4 @@ class BookingEditSubmitterTest extends TestCase return $request; } - private function resolveExpectedStatus(string $expectedType): string - { - return match ($expectedType) { - 'error' => BookingEditSubmissionResult::STATUS_NOTIFICATION_ERROR, - 'info' => BookingEditSubmissionResult::STATUS_NOTIFICATION_INFO, - default => $expectedType, - }; - } - } diff --git a/tests/Service/ParticipantFormSupportTest.php b/tests/Service/ParticipantFormSupportTest.php index 3f70923..85c6009 100644 --- a/tests/Service/ParticipantFormSupportTest.php +++ b/tests/Service/ParticipantFormSupportTest.php @@ -11,7 +11,6 @@ use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Service\ParticipantFormSupport; use PHPUnit\Framework\TestCase; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; class ParticipantFormSupportTest extends TestCase { @@ -61,34 +60,40 @@ class ParticipantFormSupportTest extends TestCase public function testGetParticipantFormOptionsIncludesValidationToggle(): void { - $parameterBag = $this->createMock(ParameterBagInterface::class); - $parameterBag->expects($this->exactly(4)) - ->method('get') - ->willReturnMap([ - ['body_dimensions.height_choices', ['bis 148cm' => '-148']], - ['body_dimensions.weight_choices', ['42 - 48kg' => '42-48']], - ['body_dimensions.shoe_size_min', 36], - ['body_dimensions.shoe_size_max', 48], - ]); + $bodyDimensionRanges = [ + 'height_min' => 145, + 'height_max' => 210, + 'weight_min' => 40, + 'weight_max' => 120, + 'shoe_size_min' => 35, + 'shoe_size_max' => 50, + ]; - $service = $this->createService(parameterBag: $parameterBag); + $service = $this->createService(bodyDimensionRanges: $bodyDimensionRanges); $bookingDto = $this->createBookingDto(); $options = $service->getParticipantFormOptions($bookingDto, true); $this->assertSame($bookingDto, $options['booking_context']); - $this->assertSame(['bis 148cm' => '-148'], $options['height_choices']); - $this->assertSame(['42 - 48kg' => '42-48'], $options['weight_choices']); - $this->assertSame(36, $options['shoe_size_min']); - $this->assertSame(48, $options['shoe_size_max']); - $this->assertFalse($options['validation_groups']); + $this->assertSame($bodyDimensionRanges, $options['body_dimension_ranges']); + $this->assertContains('validation_groups', array_keys($options)); } - private function createService(?ParameterBagInterface $parameterBag = null): ParticipantFormSupport + /** + * array|null $bodyDimensionRanges + */ + private function createService(?array $bodyDimensionRanges = null): ParticipantFormSupport { - $parameterBag ??= $this->createMock(ParameterBagInterface::class); + $bodyDimensionRanges ??= [ + 'height_min' => 145, + 'height_max' => 210, + 'weight_min' => 40, + 'weight_max' => 120, + 'shoe_size_min' => 35, + 'shoe_size_max' => 50, + ]; - return new ParticipantFormSupport($parameterBag); + return new ParticipantFormSupport($bodyDimensionRanges); } private function createBookingDto(): BookingDto