diff --git a/backend/app/Listeners/Order/SendOrderDetailsEmailListener.php b/backend/app/Listeners/Order/SendOrderDetailsEmailListener.php index 5cfa051d..85ee2256 100644 --- a/backend/app/Listeners/Order/SendOrderDetailsEmailListener.php +++ b/backend/app/Listeners/Order/SendOrderDetailsEmailListener.php @@ -5,7 +5,7 @@ use HiEvents\Events\OrderStatusChangedEvent; use HiEvents\Jobs\Order\SendOrderDetailsEmailJob; -readonly class SendOrderDetailsEmailListener +class SendOrderDetailsEmailListener { public function handle(OrderStatusChangedEvent $changedEvent): void { diff --git a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Handlers/Order/CompleteOrderHandler.php index 33a18197..0d9751cf 100644 --- a/backend/app/Services/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Handlers/Order/CompleteOrderHandler.php @@ -15,10 +15,12 @@ use HiEvents\DomainObjects\Status\AttendeeStatus; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\Status\OrderStatus; +use HiEvents\DomainObjects\TicketDomainObject; use HiEvents\DomainObjects\TicketPriceDomainObject; use HiEvents\Events\OrderStatusChangedEvent; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Helper\IdHelper; +use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; @@ -37,14 +39,14 @@ /** * @todo - Tidy this up */ -readonly class CompleteOrderHandler +class CompleteOrderHandler { public function __construct( - private OrderRepositoryInterface $orderRepository, - private AttendeeRepositoryInterface $attendeeRepository, - private QuestionAnswerRepositoryInterface $questionAnswersRepository, - private TicketQuantityUpdateService $ticketQuantityUpdateService, - private TicketPriceRepositoryInterface $ticketPriceRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly AttendeeRepositoryInterface $attendeeRepository, + private readonly QuestionAnswerRepositoryInterface $questionAnswersRepository, + private readonly TicketQuantityUpdateService $ticketQuantityUpdateService, + private readonly TicketPriceRepositoryInterface $ticketPriceRepository, ) { } @@ -89,7 +91,6 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order private function createAttendees(Collection $attendees, OrderDomainObject $order): void { $inserts = []; - $publicIdIndex = 1; $ticketsPrices = $this->ticketPriceRepository->findWhereIn( field: TicketPriceDomainObjectAbstract::ID, @@ -97,6 +98,7 @@ private function createAttendees(Collection $attendees, OrderDomainObject $order ); $this->validateTicketPriceIdsMatchOrder($order, $ticketsPrices); + $this->validateAttendees($order, $attendees); foreach ($attendees as $attendee) { $ticketId = $ticketsPrices->first( @@ -192,7 +194,7 @@ private function createAttendeeQuestions( private function validateOrder(OrderDomainObject $order): void { if ($order->getEmail() !== null) { - throw new ResourceConflictException(__('This order is has already been processed')); + throw new ResourceConflictException(__('This order has already been processed')); } if (Carbon::createFromTimeString($order->getReservedUntil())->isPast()) { @@ -210,7 +212,11 @@ private function validateOrder(OrderDomainObject $order): void private function getOrder(string $orderShortId): OrderDomainObject { $order = $this->orderRepository - ->loadRelation(OrderItemDomainObject::class) + ->loadRelation( + new Relationship( + domainObject: OrderItemDomainObject::class, + nested: [new Relationship(TicketDomainObject::class, name: 'ticket')] + )) ->findByShortId($orderShortId); if ($order === null) { @@ -258,4 +264,18 @@ private function validateTicketPriceIdsMatchOrder(OrderDomainObject $order, Coll throw new ResourceConflictException(__('There is an unexpected ticket price ID in the order')); } } + + /** + * @throws ResourceConflictException + */ + private function validateAttendees(OrderDomainObject $order, Collection $attendees): void + { + $orderAttendeeCount = $order->getOrderItems()->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()); + + if ($orderAttendeeCount !== $attendees->count()) { + throw new ResourceConflictException( + __('The number of attendees does not match the number of tickets in the order') + ); + } + } } diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 4f3d5a61..9b11db4f 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -21,7 +21,7 @@ - + diff --git a/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php new file mode 100644 index 00000000..a68f595e --- /dev/null +++ b/backend/tests/Unit/Services/Handlers/Order/CompleteOrderHandlerTest.php @@ -0,0 +1,299 @@ +andReturnUsing(fn($callback) => $callback(Mockery::mock(Connection::class))); + + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->questionAnswersRepository = Mockery::mock(QuestionAnswerRepositoryInterface::class); + $this->ticketQuantityUpdateService = Mockery::mock(TicketQuantityUpdateService::class); + $this->ticketPriceRepository = Mockery::mock(TicketPriceRepositoryInterface::class); + + $this->completeOrderHandler = new CompleteOrderHandler( + $this->orderRepository, + $this->attendeeRepository, + $this->questionAnswersRepository, + $this->ticketQuantityUpdateService, + $this->ticketPriceRepository + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testHandleSuccessfullyCompletesOrder(): void + { + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + $updatedOrder = $this->createMockOrder(); + + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + + $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + + $this->attendeeRepository->shouldReceive('insert')->andReturn(true); + $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection([$this->createMockAttendee()])); + + $this->ticketQuantityUpdateService->shouldReceive('updateQuantitiesFromOrder'); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + + $this->assertTrue(true); + } + + public function testHandleThrowsResourceNotFoundExceptionWhenOrderNotFound(): void + { + $this->expectException(ResourceNotFoundException::class); + + $orderShortId = 'NONEXISTENT'; + $orderData = $this->createMockCompleteOrderDTO(); + + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturnNull(); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + } + + public function testHandleThrowsResourceConflictExceptionWhenOrderAlreadyProcessed(): void + { + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('This order has already been processed'); + + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + + $order = $this->createMockOrder(OrderStatus::COMPLETED); + $order->setEmail('d@d.com'); + $order->setTotalGross(0); + + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + } + + public function testHandleThrowsResourceConflictExceptionWhenOrderExpired(): void + { + $this->expectException(ResourceConflictException::class); + + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + $order->setEmail('d@d.com'); + $order->setReservedUntil(Carbon::now()->subHour()->toDateTimeString()); + $order->setTotalGross(100); + + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + } + + public function testHandleUpdatesTicketQuantitiesForFreeOrder(): void + { + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + $updatedOrder = $this->createMockOrder(OrderStatus::COMPLETED); + + $order->setTotalGross(0); + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); + + $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + + $this->attendeeRepository->shouldReceive('insert')->andReturn(true); + $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection([$this->createMockAttendee()])); + + $this->ticketQuantityUpdateService->shouldReceive('updateQuantitiesFromOrder')->once(); + + $order = $this->completeOrderHandler->handle($orderShortId, $orderData); + + $this->assertSame($order->getStatus(), OrderStatus::COMPLETED->name); + } + + public function testHandleDoesNotUpdateTicketQuantitiesForPaidOrder(): void + { + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + $updatedOrder = $this->createMockOrder(); + + $order->setTotalGross(10); + + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); + + $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + + $this->attendeeRepository->shouldReceive('insert')->andReturn(true); + $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection([$this->createMockAttendee()])); + + $this->ticketQuantityUpdateService->shouldNotReceive('updateQuantitiesFromOrder'); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + + $this->expectNotToPerformAssertions(); + } + + public function testHandleThrowsExceptionWhenAttendeeInsertFails(): void + { + $this->expectException(Exception::class); + + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + $updatedOrder = $this->createMockOrder(); + + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); + + $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + + $this->attendeeRepository->shouldReceive('insert')->andReturn(false); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + } + + public function testExceptionIsThrowWhenAttendeeCountDoesNotMatchOrderItemsCount(): void + { + $this->expectException(ResourceConflictException::class); + $this->expectExceptionMessage('The number of attendees does not match the number of tickets in the order'); + + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + $updatedOrder = $this->createMockOrder(); + + $order->getOrderItems()->first()->setQuantity(2); + + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); + + $this->ticketPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockTicketPrice()])); + + $this->attendeeRepository->shouldReceive('insert')->andReturn(true); + $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection()); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + } + + private function createMockCompleteOrderDTO(): CompleteOrderDTO + { + $orderDTO = new CompleteOrderOrderDTO( + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + questions: null, + ); + + $attendeeDTO = new CompleteOrderAttendeeDTO( + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + ticket_price_id: 1 + ); + + return new CompleteOrderDTO( + order: $orderDTO, + attendees: new Collection([$attendeeDTO]) + ); + } + + private function createMockOrder(OrderStatus $status = OrderStatus::RESERVED): OrderDomainObject|MockInterface + { + return (new OrderDomainObject()) + ->setEmail(null) + ->setReservedUntil(Carbon::now()->addHour()->toDateTimeString()) + ->setStatus($status->name) + ->setId(1) + ->setEventId(1) + ->setLocale('en') + ->setTotalGross(10) + ->setOrderItems(new Collection([ + $this->createMockOrderItem() + ])); + } + + private function createMockOrderItem(): OrderItemDomainObject|MockInterface + { + return (new OrderItemDomainObject()) + ->setId(1) + ->setTicketId(1) + ->setQuantity(1) + ->setPrice(10) + ->setTotalGross(10) + ->setTicketPriceId(1); + } + + private function createMockTicketPrice(): TicketPriceDomainObject|MockInterface + { + $ticketPrice = Mockery::mock(TicketPriceDomainObject::class); + $ticketPrice->shouldReceive('getId')->andReturn(1); + $ticketPrice->shouldReceive('getTicketId')->andReturn(1); + return $ticketPrice; + } + + private function createMockAttendee(): AttendeeDomainObject|MockInterface + { + $attendee = Mockery::mock(AttendeeDomainObject::class); + $attendee->shouldReceive('getId')->andReturn(1); + $attendee->shouldReceive('getTicketId')->andReturn(1); + return $attendee; + } +}