setStatus($status); self::assertNull($booking->getAcceptedAt()); self::assertSame('Angebot', $booking->recordLabel()); } /** * @return iterable */ public static function statusesBeforeTheCustomerCommits(): iterable { yield 'draft' => [AccommodationBookingStatus::Draft]; yield 'requested' => [AccommodationBookingStatus::Requested]; yield 'open' => [AccommodationBookingStatus::Open]; } #[DataProvider('statusesAfterTheCustomerCommits')] public function testARecordIsCalledABookingOnceTheCustomerHasCommitted(AccommodationBookingStatus $status): void { $booking = new AccommodationBooking(); $booking->setStatus($status); $booking->setAcceptedAt(new \DateTimeImmutable()); self::assertSame('Buchung', $booking->recordLabel()); } /** * @return iterable */ public static function statusesAfterTheCustomerCommits(): iterable { yield 'received' => [AccommodationBookingStatus::Received]; yield 'confirmed' => [AccommodationBookingStatus::Confirmed]; } public function testADiscardedRecordKeepsWhicheverItHadBecome(): void { // An Absage can close either an offer nobody accepted or a booking that fell // through, so the label follows the commitment rather than the status. $unaccepted = new AccommodationBooking(); $unaccepted->setStatus(AccommodationBookingStatus::Discarded); $accepted = new AccommodationBooking(); $accepted->setStatus(AccommodationBookingStatus::Discarded); $accepted->setAcceptedAt(new \DateTimeImmutable()); self::assertSame('Angebot', $unaccepted->recordLabel()); self::assertSame('Buchung', $accepted->recordLabel()); } public function testADraftIsTheOnlyStatusTheCustomerMustNotReach(): void { // Entwurf is the office's own workbench — nothing has been offered yet, so there is // nothing an access link could show. Whether a link that exists still opens anything // is a separate question the offer page answers (a discarded booking does not). foreach (AccommodationBookingStatus::cases() as $status) { $booking = new AccommodationBooking(); $booking->setStatus($status); self::assertSame( AccommodationBookingStatus::Draft !== $status, $booking->isCustomerAccessible(), $status->value, ); } } public function testAnImplausibleTotalIsRefusedRatherThanStored(): void { // The pax count and the nights are capped, so what gets here is a catalog price with a // digit too many. A stored total would end up on an offer; the INT column would answer // it with SQLSTATE 22003 and say nothing about the cause. $booking = new AccommodationBooking(); $booking->setDateFrom(new \DateTimeImmutable('2026-09-23')); $booking->setDateTo(new \DateTimeImmutable('2026-09-25')); $booking->setPaxCount(40); $this->expectException(ImplausibleBookingPriceException::class); $booking->setPriceSnapshot(['total' => AccommodationBooking::MAX_TOTAL_PRICE + 1], AccommodationBooking::MAX_TOTAL_PRICE + 1, 'EUR', 1); } public function testATotalAtTheCeilingIsStillStored(): void { $booking = new AccommodationBooking(); $booking->setPriceSnapshot(['total' => AccommodationBooking::MAX_TOTAL_PRICE], AccommodationBooking::MAX_TOTAL_PRICE, 'EUR', 1); self::assertSame(AccommodationBooking::MAX_TOTAL_PRICE, $booking->getTotalPrice()); } public function testTheCeilingLeavesTheIntColumnRoomToSpare(): void { // The ceiling exists to keep implausible prices out, not to use up the column: a total // that needs more room than this is a mistake, never a wider column. self::assertLessThan(2147483647, AccommodationBooking::MAX_TOTAL_PRICE); } public function testTheLabelIsIndependentOfWhereTheBookingCameFrom(): void { // Origin answers "how did this come about" and is deliberately not the same // question — a confirmed booking that started as an offer is still a Buchung. foreach (AccommodationBookingOrigin::cases() as $origin) { $booking = new AccommodationBooking(); $booking->setOrigin($origin); $booking->setStatus(AccommodationBookingStatus::Confirmed); $booking->setAcceptedAt(new \DateTimeImmutable()); self::assertSame('Buchung', $booking->recordLabel(), $origin->value); } } }