eligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class); $this->eligibilityChecker->method('isParticipantEligible')->willReturn(true); $this->assembler = $this->createAssembler($this->eligibilityChecker); } public function testServicePricingWithoutBulkInsurance(): void { $travel = new Travel(); $travel->insurances = []; $insurance1 = $this->createInsurance(1, 'Insurance A', 50.0); $insurance2 = $this->createInsurance(2, 'Insurance B', 75.0); $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insurance1; $participant1->bulkInsuranceBooking = false; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = $insurance2; $bookingDto = new BookingDto($travel, 2); $bookingDto->participants = [$participant1, $participant2]; $result = $this->assembler->calculateServicePricing($bookingDto); $this->assertNotEmpty($result); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertCount(2, $insuranceGroup['services'], 'Should have 2 different insurance line items'); } public function testServicePricingWithBulkInsuranceSamePriceTier(): void { $insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV'); $insurance->travelPriceFrom = 0.0; $insurance->travelPriceTo = 10000.0; $travel = new Travel(); $travel->insurances = [$insurance]; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insurance; $participant1->bulkInsuranceBooking = true; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = null; $participant3 = new ParticipantDto(); $participant3->index = 2; $participant3->insurance = null; $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 $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item'); $this->assertEquals(3, $insuranceGroup['services'][0]['participantCount'], 'Should count all 3 participants'); $this->assertEquals(150.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 3 × €50'); } public function testServicePricingWithBulkInsuranceShowsPriceTierAdjustment(): void { $insuranceTier1 = $this->createInsurance(1, 'Reise-Rücktritt', 30.0, 'RRV'); $insuranceTier1->travelPriceFrom = 0.0; $insuranceTier1->travelPriceTo = 500.0; $insuranceTier2 = $this->createInsurance(2, 'Reise-Rücktritt', 50.0, 'RRV'); $insuranceTier2->travelPriceFrom = 501.0; $insuranceTier2->travelPriceTo = 1000.0; $travel = new Travel(); $travel->insurances = [$insuranceTier1, $insuranceTier2]; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insuranceTier2; $participant1->bulkInsuranceBooking = true; $bookingDto = new BookingDto($travel, 1); $bookingDto->participants = [$participant1]; $result = $this->assembler->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count applicant'); } public function testServicePricingWithBulkInsuranceWhenNoBulkEnabled(): void { $insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV'); $travel = new Travel(); $travel->insurances = [$insurance]; $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insurance; $participant1->bulkInsuranceBooking = false; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = null; $bookingDto = new BookingDto($travel, 2); $bookingDto->participants = [$participant1, $participant2]; $result = $this->assembler->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item'); $this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant'); $this->assertEquals(50.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 1 × €50'); } public function testServicePricingWithIneligibleParticipant(): void { $insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV'); $travel = new Travel(); $travel->insurances = [$insurance]; $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insurance; $participant1->bulkInsuranceBooking = false; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = null; $bookingDto = new BookingDto($travel, 2); $bookingDto->participants = [$participant1, $participant2]; $eligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class); $eligibilityChecker->method('isParticipantEligible') ->willReturnCallback(fn ($booking, int $index): bool => 0 === $index); $result = $this->createAssembler($eligibilityChecker)->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item'); $this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant'); } public function testServicePricingWithBulkInsuranceCountsAllParticipants(): void { $insuranceA = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV'); $travel = new Travel(); $travel->insurances = [$insuranceA]; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insuranceA; $participant1->bulkInsuranceBooking = true; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = null; $participant3 = new ParticipantDto(); $participant3->index = 2; $participant3->insurance = null; $bookingDto = new BookingDto($travel, 3); $bookingDto->participants = [$participant1, $participant2, $participant3]; $this->createCascade()->apply($bookingDto); $result = $this->assembler->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $totalParticipants = array_sum(array_column($insuranceGroup['services'], 'participantCount')); $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(); return new BookingPricingAssembler( $roomPricingCalculator, $eligibilityChecker, ); } private function createInsurance(int $id, string $label, float $price, string $subType = 'RRV'): Insurance { $insurance = new Insurance(); $insurance->id = (string) $id; $insurance->label = $label; $insurance->price = $price; $insurance->subType = $subType; $insurance->package = false; $insurance->complementary = false; return $insurance; } private function findServiceGroup(array $groups, string $groupName): ?array { foreach ($groups as $group) { if ($group['groupName'] === $groupName) { return $group; } } return null; } }