fix: incorrect rules around family insurances

addresses #869dr80qj
This commit is contained in:
Björn Fromme
2026-08-05 15:11:37 +02:00
parent 736128476b
commit 8a46908856
15 changed files with 859 additions and 50 deletions
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service\Condition;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Condition\FamilyInsuranceActiveCondition;
use PHPUnit\Framework\TestCase;
class FamilyInsuranceActiveConditionTest extends TestCase
{
private FamilyInsuranceActiveCondition $condition;
protected function setUp(): void
{
$this->condition = new FamilyInsuranceActiveCondition();
}
public function testAlwaysFalseForApplicant(): void
{
$applicant = new ParticipantDto();
$applicant->index = 0;
$applicant->insurance = $this->createFamilyInsurance();
$bookingDto = new BookingDto(new Travel(), 1);
$bookingDto->participants = [$applicant];
$result = $this->condition->evaluate($bookingDto, 0, []);
$this->assertFalse($result, 'Applicant is never affected by their own family insurance selection');
}
public function testTrueForDependentWhenApplicantHasFamilyInsurance(): void
{
$applicant = new ParticipantDto();
$applicant->index = 0;
$applicant->insurance = $this->createFamilyInsurance();
$dependent = new ParticipantDto();
$dependent->index = 1;
$bookingDto = new BookingDto(new Travel(), 1);
$bookingDto->participants = [$applicant, $dependent];
$result = $this->condition->evaluate($bookingDto, 1, []);
$this->assertTrue($result, 'Dependent insurance field must be hidden when applicant has family insurance');
}
public function testFalseForDependentWhenApplicantHasNonFamilyInsurance(): void
{
$nonFamilyInsurance = new Insurance();
$nonFamilyInsurance->familyInsurance = false;
$applicant = new ParticipantDto();
$applicant->index = 0;
$applicant->insurance = $nonFamilyInsurance;
$dependent = new ParticipantDto();
$dependent->index = 1;
$bookingDto = new BookingDto(new Travel(), 1);
$bookingDto->participants = [$applicant, $dependent];
$result = $this->condition->evaluate($bookingDto, 1, []);
$this->assertFalse($result, 'Dependent should keep their own insurance choice for non-family insurance');
}
public function testFalseForDependentWhenApplicantHasNoInsurance(): void
{
$applicant = new ParticipantDto();
$applicant->index = 0;
$dependent = new ParticipantDto();
$dependent->index = 1;
$bookingDto = new BookingDto(new Travel(), 1);
$bookingDto->participants = [$applicant, $dependent];
$result = $this->condition->evaluate($bookingDto, 1, []);
$this->assertFalse($result);
}
public function testGetDependentFieldsReturnsInsurance(): void
{
$result = $this->condition->getDependentFields();
$this->assertSame(['insurance'], $result);
}
public function testGetDescriptionReturnsString(): void
{
$result = $this->condition->getDescription();
$this->assertIsString($result);
$this->assertStringContainsString('family insurance', $result);
}
private function createFamilyInsurance(): Insurance
{
$insurance = new Insurance();
$insurance->familyInsurance = true;
return $insurance;
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\CreateFieldStateProvider;
use App\Service\ParticipantEligibilityChecker;
use PHPUnit\Framework\TestCase;
/**
* Integration-level coverage for cross-participant field-state re-evaluation: proves the
* dependent's 'insurance' field state actually flips when the applicant's insurance
* selection changes, wiring FamilyInsuranceActiveCondition through the real
* CreateFieldStateProvider composition rather than testing the condition in isolation.
*/
class CreateFieldStateProviderTest extends TestCase
{
private CreateFieldStateProvider $provider;
protected function setUp(): void
{
$this->provider = new CreateFieldStateProvider(new ParticipantEligibilityChecker());
}
public function testDependentInsuranceFieldHidesWhenApplicantSelectsFamilyInsuranceThenReappearsAfterSwitch(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('+30 days');
$travel->dateTo = new \DateTimeImmutable('+37 days');
$applicant = new ParticipantDto();
$applicant->index = 0;
// Baby age (0-2 years at travel date) is always eligible regardless of skipass availability
$applicant->dateOfBirth = $travel->dateFrom->modify('-1 year');
$dependent = new ParticipantDto();
$dependent->index = 1;
$dependent->dateOfBirth = $travel->dateFrom->modify('-1 year');
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$applicant, $dependent];
$this->assertTrue(
$this->provider->shouldIncludeField('insurance', $bookingDto, 1),
'Dependent should see their own insurance field before the applicant selects family insurance'
);
$familyInsurance = new Insurance();
$familyInsurance->familyInsurance = true;
$applicant->insurance = $familyInsurance;
$this->assertFalse(
$this->provider->shouldIncludeField('insurance', $bookingDto, 1),
'Dependent insurance field must be hidden once the applicant selects family insurance'
);
$nonFamilyInsurance = new Insurance();
$nonFamilyInsurance->familyInsurance = false;
$applicant->insurance = $nonFamilyInsurance;
$this->assertTrue(
$this->provider->shouldIncludeField('insurance', $bookingDto, 1),
'Dependent insurance field must reappear once the applicant switches away from family insurance'
);
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantFieldOptionsProvider;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
use App\Service\ServiceAvailabilityCalculator;
use App\Service\ServiceLabelFormatter;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Regression tests for the family-insurance choices-list bug: the applicant selects a
* family insurance, it's correctly reassigned to the total-price tier on submit, but
* the re-rendered form showed no selection because the choices list was still filtered
* by the applicant's individual price instead of the total booking price.
*/
class ParticipantFieldOptionsProviderInsuranceTest extends TestCase
{
private ParticipantFieldOptionsProvider $provider;
private BookingPriceCalculator $priceCalculatorService;
protected function setUp(): void
{
$serviceAvailabilityCalculator = $this->createMock(ServiceAvailabilityCalculator::class);
// InsuranceManager is stateless - use the real implementation so actual
// eligibility/price-tier filtering runs, not a stubbed-out mock.
$insuranceService = new InsuranceManager();
$this->priceCalculatorService = $this->createMock(BookingPriceCalculator::class);
$serviceLabelFormatter = new ServiceLabelFormatter();
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnCallback(fn (string $message) => $message);
$this->provider = new ParticipantFieldOptionsProvider(
$serviceAvailabilityCalculator,
$insuranceService,
$this->priceCalculatorService,
$serviceLabelFormatter,
$translator
);
}
public function testFamilyInsuranceCorrectTierAppearsInApplicantChoicesUsingTotalPrice(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
// Low tier only matches the applicant's individual price (300), not the total (450)
$lowTierFamilyInsurance = new Insurance();
$lowTierFamilyInsurance->id = '1';
$lowTierFamilyInsurance->label = 'Reise-Rücktritt Familie';
$lowTierFamilyInsurance->familyInsurance = true;
$lowTierFamilyInsurance->travelPriceFrom = 0.0;
$lowTierFamilyInsurance->travelPriceTo = 400.0;
// Correct tier matches the total booking price (450), not the applicant's individual price (300)
$correctTierFamilyInsurance = new Insurance();
$correctTierFamilyInsurance->id = '2';
$correctTierFamilyInsurance->label = 'Reise-Rücktritt Familie';
$correctTierFamilyInsurance->familyInsurance = true;
$correctTierFamilyInsurance->travelPriceFrom = 400.01;
$correctTierFamilyInsurance->travelPriceTo = 600.0;
$travel->insurances = [$lowTierFamilyInsurance, $correctTierFamilyInsurance];
$applicant = new ParticipantDto();
$applicant->index = 0;
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
$child = new ParticipantDto();
$child->index = 1;
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$applicant, $child];
$this->priceCalculatorService
->method('calculateIndividualParticipantPriceExcludingInsurance')
->willReturn(300.0);
$this->priceCalculatorService
->method('calculateTotalBookingPriceExcludingInsurance')
->willReturn(450.0);
$options = $this->provider->getFieldOptions('insurance', $bookingDto, 0);
$choiceIds = array_map(fn (Insurance $insurance) => $insurance->id, $options['choices']);
$this->assertContains('2', $choiceIds, 'Family insurance tier matching the total booking price must be selectable');
$this->assertNotContains('1', $choiceIds, 'Family insurance tier that only matches the individual price must not be selectable');
}
public function testFamilyInsuranceNeverAppearsInDependentChoices(): void
{
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
$familyInsurance = new Insurance();
$familyInsurance->id = '1';
$familyInsurance->label = 'Reise-Rücktritt Familie';
$familyInsurance->familyInsurance = true;
$travel->insurances = [$familyInsurance];
$applicant = new ParticipantDto();
$applicant->index = 0;
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
$child = new ParticipantDto();
$child->index = 1;
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$applicant, $child];
$this->priceCalculatorService
->method('calculateIndividualParticipantPriceExcludingInsurance')
->willReturn(0.0);
$this->priceCalculatorService
->method('calculateTotalBookingPriceExcludingInsurance')
->willReturn(450.0);
$options = $this->provider->getFieldOptions('insurance', $bookingDto, 1);
$choiceIds = array_map(fn (Insurance $insurance) => $insurance->id, $options['choices']);
$this->assertNotContains('1', $choiceIds, 'Family insurance must never be selectable for a non-applicant participant');
}
}
@@ -31,6 +31,8 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
// Mock price calculator to return a default price
$this->priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')
->willReturn(500.0);
$this->priceCalculatorService->method('resolveInsuranceTravelPrice')
->willReturn(500.0);
$this->handler = new ParticipantInsuranceFieldHandler($this->insuranceService, $this->priceCalculatorService);
}