diff --git a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php index 8867a5bf9..5278ef224 100644 --- a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php @@ -19,19 +19,22 @@ use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO; use HiEvents\Services\Domain\Order\OrderItemProcessingService; use HiEvents\Services\Domain\Order\OrderManagementService; +use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService; use Illuminate\Database\DatabaseManager; use Illuminate\Validation\UnauthorizedException; +use Illuminate\Validation\ValidationException; use Throwable; class CreateOrderHandler { public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly PromoCodeRepositoryInterface $promoCodeRepository, - private readonly AffiliateRepositoryInterface $affiliateRepository, - private readonly OrderManagementService $orderManagementService, - private readonly OrderItemProcessingService $orderItemProcessingService, - private readonly DatabaseManager $databaseManager, + private readonly EventRepositoryInterface $eventRepository, + private readonly PromoCodeRepositoryInterface $promoCodeRepository, + private readonly AffiliateRepositoryInterface $affiliateRepository, + private readonly OrderManagementService $orderManagementService, + private readonly OrderItemProcessingService $orderItemProcessingService, + private readonly AvailableProductQuantitiesFetchService $availableProductQuantitiesFetchService, + private readonly DatabaseManager $databaseManager, ) { } @@ -46,6 +49,8 @@ public function handle( ): OrderDomainObject { return $this->databaseManager->transaction(function () use ($eventId, $createOrderPublicDTO, $deleteExistingOrdersForSession) { + $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$eventId]); + $event = $this->eventRepository ->loadRelation(EventSettingDomainObject::class) ->findById($eventId); @@ -59,6 +64,8 @@ public function handle( $this->orderManagementService->deleteExistingOrders($eventId, $createOrderPublicDTO->session_identifier); } + $this->validateProductAvailability($eventId, $createOrderPublicDTO); + $order = $this->orderManagementService->createNewOrder( eventId: $eventId, event: $event, @@ -119,4 +126,32 @@ public function validateEventStatus(EventDomainObject $event, CreateOrderPublicD ); } } + + /** + * @throws ValidationException + */ + private function validateProductAvailability(int $eventId, CreateOrderPublicDTO $createOrderPublicDTO): void + { + $availability = $this->availableProductQuantitiesFetchService + ->getAvailableProductQuantities($eventId, ignoreCache: true); + + foreach ($createOrderPublicDTO->products as $product) { + foreach ($product->quantities as $priceQuantity) { + if ($priceQuantity->quantity <= 0) { + continue; + } + + $available = $availability->productQuantities + ->where('product_id', $product->product_id) + ->where('price_id', $priceQuantity->price_id) + ->first()?->quantity_available ?? 0; + + if ($priceQuantity->quantity > $available) { + throw ValidationException::withMessages([ + 'products' => __('Not enough products available. Please try again.'), + ]); + } + } + } + } } diff --git a/backend/app/Services/Domain/Order/OrderManagementService.php b/backend/app/Services/Domain/Order/OrderManagementService.php index f788f2646..0e627482f 100644 --- a/backend/app/Services/Domain/Order/OrderManagementService.php +++ b/backend/app/Services/Domain/Order/OrderManagementService.php @@ -40,7 +40,7 @@ public function createNewOrder( string $locale, ?PromoCodeDomainObject $promoCode, ?AffiliateDomainObject $affiliate = null, - string $sessionId = null, + ?string $sessionId = null, ): OrderDomainObject { $reservedUntil = Carbon::now()->addMinutes($timeOutMinutes); diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php new file mode 100644 index 000000000..21a06aeb0 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php @@ -0,0 +1,214 @@ +eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class); + $this->affiliateRepository = Mockery::mock(AffiliateRepositoryInterface::class); + $this->orderManagementService = Mockery::mock(OrderManagementService::class); + $this->orderItemProcessingService = Mockery::mock(OrderItemProcessingService::class); + $this->availabilityService = Mockery::mock(AvailableProductQuantitiesFetchService::class); + $this->databaseManager = Mockery::mock(DatabaseManager::class); + + $this->databaseManager->shouldReceive('transaction') + ->andReturnUsing(fn($callback) => $callback()); + + $this->handler = new CreateOrderHandler( + $this->eventRepository, + $this->promoCodeRepository, + $this->affiliateRepository, + $this->orderManagementService, + $this->orderItemProcessingService, + $this->availabilityService, + $this->databaseManager, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testAcquiresAdvisoryLockBeforeCreatingOrder(): void + { + $eventId = 42; + + $this->databaseManager->shouldReceive('statement') + ->once() + ->with('SELECT pg_advisory_xact_lock(?)', [$eventId]) + ->andReturn(true); + + $this->setupSuccessfulOrderCreation($eventId); + + $result = $this->handler->handle($eventId, $this->createOrderDTO()); + $this->assertInstanceOf(OrderDomainObject::class, $result); + } + + public function testThrowsWhenProductQuantityExceedsAvailability(): void + { + $eventId = 1; + + $this->databaseManager->shouldReceive('statement')->andReturn(true); + + $this->setupEventMock($eventId); + $this->orderManagementService->shouldReceive('deleteExistingOrders'); + + $this->availabilityService->shouldReceive('getAvailableProductQuantities') + ->with($eventId, true) + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: collect([ + AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => 10, + 'price_id' => 100, + 'product_title' => 'Test', + 'price_label' => null, + 'quantity_available' => 2, + 'quantity_reserved' => 0, + 'initial_quantity_available' => 10, + ]), + ]), + )); + + $dto = $this->createOrderDTO(quantity: 5); + + $this->expectException(ValidationException::class); + $this->handler->handle($eventId, $dto); + } + + public function testPassesWhenQuantityIsWithinAvailability(): void + { + $eventId = 1; + + $this->databaseManager->shouldReceive('statement')->andReturn(true); + $this->setupSuccessfulOrderCreation($eventId, productId: 10, priceId: 100, available: 5); + + $dto = $this->createOrderDTO(quantity: 2); + + $result = $this->handler->handle($eventId, $dto); + $this->assertInstanceOf(OrderDomainObject::class, $result); + } + + public function testSkipsZeroQuantityProducts(): void + { + $eventId = 1; + + $this->databaseManager->shouldReceive('statement')->andReturn(true); + $this->setupSuccessfulOrderCreation($eventId, available: 0); + + $dto = $this->createOrderDTO(quantity: 0); + + $result = $this->handler->handle($eventId, $dto); + $this->assertInstanceOf(OrderDomainObject::class, $result); + } + + private function createOrderDTO(int $productId = 10, int $priceId = 100, int $quantity = 1): CreateOrderPublicDTO + { + return CreateOrderPublicDTO::fromArray([ + 'is_user_authenticated' => false, + 'session_identifier' => 'test-session', + 'order_locale' => 'en', + 'products' => collect([ + ProductOrderDetailsDTO::fromArray([ + 'product_id' => $productId, + 'quantities' => collect([ + OrderProductPriceDTO::fromArray([ + 'price_id' => $priceId, + 'quantity' => $quantity, + ]), + ]), + ]), + ]), + ]); + } + + private function setupEventMock(int $eventId): void + { + $eventSettings = Mockery::mock(EventSettingDomainObject::class); + $eventSettings->shouldReceive('getOrderTimeoutInMinutes')->andReturn(15); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getId')->andReturn($eventId); + $event->shouldReceive('getStatus')->andReturn(EventStatus::LIVE->name); + $event->shouldReceive('getEventSettings')->andReturn($eventSettings); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event); + } + + private function setupSuccessfulOrderCreation( + int $eventId, + int $productId = 10, + int $priceId = 100, + int $available = 10, + ): void + { + $this->setupEventMock($eventId); + + $this->orderManagementService->shouldReceive('deleteExistingOrders'); + + $this->availabilityService->shouldReceive('getAvailableProductQuantities') + ->with($eventId, true) + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: collect([ + AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => $productId, + 'price_id' => $priceId, + 'product_title' => 'Test Product', + 'price_label' => null, + 'quantity_available' => $available, + 'quantity_reserved' => 0, + 'initial_quantity_available' => 100, + ]), + ]), + )); + + $order = Mockery::mock(OrderDomainObject::class); + $order->shouldReceive('getId')->andReturn(1); + + $this->orderManagementService->shouldReceive('createNewOrder')->andReturn($order); + + $orderItems = collect([Mockery::mock(OrderItemDomainObject::class)]); + $this->orderItemProcessingService->shouldReceive('process')->andReturn($orderItems); + + $this->orderManagementService->shouldReceive('updateOrderTotals')->andReturn($order); + } +}