249 lines
10 KiB
PHP
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;
|
|
}
|
|
}
|