fix: avoid selection trap for mandatory services with age constraints
This commit is contained in:
@@ -191,12 +191,19 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
|
||||
$attributes = [];
|
||||
|
||||
// Make readonly and checked for mandatory services (pre-selection handled by service layer)
|
||||
// Make mandatory services readonly only when actually selected by participant.
|
||||
// Unselected mandatory services remain interactive so the user can select them
|
||||
// after age changes (e.g. agency skeleton bookings where DOB is corrected).
|
||||
if (true === $service->mandatory) {
|
||||
$attributes['checked'] = true;
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
$isSelected = null !== $participant
|
||||
&& $this->hasServiceById($participant->additionalServices, $service->id);
|
||||
|
||||
if (true === $isSelected) {
|
||||
$attributes['readonly'] = true;
|
||||
$attributes['data-tooltip'] = 'Diese Leistung ist nicht abwählbar';
|
||||
}
|
||||
}
|
||||
|
||||
// Add service description as data attribute for frontend use
|
||||
if (null !== $service->description && '' !== trim($service->description)) {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Service;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\InsuranceService;
|
||||
use App\Service\ServiceAvailabilityCalculator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Tests that mandatory services in choice_attr are only locked (readonly)
|
||||
* when actually selected in the participant's DTO, preventing incorrect
|
||||
* force-locking after age-related service changes.
|
||||
*/
|
||||
class ParticipantFieldOptionsProviderMandatoryServiceTest extends TestCase
|
||||
{
|
||||
private ParticipantFieldOptionsProvider $provider;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class);
|
||||
$insuranceService = $this->createMock(InsuranceService::class);
|
||||
$priceCalculatorService = $this->createMock(BookingPriceCalculatorService::class);
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
|
||||
$this->provider = new ParticipantFieldOptionsProvider(
|
||||
$serviceAvailabilityCalculator,
|
||||
$insuranceService,
|
||||
$priceCalculatorService,
|
||||
$translator
|
||||
);
|
||||
}
|
||||
|
||||
public function testMandatoryServiceSelectedHasReadonlyAndTooltip(): void
|
||||
{
|
||||
$mandatoryService = $this->createMandatoryService(1, 'Ortstaxe');
|
||||
$travel = $this->createTravelWithAdditionalServices([$mandatoryService]);
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
|
||||
$participant = new ParticipantDto();
|
||||
$participant->index = 0;
|
||||
$participant->dateOfBirth = $travel->dateFrom->modify('-25 years');
|
||||
$participant->additionalServices = [$mandatoryService];
|
||||
$bookingDto->participants[0] = $participant;
|
||||
|
||||
$options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0);
|
||||
$choiceAttr = $options['choice_attr'];
|
||||
$attributes = $choiceAttr($mandatoryService);
|
||||
|
||||
$this->assertTrue($attributes['readonly']);
|
||||
$this->assertSame('Diese Leistung ist nicht abwählbar', $attributes['data-tooltip']);
|
||||
$this->assertArrayNotHasKey('checked', $attributes);
|
||||
}
|
||||
|
||||
public function testMandatoryServiceNotSelectedHasNoReadonlyOrChecked(): void
|
||||
{
|
||||
$mandatoryService = $this->createMandatoryService(1, 'Ortstaxe');
|
||||
$travel = $this->createTravelWithAdditionalServices([$mandatoryService]);
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
|
||||
$participant = new ParticipantDto();
|
||||
$participant->index = 0;
|
||||
$participant->dateOfBirth = $travel->dateFrom->modify('-25 years');
|
||||
$participant->additionalServices = [];
|
||||
$bookingDto->participants[0] = $participant;
|
||||
|
||||
$options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0);
|
||||
$choiceAttr = $options['choice_attr'];
|
||||
$attributes = $choiceAttr($mandatoryService);
|
||||
|
||||
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||
$this->assertArrayNotHasKey('checked', $attributes);
|
||||
$this->assertArrayNotHasKey('data-tooltip', $attributes);
|
||||
}
|
||||
|
||||
public function testNonMandatoryServiceUnaffected(): void
|
||||
{
|
||||
$nonMandatoryService = new Service();
|
||||
$nonMandatoryService->id = 2;
|
||||
$nonMandatoryService->label = 'Keycard-Pfand';
|
||||
$nonMandatoryService->subType = Constants::TOKEN_ADDITIONAL;
|
||||
$nonMandatoryService->available = 10;
|
||||
$nonMandatoryService->price = 10.0;
|
||||
$nonMandatoryService->mandatory = false;
|
||||
|
||||
$travel = $this->createTravelWithAdditionalServices([$nonMandatoryService]);
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
|
||||
$participant = new ParticipantDto();
|
||||
$participant->index = 0;
|
||||
$participant->dateOfBirth = $travel->dateFrom->modify('-25 years');
|
||||
$participant->additionalServices = [$nonMandatoryService];
|
||||
$bookingDto->participants[0] = $participant;
|
||||
|
||||
$options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0);
|
||||
$choiceAttr = $options['choice_attr'];
|
||||
$attributes = $choiceAttr($nonMandatoryService);
|
||||
|
||||
$this->assertArrayNotHasKey('readonly', $attributes);
|
||||
$this->assertArrayNotHasKey('checked', $attributes);
|
||||
}
|
||||
|
||||
public function testNullParticipantReturnsEmptyOptions(): void
|
||||
{
|
||||
$mandatoryService = $this->createMandatoryService(1, 'Ortstaxe');
|
||||
$travel = $this->createTravelWithAdditionalServices([$mandatoryService]);
|
||||
$bookingDto = new BookingDto($travel, 1);
|
||||
|
||||
// Do not set participant — age filtering cannot determine choices, returns empty
|
||||
$options = $this->provider->getFieldOptions('additionalServices', $bookingDto, 0);
|
||||
|
||||
$this->assertEmpty($options);
|
||||
}
|
||||
|
||||
private function createMandatoryService(int $id, string $label): Service
|
||||
{
|
||||
$service = new Service();
|
||||
$service->id = $id;
|
||||
$service->label = $label;
|
||||
$service->subType = Constants::TOKEN_ADDITIONAL;
|
||||
$service->available = 10;
|
||||
$service->price = 5.0;
|
||||
$service->mandatory = true;
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
private function createTravelWithAdditionalServices(array $services): Travel
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->dateFrom = new \DateTimeImmutable('+30 days');
|
||||
$travel->dateTo = new \DateTimeImmutable('+37 days');
|
||||
|
||||
$indexed = [];
|
||||
foreach ($services as $service) {
|
||||
$indexed[$service->id] = $service;
|
||||
}
|
||||
$travel->additionalServices = $indexed;
|
||||
|
||||
return $travel;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user