wip: group inquiries and bookings
This commit is contained in:
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]',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user