wip: group inquiries and bookings

This commit is contained in:
Björn Fromme committed 2026-07-14 11:29:43 +02:00
1 parent 6a6051e92b
commit 505bda46cf
115 files changed
+8107 -424

No files matched your search

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Groups\AccommodationBooking;
use App\Service\AccommodationBookingLinkSigner;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AccommodationBookingLinkSignerTest extends TestCase
{
public function testSignThenIsValidLinkRequestSucceeds(): void
{
$booking = $this->bookingWithAccessLink();
$signer = $this->createSigner();
$signedUrl = $signer->sign($booking);
$request = Request::create($signedUrl);
self::assertTrue($signer->isValidLinkRequest($request, $booking));
}
public function testTamperedQueryParamFails(): void
{
$booking = $this->bookingWithAccessLink();
$signer = $this->createSigner();
$signedUrl = $signer->sign($booking);
self::assertTrue($signer->isValidLinkRequest(Request::create($signedUrl), $booking));
// Flip the `t` value while keeping the original _hash — signature no longer matches.
$tamperedUrl = preg_replace('/(?<=[?&]t=)\d+/', '999999999', $signedUrl);
self::assertNotNull($tamperedUrl);
self::assertFalse($signer->isValidLinkRequest(Request::create($tamperedUrl), $booking));
}
public function testRegeneratedLinkInvalidatesThePreviousOne(): void
{
$booking = $this->bookingWithAccessLink();
$signer = $this->createSigner();
$signedUrl = $signer->sign($booking);
$request = Request::create($signedUrl);
// Regenerating overwrites accessLinkIssuedAt — the old signed `t` no longer matches.
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable('+1 minute'));
self::assertFalse($signer->isValidLinkRequest($request, $booking));
}
public function testExpiredLinkFails(): void
{
$booking = new AccommodationBooking();
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable('-91 days'));
$signer = $this->createSigner();
$signedUrl = $signer->sign($booking);
$request = Request::create($signedUrl);
self::assertFalse($signer->isValidLinkRequest($request, $booking));
}
public function testMissingAccessLinkIssuedAtFails(): void
{
$booking = new AccommodationBooking();
$signer = $this->createSigner();
$request = Request::create('https://example.com/groups/booking/offer/'.$booking->getUuid().'?t=123');
self::assertFalse($signer->isValidLinkRequest($request, $booking));
}
public function testSignThrowsWithoutAccessLinkIssuedAt(): void
{
$booking = new AccommodationBooking();
$signer = $this->createSigner();
$this->expectException(\LogicException::class);
$signer->sign($booking);
}
public function testExpiresAtIsNinetyDaysAfterIssuedAt(): void
{
$issuedAt = new \DateTimeImmutable('2026-01-01T00:00:00+00:00');
$booking = new AccommodationBooking();
$booking->setAccessLinkIssuedAt($issuedAt);
$signer = $this->createSigner();
self::assertSame('2026-04-01T00:00:00+00:00', $signer->expiresAt($booking)?->format(\DATE_ATOM));
}
private function bookingWithAccessLink(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setPaxCount(10);
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
return $booking;
}
private function createSigner(): AccommodationBookingLinkSigner
{
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
$urlGenerator
->method('generate')
->willReturnCallback(static fn (string $name, array $parameters) => sprintf(
'https://example.com/groups/booking/offer/%s?t=%s',
$parameters['uuid'],
$parameters['t'],
));
return new AccommodationBookingLinkSigner($urlGenerator, 'test-secret');
}
}
@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Email\Mailer;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
use App\Model\AccommodationBookingQueryParams;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use App\Service\CmsDataProvider;
use App\Service\PriceTimelineBuilder;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class AccommodationBookingServiceTest extends TestCase
{
public function testInitFromParamsAcceptsStrictCalendarDates(): void
{
$service = $this->createServiceWithAccommodation();
$dto = $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-28', '2026-03-01'));
self::assertSame('2026-02-28', $dto->dateFrom?->format('Y-m-d'));
self::assertSame('2026-03-01', $dto->dateTo?->format('Y-m-d'));
}
public function testInitFromParamsPrefillsPaxCountFromEffectiveMinPax(): void
{
$price = (new AccommodationPrice())
->setDateFrom(new \DateTimeImmutable('2026-02-01'))
->setDateTo(new \DateTimeImmutable('2026-03-31'))
->setIncludedPax(4);
$service = $this->createServiceWithAccommodation([$price]);
$dto = $service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-28', '2026-03-01'));
self::assertSame(4, $dto->paxCount);
}
public function testInitFromParamsRejectsNormalizedInvalidCalendarDates(): void
{
$service = $this->createServiceWithAccommodation();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Ungültiges Datumsformat. Erwartet: YYYY-MM-DD.');
$service->initFromParams(new AccommodationBookingQueryParams('HOTEL', '2026-02-31', '2026-03-05'));
}
public function testIssueAccessLinkForDirectBookingSetsTimestampForDirectBooking(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$service->issueAccessLinkForDirectBooking($booking);
self::assertNotNull($booking->getAccessLinkIssuedAt());
}
public function testIssueAccessLinkForDirectBookingNoOpsForInquiry(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$service->issueAccessLinkForDirectBooking($booking);
self::assertNull($booking->getAccessLinkIssuedAt());
}
public function testIssueAccessLinkForDirectBookingNoOpsWhenAlreadySet(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$issuedAt = new \DateTimeImmutable('2026-01-01');
$booking->setAccessLinkIssuedAt($issuedAt);
$service->issueAccessLinkForDirectBooking($booking);
self::assertSame($issuedAt, $booking->getAccessLinkIssuedAt());
}
public function testSendCustomerConfirmationEmailAlwaysSendsWithLinkWhenIssued(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(false);
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('sign')->with($booking)->willReturn('https://example.com/offer/signed-link');
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->willReturn(['total' => 1000, 'currency' => 'EUR']);
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::callback(static fn (array $context) => 'https://example.com/offer/signed-link' === $context['accessLink']),
self::callback(static fn (array $options) => '[email protected]' === $options['to']
&& 'email/accommodation_booking_customer.html.twig' === $options['template']),
);
$service = $this->createServiceWithAccommodation(mailer: $mailer, linkSigner: $linkSigner, breakdownCalculator: $breakdownCalculator);
$service->sendCustomerConfirmationEmail($booking);
}
public function testSendCustomerConfirmationEmailSendsWithoutLinkForInquiry(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->willReturn(null);
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::callback(static fn (array $context) => null === $context['accessLink']),
self::anything(),
);
$service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator);
$service->sendCustomerConfirmationEmail($booking);
}
public function testSendCustomerConfirmationEmailLogsAndSwallowsMailerFailures(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(true);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->willReturn(null);
$mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('error');
$service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator, logger: $logger);
$service->sendCustomerConfirmationEmail($booking);
}
public function testRegenerateAccessLinkAndNotifyCustomerAlwaysOverwritesAndSends(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setIsInquiry(true);
$previousIssuedAt = new \DateTimeImmutable('2026-01-01');
$booking->setAccessLinkIssuedAt($previousIssuedAt);
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->willReturn(null);
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::once())->method('createAndSendEmail');
$service = $this->createServiceWithAccommodation(
entityManager: $entityManager,
mailer: $mailer,
breakdownCalculator: $breakdownCalculator,
);
$service->regenerateAccessLinkAndNotifyCustomer($booking);
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
}
public function testAcceptBookingTransitionsInquiryToBooking(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setIsInquiry(true);
$service->acceptBooking($booking);
self::assertFalse($booking->isInquiry());
self::assertNotNull($booking->getAcceptedAt());
}
public function testAcceptBookingIsIdempotent(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager);
$booking = new AccommodationBooking();
$booking->setIsInquiry(false);
$service->acceptBooking($booking);
self::assertNull($booking->getAcceptedAt());
}
/**
* @param AccommodationPrice[] $prices
*/
private function createServiceWithAccommodation(
array $prices = [],
?EntityManagerInterface $entityManager = null,
?Mailer $mailer = null,
?LoggerInterface $logger = null,
?AccommodationBookingLinkSigner $linkSigner = null,
?AccommodationBookingBreakdownCalculator $breakdownCalculator = null,
): AccommodationBookingService {
$accommodation = (new Accommodation())
->setName('Hotel')
->setCalendarCode('HOTEL')
->setMaxAdolescentAge(17);
$accommodationRepo = $this->createMock(AccommodationRepository::class);
$accommodationRepo
->method('findOneBy')
->with(['calendarCode' => 'HOTEL'])
->willReturn($accommodation);
$priceRepo = $this->createMock(AccommodationPriceRepository::class);
$priceRepo
->method('findByHotelCodeAndDateRange')
->willReturn($prices);
return new AccommodationBookingService(
$accommodationRepo,
$priceRepo,
$this->createMock(AdditionalServiceRepository::class),
$this->createMock(BoardServiceRepository::class),
new PriceTimelineBuilder(),
$entityManager ?? $this->createMock(EntityManagerInterface::class),
$mailer ?? $this->createMock(Mailer::class),
$logger ?? $this->createMock(LoggerInterface::class),
$this->createMock(CmsDataProvider::class),
$linkSigner ?? $this->createMock(AccommodationBookingLinkSigner::class),
$breakdownCalculator ?? $this->createMock(AccommodationBookingBreakdownCalculator::class),
'[email protected]',
);
}
}