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;
+ }
+}