feat: additional confirmation cycle and status for groups inquiries

This commit is contained in:
Björn Fromme
2026-08-18 12:48:17 +02:00
parent 5045846eed
commit 8dee65d0dc
28 changed files with 677 additions and 95 deletions
@@ -169,7 +169,7 @@ class AccommodationBookingServiceTest extends TestCase
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Accepted);
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
@@ -265,7 +265,7 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
}
public function testAcceptBookingAcceptsOpenInquiryAndSendsNotifications(): void
public function testAcceptBookingMovesOpenInquiryToReceivedAndNotifiesTheOfficeOnly(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
@@ -274,22 +274,23 @@ class AccommodationBookingServiceTest extends TestCase
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setEmail('[email protected]');
// The customer hears nothing until the office has validated the booking.
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::exactly(2))
->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
self::callback(static fn (array $options) => in_array($options['to'], ['[email protected]', '[email protected]'], true)
self::callback(static fn (array $options) => '[email protected]' === $options['to']
&& '[email protected]' === $options['from']
&& in_array($options['template'], ['email/offer_accepted.html.twig', 'email/offer_accepted_customer.html.twig'], true)),
&& 'email/offer_accepted.html.twig' === $options['template']),
);
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$service->acceptBooking($booking);
self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus());
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage');
self::assertNotNull($booking->getAcceptedAt());
}
@@ -339,7 +340,7 @@ class AccommodationBookingServiceTest extends TestCase
public function testAcceptBookingDoesNotStoreRemarksWhenOfferIsNotOpen(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Accepted);
$booking->setStatus(AccommodationBookingStatus::Received);
$booking->setRemarks('vom Telefonat');
$service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class));
@@ -349,32 +350,105 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame('vom Telefonat', $booking->getRemarks());
}
public function testAcceptBookingSkipsTheCustomerEmailWhenNoAddressIsStored(): void
public function testConfirmBookingConfirmsReceivedBookingAndNotifiesTheCustomer(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setStatus(AccommodationBookingStatus::Received);
$booking->setEmail('[email protected]');
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('sign')->with($booking)->willReturn('https://example.com/offer/signed-link');
// 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']),
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('[email protected]');
$service->confirmBooking($booking);
self::assertSame($status, $booking->getStatus());
self::assertNull($booking->getConfirmedAt());
}
}
public function testBookingEmailsCarryThePriceBreakdown(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$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 offer accepted customer email', self::anything());
->with('Failed to send booking confirmed customer email', self::anything());
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
$service->acceptBooking($booking);
$service->confirmBooking($booking);
self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus(), 'the acceptance itself must not fail');
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus(), 'the confirmation itself must not fail');
}
public function testAcceptBookingIsIdempotentAndSendsNoNotifications(): void
@@ -388,7 +462,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Accepted);
$booking->setStatus(AccommodationBookingStatus::Received);
$service->acceptBooking($booking);
@@ -418,7 +492,7 @@ class AccommodationBookingServiceTest extends TestCase
public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Accepted);
$booking->setStatus(AccommodationBookingStatus::Received);
$mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
@@ -431,11 +505,11 @@ class AccommodationBookingServiceTest extends TestCase
$service->sendOfferAcceptedNotificationEmail($booking);
}
public function testSendOfferAcceptedCustomerEmailLogsAndSwallowsMailerFailures(): void
public function testSendBookingConfirmedCustomerEmailLogsAndSwallowsMailerFailures(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Accepted);
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
@@ -445,7 +519,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
$service->sendOfferAcceptedCustomerEmail($booking);
$service->sendBookingConfirmedCustomerEmail($booking);
}
public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void