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); // Eingegangen is what persist() gives a direct booking — it is never a draft, which // is the one status that would hold the link back. $booking = new AccommodationBooking(); $booking->setOrigin(AccommodationBookingOrigin::Direct); $booking->setStatus(AccommodationBookingStatus::Received); $service->issueAccessLinkForDirectBooking($booking); self::assertNotNull($booking->getAccessLinkIssuedAt()); } public function testIssueAccessLinkForDirectBookingNoOpsForAnOffer(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = new AccommodationBooking(); $booking->setOrigin(AccommodationBookingOrigin::Offer); $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->setOrigin(AccommodationBookingOrigin::Direct); $booking->setStatus(AccommodationBookingStatus::Received); $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::Confirmed); $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 testSendAccessLinkRefreshesTheLinkBeforeSending(): void { // The mail is the only way a link reaches the customer, so every send hands out a // link that is good for another full TTL — the previous one stops working. $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::once())->method('createAndSendEmail'); $service = $this->createServiceWithAccommodation( entityManager: $entityManager, mailer: $mailer, breakdownCalculator: $breakdownCalculator, ); $service->sendAccessLink($booking); self::assertGreaterThan($previousIssuedAt, $booking->getAccessLinkIssuedAt()); } public function testSendAccessLinkNoOpsForADraft(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Draft); $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); $service->sendAccessLink($booking); self::assertNull($booking->getAccessLinkIssuedAt()); } public function testADraftGetsNoAccessLinkGenerated(): void { // A draft has not been offered to anyone, so there is nothing a link could show — // the link is issued by sendOffer(), together with the mail that carries it. $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Draft); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $service->issueAccessLink($booking); self::assertNull($booking->getAccessLinkIssuedAt()); } public function testAnInquiryIsPersistedAsRequestedSoTheOfficeStillOwesAnOffer(): void { // Angefragt, not Offen: the customer has asked, nobody has offered anything yet. $booking = $this->persistDto(forceInquiry: true); self::assertSame(AccommodationBookingStatus::Requested, $booking->getStatus()); self::assertSame(AccommodationBookingOrigin::Offer, $booking->getOrigin()); self::assertNull($booking->getAcceptedAt(), 'asking is not committing'); } public function testADirectBookingIsPersistedAsReceivedAwaitingTheOffice(): void { $booking = $this->persistDto(forceInquiry: false); self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus()); self::assertSame(AccommodationBookingOrigin::Direct, $booking->getOrigin()); self::assertNotNull($booking->getAcceptedAt(), 'booking directly is the commitment'); } private function persistDto(bool $forceInquiry): AccommodationBooking { // A price covering the whole window with no minimum the booking misses, so // computeInquiryStatus() has no reason of its own to force an inquiry. $price = (new AccommodationPrice()) ->setDateFrom(new \DateTimeImmutable('2026-07-01')) ->setDateTo(new \DateTimeImmutable('2026-09-30')) ->setIncludedPax(10) ->setMinNights(2); $dto = new AccommodationBookingDto(); $dto->dateFrom = new \DateTimeImmutable('2026-08-01'); $dto->dateTo = new \DateTimeImmutable('2026-08-05'); $dto->paxCount = 40; $dto->groupName = 'Schulklasse 7b'; $dto->email = 'customer@example.com'; $dto->forceInquiry = $forceInquiry; $accommodation = (new Accommodation())->setName('Hotel')->setCalendarCode('HOTEL')->setMaxAdolescentAge(17); return $this->createServiceWithAccommodation()->persist( $dto, $accommodation, [$price], ['additionalServices' => [], 'boardServices' => []], ); } public function testSendOfferIssuesTheLinkMovesToOpenAndMailsTheCustomer(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::atLeastOnce())->method('flush'); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Requested); $booking->setEmail('customer@example.com'); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( self::anything(), self::callback(static fn (array $options) => 'customer@example.com' === $options['to'] && 'Dein Angebot ist bereit' === $options['subject'] && 'email/accommodation_booking_customer.html.twig' === $options['template']), ); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $service->sendOffer($booking); self::assertSame(AccommodationBookingStatus::Open, $booking->getStatus()); self::assertNotNull($booking->getAccessLinkIssuedAt(), 'the mail links to the offer, so the link has to exist by the time it goes out'); } public function testSendOfferPublishesAnAdminAuthoredDraftTheSameWay(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Draft); $booking->setEmail('customer@example.com'); $service = $this->createServiceWithAccommodation(); $service->sendOffer($booking); self::assertSame(AccommodationBookingStatus::Open, $booking->getStatus()); } public function testSendOfferKeepsAnAlreadyIssuedLinkSoEarlierLinksStayValid(): void { $issuedAt = new \DateTimeImmutable('2026-01-01'); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Requested); $booking->setEmail('customer@example.com'); $booking->setAccessLinkIssuedAt($issuedAt); $service = $this->createServiceWithAccommodation(); $service->sendOffer($booking); self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt()); } /** * @dataProvider statusesThatAreNotAwaitingAnOffer */ public function testSendOfferNoOpsOnceTheOfferIsOut(AccommodationBookingStatus $status): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $booking = new AccommodationBooking(); $booking->setStatus($status); $booking->setEmail('customer@example.com'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $service->sendOffer($booking); self::assertSame($status, $booking->getStatus()); } /** * @return iterable */ public static function statusesThatAreNotAwaitingAnOffer(): iterable { yield 'open' => [AccommodationBookingStatus::Open]; yield 'received' => [AccommodationBookingStatus::Received]; yield 'confirmed' => [AccommodationBookingStatus::Confirmed]; yield 'discarded' => [AccommodationBookingStatus::Discarded]; } public function testDiscardBookingClosesTheBookingSilently(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setEmail('customer@example.com'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $service->discardBooking($booking); self::assertSame(AccommodationBookingStatus::Discarded, $booking->getStatus()); } /** * @dataProvider closedStatuses */ public function testDiscardBookingNoOpsForAClosedBooking(AccommodationBookingStatus $status): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $booking = new AccommodationBooking(); $booking->setStatus($status); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $service->discardBooking($booking); self::assertSame($status, $booking->getStatus()); } /** * @return iterable */ public static function closedStatuses(): iterable { yield 'confirmed' => [AccommodationBookingStatus::Confirmed]; yield 'discarded' => [AccommodationBookingStatus::Discarded]; } /** * The dead end this flow was rebuilt to remove: Offen used to mean both "inquiry not yet * offered" and "offer out with the customer", and acceptBooking() additionally demanded * the Anfrage type. A direct booking nudged into Offen therefore rendered an offer page * whose accept button silently did nothing, forever. Offen is now reachable only through * sendOffer(), and acceptance turns on the status alone. */ public function testAnOpenBookingCanAlwaysBeAcceptedWhateverItsOrigin(): void { foreach (AccommodationBookingOrigin::cases() as $origin) { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setOrigin($origin); $booking->setEmail('customer@example.com'); $this->createServiceWithAccommodation()->acceptBooking($booking); self::assertSame( AccommodationBookingStatus::Received, $booking->getStatus(), sprintf('an offer that is out must be acceptable, %s origin included', $origin->value), ); } } public function testTheOriginSurvivesTheWholeLifecycleUntouched(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Requested); $booking->setOrigin(AccommodationBookingOrigin::Offer); $booking->setEmail('customer@example.com'); $service = $this->createServiceWithAccommodation(); $service->sendOffer($booking); $service->acceptBooking($booking); $service->confirmBooking($booking); self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus()); self::assertSame( AccommodationBookingOrigin::Offer, $booking->getOrigin(), 'where a booking came from stays true at every point of its life', ); } public function testAcceptBookingMovesOpenOfferToReceivedAndNotifiesTheOfficeOnly(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Open); $booking->setEmail('customer@example.com'); // The customer hears nothing until the office has validated the booking. $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'] && 'office@example.com' === $options['from'] && 'email/offer_accepted.html.twig' === $options['template']), ); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $service->acceptBooking($booking); self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus()); self::assertSame(AccommodationBookingOrigin::Offer, $booking->getOrigin(), 'accepting does not rewrite where the booking came from'); 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::Received); $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 testConfirmBookingConfirmsReceivedBookingAndNotifiesTheCustomer(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Received); $booking->setEmail('customer@example.com'); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('sign')->with($booking)->willReturn('https://example.com/offer/signed-link'); $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'] && 'Deine Buchung ist bestätigt' === $options['subject'] && 'email/booking_confirmed_customer.html.twig' === $options['template']), ); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer, linkSigner: $linkSigner); $service->confirmBooking($booking); self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus()); self::assertNotNull($booking->getConfirmedAt()); self::assertNotNull($booking->getAccessLinkIssuedAt(), 'the confirmation links to the booking view'); } public function testConfirmBookingNoOpsForAnythingNotAwaitingValidation(): 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); foreach ([AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, AccommodationBookingStatus::Confirmed, AccommodationBookingStatus::Discarded] as $status) { $booking = new AccommodationBooking(); $booking->setStatus($status); $booking->setEmail('customer@example.com'); $service->confirmBooking($booking); self::assertSame($status, $booking->getStatus()); self::assertNull($booking->getConfirmedAt()); } } public function testBookingEmailsCarryThePriceBreakdown(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Open); $breakdown = ['total' => 10000, 'currency' => 'EUR']; $booking->setPriceSnapshot($breakdown, 9000, 'CHF', 1); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator->method('compute')->with($booking)->willReturn($breakdown); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( // The stored pricing currency wins over the one frozen in the breakdown. self::callback(static fn (array $context) => $breakdown === $context['priceBreakdown'] && 'CHF' === $context['currency']), self::anything(), ); $service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator); $service->sendCustomerConfirmationEmail($booking); } public function testConfirmBookingSkipsTheCustomerEmailWhenNoAddressIsStored(): void { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Received); $mailer = $this->createMock(Mailer::class); $mailer->expects(self::never())->method('createAndSendEmail'); $logger = $this->createMock(LoggerInterface::class); $logger ->expects(self::once()) ->method('warning') ->with('Failed to send booking confirmed customer email', self::anything()); $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger); $service->confirmBooking($booking); self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus(), 'the confirmation 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::Received); $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->setOrigin(AccommodationBookingOrigin::Offer); $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::Received); $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 testSendBookingConfirmedCustomerEmailLogsAndSwallowsMailerFailures(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Confirmed); $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->sendBookingConfirmedCustomerEmail($booking); } public function testTheConfirmationEmailCarriesTheBookingPdf(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Confirmed); $attachment = new PdfAttachment('%PDF-1.7', 'buchung.pdf'); $pdfGenerator = $this->createMock(AccommodationBookingPdfGenerator::class); $pdfGenerator->expects(self::once())->method('createAttachment')->with($booking)->willReturn($attachment); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( self::anything(), self::callback(fn (array $options) => [$attachment] === $options['attachments']), ); $service = $this->createServiceWithAccommodation(mailer: $mailer, pdfGenerator: $pdfGenerator); $service->sendBookingConfirmedCustomerEmail($booking); } public function testAFailingPdfStillLetsTheConfirmationEmailGoOut(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); $booking->setStatus(AccommodationBookingStatus::Confirmed); $pdfGenerator = $this->createMock(AccommodationBookingPdfGenerator::class); $pdfGenerator->method('createAttachment')->willThrowException(new \RuntimeException('no prices')); $mailer = $this->createMock(Mailer::class); $mailer ->expects(self::once()) ->method('createAndSendEmail') ->with( self::anything(), self::callback(fn (array $options) => [] === $options['attachments']), ); $logger = $this->createMock(LoggerInterface::class); $logger->expects(self::once())->method('warning'); $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger, pdfGenerator: $pdfGenerator); $service->sendBookingConfirmedCustomerEmail($booking); } /** * The discount arithmetic itself lives in — and is tested with — * AccommodationBookingBreakdownCalculator; what matters here is that the raw breakdown is * frozen and the stored total is the calculator's discounted one. */ public function testRefreshPriceSnapshotStoresTheRawBreakdownAndTheDiscountedTotal(): void { $booking = new AccommodationBooking(); $breakdown = ['total' => 12345, 'currency' => 'CHF']; $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator ->expects(self::once()) ->method('computeCurrent') ->with($booking) ->willReturn($breakdown); $breakdownCalculator ->method('withDiscounts') ->with($booking, $breakdown) ->willReturn($breakdown + ['discounts' => [], 'discountedTotal' => 10895]); $service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator); $service->refreshPriceSnapshot($booking); self::assertSame($breakdown, $booking->getPriceBreakdown(), 'the derived keys must not be persisted'); self::assertSame(10895, $booking->getTotalPrice()); self::assertSame('CHF', $booking->getPricingCurrency()); self::assertSame(1, $booking->getPricingVersion()); } public function testChangeAccommodationSwapsTheHouseAndDropsTheBookedServices(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = $this->draftWithServices(); $newAccommodation = (new Accommodation())->setName('Berghaus')->setCalendarCode('BERG'); $service->changeAccommodation($booking, $newAccommodation); self::assertSame($newAccommodation, $booking->getAccommodation()); // The services belong to the old house's catalog and would carry its prices along. self::assertNull($booking->getBoardServiceLabel()); self::assertNull($booking->getBoardServicePrice()); self::assertNull($booking->getBoardServiceOriginalId()); self::assertSame([], $booking->getAdditionalServices()); self::assertNull($booking->getTotalPrice(), 'the frozen price belonged to the old house'); } public function testChangeAccommodationNoOpsForANonDraft(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = $this->draftWithServices(); $booking->setStatus(AccommodationBookingStatus::Open); $oldAccommodation = $booking->getAccommodation(); $service->changeAccommodation($booking, (new Accommodation())->setName('Berghaus')); self::assertSame($oldAccommodation, $booking->getAccommodation()); self::assertSame('Vollpension', $booking->getBoardServiceLabel()); self::assertCount(1, $booking->getAdditionalServices()); } public function testChangeAccommodationNoOpsWhenTheHouseIsUnchanged(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); $service = $this->createServiceWithAccommodation(entityManager: $entityManager); $booking = $this->draftWithServices(); $accommodation = $booking->getAccommodation(); self::assertNotNull($accommodation); $service->changeAccommodation($booking, $accommodation); self::assertSame('Vollpension', $booking->getBoardServiceLabel()); self::assertCount(1, $booking->getAdditionalServices()); } private function draftWithServices(): AccommodationBooking { $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Draft); $booking->setAccommodation((new Accommodation())->setName('Seehaus')->setCalendarCode('SEE')); $booking->setBoardServiceLabel('Vollpension'); $booking->setBoardServicePrice(4500); $booking->setBoardServiceOriginalId(7); $booking->addAdditionalServiceSnapshot('Bettwäsche', 1200, AdditionalServiceType::Flat, 12); $booking->setPriceSnapshot(['total' => 12345], 12345, 'EUR', 1); return $booking; } /** * @param AccommodationPrice[] $prices */ private function createServiceWithAccommodation( array $prices = [], ?EntityManagerInterface $entityManager = null, ?Mailer $mailer = null, ?LoggerInterface $logger = null, ?AccommodationBookingLinkSigner $linkSigner = null, ?AccommodationBookingBreakdownCalculator $breakdownCalculator = null, ?AccommodationBookingPdfGenerator $pdfGenerator = 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), $pdfGenerator ?? $this->createMock(AccommodationBookingPdfGenerator::class), 'office@example.com', ); } }