createServiceWithAccommodation(); $dto = $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-28', '2026-03-01')); self::assertSame('2026-02-28', $dto->dateFrom?->format('Y-m-d')); self::assertSame('2026-03-01', $dto->dateTo?->format('Y-m-d')); } public function testInitFromParamsPrefillsPaxCountFromEffectiveMinPax(): void { $price = (new AccommodationPrice()) ->setDateFrom(new \DateTimeImmutable('2026-02-01')) ->setDateTo(new \DateTimeImmutable('2026-03-31')) ->setIncludedPax(4); $service = $this->createServiceWithAccommodation([$price]); $dto = $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-28', '2026-03-01')); self::assertSame(4, $dto->paxCount); } public function testInitFromParamsRejectsNormalizedInvalidCalendarDates(): void { $service = $this->createServiceWithAccommodation(); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Ungültiges Datumsformat. Erwartet: YYYY-MM-DD.'); $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-31', '2026-03-05')); } public function testIssueAccessLinkForDirectBookingSetsTimestampForDirectBooking(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); $booking->setType(AccommodationBookingType::Booking); $service->issueAccessLinkForDirectBooking($booking); self::assertNotNull($booking->getAccessLinkIssuedAt()); } public function testIssueAccessLinkForDirectBookingNoOpsForInquiry(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); $booking->setType(AccommodationBookingType::Inquiry); $service->issueAccessLinkForDirectBooking($booking); self::assertNull($booking->getAccessLinkIssuedAt()); } public function testIssueAccessLinkForDirectBookingNoOpsWhenAlreadySet(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); $booking->setType(AccommodationBookingType::Booking); $issuedAt = new \DateTimeImmutable('2026-01-01'); $booking->setAccessLinkIssuedAt($issuedAt); $service->issueAccessLinkForDirectBooking($booking); self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt()); } public function testIssueAccessLinkSetsTimestampRegardlessOfStatus(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $service->issueAccessLink($booking); self::assertNotNull($booking->getAccessLinkIssuedAt()); } public function testIssueAccessLinkNoOpsWhenAlreadySet(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $issuedAt = new \DateTimeImmutable('2026-01-01'); $booking->setAccessLinkIssuedAt($issuedAt); $service->issueAccessLink($booking); self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt()); } public function testSendCustomerConfirmationEmailUsesOfferSubjectForInquiryWithLink(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('sign')->willReturn('https://example.com/offer/signed-link'); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( self::anything(), self::callback(static fn (array $options) => 'Dein Angebot ist bereit' === $options['subject']), ); $service = $this->createServiceWithAccommodation(mailer: $mailer, linkSigner: $linkSigner, breakdownCalculator: $breakdownCalculator); $service->sendCustomerConfirmationEmail($booking); } public function testSendCustomerConfirmationEmailAlwaysSendsWithLinkWhenIssued(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('sign')->with($booking)->willReturn('https://example.com/offer/signed-link'); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(['total' => 1000, 'currency' => 'EUR']); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( self::callback(static fn (array $context) => 'https://example.com/offer/signed-link' === $context['accessLink']), self::callback(static fn (array $options) => 'customer@example.com' === $options['to'] && 'office@example.com' === $options['from'] && 'email/accommodation_booking_customer.html.twig' === $options['template']), ); $service = $this->createServiceWithAccommodation(mailer: $mailer, linkSigner: $linkSigner, breakdownCalculator: $breakdownCalculator); $service->sendCustomerConfirmationEmail($booking); } public function testSendCustomerConfirmationEmailSendsWithoutLinkForInquiry(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Open); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( self::callback(static fn (array $context) => null === $context['accessLink']), self::anything(), ); $service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator); $service->sendCustomerConfirmationEmail($booking); } public function testSendCustomerConfirmationEmailLogsAndSwallowsMailerFailures(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Open); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); $mailer = $this->createMock(Mailer::class); $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); $logger = $this->createMock(LoggerInterface::class); $logger->expects(self::once())->method('error'); $service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator, logger: $logger); $service->sendCustomerConfirmationEmail($booking); } public function testRegenerateAccessLinkOverwritesAccessLinkIssuedAt(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Open); $previousIssuedAt = new \DateTimeImmutable('2026-01-01'); $booking->setAccessLinkIssuedAt($previousIssuedAt); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->willReturn(null); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $service = $this->createServiceWithAccommodation( entityManager: $entityManager, mailer: $mailer, breakdownCalculator: $breakdownCalculator, ); $service->regenerateAccessLink($booking); self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt()); } public function testAcceptBookingAcceptsOpenInquiryAndSendsNotifications(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setEmail('customer@example.com'); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::exactly(2)) ->method('createAndSendEmail') ->with( self::anything(), self::callback(static fn (array $options) => in_array($options['to'], ['office@example.com', 'customer@example.com'], true) && 'office@example.com' === $options['from'] && in_array($options['template'], ['email/offer_accepted.html.twig', 'email/offer_accepted_customer.html.twig'], true)), ); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $service->acceptBooking($booking); self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus()); self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage'); self::assertNotNull($booking->getAcceptedAt()); } public function testAcceptBookingStoresTrimmedRemarks(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setEmail('customer@example.com'); $booking->setRemarks('vom Telefonat'); $service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class)); $service->acceptBooking($booking, " Bitte Zimmer im EG\nund Frühstück um 8 \n"); self::assertSame("Bitte Zimmer im EG\nund Frühstück um 8", $booking->getRemarks(), 'inner line breaks survive, outer whitespace does not'); } public function testAcceptBookingClearsRemarksForEmptySubmission(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setEmail('customer@example.com'); $booking->setRemarks('vom Telefonat'); $service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class)); $service->acceptBooking($booking, ' '); self::assertNull($booking->getRemarks(), 'an emptied textarea clears the stored remark'); } public function testAcceptBookingLeavesRemarksUntouchedWithoutSubmission(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setEmail('customer@example.com'); $booking->setRemarks('vom Telefonat'); $service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class)); $service->acceptBooking($booking); self::assertSame('vom Telefonat', $booking->getRemarks(), 'the API path must not wipe the remark'); } public function testAcceptBookingDoesNotStoreRemarksWhenOfferIsNotOpen(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setRemarks('vom Telefonat'); $service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class)); $service->acceptBooking($booking, 'zu spät'); self::assertSame('vom Telefonat', $booking->getRemarks()); } public function testAcceptBookingSkipsTheCustomerEmailWhenNoAddressIsStored(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); // Only the office notification goes out — the customer copy has no recipient. $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( self::anything(), self::callback(static fn (array $options) => 'office@example.com' === $options['to']), ); $logger = $this->createMock(LoggerInterface::class); $logger ->expects(self::once()) ->method('warning') ->with('Failed to send offer accepted customer email', self::anything()); $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger); $service->acceptBooking($booking); self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus(), 'the acceptance itself must not fail'); } public function testAcceptBookingIsIdempotentAndSendsNoNotifications(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Accepted); $service->acceptBooking($booking); self::assertNull($booking->getAcceptedAt()); } public function testAcceptBookingNoOpsForOfferThatIsNotOpenYet(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $booking = new AccommodationBooking(); $booking->setType(AccommodationBookingType::Inquiry); $booking->setStatus(AccommodationBookingStatus::Draft); $service->acceptBooking($booking); self::assertSame(AccommodationBookingStatus::Draft, $booking->getStatus()); self::assertNull($booking->getAcceptedAt()); } public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Accepted); $mailer = $this->createMock(Mailer::class); $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); $logger = $this->createMock(LoggerInterface::class); $logger->expects(self::once())->method('error'); $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger); $service->sendOfferAcceptedNotificationEmail($booking); } public function testSendOfferAcceptedCustomerEmailLogsAndSwallowsMailerFailures(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Accepted); $mailer = $this->createMock(Mailer::class); $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); $logger = $this->createMock(LoggerInterface::class); $logger->expects(self::once())->method('error'); $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger); $service->sendOfferAcceptedCustomerEmail($booking); } public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void { $booking = new AccommodationBooking(); $booking->setAccommodationDiscount(10); $booking->setBoardServiceDiscount(20); $booking->setAdditionalServicesDiscount(50); $breakdown = [ 'total' => 12345, 'currency' => 'CHF', 'basePrice' => 8000, 'additionalPersonsPrice' => 2000, 'boardPrice' => 1000, 'servicesPrice' => 500, ]; $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator ->expects(self::once()) ->method('computeCurrent') ->with($booking) ->willReturn($breakdown); $service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator); $service->refreshPriceSnapshot($booking); // accommodation: (8000+2000)*10% = 1000, board: 1000*20% = 200, services: 500*50% = 250 self::assertSame($breakdown, $booking->getPriceBreakdown()); self::assertSame(12345 - 1000 - 200 - 250, $booking->getTotalPrice()); self::assertSame('CHF', $booking->getPricingCurrency()); self::assertSame(1, $booking->getPricingVersion()); } /** * @param AccommodationPrice[] $prices */ private function createServiceWithAccommodation( array $prices = [], ?EntityManagerInterface $entityManager = null, ?Mailer $mailer = null, ?LoggerInterface $logger = null, ?AccommodationBookingLinkSigner $linkSigner = null, ?AccommodationBookingBreakdownCalculator $breakdownCalculator = null, ): AccommodationBookingService { $accommodation = (new Accommodation()) ->setName('Hotel') ->setCalendarCode('HOTEL') ->setMaxAdolescentAge(17); $accommodationRepo = $this->createMock(AccommodationRepository::class); $accommodationRepo ->method('findOneBy') ->with(['calendarCode' => 'HOTEL']) ->willReturn($accommodation); $priceRepo = $this->createMock(AccommodationPriceRepository::class); $priceRepo ->method('findByHotelCodeAndDateRange') ->willReturn($prices); return new AccommodationBookingService( $accommodationRepo, $priceRepo, $this->createMock(AdditionalServiceRepository::class), $this->createMock(BoardServiceRepository::class), new PriceTimelineBuilder(), $entityManager ?? $this->createMock(EntityManagerInterface::class), $mailer ?? $this->createMock(Mailer::class), $logger ?? $this->createMock(LoggerInterface::class), $this->createMock(CmsDataProvider::class), $linkSigner ?? $this->createMock(AccommodationBookingLinkSigner::class), $breakdownCalculator ?? $this->createMock(AccommodationBookingBreakdownCalculator::class), 'office@example.com', ); } }