useInsuranceService($this->createStub(InsuranceManager::class)); $this->priceCalculatorService = $this->createStub(BookingPriceCalculator::class); // Price calculator returns a default price $this->priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance') ->willReturn(500.0); $this->priceCalculatorService->method('resolveInsuranceTravelPrice') ->willReturn(500.0); } /** * Installs the insurance service every test runs against, applying the * getSelectableInsurances() pass-through they all rely on. Tests that set * expectations pass a mock in here instead of the setUp() stub. */ private function useInsuranceService(InsuranceManager&Stub $insuranceService): void { $insuranceService->method('getSelectableInsurances') ->willReturnCallback(fn (Travel $travel) => $travel->insurances ?? []); $this->insuranceService = $insuranceService; } private function handler(): ParticipantInsuranceFieldHandler { // Real checker over the doubled collaborators - the selection-time recording below is // only meaningful against the actual "is a family insurance available" definition return $this->handler ??= new ParticipantInsuranceFieldHandler( $this->insuranceService, $this->priceCalculatorService, new FamilyInsuranceAvailabilityChecker($this->insuranceService, $this->priceCalculatorService), ); } public function testGetFieldName(): void { $this->assertEquals('insurance', $this->handler()->getFieldName()); } public function testGetDependencies(): void { $dependencies = $this->handler()->getDependencies(); // Insurance handler depends on all price-affecting fields to ensure accurate reassignment $expectedDependencies = [ 'dateOfBirth', 'skiPass', 'rentals', 'courses', 'additionalServices', 'board', 'transportationOutbound', 'transportationInbound', 'pickup', 'parking', ]; $this->assertEquals($expectedDependencies, $dependencies); } public function testShouldProcessReturnsTrueInCreateMode(): void { $result = $this->handler()->shouldProcess([], BookingDto::MODE_CREATE, 0); $this->assertTrue($result); } public function testShouldProcessReturnsFalseInEditMode(): void { $result = $this->handler()->shouldProcess([], BookingDto::MODE_EDIT, 0); $this->assertFalse($result, 'Insurance handler should not process in edit mode as API does not return insurance data'); } public function testProcessFieldSetsInsuranceToNullWhenNoParticipant(): void { $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn(null); $this->handler()->processField(['insurance' => '123'], $bookingDto, 0); // No assertions needed - just ensure no exceptions are thrown $this->addToAssertionCount(1); } public function testProcessFieldClearsInsuranceWhenNullSelection(): void { $participant = new ParticipantDto(); $participant->insurance = $this->createInsurance('123'); $travel = new Travel(); $travel->insurances = []; $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->handler()->processField(['insurance' => null], $bookingDto, 0); $this->assertNull($participant->insurance); } public function testProcessFieldClearsInsuranceWhenEmptyStringSelection(): void { $participant = new ParticipantDto(); $participant->insurance = $this->createInsurance('123'); $travel = new Travel(); $travel->insurances = []; $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->handler()->processField(['insurance' => ''], $bookingDto, 0); $this->assertNull($participant->insurance); } public function testProcessFieldClearsInsuranceWhenMissingFromData(): void { $participant = new ParticipantDto(); $participant->insurance = $this->createInsurance('123'); $travel = new Travel(); $travel->insurances = []; $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->handler()->processField([], $bookingDto, 0); $this->assertNull($participant->insurance); } public function testProcessFieldClearsInsuranceWhenNotFoundInAvailableInsurances(): void { $participant = new ParticipantDto(); $travel = new Travel(); $travel->insurances = [$this->createInsurance('456')]; // Different ID $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->handler()->processField(['insurance' => '123'], $bookingDto, 0); $this->assertNull($participant->insurance); } public function testProcessFieldSetsInsuranceWhenEligible(): void { $this->useInsuranceService($this->createMock(InsuranceManager::class)); $insurance = $this->createInsurance('123'); $participant = new ParticipantDto(); $travel = new Travel(); $travel->insurances = [$insurance]; $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->insuranceService ->expects($this->once()) ->method('getEligibleInsurances') ->with([$insurance], $participant, $bookingDto, 500.0) ->willReturn([$insurance]); $this->handler()->processField(['insurance' => '123'], $bookingDto, 0); $this->assertSame($insurance, $participant->insurance); } public function testProcessFieldClearsInsuranceWhenNotEligible(): void { $this->useInsuranceService($this->createMock(InsuranceManager::class)); $insurance = $this->createInsurance('123'); $participant = new ParticipantDto(); $travel = new Travel(); $travel->insurances = [$insurance]; $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->insuranceService ->expects($this->once()) ->method('getEligibleInsurances') ->with([$insurance], $participant, $bookingDto, 500.0) ->willReturn([]); // Not eligible $this->handler()->processField(['insurance' => '123'], $bookingDto, 0); $this->assertNull($participant->insurance); } public function testProcessFieldSwitchingToNewInsuranceIsNotOverwrittenByStaleResubmissionCheck(): void { $this->useInsuranceService($this->createMock(InsuranceManager::class)); // Regression: switching from one already-selected insurance to a different one must // not be re-validated (and potentially overwritten) against the OLD, now-stale // insurance by the "form resubmission" block afterward. $oldInsurance = $this->createInsurance('1'); $newInsurance = $this->createInsurance('2'); $participant = new ParticipantDto(); $participant->insurance = $oldInsurance; $travel = new Travel(); $travel->insurances = [$oldInsurance, $newInsurance]; $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; // Only the NEW insurance is ever eligible - the old one would fail re-validation // if the (buggy) resubmission block ran a second time after this new selection. $this->insuranceService ->expects($this->once()) ->method('getEligibleInsurances') ->willReturn([$newInsurance]); $this->handler()->processField(['insurance' => '2'], $bookingDto, 0); $this->assertSame($newInsurance, $participant->insurance); } public function testProcessFieldWorksWithStringAndIntegerIds(): void { $this->useInsuranceService($this->createMock(InsuranceManager::class)); $insurance = $this->createInsurance(123); // Integer ID $participant = new ParticipantDto(); $travel = new Travel(); $travel->insurances = [$insurance]; $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->insuranceService ->expects($this->once()) ->method('getEligibleInsurances') ->willReturn([$insurance]); $this->handler()->processField(['insurance' => '123'], $bookingDto, 0); // String selection $this->assertSame($insurance, $participant->insurance); } public function testProcessFieldHandlesEmptyInsurancesArray(): void { $participant = new ParticipantDto(); $travel = new Travel(); $travel->insurances = []; // No insurances available $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->handler()->processField(['insurance' => '123'], $bookingDto, 0); $this->assertNull($participant->insurance); } public function testProcessFieldHandlesNullInsurancesProperty(): void { $participant = new ParticipantDto(); $travel = new Travel(); // $travel->insurances not set, defaults to [] $bookingDto = $this->createMockBookingDto(); $bookingDto->method('getParticipant')->with(0)->willReturn($participant); $bookingDto->travel = $travel; $this->handler()->processField(['insurance' => '123'], $bookingDto, 0); $this->assertNull($participant->insurance); } public function testProcessFieldRecordsIneligibilityWhenApplicantSelectsInsuranceBeforeFamilyEligible(): void { $nonFamilyInsurance = $this->createInsurance('1'); $nonFamilyInsurance->familyInsurance = false; $travel = new Travel(); $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); $travel->dateTo = new \DateTimeImmutable('2025-08-08'); $travel->insurances = [$nonFamilyInsurance]; $applicant = new ParticipantDto(); $applicant->index = 0; $applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); // No dependents yet - not (and cannot be) a family booking at selection time $bookingDto = new BookingDto($travel, 1); $bookingDto->participants = [$applicant]; $this->insuranceService->method('getEligibleInsurances')->willReturn([$nonFamilyInsurance]); $this->handler()->processField(['insurance' => '1'], $bookingDto, 0); $this->assertTrue( $bookingDto->applicantInsuranceChosenWhileFamilyIneligible, 'Choosing non-family insurance while ineligible must be recorded so a later hint is justified' ); } public function testProcessFieldDoesNotRecordIneligibilityWhenApplicantSelectsInsuranceWhileAlreadyFamilyEligible(): void { $nonFamilyInsurance = $this->createInsurance('1'); $nonFamilyInsurance->familyInsurance = false; $familyInsurance = $this->createInsurance('2'); $familyInsurance->familyInsurance = true; $travel = new Travel(); $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); $travel->dateTo = new \DateTimeImmutable('2025-08-08'); $travel->insurances = [$nonFamilyInsurance, $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'); // Dependent's date of birth is already known, and a family insurance product is // actually eligible at the current price - family insurance was genuinely // available at the moment the applicant deliberately chose a non-family insurance. $bookingDto = new BookingDto($travel, 1); $bookingDto->participants = [$applicant, $child]; $this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0); $this->insuranceService->method('getEligibleInsurances')->willReturnCallback( fn (array $insurances) => $insurances ); $this->handler()->processField(['insurance' => '1'], $bookingDto, 0); $this->assertFalse( $bookingDto->applicantInsuranceChosenWhileFamilyIneligible, 'Must not claim family insurance "just became available" when it was already eligible at selection time' ); } public function testProcessFieldRecordsIneligibilityWhenFamilyCompositionExistsButNoPriceTierMatches(): void { // Regression: family composition (adult + child) alone isn't enough - a family // insurance product must actually match the current total price for it to count // as "eligible". If it doesn't, the applicant's choice was genuinely made while // family insurance was NOT selectable, even though isFamilyBooking() is already true. $nonFamilyInsurance = $this->createInsurance('1'); $nonFamilyInsurance->familyInsurance = false; $familyInsurance = $this->createInsurance('2'); $familyInsurance->familyInsurance = true; $travel = new Travel(); $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); $travel->dateTo = new \DateTimeImmutable('2025-08-08'); $travel->insurances = [$nonFamilyInsurance, $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('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0); $this->insuranceService->method('getEligibleInsurances')->willReturnCallback( // No family insurance matches the current price tier, even though the // participant composition already qualifies as a family booking. fn (array $insurances) => array_values(array_filter($insurances, fn ($i) => false === $i->familyInsurance)) ); $this->handler()->processField(['insurance' => '1'], $bookingDto, 0); $this->assertTrue( $bookingDto->applicantInsuranceChosenWhileFamilyIneligible, 'Family composition alone must not count as eligible - no family insurance actually matched the price' ); } public function testGetFieldStateModificationsReturnsEmptyArray(): void { $bookingDto = $this->createStub(BookingDto::class); $result = $this->handler()->getFieldStateModifications([], $bookingDto, 0); $this->assertIsArray($result); $this->assertEmpty($result); } public function testGetAffectedFieldNamesReturnsEmptyArray(): void { $result = $this->handler()->getAffectedFieldNames(); $this->assertIsArray($result); $this->assertEmpty($result); } private function createInsurance(string|int $id, string $label = 'Test Insurance'): Insurance { $insurance = new Insurance(); $insurance->id = (string) $id; $insurance->label = $label; return $insurance; } private function createMockBookingDto(): BookingDto { return $this->createMock(BookingDto::class); } }