feat: duplicate an accommodation booking into a new draft
addresses #869f6z44f
This commit is contained in:
1 parent
2aa98b81fe
commit
29f295abf6
9 files changed
+470
No files matched your search
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin\AccommodationBooking;
|
||||
|
||||
use App\Controller\Admin\AccommodationBooking\DuplicateController;
|
||||
use App\Entity\Groups\AccommodationBooking;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Service\AccommodationBookingService;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* Covers the guards around copying — the copy itself is tested in AccommodationBookingTest
|
||||
* and AccommodationBookingServiceTest.
|
||||
*/
|
||||
class DuplicateControllerTest extends TestCase
|
||||
{
|
||||
public function testGetRendersTheConfirmationModal(): void
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('duplicate');
|
||||
|
||||
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
|
||||
$response = $controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate'));
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertSame('admin/accommodation_booking/modal_duplicate.html.twig', $controller->renderedView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copying does not touch the original, so unlike absagen and löschen it is offered in
|
||||
* every status — "same group, next season" starts from a confirmed booking.
|
||||
*/
|
||||
#[DataProvider('everyStatus')]
|
||||
public function testABookingMayBeCopiedInAnyStatus(AccommodationBookingStatus $status): void
|
||||
{
|
||||
$booking = $this->openBooking();
|
||||
$booking->setStatus($status);
|
||||
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::once())
|
||||
->method('duplicate')
|
||||
->with($booking)
|
||||
->willReturn(new AccommodationBooking());
|
||||
|
||||
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
|
||||
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/duplicate', 'POST'));
|
||||
|
||||
self::assertTrue($response->headers->has('HX-Redirect'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{AccommodationBookingStatus}>
|
||||
*/
|
||||
public static function everyStatus(): iterable
|
||||
{
|
||||
foreach (AccommodationBookingStatus::cases() as $status) {
|
||||
yield $status->value => [$status];
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheBrowserIsSentToTheCopyAndKeepsTheReturnUrl(): void
|
||||
{
|
||||
$bookingService = $this->createStub(AccommodationBookingService::class);
|
||||
$bookingService->method('duplicate')->willReturn(new AccommodationBooking());
|
||||
|
||||
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class));
|
||||
|
||||
$response = $controller->index(
|
||||
$this->openBooking(),
|
||||
Request::create('/admin/accommodation-booking/1/duplicate?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2', 'POST'),
|
||||
);
|
||||
|
||||
$location = (string) $response->headers->get('HX-Redirect');
|
||||
|
||||
self::assertStringContainsString('app_admin_accommodationbooking_edit', $location);
|
||||
self::assertStringContainsString('r=', $location);
|
||||
}
|
||||
|
||||
public function testPostWithAnInvalidTokenIsDenied(): void
|
||||
{
|
||||
$bookingService = $this->createMock(AccommodationBookingService::class);
|
||||
$bookingService->expects(self::never())->method('duplicate');
|
||||
|
||||
$controller = new TestableDuplicateController($bookingService, $this->createStub(LoggerInterface::class), tokenValid: false);
|
||||
|
||||
$this->expectException(AccessDeniedException::class);
|
||||
|
||||
$controller->index($this->openBooking(), Request::create('/admin/accommodation-booking/1/duplicate', 'POST'));
|
||||
}
|
||||
|
||||
private function openBooking(): AccommodationBooking
|
||||
{
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setStatus(AccommodationBookingStatus::Open);
|
||||
$booking->setGroupName('Schulklasse 7b');
|
||||
$booking->setEmail('[email protected]');
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableDuplicateController extends DuplicateController
|
||||
{
|
||||
public ?string $renderedView = null;
|
||||
|
||||
/** @var list<array{type: string, message: mixed}> */
|
||||
public array $flashes = [];
|
||||
|
||||
public function __construct(
|
||||
AccommodationBookingService $bookingService,
|
||||
LoggerInterface $logger,
|
||||
private readonly bool $tokenValid = true,
|
||||
) {
|
||||
parent::__construct($bookingService, $logger);
|
||||
}
|
||||
|
||||
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
|
||||
{
|
||||
return $this->tokenValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderedView = $view;
|
||||
|
||||
return new Response();
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
|
||||
{
|
||||
return '/'.$route.'?'.http_build_query($parameters);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -131,4 +132,117 @@ class AccommodationBookingTest extends TestCase
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1121,6 +1121,57 @@ class AccommodationBookingServiceTest extends TestCase
|
||||
self::assertSame(['total' => 1000], $ctx->priceBreakdown);
|
||||
}
|
||||
|
||||
public function testDuplicatePersistsACopyAndLeavesTheOriginalAlone(): void
|
||||
{
|
||||
$booking = (new AccommodationBooking())
|
||||
->setGroupName('Skiclub Nord')
|
||||
->setPaxCount(30);
|
||||
$booking->setStatus(AccommodationBookingStatus::Confirmed);
|
||||
|
||||
$persisted = null;
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
$entityManager->expects(self::once())
|
||||
->method('persist')
|
||||
->willReturnCallback(function (object $entity) use (&$persisted): void { $persisted = $entity; });
|
||||
$entityManager->expects(self::once())->method('flush');
|
||||
|
||||
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
|
||||
|
||||
$copy = $service->duplicate($booking);
|
||||
|
||||
self::assertSame($persisted, $copy);
|
||||
self::assertNotSame($booking, $copy);
|
||||
self::assertSame(30, $copy->getPaxCount());
|
||||
self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus());
|
||||
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
|
||||
self::assertSame('Skiclub Nord', $booking->getGroupName());
|
||||
}
|
||||
|
||||
public function testDuplicateMarksTheCopyByName(): void
|
||||
{
|
||||
$booking = (new AccommodationBooking())->setGroupName('Skiclub Nord');
|
||||
|
||||
$copy = $this->createServiceWithAccommodation()->duplicate($booking);
|
||||
|
||||
self::assertSame('Skiclub Nord (Kopie)', $copy->getGroupName());
|
||||
}
|
||||
|
||||
public function testDuplicateNeverRecomputesThePrice(): void
|
||||
{
|
||||
// The copy has to show what the original was quoted at, not today's catalog prices.
|
||||
$booking = new AccommodationBooking();
|
||||
$booking->setPriceSnapshot(['total' => 500000], 480000, 'CHF', 1);
|
||||
|
||||
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
|
||||
$breakdownCalculator->expects(self::never())->method('computeCurrent');
|
||||
|
||||
$copy = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator)->duplicate($booking);
|
||||
|
||||
self::assertSame(['total' => 500000], $copy->getPriceBreakdown());
|
||||
self::assertSame(480000, $copy->getTotalPrice());
|
||||
self::assertSame('CHF', $copy->getPricingCurrency());
|
||||
}
|
||||
|
||||
private function createBookingWithContactData(): AccommodationBooking
|
||||
{
|
||||
return (new AccommodationBooking())
|
||||
|
||||
Reference in new issue
Block a user