444 lines
19 KiB
PHP
444 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Entity\Groups;
|
|
|
|
use App\Entity\Groups\AccommodationBooking;
|
|
use App\Entity\Groups\AccommodationBookingPeriod;
|
|
use App\Entity\User;
|
|
use App\Enum\Groups\AccommodationBookingOrigin;
|
|
use App\Enum\Groups\AccommodationBookingPeriodPosition;
|
|
use App\Enum\Groups\AccommodationBookingStatus;
|
|
use App\Exception\ImplausibleBookingPriceException;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\TestCase;
|
|
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
|
use Symfony\Component\Validator\Validation;
|
|
|
|
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());
|
|
}
|
|
|
|
public function testACopyCarriesItsOwnSplitOffPeriods(): void
|
|
{
|
|
// The periods are part of the quote being varied, but editing them on the copy must
|
|
// not reach back into the original.
|
|
$booking = $this->confirmedBooking();
|
|
$booking->addPeriod((new AccommodationBookingPeriod(
|
|
AccommodationBookingPeriodPosition::Leading,
|
|
new \DateTimeImmutable('2026-03-01'),
|
|
new \DateTimeImmutable('2026-03-02'),
|
|
))->setLines([['label' => 'Vorausteam', 'amount' => 30000]]));
|
|
$original = $booking->getPeriods()->first();
|
|
|
|
$copy = clone $booking;
|
|
$copied = $copy->getPeriods()->first();
|
|
|
|
self::assertCount(1, $copy->getPeriods());
|
|
self::assertNotSame($original, $copied);
|
|
self::assertSame($copy, $copied->getBooking());
|
|
self::assertNull($copied->getId());
|
|
self::assertSame($original->getLines(), $copied->getLines());
|
|
self::assertSame($booking, $original->getBooking());
|
|
}
|
|
|
|
public function testWithoutPeriodsTheMainPeriodIsTheWholeStay(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
|
|
|
self::assertEquals(new \DateTimeImmutable('2026-03-01'), $booking->getMainDateFrom());
|
|
self::assertEquals(new \DateTimeImmutable('2026-03-08'), $booking->getMainDateTo());
|
|
self::assertSame(7, $booking->getMainNights());
|
|
self::assertSame(0, $booking->getPeriodsTotal());
|
|
}
|
|
|
|
public function testTheMainPeriodIsWhatRemainsBetweenTheSplitOffPeriods(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 2000));
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 3000));
|
|
|
|
self::assertEquals(new \DateTimeImmutable('2026-03-03'), $booking->getMainDateFrom());
|
|
self::assertEquals(new \DateTimeImmutable('2026-03-07'), $booking->getMainDateTo());
|
|
self::assertSame(4, $booking->getMainNights());
|
|
self::assertSame(7, $booking->getNights(), 'the stay itself is unchanged');
|
|
self::assertSame(5000, $booking->getPeriodsTotal());
|
|
}
|
|
|
|
public function testPeriodsThatTileTheEdgesOfTheStayAreValid(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0));
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0));
|
|
|
|
self::assertCount(0, $this->validate($booking));
|
|
}
|
|
|
|
public function testMovingTheArrivalAwayFromALeadingPeriodIsReportedOnTheArrival(): void
|
|
{
|
|
$booking = $this->stay('2026-03-02', '2026-03-08');
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0));
|
|
|
|
$violations = $this->validate($booking);
|
|
|
|
self::assertCount(1, $violations);
|
|
self::assertSame('dateFrom', $violations[0]->getPropertyPath());
|
|
}
|
|
|
|
public function testASecondPeriodOnTheSameSideIsRefused(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
|
|
|
self::assertCount(1, $this->validate($booking));
|
|
}
|
|
|
|
public function testMovingTheDepartureAwayFromATrailingPeriodIsReportedOnTheDeparture(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-09');
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0));
|
|
|
|
$violations = $this->validate($booking);
|
|
|
|
self::assertCount(1, $violations);
|
|
self::assertSame('dateTo', $violations[0]->getPropertyPath());
|
|
}
|
|
|
|
public function testTheGroupMustKeepAtLeastOneNight(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-03');
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0));
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-02', '2026-03-03', 0));
|
|
|
|
$violations = $this->validate($booking);
|
|
|
|
self::assertCount(1, $violations);
|
|
self::assertSame('dateTo', $violations[0]->getPropertyPath());
|
|
}
|
|
|
|
public function testAPeriodWithoutANightIsRefused(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
|
$booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-01', 0));
|
|
|
|
self::assertCount(1, $this->validate($booking));
|
|
}
|
|
|
|
public function testSplittingOffAttachesThePeriodToItsEdgeOfTheStay(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
|
|
|
$first = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
|
$last = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
|
|
|
self::assertSame(['2026-03-01', '2026-03-03'], $this->range($first));
|
|
self::assertSame(['2026-03-07', '2026-03-08'], $this->range($last));
|
|
self::assertSame(4, $booking->getMainNights());
|
|
self::assertCount(0, $this->validate($booking));
|
|
}
|
|
|
|
public function testEachSideCanOnlyBeSplitOnce(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
|
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
|
|
|
self::assertFalse($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading));
|
|
self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Trailing));
|
|
|
|
$this->expectException(\LogicException::class);
|
|
$booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
|
}
|
|
|
|
public function testResizingKeepsAPeriodAtItsEdge(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-10');
|
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1);
|
|
|
|
$booking->resizePeriod($period, 3);
|
|
|
|
self::assertSame(['2026-03-07', '2026-03-10'], $this->range($period));
|
|
self::assertSame(6, $booking->getMainNights());
|
|
}
|
|
|
|
public function testRemovingAPeriodGivesItsNightsBackAndFreesTheSide(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-08');
|
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2);
|
|
|
|
$booking->removePeriod($period);
|
|
|
|
self::assertNull($period->getBooking());
|
|
self::assertSame(7, $booking->getMainNights());
|
|
self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading));
|
|
}
|
|
|
|
public function testGrowingAPeriodIntoTheLastNightOfTheGroupIsRefused(): void
|
|
{
|
|
$booking = $this->stay('2026-03-01', '2026-03-04');
|
|
$period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1);
|
|
|
|
$booking->resizePeriod($period, 3);
|
|
|
|
self::assertCount(1, $this->validate($booking));
|
|
}
|
|
|
|
/** @return array{string, string} */
|
|
private function range(AccommodationBookingPeriod $period): array
|
|
{
|
|
return [$period->getDateFrom()->format('Y-m-d'), $period->getDateTo()->format('Y-m-d')];
|
|
}
|
|
|
|
private function stay(string $from, string $to): AccommodationBooking
|
|
{
|
|
return (new AccommodationBooking())
|
|
->setDateFrom(new \DateTimeImmutable($from))
|
|
->setDateTo(new \DateTimeImmutable($to));
|
|
}
|
|
|
|
private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, int $amount): AccommodationBookingPeriod
|
|
{
|
|
return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to)))
|
|
->setLines([['label' => 'Vorausteam', 'amount' => $amount]]);
|
|
}
|
|
|
|
private function validate(AccommodationBooking $booking): ConstraintViolationListInterface
|
|
{
|
|
return Validation::createValidatorBuilder()
|
|
->enableAttributeMapping()
|
|
->getValidator()
|
|
->validate($booking);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|