348 lines
14 KiB
PHP
348 lines
14 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Service;
|
||
|
||
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;
|
||
use App\Service\ParticipantPricingCalculator;
|
||
use App\Service\RoomPricingCalculator;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
class BookingPricingAssemblerTest extends TestCase
|
||
{
|
||
private BookingPricingAssembler $assembler;
|
||
private ParticipantEligibilityChecker $eligibilityChecker;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
$this->eligibilityChecker = $this->createMock(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->createMock(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;
|
||
}
|
||
}
|