fix: resolve stale dependent selections after booking family insurance
This commit is contained in:
@@ -21,6 +21,7 @@ use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -63,6 +64,7 @@ class BookingDataProcessorTest extends TestCase
|
||||
$serviceProcessor,
|
||||
$payloadBuilder,
|
||||
$personalDataSynchronizer,
|
||||
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -876,6 +878,7 @@ class BookingDataProcessorTest extends TestCase
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
$payloadBuilder,
|
||||
new PersonalDataSynchronizer(),
|
||||
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||
);
|
||||
|
||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||
@@ -925,6 +928,7 @@ class BookingDataProcessorTest extends TestCase
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
new BookingPayloadBuilder(new ServiceMappingCollector()),
|
||||
new PersonalDataSynchronizer(),
|
||||
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||
);
|
||||
|
||||
$processor->createUpdateRequestPayload($formData);
|
||||
@@ -978,6 +982,7 @@ class BookingDataProcessorTest extends TestCase
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
$payloadBuilder,
|
||||
new PersonalDataSynchronizer(),
|
||||
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||
);
|
||||
|
||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||
@@ -1024,6 +1029,7 @@ class BookingDataProcessorTest extends TestCase
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
$payloadBuilder,
|
||||
new PersonalDataSynchronizer(),
|
||||
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||
);
|
||||
|
||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||
@@ -1069,6 +1075,7 @@ class BookingDataProcessorTest extends TestCase
|
||||
new ParticipantServiceProcessor(new NullLogger()),
|
||||
$payloadBuilder,
|
||||
new PersonalDataSynchronizer(),
|
||||
new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService),
|
||||
);
|
||||
|
||||
$processor->createBookingRequestPayload($bookingDto, 'ANFRAGE');
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Booking\Edit;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\Controller\Booking\Edit\ParticipantController;
|
||||
use App\Entity\User;
|
||||
use App\Form\BookingParticipantType;
|
||||
@@ -15,6 +15,8 @@ use App\Form\Model\BookingEditContext;
|
||||
use App\Form\Model\BookingSummaryDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Form\Model\ParticipantEditDto;
|
||||
use App\Security\Crypt;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingConfigurator;
|
||||
use App\Service\BookingEditContextFactory;
|
||||
use App\Service\BookingEditDataLoader;
|
||||
@@ -22,7 +24,6 @@ use App\Service\BookingEditDraftManager;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\ParticipantDataPrefiller;
|
||||
use App\Service\ParticipantFormSupport;
|
||||
use App\Security\Crypt;
|
||||
use Carbon\CarbonImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -133,6 +134,7 @@ class ParticipantControllerTest extends TestCase
|
||||
$bookingSessionService,
|
||||
$prepopulationService,
|
||||
$participantFormSupportService,
|
||||
$this->createMock(ApplicantInsuranceCascade::class),
|
||||
$user,
|
||||
$form,
|
||||
);
|
||||
@@ -241,6 +243,7 @@ class ParticipantControllerTest extends TestCase
|
||||
$bookingSessionService,
|
||||
$prepopulationService,
|
||||
$participantFormSupportService,
|
||||
$this->createMock(ApplicantInsuranceCascade::class),
|
||||
$user,
|
||||
$form,
|
||||
);
|
||||
@@ -317,6 +320,7 @@ final class TestableParticipantController extends ParticipantController
|
||||
BookingSessionManager $bookingSessionService,
|
||||
ParticipantDataPrefiller $prepopulationService,
|
||||
ParticipantFormSupport $participantFormSupportService,
|
||||
ApplicantInsuranceCascade $applicantInsuranceCascade,
|
||||
private readonly User $user,
|
||||
FormInterface $form,
|
||||
) {
|
||||
@@ -330,6 +334,7 @@ final class TestableParticipantController extends ParticipantController
|
||||
$bookingSessionService,
|
||||
$prepopulationService,
|
||||
$participantFormSupportService,
|
||||
$applicantInsuranceCascade,
|
||||
new NullLogger(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\InsuranceManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ApplicantInsuranceCascadeTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Regression for the production report: an adult and two children each picked their own
|
||||
* insurance, then the applicant switched to the family insurance that had become available.
|
||||
* The dependents' own selections used to survive in the session DTO until submit, which is
|
||||
* what left the sidebar summary listing two superseded policies.
|
||||
*/
|
||||
public function testFamilyInsuranceClearsDependentsOwnSelections(): void
|
||||
{
|
||||
$familyInsurance = $this->createInsurance('family-1', true);
|
||||
$childInsuranceA = $this->createInsurance('child-a', false);
|
||||
$childInsuranceB = $this->createInsurance('child-b', false);
|
||||
|
||||
$bookingDto = $this->createBooking([
|
||||
$this->createParticipant(0, $familyInsurance),
|
||||
$this->createParticipant(1, $childInsuranceA),
|
||||
$this->createParticipant(2, $childInsuranceB),
|
||||
]);
|
||||
|
||||
$cascade = $this->createCascade([
|
||||
0 => $familyInsurance,
|
||||
1 => null,
|
||||
2 => null,
|
||||
], [$familyInsurance, $childInsuranceA, $childInsuranceB]);
|
||||
|
||||
$cascade->apply($bookingDto);
|
||||
|
||||
$this->assertSame($familyInsurance, $bookingDto->participants[0]->insurance);
|
||||
$this->assertNull($bookingDto->participants[1]->insurance, 'First child must lose their own policy');
|
||||
$this->assertNull($bookingDto->participants[2]->insurance, 'Second child must lose their own policy');
|
||||
}
|
||||
|
||||
/**
|
||||
* The cascade runs on every request, so applying it repeatedly must converge rather than
|
||||
* drift - otherwise a simple page refresh could change the booking.
|
||||
*/
|
||||
public function testApplyingTwiceIsIdempotent(): void
|
||||
{
|
||||
$familyInsurance = $this->createInsurance('family-1', true);
|
||||
$childInsurance = $this->createInsurance('child-a', false);
|
||||
|
||||
$bookingDto = $this->createBooking([
|
||||
$this->createParticipant(0, $familyInsurance),
|
||||
$this->createParticipant(1, $childInsurance),
|
||||
]);
|
||||
|
||||
$cascade = $this->createCascade([
|
||||
0 => $familyInsurance,
|
||||
1 => null,
|
||||
], [$familyInsurance, $childInsurance]);
|
||||
|
||||
$cascade->apply($bookingDto);
|
||||
$cascade->apply($bookingDto);
|
||||
|
||||
$this->assertSame($familyInsurance, $bookingDto->participants[0]->insurance);
|
||||
$this->assertNull($bookingDto->participants[1]->insurance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Family insurance propagates on its own merit - the bulk checkbox is irrelevant to it.
|
||||
*/
|
||||
public function testFamilyInsurancePropagatesWithoutBulkCheckbox(): void
|
||||
{
|
||||
$familyInsurance = $this->createInsurance('family-1', true);
|
||||
$childInsurance = $this->createInsurance('child-a', false);
|
||||
|
||||
$applicant = $this->createParticipant(0, $familyInsurance);
|
||||
$applicant->bulkInsuranceBooking = false;
|
||||
|
||||
$bookingDto = $this->createBooking([$applicant, $this->createParticipant(1, $childInsurance)]);
|
||||
|
||||
$cascade = $this->createCascade([0 => $familyInsurance, 1 => null], [$familyInsurance, $childInsurance]);
|
||||
$cascade->apply($bookingDto);
|
||||
|
||||
$this->assertNull($bookingDto->participants[1]->insurance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk booking assigns the applicant's insurance type to dependents, re-tiered per
|
||||
* participant by InsuranceManager.
|
||||
*/
|
||||
public function testBulkInsuranceAssignsTierToDependents(): void
|
||||
{
|
||||
$applicantInsurance = $this->createInsurance('tier-high', false);
|
||||
$dependentTier = $this->createInsurance('tier-low', false);
|
||||
|
||||
$applicant = $this->createParticipant(0, $applicantInsurance);
|
||||
$applicant->bulkInsuranceBooking = true;
|
||||
|
||||
$dependent = $this->createParticipant(1, null);
|
||||
|
||||
$bookingDto = $this->createBooking([$applicant, $dependent]);
|
||||
|
||||
$cascade = $this->createCascade([
|
||||
0 => $applicantInsurance,
|
||||
1 => $dependentTier,
|
||||
], [$applicantInsurance, $dependentTier]);
|
||||
|
||||
$cascade->apply($bookingDto);
|
||||
|
||||
$this->assertSame($applicantInsurance, $bookingDto->participants[0]->insurance, 'Applicant keeps their own choice');
|
||||
$this->assertSame($dependentTier, $bookingDto->participants[1]->insurance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neither bulk nor family: every participant keeps whatever they chose individually.
|
||||
*/
|
||||
public function testNoPropagationWithoutBulkOrFamily(): void
|
||||
{
|
||||
$applicantInsurance = $this->createInsurance('own-a', false);
|
||||
$dependentInsurance = $this->createInsurance('own-b', false);
|
||||
|
||||
$bookingDto = $this->createBooking([
|
||||
$this->createParticipant(0, $applicantInsurance),
|
||||
$this->createParticipant(1, $dependentInsurance),
|
||||
]);
|
||||
|
||||
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||
$insuranceService->expects($this->never())->method('batchAssignInsuranceToParticipants');
|
||||
|
||||
$cascade = new ApplicantInsuranceCascade(
|
||||
$insuranceService,
|
||||
$this->createMock(BookingPriceCalculator::class)
|
||||
);
|
||||
|
||||
$cascade->apply($bookingDto);
|
||||
|
||||
$this->assertSame($applicantInsurance, $bookingDto->participants[0]->insurance);
|
||||
$this->assertSame($dependentInsurance, $bookingDto->participants[1]->insurance);
|
||||
}
|
||||
|
||||
/**
|
||||
* In edit mode a dependent's individually selected, locked-in insurance must survive a
|
||||
* non-family bulk assignment.
|
||||
*/
|
||||
public function testEditModeKeepsIndividuallySelectedInsuranceForNonFamilyBulk(): void
|
||||
{
|
||||
$applicantInsurance = $this->createInsurance('tier-high', false);
|
||||
$dependentOwn = $this->createInsurance('dependent-own', false);
|
||||
$wouldBeAssigned = $this->createInsurance('tier-low', false);
|
||||
|
||||
$applicant = $this->createParticipant(0, $applicantInsurance);
|
||||
$applicant->bulkInsuranceBooking = true;
|
||||
|
||||
$bookingDto = $this->createBooking([$applicant, $this->createParticipant(1, $dependentOwn)]);
|
||||
$bookingDto->booking = new Booking(); // BookingDto derives MODE_EDIT from a present booking
|
||||
|
||||
$cascade = $this->createCascade([
|
||||
0 => $applicantInsurance,
|
||||
1 => $wouldBeAssigned,
|
||||
], [$applicantInsurance, $dependentOwn, $wouldBeAssigned]);
|
||||
|
||||
$cascade->apply($bookingDto);
|
||||
|
||||
$this->assertSame($dependentOwn, $bookingDto->participants[1]->insurance, 'Locked individual selection must survive');
|
||||
}
|
||||
|
||||
/**
|
||||
* Family insurance is exempt from the edit-mode protection: a dependent can never
|
||||
* legitimately hold their own policy alongside it.
|
||||
*/
|
||||
public function testEditModeFamilyInsuranceStillClearsDependents(): void
|
||||
{
|
||||
$familyInsurance = $this->createInsurance('family-1', true);
|
||||
$dependentOwn = $this->createInsurance('dependent-own', false);
|
||||
|
||||
$bookingDto = $this->createBooking([
|
||||
$this->createParticipant(0, $familyInsurance),
|
||||
$this->createParticipant(1, $dependentOwn),
|
||||
]);
|
||||
$bookingDto->booking = new Booking(); // BookingDto derives MODE_EDIT from a present booking
|
||||
|
||||
$cascade = $this->createCascade([0 => $familyInsurance, 1 => null], [$familyInsurance, $dependentOwn]);
|
||||
$cascade->apply($bookingDto);
|
||||
|
||||
$this->assertNull($bookingDto->participants[1]->insurance);
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
/**
|
||||
* @param array<int, Insurance|null> $assignments
|
||||
* @param array<Insurance> $selectable
|
||||
*/
|
||||
private function createCascade(array $assignments, array $selectable): ApplicantInsuranceCascade
|
||||
{
|
||||
$insuranceService = $this->createMock(InsuranceManager::class);
|
||||
$insuranceService->method('getSelectableInsurances')->willReturn($selectable);
|
||||
$insuranceService->method('batchAssignInsuranceToParticipants')->willReturn($assignments);
|
||||
|
||||
$priceCalculator = $this->createMock(BookingPriceCalculator::class);
|
||||
$priceCalculator->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0);
|
||||
|
||||
return new ApplicantInsuranceCascade($insuranceService, $priceCalculator);
|
||||
}
|
||||
|
||||
private function createInsurance(string $id, bool $familyInsurance): Insurance
|
||||
{
|
||||
$insurance = new Insurance();
|
||||
$insurance->id = $id;
|
||||
$insurance->label = 'Insurance '.$id;
|
||||
$insurance->price = 50.0;
|
||||
$insurance->subType = 'RRV';
|
||||
$insurance->package = false;
|
||||
$insurance->complementary = false;
|
||||
$insurance->familyInsurance = $familyInsurance;
|
||||
|
||||
return $insurance;
|
||||
}
|
||||
|
||||
private function createParticipant(int $index, ?Insurance $insurance): ParticipantDto
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->index = $index;
|
||||
$participant->insurance = $insurance;
|
||||
|
||||
return $participant;
|
||||
}
|
||||
|
||||
/** @param array<ParticipantDto> $participants */
|
||||
private function createBooking(array $participants): BookingDto
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 1;
|
||||
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2026-02-08');
|
||||
|
||||
$bookingDto = new BookingDto($travel, count($participants));
|
||||
$bookingDto->participants = $participants;
|
||||
|
||||
return $bookingDto;
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,6 @@ class BookingPriceCalculatorTest extends TestCase
|
||||
$this->pricingAssembler = new BookingPricingAssembler(
|
||||
$this->roomPricingCalculator,
|
||||
$eligibilityChecker,
|
||||
new InsuranceManager(),
|
||||
$this->participantPricingCalculator,
|
||||
);
|
||||
|
||||
$this->service = new BookingPriceCalculator(
|
||||
|
||||
@@ -8,6 +8,8 @@ use App\BusProNet\Model\Insurance;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\ParticipantDto;
|
||||
use App\Service\ApplicantInsuranceCascade;
|
||||
use App\Service\BookingPriceCalculator;
|
||||
use App\Service\BookingPricingAssembler;
|
||||
use App\Service\InsuranceManager;
|
||||
use App\Service\ParticipantEligibilityChecker;
|
||||
@@ -83,6 +85,10 @@ class BookingPricingAssemblerTest extends TestCase
|
||||
$bookingDto = new BookingDto($travel, 3);
|
||||
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||
|
||||
// Bulk assignment is materialised into the DTO by the cascade; the assembler then
|
||||
// simply reports what each participant holds.
|
||||
$this->createCascade()->apply($bookingDto);
|
||||
|
||||
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||
|
||||
// 3× same insurance should aggregate into one line item
|
||||
@@ -207,6 +213,8 @@ class BookingPricingAssemblerTest extends TestCase
|
||||
$bookingDto = new BookingDto($travel, 3);
|
||||
$bookingDto->participants = [$participant1, $participant2, $participant3];
|
||||
|
||||
$this->createCascade()->apply($bookingDto);
|
||||
|
||||
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||
|
||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||
@@ -216,8 +224,93 @@ class BookingPricingAssemblerTest extends TestCase
|
||||
$this->assertEquals(3, $totalParticipants, 'Should count all 3 participants across all tiers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression for the production report: an adult and two children each picked their own
|
||||
* insurance, then the applicant switched to the family insurance that had become
|
||||
* available. The sidebar kept listing the two children's superseded policies, and the
|
||||
* grand total stayed inflated by their premiums.
|
||||
*
|
||||
* The sidebar reports whatever the DTO holds, so this asserts the end-to-end contract:
|
||||
* once ApplicantInsuranceCascade has run, only the family policy remains to aggregate.
|
||||
*/
|
||||
public function testFamilyInsuranceLeavesOnlyOneInsuranceLineItem(): void
|
||||
{
|
||||
$travel = $this->createFamilyTravel();
|
||||
|
||||
$familyInsurance = $this->createInsurance(1, 'Familienversicherung', 120.0);
|
||||
$familyInsurance->familyInsurance = true;
|
||||
|
||||
$applicant = $this->createParticipantWithAge(0, '1985-06-15');
|
||||
$applicant->insurance = $familyInsurance;
|
||||
$applicant->bulkInsuranceBooking = false;
|
||||
|
||||
$childOne = $this->createParticipantWithAge(1, '2016-01-01');
|
||||
$childOne->insurance = $this->createInsurance(2, 'Insurance A', 50.0);
|
||||
|
||||
$childTwo = $this->createParticipantWithAge(2, '2018-01-01');
|
||||
$childTwo->insurance = $this->createInsurance(3, 'Insurance B', 75.0);
|
||||
|
||||
$travel->insurances = [$familyInsurance];
|
||||
|
||||
$bookingDto = new BookingDto($travel, 3);
|
||||
$bookingDto->participants = [$applicant, $childOne, $childTwo];
|
||||
|
||||
$this->createCascade()->apply($bookingDto);
|
||||
|
||||
$result = $this->assembler->calculateServicePricing($bookingDto);
|
||||
|
||||
$insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen');
|
||||
$this->assertNotNull($insuranceGroup, 'Insurance group should exist');
|
||||
$this->assertCount(
|
||||
1,
|
||||
$insuranceGroup['services'],
|
||||
'Only the family policy may be listed - the children\'s superseded selections must be gone'
|
||||
);
|
||||
|
||||
$lineItem = array_values($insuranceGroup['services'])[0];
|
||||
$this->assertSame('Familienversicherung', $lineItem['label']);
|
||||
$this->assertEquals(1, $lineItem['participantCount'], 'Family policy is held once, by the applicant');
|
||||
$this->assertEqualsWithDelta(120.0, $insuranceGroup['groupTotal'], 0.001, 'Total must not include the children\'s former premiums');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
private function createCascade(): ApplicantInsuranceCascade
|
||||
{
|
||||
$roomPricingCalculator = new RoomPricingCalculator();
|
||||
$participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator);
|
||||
$insuranceManager = new InsuranceManager();
|
||||
|
||||
return new ApplicantInsuranceCascade(
|
||||
$insuranceManager,
|
||||
new BookingPriceCalculator(
|
||||
$roomPricingCalculator,
|
||||
$this->createAssembler($this->eligibilityChecker),
|
||||
$participantPricingCalculator
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private function createFamilyTravel(): Travel
|
||||
{
|
||||
$travel = new Travel();
|
||||
$travel->id = 1;
|
||||
$travel->dateFrom = new \DateTimeImmutable('2026-02-01');
|
||||
$travel->dateTo = new \DateTimeImmutable('2026-02-08');
|
||||
$travel->insurances = [];
|
||||
|
||||
return $travel;
|
||||
}
|
||||
|
||||
private function createParticipantWithAge(int $index, string $dateOfBirth): ParticipantDto
|
||||
{
|
||||
$participant = new ParticipantDto();
|
||||
$participant->index = $index;
|
||||
$participant->dateOfBirth = new \DateTimeImmutable($dateOfBirth);
|
||||
|
||||
return $participant;
|
||||
}
|
||||
|
||||
private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler
|
||||
{
|
||||
$roomPricingCalculator = new RoomPricingCalculator();
|
||||
@@ -225,8 +318,6 @@ class BookingPricingAssemblerTest extends TestCase
|
||||
return new BookingPricingAssembler(
|
||||
$roomPricingCalculator,
|
||||
$eligibilityChecker,
|
||||
new InsuranceManager(),
|
||||
new ParticipantPricingCalculator($roomPricingCalculator),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user