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->createStub(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 $assignments * @param array $selectable */ private function createCascade(array $assignments, array $selectable): ApplicantInsuranceCascade { $insuranceService = $this->createStub(InsuranceManager::class); $insuranceService->method('getSelectableInsurances')->willReturn($selectable); $insuranceService->method('batchAssignInsuranceToParticipants')->willReturn($assignments); $priceCalculator = $this->createStub(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 $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; } }