From 0ceabe465b41e45c8fd9794d7cf6e0a9be91a730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 26 Sep 2025 16:34:32 +0200 Subject: [PATCH] wip: insurance booking phases 1 and 2 --- docs/insurance-booking-implementation-plan.md | 402 ++++++++++++++++++ src/BusProNet/Model/Insurance.php | 3 + src/BusProNet/XmlParser/InsuranceParser.php | 1 + src/Form/Model/ParticipantDto.php | 10 +- src/Service/InsuranceTypeResolver.php | 135 ++++++ .../XmlParser/InsuranceParserTest.php | 180 ++++++++ tests/Form/Model/ParticipantDtoTest.php | 125 ++++++ tests/Service/InsuranceTypeResolverTest.php | 257 +++++++++++ 8 files changed, 1109 insertions(+), 4 deletions(-) create mode 100644 docs/insurance-booking-implementation-plan.md create mode 100644 src/Service/InsuranceTypeResolver.php create mode 100644 tests/BusProNet/XmlParser/InsuranceParserTest.php create mode 100644 tests/Form/Model/ParticipantDtoTest.php create mode 100644 tests/Service/InsuranceTypeResolverTest.php diff --git a/docs/insurance-booking-implementation-plan.md b/docs/insurance-booking-implementation-plan.md new file mode 100644 index 0000000..4c86f5f --- /dev/null +++ b/docs/insurance-booking-implementation-plan.md @@ -0,0 +1,402 @@ +# Insurance Booking System Implementation Plan + +## Overview + +This document outlines the complete implementation plan for making travel insurances bookable per participant in the MyEP Next Booking system. The implementation includes sophisticated criteria matching, automatic re-selection when participant prices change, and comprehensive form integration. + +## Current System Status + +### ✅ Already Implemented +- **Insurance XML Parsing**: `InsuranceParser` and `InsuranceLoader` classes +- **Insurance Model**: Complete with price ranges, age constraints, dates, family flags +- **Family Status Logic**: `BookingCreateDto::isFamilyBooking()` method +- **Individual Pricing**: `BookingPriceCalculatorService::calculateIndividualParticipantPrice()` method +- **Field Handler Architecture**: Existing pattern for dynamic form fields +- **HTMX Integration**: Real-time form updates infrastructure + +### ❌ Missing Implementation +- Insurance subtype parsing and type resolution system +- Participant insurance field and selection logic +- Insurance matching service with criteria validation +- Auto-reselection when participant price changes +- Form integration and field handlers +- Frontend templates and UX + +## Key Technical Discoveries + +### Insurance XML Structure Analysis +- **Individual Insurances**: Have `unterart` (subtype) attribute (RRV, PAK, OHN) +- **Insurance Packages**: No subtype but contain individual insurances +- **Family Detection**: Use `familienversicherung` boolean attribute (not label parsing) +- **Package Type Resolution**: Analyze contained insurances to determine dominant type + +### Age Calculation Requirement +- **Critical**: Use travel start date for age calculation, not current date +- Insurance eligibility based on participant's age at time of travel + +## Implementation Phases + +### Phase 1: Insurance Type System +**Goal**: Add type resolution capability to distinguish insurance types + +#### 1.1 Extend Insurance Model +- [ ] Add `subType` property to `Insurance` model (from XML `unterart`) +- [ ] Add computed `type` property (resolved via service) +- [ ] Update serialization groups if needed + +#### 1.2 Update Insurance Parser +- [ ] Modify `InsuranceParser::parseInsuranceNode()` to parse `unterart` attribute +- [ ] Add subtype to individual insurance parsing +- [ ] Ensure package parsing maintains existing functionality + +#### 1.3 Create Insurance Type Resolver +- [ ] Create `InsuranceTypeResolver` service +- [ ] Implement type resolution for individual insurances +- [ ] Implement package type resolution via contained insurance analysis +- [ ] Define type constants: `TRAVEL_CANCELLATION`, `TRAVEL_PROTECTION`, etc. +- [ ] Handle family variants using `familyInsurance` boolean + +```php +// Type Constants +const TRAVEL_CANCELLATION = 'TRAVEL_CANCELLATION'; +const TRAVEL_CANCELLATION_FAMILY = 'TRAVEL_CANCELLATION_FAMILY'; +const TRAVEL_PROTECTION = 'TRAVEL_PROTECTION'; +const TRAVEL_PROTECTION_FAMILY = 'TRAVEL_PROTECTION_FAMILY'; +``` + +#### 1.4 Testing +- [ ] Create `InsuranceTypeResolverTest` +- [ ] Test individual insurance type resolution +- [ ] Test package type resolution +- [ ] Test family variant detection +- [ ] Verify existing insurance parsing still works + +### Phase 2: Enhanced Age Calculation +**Goal**: Support age calculation at specific dates (travel start date) + +#### 2.1 Update ParticipantDto +- [ ] Add `getAgeAtDate(\DateTimeImmutable $referenceDate): ?int` method +- [ ] Keep existing `getAge(): ?int` for backward compatibility +- [ ] Ensure proper null handling for missing birth dates + +#### 2.2 Testing +- [ ] Add tests for `getAgeAtDate()` method +- [ ] Test edge cases (leap years, same day, etc.) +- [ ] Verify existing age calculation still works + +### Phase 3: Insurance Matching Service +**Goal**: Implement comprehensive insurance matching with all criteria + +#### 3.1 Create Insurance Matching Service +- [ ] Create `InsuranceMatchingService` class +- [ ] Implement `getMatchingInsurances()` method with all criteria: + - Age at travel date validation + - Price range validation + - Booking date validation + - Travel date validation + - Family insurance validation +- [ ] Implement `getMatchingInsurancesByType()` for auto-reselection +- [ ] Add "No Insurance" option handling + +#### 3.2 Auto-Reselection Logic +- [ ] Implement `handlePriceChange()` method +- [ ] Find matching insurance of same type when price changes +- [ ] Maintain coverage when possible, fallback to null +- [ ] Log auto-reselection events for debugging + +#### 3.3 Service Registration +- [ ] Register service in `services.yaml` +- [ ] Configure dependencies (InsuranceLoader, InsuranceTypeResolver) + +#### 3.4 Testing +- [ ] Create comprehensive `InsuranceMatchingServiceTest` +- [ ] Test all matching criteria combinations +- [ ] Test auto-reselection scenarios +- [ ] Test edge cases and boundary conditions + +### Phase 4: Participant DTO Enhancement +**Goal**: Add insurance property and integrate with pricing + +#### 4.1 Extend ParticipantDto +- [ ] Add `?Insurance $insurance = null` property +- [ ] Add validation groups if needed +- [ ] Ensure proper serialization/deserialization + +#### 4.2 Update Price Calculation +- [ ] Modify `BookingPriceCalculatorService::calculateParticipantServiceTotal()` +- [ ] Include insurance price in participant total +- [ ] Update `calculateIndividualParticipantPrice()` to include insurance +- [ ] Update `calculateAllParticipantIndividualPrices()` accordingly + +#### 4.3 Testing +- [ ] Update `BookingPriceCalculatorServiceTest` +- [ ] Test price calculation with insurance +- [ ] Test pricing without insurance +- [ ] Verify individual participant pricing includes insurance + +### Phase 5: Form Field Handler +**Goal**: Create form field handler for insurance selection + +#### 5.1 Create Insurance Field Handler +- [ ] Create `ParticipantInsuranceFieldHandler` extending `AbstractParticipantFieldHandler` +- [ ] Implement field processing logic +- [ ] Handle insurance selection validation +- [ ] Implement auto-reselection on price changes +- [ ] Clear insurance if criteria no longer match + +#### 5.2 Field Dependencies +- [ ] Define dependencies: `dateOfBirth` (for age calculation) +- [ ] Handle field visibility based on available insurances +- [ ] Implement proper error handling + +#### 5.3 Service Registration +- [ ] Register handler in `services.yaml` with proper tags +- [ ] Set appropriate priority in relation to other handlers + +#### 5.4 Testing +- [ ] Create `ParticipantInsuranceFieldHandlerTest` +- [ ] Test field processing +- [ ] Test auto-reselection scenarios +- [ ] Test validation logic + +### Phase 6: Form Integration +**Goal**: Add insurance field to participant form + +#### 6.1 Update Form Type +- [ ] Modify `BookingCreateParticipantType` +- [ ] Add insurance choice field +- [ ] Configure field type and options +- [ ] Add HTMX trigger attributes + +#### 6.2 Field Options Provider +- [ ] Add insurance field to `ParticipantFieldOptionsProvider` +- [ ] Generate "No Insurance" option +- [ ] Generate valid insurance options per participant +- [ ] Use `InsuranceMatchingService` for filtering +- [ ] Format options with price and type information + +#### 6.3 Field State Provider Integration +- [ ] Update `CreateFieldStateProvider` if needed +- [ ] Handle field visibility conditions +- [ ] Register field state conditions + +#### 6.4 Testing +- [ ] Test form field rendering +- [ ] Test field options generation +- [ ] Test HTMX integration + +### Phase 7: Controller Integration +**Goal**: Update controllers to handle insurance data + +#### 7.1 Update Step 2 Controller +- [ ] Ensure insurance data is included in template variables +- [ ] Handle insurance-related HTMX updates +- [ ] Update participant price calculations to include insurance + +#### 7.2 Error Handling +- [ ] Add proper error handling for insurance validation +- [ ] Handle insurance matching failures gracefully +- [ ] Provide user-friendly error messages + +#### 7.3 Testing +- [ ] Test controller responses include insurance data +- [ ] Test HTMX updates work correctly +- [ ] Test error scenarios + +### Phase 8: Frontend Templates +**Goal**: Add insurance field to templates and display pricing + +#### 8.1 Template Updates +- [ ] Add insurance field to `create_step_2.html.twig` +- [ ] Position field appropriately in participant sections +- [ ] Add proper labeling and help text +- [ ] Configure HTMX attributes for real-time updates + +#### 8.2 Pricing Display +- [ ] Update participant headers to show insurance pricing +- [ ] Include insurance in individual participant price display +- [ ] Update booking summary to include insurance totals + +#### 8.3 UX Enhancements +- [ ] Show criteria-based availability information +- [ ] Display auto-reselection notifications +- [ ] Add loading states for HTMX updates + +### Phase 9: Advanced Features +**Goal**: Implement applicant control and bulk operations + +#### 9.1 Applicant Insurance Control +- [ ] Add special UI for first participant (applicant) +- [ ] Implement bulk insurance selection +- [ ] Respect individual criteria while allowing bulk operations +- [ ] Add override capabilities with proper validation + +#### 9.2 Real-time Price Integration +- [ ] Monitor price changes via HTMX +- [ ] Trigger auto-reselection when participant price changes +- [ ] Provide visual feedback for automatic changes +- [ ] Update pricing summary dynamically + +#### 9.3 Enhanced UX +- [ ] Add insurance type grouping in selection +- [ ] Implement insurance comparison features +- [ ] Add detailed insurance information display +- [ ] Improve mobile responsiveness + +### Phase 10: Testing & Quality Assurance +**Goal**: Comprehensive testing and code quality + +#### 10.1 Integration Testing +- [ ] Create end-to-end insurance booking tests +- [ ] Test complete booking flow with insurances +- [ ] Test auto-reselection scenarios +- [ ] Test applicant control features + +#### 10.2 Performance Testing +- [ ] Test insurance matching performance with large datasets +- [ ] Optimize insurance filtering algorithms +- [ ] Test HTMX update performance + +#### 10.3 Code Quality +- [ ] Run PHP CS Fixer on all new files +- [ ] Ensure PSR-12 compliance +- [ ] Add proper PHPDoc documentation +- [ ] Review and optimize service dependencies + +#### 10.4 User Acceptance Testing +- [ ] Test with real insurance data +- [ ] Validate business logic with stakeholders +- [ ] Test edge cases and error scenarios +- [ ] Gather user feedback on UX + +## Technical Architecture + +### New Classes Overview + +``` +src/Service/ +├── InsuranceTypeResolver.php # Type resolution for insurances and packages +├── InsuranceMatchingService.php # Criteria-based insurance matching +└── Insurance/ + ├── Criteria/ + │ ├── AgeCriterion.php # Age-based matching + │ ├── PriceCriterion.php # Price range matching + │ ├── DateCriterion.php # Date validation + │ └── FamilyCriterion.php # Family insurance validation + └── Matcher/ + └── InsuranceMatcher.php # Core matching logic + +src/Form/Service/ +├── ParticipantInsuranceFieldHandler.php # Form field processing +└── Insurance/ + └── InsuranceFieldOptionsProvider.php # Field options generation + +tests/Service/ +├── InsuranceTypeResolverTest.php +├── InsuranceMatchingServiceTest.php +└── Insurance/ + └── Criteria/ + ├── AgeCriterionTest.php + ├── PriceCriterionTest.php + ├── DateCriterionTest.php + └── FamilyCriterionTest.php + +tests/Form/Service/ +└── ParticipantInsuranceFieldHandlerTest.php +``` + +### Database Changes +- **None required** - All insurance data loaded from XML + +### Configuration Changes +- Service registrations in `services.yaml` +- Field handler tags and priorities +- Form field configurations + +## Implementation Guidelines + +### Code Standards +- Follow PSR-12 coding standards +- Use `declare(strict_types=1)` on all files +- Apply Symfony coding standards via php-cs-fixer +- Use English for all variable and constant names +- Follow existing architectural patterns + +### Testing Standards +- Minimum 90% code coverage for new classes +- Unit tests for all service methods +- Integration tests for form processing +- End-to-end tests for complete booking flow +- Performance tests for matching algorithms + +### Documentation Standards +- PHPDoc for all public methods +- Business logic documentation in comments +- Update CLAUDE.md with new features +- Create user documentation for insurance features + +## Risk Mitigation + +### Technical Risks +1. **Performance**: Large insurance datasets could slow matching + - **Mitigation**: Implement caching and optimize algorithms +2. **Complexity**: Auto-reselection logic could introduce bugs + - **Mitigation**: Comprehensive testing and logging +3. **Data Integrity**: Price changes could cause inconsistent states + - **Mitigation**: Atomic operations and validation + +### Business Risks +1. **Incorrect Matching**: Wrong insurance eligibility could cause issues + - **Mitigation**: Thorough testing with real data and stakeholder validation +2. **User Confusion**: Complex insurance options could confuse users + - **Mitigation**: Clear UX design and comprehensive help text + +## Success Criteria + +### Functional Requirements +- ✅ Participants can select appropriate insurances based on all criteria +- ✅ Auto-reselection works when participant price changes +- ✅ Applicant can manage insurances for all participants +- ✅ Real-time pricing updates include insurance costs +- ✅ Form validation prevents invalid insurance selections + +### Performance Requirements +- ✅ Insurance matching completes within 100ms for typical datasets +- ✅ HTMX updates complete within 500ms +- ✅ Page load times remain under 2 seconds + +### Quality Requirements +- ✅ 90%+ code coverage for new functionality +- ✅ Zero critical bugs in production +- ✅ PSR-12 compliance for all new code +- ✅ Comprehensive documentation + +## Future Enhancements + +### Phase 11+ (Future) +- Insurance comparison tools +- Advanced filtering and search +- Insurance recommendation engine +- Historical insurance selection analytics +- Multi-language insurance descriptions +- PDF insurance documentation generation + +## Dependencies + +### External Dependencies +- Insurance XML data must be available and properly formatted +- BusProNet API integration for insurance booking +- Travel data must include proper date information + +### Internal Dependencies +- Existing individual pricing calculation system +- Field handler architecture +- HTMX integration infrastructure +- Family status determination logic + +--- + +**Document Version**: 1.0 +**Last Updated**: 2025-09-26 +**Author**: Claude Code Implementation Plan +**Status**: Ready for Implementation \ No newline at end of file diff --git a/src/BusProNet/Model/Insurance.php b/src/BusProNet/Model/Insurance.php index bb5d44c..21f6ef5 100644 --- a/src/BusProNet/Model/Insurance.php +++ b/src/BusProNet/Model/Insurance.php @@ -25,6 +25,9 @@ class Insurance #[Groups(['api:single', 'api:list'])] public ?string $label = null; + #[Groups(['api:single', 'api:list'])] + public ?string $subType = null; + #[Groups(['api:single', 'api:list'])] public ?float $price = null; diff --git a/src/BusProNet/XmlParser/InsuranceParser.php b/src/BusProNet/XmlParser/InsuranceParser.php index 0690db0..c3a071e 100644 --- a/src/BusProNet/XmlParser/InsuranceParser.php +++ b/src/BusProNet/XmlParser/InsuranceParser.php @@ -74,6 +74,7 @@ class InsuranceParser extends AbstractParser $insurance->id = $isPackage ? $idValue : (int) $idValue; $insurance->code = $node->attr('code'); $insurance->label = $node->attr('bezeichnung'); + $insurance->subType = $node->attr('unterart'); $insurance->price = $node->attr('preis') ? $this->stringToFloat($node->attr('preis')) : null; $insurance->familyInsurance = $this->stringToBool($node->attr('familienversicherung')); diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index dfb04c2..4616e15 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -108,21 +108,23 @@ class ParticipantDto } /** - * Calculates the participant's current age in complete years. + * Calculates the participant's age in complete years. * * Uses the same logic as existing age evaluators in the system * for consistency across age-related calculations. * + * @param \DateTimeImmutable|null $referenceDate The date to calculate age at (defaults to current date) + * * @return int|null The calculated age in complete years, or null if no birth date */ - public function getAge(): ?int + public function getAge(?\DateTimeImmutable $referenceDate = null): ?int { if (null === $this->dateOfBirth) { return null; } - $today = new \DateTimeImmutable(); + $referenceDate = $referenceDate ?? new \DateTimeImmutable(); - return $this->dateOfBirth->diff($today)->y; + return $this->dateOfBirth->diff($referenceDate)->y; } } diff --git a/src/Service/InsuranceTypeResolver.php b/src/Service/InsuranceTypeResolver.php new file mode 100644 index 0000000..8e28829 --- /dev/null +++ b/src/Service/InsuranceTypeResolver.php @@ -0,0 +1,135 @@ +package) { + return $this->mapSubtypeToType($insurance->subType, $insurance->familyInsurance); + } + + return $this->resolvePackageType($insurance); + } + + /** + * Resolves the type for an insurance package by analyzing contained insurances. + * + * @param Insurance $package The package to analyze + * + * @return string|null The resolved type or null if no primary type found + */ + private function resolvePackageType(Insurance $package): ?string + { + // Get primary (non-deductible) insurance type from contained insurances + $primaryType = null; + foreach ($package->containedInsurances as $contained) { + $baseType = $this->mapSubtypeToBaseType($contained->subType); + if ($baseType && self::DEDUCTIBLE !== $baseType) { + $primaryType = $baseType; + break; + } + } + + // Apply family suffix using package's familyInsurance flag + return $primaryType ? $primaryType.($package->familyInsurance ? '_FAMILY' : '') : null; + } + + /** + * Maps a subtype to a full type including family variant. + * + * @param string|null $subtype The subtype to map + * @param bool $isFamily Whether this is a family insurance + * + * @return string|null The mapped type or null if unmappable + */ + private function mapSubtypeToType(?string $subtype, bool $isFamily): ?string + { + $baseType = $this->mapSubtypeToBaseType($subtype); + + return $baseType ? $baseType.($isFamily ? '_FAMILY' : '') : null; + } + + /** + * Maps a subtype to its base type constant. + * + * @param string|null $subtype The subtype to map + * + * @return string|null The base type constant or null if unmappable + */ + private function mapSubtypeToBaseType(?string $subtype): ?string + { + return match ($subtype) { + 'RRV' => self::TRAVEL_CANCELLATION, + 'PAK' => self::TRAVEL_PROTECTION, + 'OHN' => self::DEDUCTIBLE, + default => null, + }; + } + + /** + * Gets all available insurance type constants. + * + * @return array Array of all type constants + */ + public function getAllTypes(): array + { + return [ + self::TRAVEL_CANCELLATION, + self::TRAVEL_CANCELLATION_FAMILY, + self::TRAVEL_PROTECTION, + self::TRAVEL_PROTECTION_FAMILY, + self::DEDUCTIBLE, + ]; + } + + /** + * Checks if a type is a family variant. + * + * @param string $type The type to check + * + * @return bool True if the type is a family variant + */ + public function isFamilyType(string $type): bool + { + return str_ends_with($type, '_FAMILY'); + } + + /** + * Gets the base type from a family type. + * + * @param string $type The type to get the base for + * + * @return string The base type (without _FAMILY suffix) + */ + public function getBaseType(string $type): string + { + return str_replace('_FAMILY', '', $type); + } +} diff --git a/tests/BusProNet/XmlParser/InsuranceParserTest.php b/tests/BusProNet/XmlParser/InsuranceParserTest.php new file mode 100644 index 0000000..b81e098 --- /dev/null +++ b/tests/BusProNet/XmlParser/InsuranceParserTest.php @@ -0,0 +1,180 @@ +parser = new InsuranceParser(); + } + + public function testParseIndividualInsuranceWithSubtype(): void + { + $xmlContent = ' + + + + + + + + + + + + + + +'; + + $crawler = new Crawler($xmlContent); + $insurances = $this->parser->parse($crawler); + + // Test individual insurances + package (3 individual + 1 package = 4 total) + $this->assertCount(4, $insurances); + + // Test travel cancellation insurance (RRV) + $cancellationInsurance = $insurances[178917]; + $this->assertEquals(178917, $cancellationInsurance->id); + $this->assertEquals('903100', $cancellationInsurance->code); + $this->assertEquals('Reise-Rücktritt', $cancellationInsurance->label); + $this->assertEquals('RRV', $cancellationInsurance->subType); + $this->assertEquals(5.0, $cancellationInsurance->price); + $this->assertFalse($cancellationInsurance->familyInsurance); + $this->assertFalse($cancellationInsurance->package); + + // Test travel protection insurance (PAK) with family flag + $protectionInsurance = $insurances[177236]; + $this->assertEquals(177236, $protectionInsurance->id); + $this->assertEquals('913320', $protectionInsurance->code); + $this->assertEquals('Reiseschutz Platin Auto/Bahn/Bus (Europa)', $protectionInsurance->label); + $this->assertEquals('PAK', $protectionInsurance->subType); + $this->assertEquals(9.0, $protectionInsurance->price); + $this->assertTrue($protectionInsurance->familyInsurance); + $this->assertFalse($protectionInsurance->package); + + // Test deductible insurance (OHN) - should be included because it's referenced by package + $deductibleInsurance = $insurances[177270]; + $this->assertEquals(177270, $deductibleInsurance->id); + $this->assertEquals('903551', $deductibleInsurance->code); + $this->assertEquals('Selbstbehaltübernahme', $deductibleInsurance->label); + $this->assertEquals('OHN', $deductibleInsurance->subType); + $this->assertEquals(5.0, $deductibleInsurance->price); + $this->assertFalse($deductibleInsurance->familyInsurance); + $this->assertFalse($deductibleInsurance->package); + + // Test package + $package = $insurances['P1000320']; + $this->assertEquals('P1000320', $package->id); + $this->assertEquals('Reiseschutz Platin Auto/Bahn/Bus (Europa) + Selbstbehaltübernahme', $package->label); + $this->assertNull($package->subType); // Packages don't have subtypes + $this->assertEquals(14.0, $package->price); + $this->assertFalse($package->familyInsurance); + $this->assertTrue($package->package); + $this->assertEquals([177236, 177270], $package->containedInsuranceIds); + } + + public function testParsePackageWithoutSubtype(): void + { + $xmlContent = ' + + + + + + + + + + + +'; + + $crawler = new Crawler($xmlContent); + $insurances = $this->parser->parse($crawler); + + $this->assertCount(2, $insurances); + + // Test package has no subType but has familyInsurance flag + $package = $insurances['P1000320']; + $this->assertEquals('P1000320', $package->id); + $this->assertNull($package->subType); + $this->assertTrue($package->familyInsurance); + $this->assertTrue($package->package); + } + + public function testParseInsuranceWithNullSubtype(): void + { + $xmlContent = ' + + + + +'; + + $crawler = new Crawler($xmlContent); + $insurances = $this->parser->parse($crawler); + + $this->assertCount(1, $insurances); + + $insurance = $insurances[178917]; + $this->assertEquals(178917, $insurance->id); + $this->assertNull($insurance->subType); // Missing unterart attribute should result in null + $this->assertFalse($insurance->package); + } + + public function testParseEmptyXml(): void + { + $xmlContent = ' + + + + + +'; + + $crawler = new Crawler($xmlContent); + $insurances = $this->parser->parse($crawler); + + $this->assertEmpty($insurances); + } + + public function testParseZusatzversicherungFiltering(): void + { + $xmlContent = ' + + + + + + + + + + + + + +'; + + $crawler = new Crawler($xmlContent); + $insurances = $this->parser->parse($crawler); + + // Should include: normal insurance (178917), referenced zusatz (177270), and package (P1000320) + // Should exclude: unreferenced zusatz (999999) + $this->assertCount(3, $insurances); + $this->assertArrayHasKey(178917, $insurances); + $this->assertArrayHasKey(177270, $insurances); + $this->assertArrayHasKey('P1000320', $insurances); + $this->assertArrayNotHasKey(999999, $insurances); + } +} diff --git a/tests/Form/Model/ParticipantDtoTest.php b/tests/Form/Model/ParticipantDtoTest.php new file mode 100644 index 0000000..32cee75 --- /dev/null +++ b/tests/Form/Model/ParticipantDtoTest.php @@ -0,0 +1,125 @@ +dateOfBirth = null; + + $this->assertNull($participant->getAge()); + } + + public function testGetAgeCalculatesCurrentAgeWhenNoReferenceDateProvided(): void + { + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); + + $expectedAge = (new \DateTimeImmutable())->diff($participant->dateOfBirth)->y; + $actualAge = $participant->getAge(); + + $this->assertEquals($expectedAge, $actualAge); + } + + public function testGetAgeCalculatesAgeAtSpecificReferenceDate(): void + { + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); + $referenceDate = new \DateTimeImmutable('2024-06-14'); // Day before 34th birthday + + $result = $participant->getAge($referenceDate); + + $this->assertEquals(33, $result); // Still 33 years old + } + + public function testGetAgeCalculatesAgeAtSpecificReferenceDateAfterBirthday(): void + { + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); + $referenceDate = new \DateTimeImmutable('2024-06-15'); // Exactly 34th birthday + + $result = $participant->getAge($referenceDate); + + $this->assertEquals(34, $result); // Now 34 years old + } + + public function testGetAgeCalculatesAgeForInsuranceEligibilityScenario(): void + { + // Travel starts on 2024-08-01, participant born 1989-12-25 + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('1989-12-25'); + $travelStartDate = new \DateTimeImmutable('2024-08-01'); + + $result = $participant->getAge($travelStartDate); + + // On 2024-08-01, they are still 34 (birthday not yet reached) + $this->assertEquals(34, $result); + } + + public function testGetAgeCalculatesAgeForInsuranceEligibilityScenarioAfterBirthday(): void + { + // Travel starts on 2024-12-26, participant born 1989-12-25 + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('1989-12-25'); + $travelStartDate = new \DateTimeImmutable('2024-12-26'); + + $result = $participant->getAge($travelStartDate); + + // On 2024-12-26, they just turned 35 + $this->assertEquals(35, $result); + } + + public function testGetAgeHandlesLeapYearScenarios(): void + { + // Born on Feb 29, 2000 (leap year) + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('2000-02-29'); + $referenceDate = new \DateTimeImmutable('2023-02-28'); // Non-leap year + + $result = $participant->getAge($referenceDate); + + $this->assertEquals(22, $result); // Still 22, birthday is technically next day + } + + public function testGetAgeReturnsNullWithReferenceDateWhenDateOfBirthIsNull(): void + { + $participant = new ParticipantDto(); + $participant->dateOfBirth = null; + $referenceDate = new \DateTimeImmutable('2024-08-01'); + + $result = $participant->getAge($referenceDate); + + $this->assertNull($result); + } + + public function testGetAgeWithYoungParticipant(): void + { + // Child born in 2020 + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('2020-03-15'); + $referenceDate = new \DateTimeImmutable('2024-08-01'); + + $result = $participant->getAge($referenceDate); + + $this->assertEquals(4, $result); + } + + public function testGetAgeWithElderlyParticipant(): void + { + // Elderly participant born in 1940 + $participant = new ParticipantDto(); + $participant->dateOfBirth = new \DateTimeImmutable('1940-05-20'); + $referenceDate = new \DateTimeImmutable('2024-08-01'); + + $result = $participant->getAge($referenceDate); + + $this->assertEquals(84, $result); + } +} diff --git a/tests/Service/InsuranceTypeResolverTest.php b/tests/Service/InsuranceTypeResolverTest.php new file mode 100644 index 0000000..75c4733 --- /dev/null +++ b/tests/Service/InsuranceTypeResolverTest.php @@ -0,0 +1,257 @@ +resolver = new InsuranceTypeResolver(); + } + + public function testResolveTypeForIndividualTravelCancellationInsurance(): void + { + $insurance = new Insurance(); + $insurance->package = false; + $insurance->subType = 'RRV'; + $insurance->familyInsurance = false; + + $result = $this->resolver->resolveType($insurance); + + $this->assertEquals(InsuranceTypeResolver::TRAVEL_CANCELLATION, $result); + } + + public function testResolveTypeForIndividualFamilyTravelCancellationInsurance(): void + { + $insurance = new Insurance(); + $insurance->package = false; + $insurance->subType = 'RRV'; + $insurance->familyInsurance = true; + + $result = $this->resolver->resolveType($insurance); + + $this->assertEquals(InsuranceTypeResolver::TRAVEL_CANCELLATION_FAMILY, $result); + } + + public function testResolveTypeForIndividualTravelProtectionInsurance(): void + { + $insurance = new Insurance(); + $insurance->package = false; + $insurance->subType = 'PAK'; + $insurance->familyInsurance = false; + + $result = $this->resolver->resolveType($insurance); + + $this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION, $result); + } + + public function testResolveTypeForIndividualFamilyTravelProtectionInsurance(): void + { + $insurance = new Insurance(); + $insurance->package = false; + $insurance->subType = 'PAK'; + $insurance->familyInsurance = true; + + $result = $this->resolver->resolveType($insurance); + + $this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY, $result); + } + + public function testResolveTypeForIndividualDeductibleInsurance(): void + { + $insurance = new Insurance(); + $insurance->package = false; + $insurance->subType = 'OHN'; + $insurance->familyInsurance = false; + + $result = $this->resolver->resolveType($insurance); + + $this->assertEquals(InsuranceTypeResolver::DEDUCTIBLE, $result); + } + + public function testResolveTypeForIndividualUnknownSubtype(): void + { + $insurance = new Insurance(); + $insurance->package = false; + $insurance->subType = 'UNKNOWN'; + $insurance->familyInsurance = false; + + $result = $this->resolver->resolveType($insurance); + + $this->assertNull($result); + } + + public function testResolveTypeForIndividualNullSubtype(): void + { + $insurance = new Insurance(); + $insurance->package = false; + $insurance->subType = null; + $insurance->familyInsurance = false; + + $result = $this->resolver->resolveType($insurance); + + $this->assertNull($result); + } + + public function testResolveTypeForPackageWithTravelProtectionContent(): void + { + // Create contained insurances + $protectionInsurance = new Insurance(); + $protectionInsurance->subType = 'PAK'; + + $deductibleInsurance = new Insurance(); + $deductibleInsurance->subType = 'OHN'; + + // Create package + $package = new Insurance(); + $package->package = true; + $package->familyInsurance = false; + $package->containedInsurances = [$protectionInsurance, $deductibleInsurance]; + + $result = $this->resolver->resolveType($package); + + $this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION, $result); + } + + public function testResolveTypeForFamilyPackageWithTravelProtectionContent(): void + { + // Create contained insurances + $protectionInsurance = new Insurance(); + $protectionInsurance->subType = 'PAK'; + + $deductibleInsurance = new Insurance(); + $deductibleInsurance->subType = 'OHN'; + + // Create family package + $package = new Insurance(); + $package->package = true; + $package->familyInsurance = true; + $package->containedInsurances = [$protectionInsurance, $deductibleInsurance]; + + $result = $this->resolver->resolveType($package); + + $this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY, $result); + } + + public function testResolveTypeForPackageWithTravelCancellationContent(): void + { + // Create contained insurances + $cancellationInsurance = new Insurance(); + $cancellationInsurance->subType = 'RRV'; + + $deductibleInsurance = new Insurance(); + $deductibleInsurance->subType = 'OHN'; + + // Create package + $package = new Insurance(); + $package->package = true; + $package->familyInsurance = false; + $package->containedInsurances = [$cancellationInsurance, $deductibleInsurance]; + + $result = $this->resolver->resolveType($package); + + $this->assertEquals(InsuranceTypeResolver::TRAVEL_CANCELLATION, $result); + } + + public function testResolveTypeForPackageWithOnlyDeductibleContent(): void + { + // Create contained insurance (only deductible) + $deductibleInsurance = new Insurance(); + $deductibleInsurance->subType = 'OHN'; + + // Create package + $package = new Insurance(); + $package->package = true; + $package->familyInsurance = false; + $package->containedInsurances = [$deductibleInsurance]; + + $result = $this->resolver->resolveType($package); + + $this->assertNull($result); + } + + public function testResolveTypeForPackageWithNoContainedInsurances(): void + { + $package = new Insurance(); + $package->package = true; + $package->familyInsurance = false; + $package->containedInsurances = []; + + $result = $this->resolver->resolveType($package); + + $this->assertNull($result); + } + + public function testResolveTypeForPackageWithMultiplePrimaryTypes(): void + { + // Create contained insurances with multiple primary types + $protectionInsurance = new Insurance(); + $protectionInsurance->subType = 'PAK'; + + $cancellationInsurance = new Insurance(); + $cancellationInsurance->subType = 'RRV'; + + // Create package - should return first non-deductible type found + $package = new Insurance(); + $package->package = true; + $package->familyInsurance = false; + $package->containedInsurances = [$protectionInsurance, $cancellationInsurance]; + + $result = $this->resolver->resolveType($package); + + $this->assertEquals(InsuranceTypeResolver::TRAVEL_PROTECTION, $result); + } + + public function testGetAllTypes(): void + { + $types = $this->resolver->getAllTypes(); + + $expectedTypes = [ + InsuranceTypeResolver::TRAVEL_CANCELLATION, + InsuranceTypeResolver::TRAVEL_CANCELLATION_FAMILY, + InsuranceTypeResolver::TRAVEL_PROTECTION, + InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY, + InsuranceTypeResolver::DEDUCTIBLE, + ]; + + $this->assertEquals($expectedTypes, $types); + $this->assertCount(5, $types); + } + + public function testIsFamilyType(): void + { + $this->assertTrue($this->resolver->isFamilyType(InsuranceTypeResolver::TRAVEL_CANCELLATION_FAMILY)); + $this->assertTrue($this->resolver->isFamilyType(InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY)); + $this->assertFalse($this->resolver->isFamilyType(InsuranceTypeResolver::TRAVEL_CANCELLATION)); + $this->assertFalse($this->resolver->isFamilyType(InsuranceTypeResolver::TRAVEL_PROTECTION)); + $this->assertFalse($this->resolver->isFamilyType(InsuranceTypeResolver::DEDUCTIBLE)); + } + + public function testGetBaseType(): void + { + $this->assertEquals( + InsuranceTypeResolver::TRAVEL_CANCELLATION, + $this->resolver->getBaseType(InsuranceTypeResolver::TRAVEL_CANCELLATION_FAMILY) + ); + $this->assertEquals( + InsuranceTypeResolver::TRAVEL_PROTECTION, + $this->resolver->getBaseType(InsuranceTypeResolver::TRAVEL_PROTECTION_FAMILY) + ); + $this->assertEquals( + InsuranceTypeResolver::TRAVEL_CANCELLATION, + $this->resolver->getBaseType(InsuranceTypeResolver::TRAVEL_CANCELLATION) + ); + $this->assertEquals( + InsuranceTypeResolver::DEDUCTIBLE, + $this->resolver->getBaseType(InsuranceTypeResolver::DEDUCTIBLE) + ); + } +}