feat: refactored accommodation booking status model
This commit is contained in:
@@ -9,8 +9,9 @@ use App\Email\PdfAttachment;
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Entity\Groups\AccommodationPrice;
|
||||
use App\Enum\Groups\AccommodationBookingOrigin;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Enum\Groups\AccommodationBookingType;
|
||||
use App\Form\Model\AccommodationBookingDto;
|
||||
use App\Model\AccommodationBookingQueryParams;
|
||||
use App\Repository\Groups\AccommodationPriceRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
@@ -69,14 +70,14 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setType(AccommodationBookingType::Booking);
|
||||
$booking->setOrigin(AccommodationBookingOrigin::Direct);
|
||||
|
||||
$service->issueAccessLinkForDirectBooking($booking);
|
||||
|
||||
self::assertNotNull($booking->getAccessLinkIssuedAt());
|
||||
}
|
||||
|
||||
public function testIssueAccessLinkForDirectBookingNoOpsForInquiry(): void
|
||||
public function testIssueAccessLinkForDirectBookingNoOpsForAnOffer(): void
|
||||
{
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::never())->method('flush');
|
||||
@@ -84,7 +85,7 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setType(AccommodationBookingType::Inquiry);
|
||||
$booking->setOrigin(AccommodationBookingOrigin::Offer);
|
||||
|
||||
$service->issueAccessLinkForDirectBooking($booking);
|
||||
|
||||
@@ -99,7 +100,7 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setType(AccommodationBookingType::Booking);
|
||||
$booking->setOrigin(AccommodationBookingOrigin::Direct);
|
||||
$issuedAt = new \DateTimeImmutable('2026-01-01');
|
||||
$booking->setAccessLinkIssuedAt($issuedAt);
|
||||
|
||||
@@ -267,7 +268,236 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
|
||||
}
|
||||
|
||||
public function testAcceptBookingMovesOpenInquiryToReceivedAndNotifiesTheOfficeOnly(): void
|
||||
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 = '[email protected]';
|
||||
$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('[email protected]');
|
||||
|
||||
$mailer = $this->createMock(Mailer::class);
|
||||
$mailer
|
||||
->expects(self::once())
|
||||
->method('createAndSendEmail')
|
||||
->with(
|
||||
self::anything(),
|
||||
self::callback(static fn (array $options) => '[email protected]' === $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('[email protected]');
|
||||
|
||||
$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('[email protected]');
|
||||
$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('[email protected]');
|
||||
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
|
||||
|
||||
$service->sendOffer($booking);
|
||||
|
||||
self::assertSame($status, $booking->getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{AccommodationBookingStatus}>
|
||||
*/
|
||||
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('[email protected]');
|
||||
|
||||
$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<string, array{AccommodationBookingStatus}>
|
||||
*/
|
||||
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('[email protected]');
|
||||
|
||||
$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('[email protected]');
|
||||
|
||||
$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');
|
||||
@@ -293,7 +523,7 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
$service->acceptBooking($booking);
|
||||
|
||||
self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
|
||||
self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage');
|
||||
self::assertSame(AccommodationBookingOrigin::Offer, $booking->getOrigin(), 'accepting does not rewrite where the booking came from');
|
||||
self::assertNotNull($booking->getAcceptedAt());
|
||||
}
|
||||
|
||||
@@ -482,7 +712,7 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
|
||||
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setType(AccommodationBookingType::Inquiry);
|
||||
$booking->setOrigin(AccommodationBookingOrigin::Offer);
|
||||
$booking->setStatus(AccommodationBookingStatus::Draft);
|
||||
|
||||
$service->acceptBooking($booking);
|
||||
|
||||
Reference in New Issue
Block a user