Files
myep/tests/Entity/Groups/AccommodationBookingTest.php
T
2026-09-28 08:55:01 +02:00

249 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Entity\Groups;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingOrigin;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Exception\ImplausibleBookingPriceException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class AccommodationBookingTest extends TestCase
{
/**
* The label is what the backoffice prints next to the status, so it must never
* contradict it — "Angebot · Bestätigt" is the shape of mistake this replaces.
*/
#[DataProvider('statusesBeforeTheCustomerCommits')]
public function testARecordIsCalledAnOfferUntilTheCustomerCommits(AccommodationBookingStatus $status): void
{
$booking = new AccommodationBooking();
$booking->setStatus($status);
self::assertNull($booking->getAcceptedAt());
self::assertSame('Angebot', $booking->recordLabel());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
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<string, array{AccommodationBookingStatus}>
*/
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);
}
}
public function testACopyGetsItsOwnIdentityAndAccessLinkToken(): void
{
// Two records behind one uuid would mean two records behind one customer access link.
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertNull($copy->getId());
self::assertNotSame($booking->getUuid(), $copy->getUuid());
}
public function testACopyStartsAsAFreshOfficeDraftThatNobodyHasSeen(): void
{
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus());
self::assertSame(AccommodationBookingOrigin::Offer, $copy->getOrigin());
self::assertNull($copy->getAccessLinkIssuedAt());
self::assertNull($copy->getAcceptedAt());
self::assertNull($copy->getConfirmedAt());
self::assertNull($copy->getUpdatedAt());
self::assertNull($copy->getCreatedBy());
self::assertNull($copy->getUpdatedBy());
self::assertSame('Angebot', $copy->recordLabel());
}
public function testACopyCarriesThePricingAndTheDiscountsVerbatim(): void
{
// The copy is a variation of an offer that was already quoted, so it shows the same
// numbers rather than a re-quote at today's catalog prices.
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame($booking->getPriceBreakdown(), $copy->getPriceBreakdown());
self::assertSame($booking->getTotalPrice(), $copy->getTotalPrice());
self::assertSame($booking->getPricingCurrency(), $copy->getPricingCurrency());
self::assertSame($booking->getPricingVersion(), $copy->getPricingVersion());
self::assertSame(10, $copy->getAccommodationDiscount());
self::assertSame(5, $copy->getBoardServiceDiscount());
self::assertSame(20, $copy->getAdditionalServicesDiscount());
self::assertSame(2500, $copy->getTotalDiscountAmount());
self::assertSame('Treuerabatt', $copy->getTotalDiscountLabel());
}
public function testACopyKeepsWhatTheOfficeWouldOtherwiseKeyInAgain(): void
{
$booking = $this->confirmedBooking();
$copy = clone $booking;
self::assertSame($booking->getDateFrom(), $copy->getDateFrom());
self::assertSame($booking->getDateTo(), $copy->getDateTo());
self::assertSame(42, $copy->getPaxCount());
self::assertSame('Vollpension', $copy->getBoardServiceLabel());
self::assertSame(1900, $copy->getBoardServicePrice());
self::assertSame(7, $copy->getBoardServiceOriginalId());
self::assertSame($booking->getAdditionalServices(), $copy->getAdditionalServices());
self::assertSame('Skiclub Nord', $copy->getGroupName());
self::assertSame('[email protected]', $copy->getEmail());
}
public function testCloningLeavesTheOriginalAlone(): void
{
$booking = $this->confirmedBooking();
clone $booking;
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
self::assertNotNull($booking->getAcceptedAt());
self::assertNotNull($booking->getConfirmedAt());
self::assertSame('Skiclub Nord', $booking->getGroupName());
}
/**
* A record that has been all the way through the lifecycle, so a copy of it has the most
* to reset.
*/
private function confirmedBooking(): AccommodationBooking
{
$now = new \DateTimeImmutable('2026-01-15 10:00:00');
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setOrigin(AccommodationBookingOrigin::Direct);
$booking->setAccessLinkIssuedAt($now);
$booking->setAcceptedAt($now);
$booking->setConfirmedAt($now);
$booking->setCreatedAt($now);
$booking->setUpdatedAt($now);
$booking->setCreatedBy(new User('[email protected]'));
$booking->setUpdatedBy(new User('[email protected]'));
$booking->setDateFrom(new \DateTimeImmutable('2026-03-01'));
$booking->setDateTo(new \DateTimeImmutable('2026-03-08'));
$booking->setPaxCount(42);
$booking->setGroupName('Skiclub Nord');
$booking->setEmail('[email protected]');
$booking->setBoardServiceLabel('Vollpension');
$booking->setBoardServicePrice(1900);
$booking->setBoardServiceOriginalId(7);
$booking->addAdditionalServiceSnapshot('Skipass', 12000, 'per_person', 3);
$booking->setAccommodationDiscount(10);
$booking->setBoardServiceDiscount(5);
$booking->setAdditionalServicesDiscount(20);
$booking->setTotalDiscountAmount(2500);
$booking->setTotalDiscountLabel('Treuerabatt');
$booking->setPriceSnapshot(['total' => 500000], 480000, 'EUR', 1);
return $booking;
}
}