From e8dfcd9352949bff2b24cbb4f62ebd395eeae019 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Sun, 23 Aug 2026 19:02:54 +0100 Subject: [PATCH] Feature: Dashboard e2e tests --- .../Attendee/CreateAttendeeHandlerTest.php | 363 ++++++++++++++++++ .../PartialEditAttendeeHandlerTest.php | 287 ++++++++++++++ .../EventStatisticsIncrementServiceTest.php | 114 +++++- .../Order/MarkOrderAsPaidServiceTest.php | 276 +++++++++++++ .../ChargeRefundUpdatedHandlerTest.php | 221 +++++++++++ e2e/api/api-client.ts | 19 + e2e/api/types.ts | 1 + e2e/pages/event-dashboard.page.ts | 21 + .../management/event-dashboard-stats.spec.ts | 120 ++++++ .../src/components/common/KpiGrid/index.tsx | 9 +- .../src/components/common/StatBoxes/index.tsx | 1 + 11 files changed, 1424 insertions(+), 8 deletions(-) create mode 100644 backend/tests/Unit/Services/Application/Handlers/Attendee/CreateAttendeeHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php create mode 100644 backend/tests/Unit/Services/Domain/Order/MarkOrderAsPaidServiceTest.php create mode 100644 backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandlerTest.php create mode 100644 e2e/pages/event-dashboard.page.ts create mode 100644 e2e/tests/management/event-dashboard-stats.spec.ts diff --git a/backend/tests/Unit/Services/Application/Handlers/Attendee/CreateAttendeeHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Attendee/CreateAttendeeHandlerTest.php new file mode 100644 index 0000000000..2a66b1c3de --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Attendee/CreateAttendeeHandlerTest.php @@ -0,0 +1,363 @@ +attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $this->productQuantityService = Mockery::mock(ProductQuantityUpdateService::class); + $this->orderManagementService = Mockery::mock(OrderManagementService::class); + $this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class); + $this->occurrenceEligibilityService = Mockery::mock(OccurrencePurchaseEligibilityService::class); + $this->orderAuditLogService = Mockery::mock(OrderAuditLogService::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction')->andReturnUsing(fn (callable $callback) => $callback()); + + $this->productRepository->shouldReceive('loadRelation')->andReturnSelf(); + + $this->handler = new CreateAttendeeHandler( + $this->attendeeRepository, + $this->orderRepository, + $this->productRepository, + $this->eventRepository, + $this->occurrenceRepository, + $this->productQuantityService, + $databaseManager, + Mockery::mock(TaxAndFeeRepositoryInterface::class), + new TaxAndFeeRollupService, + $this->orderManagementService, + $this->domainEventDispatcherService, + $this->occurrenceEligibilityService, + $this->orderAuditLogService, + ); + } + + public function test_a_manual_attendee_creates_a_completed_order_and_fires_the_completion_event(): void + { + $this->givenSingleEvent(); + $this->givenOccurrenceIsPurchasable(); + $this->givenTicketProduct(); + $this->productRepository->shouldReceive('getQuantityRemainingForProductPrice') + ->once()->with(self::PRODUCT_ID, self::PRODUCT_PRICE_ID)->andReturn(5); + + $this->orderRepository->shouldReceive('create') + ->once() + ->withArgs(fn (array $attributes): bool => $attributes[OrderDomainObjectAbstract::STATUS] === OrderStatus::COMPLETED->name + && $attributes[OrderDomainObjectAbstract::PAYMENT_STATUS] === OrderPaymentStatus::PAYMENT_RECEIVED->name + && $attributes[OrderDomainObjectAbstract::TOTAL_GROSS] === 25.0 + && $attributes[OrderDomainObjectAbstract::EVENT_ID] === self::EVENT_ID + && $attributes[OrderDomainObjectAbstract::IS_MANUALLY_CREATED] === true + && $attributes[OrderDomainObjectAbstract::CURRENCY] === 'USD') + ->andReturn($this->order()); + + $this->orderRepository->shouldReceive('addOrderItem') + ->once() + ->withArgs(fn (array $attributes): bool => $attributes[OrderItemDomainObjectAbstract::QUANTITY] === 1 + && $attributes[OrderItemDomainObjectAbstract::PRODUCT_PRICE_ID] === self::PRODUCT_PRICE_ID + && $attributes[OrderItemDomainObjectAbstract::PRODUCT_TYPE] === ProductType::TICKET->name + && $attributes[OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID] === self::OCCURRENCE_ID + && $attributes[OrderItemDomainObjectAbstract::TOTAL_GROSS] === 25.0) + ->andReturn(new OrderItemDomainObject); + + $this->attendeeRepository->shouldReceive('create') + ->once() + ->withArgs(fn (array $attributes): bool => $attributes[AttendeeDomainObjectAbstract::STATUS] === AttendeeStatus::ACTIVE->name + && $attributes[AttendeeDomainObjectAbstract::ORDER_ID] === self::ORDER_ID + && $attributes[AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID] === self::PRODUCT_PRICE_ID + && $attributes[AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID] === self::OCCURRENCE_ID) + ->andReturn((new AttendeeDomainObject)->setId(self::ATTENDEE_ID)); + + $this->orderManagementService->shouldReceive('updateOrderTotals')->once()->andReturn($this->order()); + + $this->productQuantityService->shouldReceive('increaseQuantitySold') + ->once() + ->with(self::PRODUCT_PRICE_ID, 1, self::OCCURRENCE_ID); + + $this->domainEventDispatcherService->shouldReceive('dispatch') + ->once() + ->with(Mockery::on(fn (OrderEvent $event) => $event->type === DomainEventType::ORDER_CREATED + && $event->orderId === self::ORDER_ID)); + + $this->orderAuditLogService->shouldNotReceive('logManualAttendeeCapacityOverride'); + + $attendee = $this->handler->handle($this->dto(amountPaid: 25, occurrenceId: self::OCCURRENCE_ID)); + + $this->assertSame(self::ATTENDEE_ID, $attendee->getId()); + + Event::assertDispatched( + OrderStatusChangedEvent::class, + fn (OrderStatusChangedEvent $event) => $event->order->getId() === self::ORDER_ID + && $event->order->isOrderCompleted() + && $event->sendEmails === true + ); + } + + public function test_a_single_event_resolves_its_only_occurrence_when_none_is_given(): void + { + $this->givenSingleEvent(); + $this->occurrenceRepository->shouldReceive('findFirstWhere') + ->once() + ->with(['event_id' => self::EVENT_ID]) + ->andReturn((new EventOccurrenceDomainObject)->setId(self::OCCURRENCE_ID)); + $this->givenOccurrenceIsPurchasable(); + $this->givenTicketProduct(); + $this->givenHappyPathPersistence(); + + $this->productQuantityService->shouldReceive('increaseQuantitySold') + ->once() + ->with(self::PRODUCT_PRICE_ID, 1, self::OCCURRENCE_ID); + + $this->handler->handle($this->dto(amountPaid: 0, occurrenceId: null)); + + Event::assertDispatched(OrderStatusChangedEvent::class); + } + + public function test_a_recurring_event_requires_an_occurrence(): void + { + $this->eventRepository->shouldReceive('findById') + ->once() + ->with(self::EVENT_ID) + ->andReturn((new EventDomainObject)->setId(self::EVENT_ID)->setType(EventType::RECURRING->name)->setCurrency('USD')); + + $this->occurrenceRepository->shouldNotReceive('findFirstWhere'); + $this->orderRepository->shouldNotReceive('create'); + + $this->expectException(ValidationException::class); + + $this->handler->handle($this->dto(amountPaid: 0, occurrenceId: null)); + } + + public function test_a_free_manual_attendee_requires_no_payment(): void + { + $this->givenSingleEvent(); + $this->givenOccurrenceIsPurchasable(); + $this->givenTicketProduct(); + $this->givenHappyPathPersistence(expectedPaymentStatus: OrderPaymentStatus::NO_PAYMENT_REQUIRED); + $this->productQuantityService->shouldReceive('increaseQuantitySold')->once(); + + $this->handler->handle($this->dto(amountPaid: 0, occurrenceId: self::OCCURRENCE_ID)); + } + + public function test_no_attendee_is_created_when_the_product_is_sold_out(): void + { + $this->givenSingleEvent(); + $this->givenOccurrenceIsPurchasable(); + $this->givenTicketProduct(); + $this->orderRepository->shouldReceive('create')->once()->andReturn($this->order()); + $this->productRepository->shouldReceive('getQuantityRemainingForProductPrice')->once()->andReturn(0); + + $this->attendeeRepository->shouldNotReceive('create'); + $this->productQuantityService->shouldNotReceive('increaseQuantitySold'); + + $this->expectException(NoTicketsAvailableException::class); + + try { + $this->handler->handle($this->dto(amountPaid: 10, occurrenceId: self::OCCURRENCE_ID)); + } finally { + Event::assertNotDispatched(OrderStatusChangedEvent::class); + } + } + + public function test_a_price_that_does_not_belong_to_the_product_is_rejected(): void + { + $this->givenSingleEvent(); + $this->givenOccurrenceIsPurchasable(); + $this->givenTicketProduct(); + $this->orderRepository->shouldReceive('create')->once()->andReturn($this->order()); + + $this->attendeeRepository->shouldNotReceive('create'); + $this->productQuantityService->shouldNotReceive('increaseQuantitySold'); + + $this->expectException(InvalidProductPriceId::class); + + $this->handler->handle($this->dto(amountPaid: 10, occurrenceId: self::OCCURRENCE_ID, productPriceId: 999)); + } + + public function test_a_capacity_override_is_audit_logged(): void + { + $this->givenSingleEvent(); + $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable') + ->once() + ->withArgs(fn (int $eventId, int $occurrenceId, int $additionalQuantity, bool $overrideCapacity): bool => $overrideCapacity === true); + $this->occurrenceEligibilityService->shouldReceive('assertProductsVisibleOnOccurrence')->once(); + $this->givenTicketProduct(); + $this->givenHappyPathPersistence(); + $this->productQuantityService->shouldReceive('increaseQuantitySold')->once(); + + $this->orderAuditLogService->shouldReceive('logManualAttendeeCapacityOverride') + ->once() + ->with(self::EVENT_ID, self::ORDER_ID, self::ATTENDEE_ID, self::OCCURRENCE_ID, '10.0.0.1', 'agent'); + + $this->handler->handle($this->dto(amountPaid: 0, occurrenceId: self::OCCURRENCE_ID, overrideCapacity: true)); + } + + private function dto( + float $amountPaid, + ?int $occurrenceId, + ?int $productPriceId = self::PRODUCT_PRICE_ID, + bool $overrideCapacity = false, + ): CreateAttendeeDTO { + return new CreateAttendeeDTO( + first_name: 'Manual', + last_name: 'Attendee', + email: 'manual@example.com', + product_id: self::PRODUCT_ID, + event_id: self::EVENT_ID, + send_confirmation_email: true, + amount_paid: $amountPaid, + locale: 'en', + product_price_id: $productPriceId, + event_occurrence_id: $occurrenceId, + override_capacity: $overrideCapacity, + client_ip: '10.0.0.1', + client_user_agent: 'agent', + ); + } + + private function order(): OrderDomainObject + { + return (new OrderDomainObject) + ->setId(self::ORDER_ID) + ->setEventId(self::EVENT_ID) + ->setStatus(OrderStatus::COMPLETED->name) + ->setCurrency('USD'); + } + + private function givenSingleEvent(): void + { + $this->eventRepository->shouldReceive('findById') + ->with(self::EVENT_ID) + ->andReturn((new EventDomainObject)->setId(self::EVENT_ID)->setType(EventType::SINGLE->name)->setCurrency('USD')); + } + + private function givenOccurrenceIsPurchasable(): void + { + $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable') + ->once() + ->withArgs(fn (int $eventId, int $occurrenceId, int $additionalQuantity, bool $overrideCapacity): bool => $eventId === self::EVENT_ID + && $occurrenceId === self::OCCURRENCE_ID + && $additionalQuantity === 1 + && $overrideCapacity === false); + $this->occurrenceEligibilityService->shouldReceive('assertProductsVisibleOnOccurrence') + ->once() + ->with(self::OCCURRENCE_ID, [self::PRODUCT_ID]); + } + + private function givenTicketProduct(): void + { + $product = (new ProductDomainObject) + ->setId(self::PRODUCT_ID) + ->setTitle('General Admission') + ->setProductType(ProductType::TICKET->name) + ->setProductPrices(collect([(new ProductPriceDomainObject)->setId(self::PRODUCT_PRICE_ID)])); + + $this->productRepository->shouldReceive('findFirstWhere') + ->once() + ->with([ + ProductDomainObjectAbstract::ID => self::PRODUCT_ID, + ProductDomainObjectAbstract::EVENT_ID => self::EVENT_ID, + ProductDomainObjectAbstract::PRODUCT_TYPE => ProductType::TICKET->name, + ]) + ->andReturn($product); + } + + private function givenHappyPathPersistence( + OrderPaymentStatus $expectedPaymentStatus = OrderPaymentStatus::NO_PAYMENT_REQUIRED, + ): void { + $this->productRepository->shouldReceive('getQuantityRemainingForProductPrice')->once()->andReturn(5); + $this->orderRepository->shouldReceive('create') + ->once() + ->withArgs(fn (array $attributes): bool => $attributes[OrderDomainObjectAbstract::PAYMENT_STATUS] === $expectedPaymentStatus->name) + ->andReturn($this->order()); + $this->orderRepository->shouldReceive('addOrderItem')->once()->andReturn(new OrderItemDomainObject); + $this->attendeeRepository->shouldReceive('create')->once()->andReturn((new AttendeeDomainObject)->setId(self::ATTENDEE_ID)); + $this->orderManagementService->shouldReceive('updateOrderTotals')->once()->andReturn($this->order()); + $this->domainEventDispatcherService->shouldReceive('dispatch')->once(); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php new file mode 100644 index 0000000000..008ee88368 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Attendee/PartialEditAttendeeHandlerTest.php @@ -0,0 +1,287 @@ +attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->productQuantityService = Mockery::mock(ProductQuantityUpdateService::class); + $this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class); + $this->cancellationService = Mockery::mock(EventStatisticsCancellationService::class); + $this->reactivationService = Mockery::mock(EventStatisticsReactivationService::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction')->andReturnUsing(fn (callable $callback) => $callback()); + + $this->handler = new PartialEditAttendeeHandler( + $this->attendeeRepository, + $this->orderRepository, + $this->productQuantityService, + $databaseManager, + $this->domainEventDispatcherService, + $this->cancellationService, + $this->reactivationService, + Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(), + ); + } + + public function test_cancelling_an_active_attendee_releases_capacity_and_decrements_statistics(): void + { + $this->givenAttendee(AttendeeStatus::ACTIVE); + $this->givenOrderExists(); + $this->expectAttendeeUpdatedWithStatus(AttendeeStatus::CANCELLED); + + $this->productQuantityService->shouldReceive('decreaseQuantitySold') + ->once() + ->with(self::PRODUCT_PRICE_ID, 1, self::OCCURRENCE_ID); + $this->productQuantityService->shouldNotReceive('increaseQuantitySold'); + + $this->cancellationService->shouldReceive('decrementForCancelledAttendee') + ->once() + ->with(self::EVENT_ID, self::ORDER_CREATED_AT, 1, self::OCCURRENCE_ID); + $this->reactivationService->shouldNotReceive('incrementForReactivatedAttendee'); + + $this->domainEventDispatcherService->shouldReceive('dispatch') + ->once() + ->with(Mockery::on( + fn (AttendeeEvent $event) => $event->type === DomainEventType::ATTENDEE_CANCELLED + && $event->attendeeId === self::ATTENDEE_ID + )); + + $this->handler->handle($this->dto(AttendeeStatus::CANCELLED)); + + Event::assertDispatched( + CapacityChangedEvent::class, + fn (CapacityChangedEvent $event) => $event->direction === CapacityChangeDirection::INCREASED + && $event->eventId === self::EVENT_ID + && $event->productPriceId === self::PRODUCT_PRICE_ID + && $event->eventOccurrenceId === self::OCCURRENCE_ID + ); + } + + public function test_reactivating_a_cancelled_attendee_consumes_capacity_and_increments_statistics(): void + { + $this->givenAttendee(AttendeeStatus::CANCELLED); + $this->givenOrderExists(); + $this->expectAttendeeUpdatedWithStatus(AttendeeStatus::ACTIVE); + + $this->productQuantityService->shouldReceive('increaseQuantitySold') + ->once() + ->with(self::PRODUCT_PRICE_ID, 1, self::OCCURRENCE_ID); + $this->productQuantityService->shouldNotReceive('decreaseQuantitySold'); + + $this->reactivationService->shouldReceive('incrementForReactivatedAttendee') + ->once() + ->with(self::EVENT_ID, self::ORDER_CREATED_AT, 1, self::OCCURRENCE_ID); + $this->cancellationService->shouldNotReceive('decrementForCancelledAttendee'); + $this->domainEventDispatcherService->shouldNotReceive('dispatch'); + + $this->handler->handle($this->dto(AttendeeStatus::ACTIVE)); + + Event::assertDispatched( + CapacityChangedEvent::class, + fn (CapacityChangedEvent $event) => $event->direction === CapacityChangeDirection::DECREASED + && $event->productPriceId === self::PRODUCT_PRICE_ID + && $event->eventOccurrenceId === self::OCCURRENCE_ID + ); + } + + public function test_setting_the_same_status_does_not_touch_capacity_or_statistics(): void + { + $this->givenAttendee(AttendeeStatus::ACTIVE); + $this->expectAttendeeUpdatedWithStatus(AttendeeStatus::ACTIVE); + + $this->productQuantityService->shouldNotReceive('increaseQuantitySold'); + $this->productQuantityService->shouldNotReceive('decreaseQuantitySold'); + $this->cancellationService->shouldNotReceive('decrementForCancelledAttendee'); + $this->reactivationService->shouldNotReceive('incrementForReactivatedAttendee'); + $this->domainEventDispatcherService->shouldNotReceive('dispatch'); + $this->orderRepository->shouldNotReceive('findFirstWhere'); + + $this->handler->handle($this->dto(AttendeeStatus::ACTIVE)); + + Event::assertNotDispatched(CapacityChangedEvent::class); + } + + public function test_editing_only_contact_details_does_not_touch_capacity_or_statistics(): void + { + $this->givenAttendee(AttendeeStatus::ACTIVE); + + $this->attendeeRepository->shouldReceive('updateByIdWhere') + ->once() + ->withArgs(function (int $id, array $attributes, array $where): bool { + return $id === self::ATTENDEE_ID + && $attributes['status'] === AttendeeStatus::ACTIVE->name + && $attributes['first_name'] === 'Renamed' + && $attributes['last_name'] === 'Last' + && $attributes['email'] === 'original@example.com' + && $where === ['event_id' => self::EVENT_ID]; + }) + ->andReturn(new AttendeeDomainObject); + + $this->productQuantityService->shouldNotReceive('increaseQuantitySold'); + $this->productQuantityService->shouldNotReceive('decreaseQuantitySold'); + $this->cancellationService->shouldNotReceive('decrementForCancelledAttendee'); + $this->reactivationService->shouldNotReceive('incrementForReactivatedAttendee'); + + $this->handler->handle(new PartialEditAttendeeDTO( + attendee_id: self::ATTENDEE_ID, + event_id: self::EVENT_ID, + first_name: 'Renamed', + last_name: null, + email: null, + status: null, + )); + + Event::assertNotDispatched(CapacityChangedEvent::class); + } + + public function test_cancelling_an_attendee_without_an_occurrence_decrements_event_level_statistics_only(): void + { + $this->givenAttendee(AttendeeStatus::ACTIVE, occurrenceId: null); + $this->givenOrderExists(); + $this->expectAttendeeUpdatedWithStatus(AttendeeStatus::CANCELLED); + + $this->productQuantityService->shouldReceive('decreaseQuantitySold') + ->once() + ->with(self::PRODUCT_PRICE_ID, 1, null); + $this->cancellationService->shouldReceive('decrementForCancelledAttendee') + ->once() + ->with(self::EVENT_ID, self::ORDER_CREATED_AT, 1, null); + $this->domainEventDispatcherService->shouldReceive('dispatch')->once(); + + $this->handler->handle($this->dto(AttendeeStatus::CANCELLED)); + } + + public function test_capacity_is_still_released_when_the_order_cannot_be_found(): void + { + $this->givenAttendee(AttendeeStatus::ACTIVE); + $this->orderRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->expectAttendeeUpdatedWithStatus(AttendeeStatus::CANCELLED); + + $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once(); + $this->cancellationService->shouldNotReceive('decrementForCancelledAttendee'); + $this->domainEventDispatcherService->shouldReceive('dispatch')->once(); + + $this->handler->handle($this->dto(AttendeeStatus::CANCELLED)); + } + + public function test_throws_when_attendee_does_not_belong_to_event(): void + { + $this->attendeeRepository->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => self::ATTENDEE_ID, 'event_id' => self::EVENT_ID]) + ->andReturnNull(); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($this->dto(AttendeeStatus::CANCELLED)); + } + + private function dto(AttendeeStatus $status): PartialEditAttendeeDTO + { + return new PartialEditAttendeeDTO( + attendee_id: self::ATTENDEE_ID, + event_id: self::EVENT_ID, + first_name: null, + last_name: null, + email: null, + status: $status->name, + ); + } + + private function givenAttendee(AttendeeStatus $status, ?int $occurrenceId = self::OCCURRENCE_ID): void + { + $attendee = (new AttendeeDomainObject) + ->setId(self::ATTENDEE_ID) + ->setEventId(self::EVENT_ID) + ->setOrderId(self::ORDER_ID) + ->setProductId(self::PRODUCT_ID) + ->setProductPriceId(self::PRODUCT_PRICE_ID) + ->setEventOccurrenceId($occurrenceId) + ->setStatus($status->name) + ->setFirstName('Original') + ->setLastName('Last') + ->setEmail('original@example.com'); + + $this->attendeeRepository->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => self::ATTENDEE_ID, 'event_id' => self::EVENT_ID]) + ->andReturn($attendee); + } + + private function givenOrderExists(): void + { + $this->orderRepository->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => self::ORDER_ID, 'event_id' => self::EVENT_ID]) + ->andReturn((new OrderDomainObject)->setId(self::ORDER_ID)->setCreatedAt(self::ORDER_CREATED_AT)); + } + + private function expectAttendeeUpdatedWithStatus(AttendeeStatus $status): void + { + $this->attendeeRepository->shouldReceive('updateByIdWhere') + ->once() + ->withArgs(fn (int $id, array $attributes, array $where): bool => $id === self::ATTENDEE_ID + && $attributes['status'] === $status->name + && $where === ['event_id' => self::EVENT_ID]) + ->andReturn(new AttendeeDomainObject); + } +} diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php index 67a12e1257..44609f8517 100644 --- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php @@ -2,7 +2,9 @@ namespace Tests\Unit\Services\Domain\EventStatistics; +use HiEvents\DomainObjects\Enums\ProductType; use HiEvents\DomainObjects\EventDailyStatisticDomainObject; +use HiEvents\DomainObjects\EventOccurrenceDailyStatisticDomainObject; use HiEvents\DomainObjects\EventStatisticDomainObject; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; @@ -36,6 +38,10 @@ class EventStatisticsIncrementServiceTest extends TestCase private MockInterface|EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository; + private MockInterface|EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository; + + private MockInterface|EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository; + private MockInterface|DatabaseManager $databaseManager; private MockInterface|OrderRepositoryInterface $orderRepository; @@ -52,8 +58,8 @@ protected function setUp(): void $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); $this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class); $this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class); - $eventOccurrenceStatisticRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class); - $eventOccurrenceDailyStatisticRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class); + $this->eventOccurrenceStatisticRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class); + $this->eventOccurrenceDailyStatisticRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class); $this->databaseManager = Mockery::mock(DatabaseManager::class); $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); $this->logger = Mockery::mock(LoggerInterface::class); @@ -64,8 +70,8 @@ protected function setUp(): void $this->productRepository, $this->eventStatisticsRepository, $this->eventDailyStatisticRepository, - $eventOccurrenceStatisticRepository, - $eventOccurrenceDailyStatisticRepository, + $this->eventOccurrenceStatisticRepository, + $this->eventOccurrenceDailyStatisticRepository, $this->databaseManager, $this->orderRepository, $this->logger, @@ -367,4 +373,104 @@ protected function tearDown(): void Mockery::close(); parent::tearDown(); } + + public function test_add_on_items_count_as_products_sold_but_not_as_attendees(): void + { + $eventId = 1; + $orderId = 321; + $occurrenceId = 77; + + $ticketItem = (new OrderItemDomainObject) + ->setProductId(1) + ->setProductType(ProductType::TICKET->name) + ->setQuantity(2) + ->setEventOccurrenceId($occurrenceId) + ->setTotalBeforeAdditions(100.00) + ->setTotalGross(110.00) + ->setTotalTax(8.00) + ->setTotalServiceFee(2.00); + + $addOnItem = (new OrderItemDomainObject) + ->setProductId(2) + ->setProductType(ProductType::GENERAL->name) + ->setQuantity(1) + ->setEventOccurrenceId($occurrenceId) + ->setTotalBeforeAdditions(20.00) + ->setTotalGross(22.00) + ->setTotalTax(1.50) + ->setTotalServiceFee(0.50); + + $order = (new OrderDomainObject) + ->setId($orderId) + ->setEventId($eventId) + ->setCreatedAt('2024-01-15 10:30:00') + ->setOrderItems(new Collection([$ticketItem, $addOnItem])) + ->setTotalGross(132.00) + ->setTotalBeforeAdditions(120.00) + ->setTotalTax(9.50) + ->setTotalFee(2.50); + + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('findById')->with($orderId)->andReturn($order); + $this->retrier->shouldReceive('retry')->andReturnUsing(fn ($callableAction) => $callableAction(1)); + $this->databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback()); + $this->logger->shouldReceive('info'); + + $this->eventStatisticsRepository->shouldReceive('findFirstWhere')->andReturnNull(); + $this->eventStatisticsRepository->shouldReceive('create') + ->once() + ->withArgs(fn (array $attributes): bool => $attributes['products_sold'] === 3 + && $attributes['attendees_registered'] === 2 + && $attributes['sales_total_gross'] === 132.00 + && $attributes['orders_created'] === 1); + + $this->eventDailyStatisticRepository->shouldReceive('findFirstWhere')->andReturnNull(); + $this->eventDailyStatisticRepository->shouldReceive('create') + ->once() + ->withArgs(fn (array $attributes): bool => $attributes['products_sold'] === 3 + && $attributes['attendees_registered'] === 2 + && $attributes['date'] === '2024-01-15'); + + $this->eventOccurrenceStatisticRepository->shouldReceive('findFirstWhere') + ->with(['event_id' => $eventId, 'event_occurrence_id' => $occurrenceId]) + ->andReturnNull(); + $this->eventOccurrenceStatisticRepository->shouldReceive('create') + ->once() + ->withArgs(fn (array $attributes): bool => $attributes['event_occurrence_id'] === $occurrenceId + && $attributes['products_sold'] === 3 + && $attributes['attendees_registered'] === 2 + && $attributes['sales_total_gross'] === 132.00 + && $attributes['sales_total_before_additions'] === 120.00 + && $attributes['total_tax'] === 9.50 + && $attributes['total_fee'] === 2.50 + && $attributes['orders_created'] === 1); + + $this->eventOccurrenceDailyStatisticRepository->shouldReceive('findFirstWhere') + ->with(['event_occurrence_id' => $occurrenceId, 'date' => '2024-01-15']) + ->andReturnNull(); + $occurrenceDailyCreate = null; + $this->eventOccurrenceDailyStatisticRepository->shouldReceive('create') + ->once() + ->andReturnUsing(function (array $attributes) use (&$occurrenceDailyCreate) { + $occurrenceDailyCreate = $attributes; + + return Mockery::mock(EventOccurrenceDailyStatisticDomainObject::class); + }); + + $this->promoCodeRepository->shouldNotReceive('incrementEach'); + + $this->productRepository->shouldReceive('increment') + ->once()->with(1, ProductDomainObjectAbstract::SALES_VOLUME, 100.00); + $this->productRepository->shouldReceive('increment') + ->once()->with(2, ProductDomainObjectAbstract::SALES_VOLUME, 20.00); + + $this->service->incrementForOrder($order); + + $this->assertSame($occurrenceId, $occurrenceDailyCreate['event_occurrence_id']); + $this->assertSame('2024-01-15', $occurrenceDailyCreate['date']); + $this->assertSame(3, $occurrenceDailyCreate['products_sold']); + $this->assertSame(2, $occurrenceDailyCreate['attendees_registered']); + $this->assertSame(132.00, $occurrenceDailyCreate['sales_total_gross']); + $this->assertSame(1, $occurrenceDailyCreate['orders_created']); + } } diff --git a/backend/tests/Unit/Services/Domain/Order/MarkOrderAsPaidServiceTest.php b/backend/tests/Unit/Services/Domain/Order/MarkOrderAsPaidServiceTest.php new file mode 100644 index 0000000000..ad39884968 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Order/MarkOrderAsPaidServiceTest.php @@ -0,0 +1,276 @@ +orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->affiliateRepository = Mockery::mock(AffiliateRepositoryInterface::class); + $this->invoiceRepository = Mockery::mock(InvoiceRepositoryInterface::class); + $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->orderApplicationFeeService = Mockery::mock(OrderApplicationFeeService::class); + $this->sendOrderDetailsService = Mockery::mock(SendOrderDetailsService::class); + $this->occurrenceStatusValidator = Mockery::mock(OccurrenceStatusValidator::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction')->andReturnUsing(fn (callable $callback) => $callback()); + + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + + $this->service = new MarkOrderAsPaidService( + $this->orderRepository, + $databaseManager, + $this->affiliateRepository, + $this->invoiceRepository, + $this->attendeeRepository, + $this->domainEventDispatcherService, + Mockery::mock(OrderApplicationFeeCalculationService::class), + $this->eventRepository, + $this->orderApplicationFeeService, + $this->sendOrderDetailsService, + $this->occurrenceStatusValidator, + ); + } + + public function test_marking_an_awaiting_offline_order_as_paid_completes_it_and_fires_the_completion_event(): void + { + $this->givenOrderAwaitingOfflinePayment(); + $this->givenEventWithoutOrganizerConfiguration(); + $this->invoiceRepository->shouldReceive('findLatestInvoiceForOrder')->once()->with(self::ORDER_ID)->andReturnNull(); + + $this->orderRepository->shouldReceive('updateFromArray') + ->once() + ->with(self::ORDER_ID, [ + OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_RECEIVED->name, + ]); + + $this->attendeeRepository->shouldReceive('updateWhere') + ->once() + ->with( + ['status' => AttendeeStatus::ACTIVE->name], + ['order_id' => self::ORDER_ID, 'status' => AttendeeStatus::AWAITING_PAYMENT->name], + ); + + $this->affiliateRepository->shouldNotReceive('incrementSales'); + + $this->domainEventDispatcherService->shouldReceive('dispatch') + ->once() + ->with(Mockery::on( + fn (OrderEvent $event) => $event->type === DomainEventType::ORDER_MARKED_AS_PAID + && $event->orderId === self::ORDER_ID + )); + + $this->sendOrderDetailsService->shouldReceive('sendCustomerOrderSummary')->once(); + + $result = $this->service->markOrderAsPaid(self::ORDER_ID, self::EVENT_ID); + + $this->assertSame(OrderStatus::COMPLETED->name, $result->getStatus()); + + Event::assertDispatched( + OrderStatusChangedEvent::class, + fn (OrderStatusChangedEvent $event) => $event->order->getId() === self::ORDER_ID + && $event->order->isOrderCompleted() + && $event->sendEmails === false + ); + } + + public function test_affiliate_sales_are_credited_with_the_order_gross_when_marked_as_paid(): void + { + $this->givenOrderAwaitingOfflinePayment(affiliateId: self::AFFILIATE_ID, totalGross: 125.5); + $this->givenEventWithoutOrganizerConfiguration(); + $this->invoiceRepository->shouldReceive('findLatestInvoiceForOrder')->andReturnNull(); + $this->orderRepository->shouldReceive('updateFromArray')->once(); + $this->attendeeRepository->shouldReceive('updateWhere')->once(); + $this->domainEventDispatcherService->shouldReceive('dispatch')->once(); + $this->sendOrderDetailsService->shouldReceive('sendCustomerOrderSummary')->once(); + + $this->affiliateRepository->shouldReceive('incrementSales') + ->once() + ->with(self::AFFILIATE_ID, 125.5); + + $this->service->markOrderAsPaid(self::ORDER_ID, self::EVENT_ID); + } + + public function test_the_latest_invoice_is_marked_paid(): void + { + $this->givenOrderAwaitingOfflinePayment(); + $this->givenEventWithoutOrganizerConfiguration(); + $this->orderRepository->shouldReceive('updateFromArray')->once(); + $this->attendeeRepository->shouldReceive('updateWhere')->once(); + $this->domainEventDispatcherService->shouldReceive('dispatch')->once(); + $this->sendOrderDetailsService->shouldReceive('sendCustomerOrderSummary')->once(); + + $this->invoiceRepository->shouldReceive('findLatestInvoiceForOrder') + ->once() + ->with(self::ORDER_ID) + ->andReturn((new InvoiceDomainObject)->setId(77)); + $this->invoiceRepository->shouldReceive('updateFromArray') + ->once() + ->with(77, ['status' => InvoiceStatus::PAID->name]); + + $this->service->markOrderAsPaid(self::ORDER_ID, self::EVENT_ID); + } + + public function test_an_order_that_is_not_awaiting_offline_payment_cannot_be_marked_as_paid(): void + { + $this->givenOrderAwaitingOfflinePayment(status: OrderStatus::COMPLETED); + $this->givenEventWithoutOrganizerConfiguration(expectSecondLoad: false); + + $this->orderRepository->shouldNotReceive('updateFromArray'); + $this->attendeeRepository->shouldNotReceive('updateWhere'); + $this->occurrenceStatusValidator->shouldNotReceive('assertOrderOccurrencesArePurchasable'); + + $this->expectException(ResourceConflictException::class); + + try { + $this->service->markOrderAsPaid(self::ORDER_ID, self::EVENT_ID); + } finally { + Event::assertNotDispatched(OrderStatusChangedEvent::class); + } + } + + public function test_an_order_for_a_cancelled_occurrence_cannot_be_marked_as_paid(): void + { + $this->givenOrderAwaitingOfflinePayment(occurrencePurchasable: false); + $this->givenEventWithoutOrganizerConfiguration(expectSecondLoad: false); + + $this->orderRepository->shouldNotReceive('updateFromArray'); + $this->attendeeRepository->shouldNotReceive('updateWhere'); + + $this->expectException(ResourceConflictException::class); + + try { + $this->service->markOrderAsPaid(self::ORDER_ID, self::EVENT_ID); + } finally { + Event::assertNotDispatched(OrderStatusChangedEvent::class); + } + } + + private function givenOrderAwaitingOfflinePayment( + OrderStatus $status = OrderStatus::AWAITING_OFFLINE_PAYMENT, + ?int $affiliateId = null, + float $totalGross = 50.0, + bool $occurrencePurchasable = true, + ): void { + $pending = (new OrderDomainObject) + ->setId(self::ORDER_ID) + ->setEventId(self::EVENT_ID) + ->setStatus($status->name) + ->setTotalGross($totalGross) + ->setCurrency('USD'); + + $this->orderRepository->shouldReceive('findFirstWhere') + ->once() + ->with([ + OrderDomainObjectAbstract::ID => self::ORDER_ID, + OrderDomainObjectAbstract::EVENT_ID => self::EVENT_ID, + ]) + ->andReturn($pending); + + if ($status !== OrderStatus::AWAITING_OFFLINE_PAYMENT) { + return; + } + + if (! $occurrencePurchasable) { + $this->occurrenceStatusValidator->shouldReceive('assertOrderOccurrencesArePurchasable') + ->once() + ->andThrow(new ResourceConflictException('occurrence cancelled')); + + return; + } + + $this->occurrenceStatusValidator->shouldReceive('assertOrderOccurrencesArePurchasable')->once(); + + $completed = (new OrderDomainObject) + ->setId(self::ORDER_ID) + ->setEventId(self::EVENT_ID) + ->setStatus(OrderStatus::COMPLETED->name) + ->setTotalGross($totalGross) + ->setCurrency('USD') + ->setAffiliateId($affiliateId); + + $this->orderRepository->shouldReceive('findById') + ->once() + ->with(self::ORDER_ID) + ->andReturn($completed); + } + + private function givenEventWithoutOrganizerConfiguration(bool $expectSecondLoad = true): void + { + $event = (new EventDomainObject) + ->setId(self::EVENT_ID) + ->setOrganizer(new OrganizerDomainObject) + ->setEventSettings(new EventSettingDomainObject); + + $this->eventRepository->shouldReceive('findById') + ->times($expectSecondLoad ? 2 : 1) + ->with(self::EVENT_ID) + ->andReturn($event); + } +} diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandlerTest.php new file mode 100644 index 0000000000..de6ff4e3ea --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandlerTest.php @@ -0,0 +1,221 @@ +orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $this->stripePaymentsRepository = Mockery::mock(StripePaymentsRepositoryInterface::class); + $this->eventStatisticsRefundService = Mockery::mock(EventStatisticsRefundService::class); + $this->orderRefundRepository = Mockery::mock(OrderRefundRepositoryInterface::class); + $this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction')->andReturnUsing(fn (callable $callback) => $callback()); + + $this->handler = new ChargeRefundUpdatedHandler( + $this->orderRepository, + $this->stripePaymentsRepository, + Mockery::mock(Logger::class)->shouldIgnoreMissing(), + $databaseManager, + $this->eventStatisticsRefundService, + $this->orderRefundRepository, + $this->domainEventDispatcherService, + ); + } + + public function test_a_partial_refund_records_the_amount_adjusts_statistics_and_marks_the_order_partially_refunded(): void + { + $this->givenStripePaymentForOrder(); + $this->givenNoExistingRefund(); + $this->givenOrder(totalGross: 100.0, totalRefunded: 0.0); + + $this->orderRepository->shouldReceive('increment') + ->once() + ->with(self::ORDER_ID, OrderDomainObjectAbstract::TOTAL_REFUNDED, 25.0); + $this->orderRepository->shouldReceive('updateFromArray') + ->once() + ->with(self::ORDER_ID, [OrderDomainObjectAbstract::REFUND_STATUS => OrderRefundStatus::PARTIALLY_REFUNDED->name]); + + $this->eventStatisticsRefundService->shouldReceive('updateForRefund') + ->once() + ->withArgs(fn (OrderDomainObject $order, MoneyValue $amount): bool => $order->getId() === self::ORDER_ID + && $amount->toMinorUnit() === 2500); + + $createdRefund = null; + $this->orderRefundRepository->shouldReceive('create') + ->once() + ->andReturnUsing(function (array $attributes) use (&$createdRefund) { + $createdRefund = $attributes; + + return new OrderRefundDomainObject; + }); + + $this->domainEventDispatcherService->shouldReceive('dispatch') + ->once() + ->with(Mockery::on(fn (OrderEvent $event) => $event->type === DomainEventType::ORDER_REFUNDED + && $event->orderId === self::ORDER_ID)); + + $this->handler->handleEvent($this->refund(amountMinor: 2500, status: 'succeeded')); + + $this->assertSame(self::ORDER_ID, $createdRefund['order_id']); + $this->assertSame(PaymentProviders::STRIPE->value, $createdRefund['payment_provider']); + $this->assertSame(self::REFUND_ID, $createdRefund['refund_id']); + $this->assertSame(25.0, $createdRefund['amount']); + $this->assertSame('USD', $createdRefund['currency']); + $this->assertSame('succeeded', $createdRefund['status']); + $this->assertSame(self::PAYMENT_INTENT_ID, $createdRefund['metadata']['payment_intent']); + } + + public function test_a_refund_that_reaches_the_order_total_marks_the_order_fully_refunded(): void + { + $this->givenStripePaymentForOrder(); + $this->givenNoExistingRefund(); + $this->givenOrder(totalGross: 100.0, totalRefunded: 60.0); + + $this->orderRepository->shouldReceive('increment')->once()->with(self::ORDER_ID, OrderDomainObjectAbstract::TOTAL_REFUNDED, 40.0); + $this->orderRepository->shouldReceive('updateFromArray') + ->once() + ->with(self::ORDER_ID, [OrderDomainObjectAbstract::REFUND_STATUS => OrderRefundStatus::REFUNDED->name]); + $this->eventStatisticsRefundService->shouldReceive('updateForRefund')->once(); + $this->orderRefundRepository->shouldReceive('create')->once()->andReturn(new OrderRefundDomainObject); + $this->domainEventDispatcherService->shouldReceive('dispatch')->once(); + + $this->handler->handleEvent($this->refund(amountMinor: 4000, status: 'succeeded')); + } + + public function test_a_replayed_refund_webhook_is_ignored(): void + { + $this->givenStripePaymentForOrder(); + $this->orderRefundRepository->shouldReceive('findFirstWhere') + ->once() + ->with(['refund_id' => self::REFUND_ID]) + ->andReturn((new OrderRefundDomainObject)->setId(1)->setRefundId(self::REFUND_ID)); + + $this->orderRepository->shouldNotReceive('findById'); + $this->orderRepository->shouldNotReceive('increment'); + $this->orderRepository->shouldNotReceive('updateFromArray'); + $this->eventStatisticsRefundService->shouldNotReceive('updateForRefund'); + $this->orderRefundRepository->shouldNotReceive('create'); + $this->domainEventDispatcherService->shouldNotReceive('dispatch'); + + $this->handler->handleEvent($this->refund(amountMinor: 2500, status: 'succeeded')); + } + + public function test_a_refund_for_an_unknown_payment_intent_is_ignored(): void + { + $this->stripePaymentsRepository->shouldReceive('findFirstWhere') + ->once() + ->with(['payment_intent_id' => self::PAYMENT_INTENT_ID]) + ->andReturnNull(); + + $this->orderRefundRepository->shouldNotReceive('findFirstWhere'); + $this->orderRepository->shouldNotReceive('findById'); + $this->eventStatisticsRefundService->shouldNotReceive('updateForRefund'); + + $this->handler->handleEvent($this->refund(amountMinor: 2500, status: 'succeeded')); + } + + public function test_a_failed_refund_marks_the_order_refund_failed_without_touching_statistics(): void + { + $this->givenStripePaymentForOrder(); + $this->givenNoExistingRefund(); + $this->givenOrder(totalGross: 100.0, totalRefunded: 0.0); + + $this->orderRepository->shouldReceive('updateFromArray') + ->once() + ->with(self::ORDER_ID, [OrderDomainObjectAbstract::REFUND_STATUS => OrderRefundStatus::REFUND_FAILED->name]); + + $this->orderRepository->shouldNotReceive('increment'); + $this->eventStatisticsRefundService->shouldNotReceive('updateForRefund'); + $this->orderRefundRepository->shouldNotReceive('create'); + $this->domainEventDispatcherService->shouldNotReceive('dispatch'); + + $this->handler->handleEvent($this->refund(amountMinor: 2500, status: 'failed')); + } + + private function refund(int $amountMinor, string $status): Refund + { + return Refund::constructFrom([ + 'id' => self::REFUND_ID, + 'object' => 'refund', + 'amount' => $amountMinor, + 'currency' => 'usd', + 'payment_intent' => self::PAYMENT_INTENT_ID, + 'status' => $status, + 'metadata' => [], + ]); + } + + private function givenStripePaymentForOrder(): void + { + $this->stripePaymentsRepository->shouldReceive('findFirstWhere') + ->once() + ->with(['payment_intent_id' => self::PAYMENT_INTENT_ID]) + ->andReturn((new StripePaymentDomainObject)->setOrderId(self::ORDER_ID)); + } + + private function givenNoExistingRefund(): void + { + $this->orderRefundRepository->shouldReceive('findFirstWhere') + ->once() + ->with(['refund_id' => self::REFUND_ID]) + ->andReturnNull(); + } + + private function givenOrder(float $totalGross, float $totalRefunded): void + { + $this->orderRepository->shouldReceive('findById') + ->once() + ->with(self::ORDER_ID) + ->andReturn((new OrderDomainObject) + ->setId(self::ORDER_ID) + ->setCurrency('USD') + ->setTotalGross($totalGross) + ->setTotalRefunded($totalRefunded)); + } +} diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts index 6928c5ce73..84bf642505 100644 --- a/e2e/api/api-client.ts +++ b/e2e/api/api-client.ts @@ -253,6 +253,25 @@ export class ApiClient { return check(this.request.post(`events/${eventId}/orders/${orderId}/cancel`, { headers: jsonHeaders })); } + listAttendees(eventId: number): Promise { + return unwrap(this.request.get(`events/${eventId}/attendees`, { headers: jsonHeaders })); + } + + async findAttendeeIdByPublicId(eventId: number, publicId: string): Promise { + const attendees = await this.listAttendees(eventId); + const attendee = attendees.find((candidate) => candidate.public_id === publicId); + if (!attendee) { + throw new Error(`Attendee ${publicId} not found among ${attendees.length} attendees for event ${eventId}`); + } + return attendee.id; + } + + updateAttendeeStatus(eventId: number, attendeeId: number, status: 'ACTIVE' | 'CANCELLED'): Promise { + return check( + this.request.patch(`events/${eventId}/attendees/${attendeeId}`, { headers: jsonHeaders, data: { status } }), + ); + } + async generateOccurrences(eventId: number, recurrenceRule: RecurrenceRule): Promise { const response = await this.request.post(`events/${eventId}/occurrences/generate`, { headers: jsonHeaders, diff --git a/e2e/api/types.ts b/e2e/api/types.ts index 125c874859..8a006ce567 100644 --- a/e2e/api/types.ts +++ b/e2e/api/types.ts @@ -53,6 +53,7 @@ export interface ProductPrice { id: number; price: number; label?: string | null; + quantity_sold?: number; } export interface ProductRecord { diff --git a/e2e/pages/event-dashboard.page.ts b/e2e/pages/event-dashboard.page.ts new file mode 100644 index 0000000000..3de4748fe1 --- /dev/null +++ b/e2e/pages/event-dashboard.page.ts @@ -0,0 +1,21 @@ +import type { Locator, Page } from '@playwright/test'; + +export type EventStatKey = 'attendees' | 'products_sold' | 'refunded' | 'gross_sales' | 'page_views' | 'orders'; + +export class EventDashboardPage { + constructor(private readonly page: Page) {} + + async goto(eventId: number): Promise { + await this.page.goto(`/manage/event/${eventId}/dashboard`); + await this.page.waitForLoadState('networkidle'); + } + + async gotoOccurrence(eventId: number, occurrenceId: number): Promise { + await this.page.goto(`/manage/event/${eventId}/occurrences/${occurrenceId}`); + await this.page.waitForLoadState('networkidle'); + } + + stat(key: EventStatKey): Locator { + return this.page.getByTestId(`event-stat-${key}-value`); + } +} diff --git a/e2e/tests/management/event-dashboard-stats.spec.ts b/e2e/tests/management/event-dashboard-stats.spec.ts new file mode 100644 index 0000000000..e810b0c9c8 --- /dev/null +++ b/e2e/tests/management/event-dashboard-stats.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '../../fixtures'; +import { + createCompletedOrder, + createCompletedPaidOrder, + createLiveEventWithProduct, + createRecurringLiveEvent, +} from '../../api/factory'; +import { EventDashboardPage, type EventStatKey } from '../../pages/event-dashboard.page'; +import { uniqueEmail } from '../../utils/unique'; + +type Ledger = Partial>; + +const formatUsd = (amount: number): string => `$${amount.toFixed(2)}`; + +async function expectLedger( + dashboard: EventDashboardPage, + eventId: number, + expected: Ledger, + occurrenceId?: number, +): Promise { + await expect(async () => { + if (occurrenceId) { + await dashboard.gotoOccurrence(eventId, occurrenceId); + } else { + await dashboard.goto(eventId); + } + for (const [key, value] of Object.entries(expected) as [EventStatKey, string][]) { + await expect(dashboard.stat(key)).toHaveText(value, { timeout: 5_000 }); + } + }).toPass({ timeout: 60_000, intervals: [2_000, 3_000, 5_000] }); +} + +test.describe('event dashboard stats ledger', () => { + test('every order path adds up on the dashboard: paid, manual attendee, order cancel, attendee cancel and reactivate', async ({ + authedPage, + api, + account, + publicApi, + }) => { + const event = await createLiveEventWithProduct(api, { organizerId: account.organizerId, price: 25 }); + const dashboard = new EventDashboardPage(authedPage); + + const orderA = await createCompletedPaidOrder(api, publicApi, event, { quantity: 2 }); + const orderB = await createCompletedPaidOrder(api, publicApi, event, { quantity: 1 }); + const manualAmountPaid = 10; + await api.createAttendee(event.eventId, { + product_id: event.productId, + product_price_id: event.priceId, + email: uniqueEmail('manual'), + first_name: 'Manual', + last_name: 'Attendee', + amount_paid: manualAmountPaid, + send_confirmation_email: false, + locale: 'en', + }); + const grossSales = formatUsd(orderA.totalGross + orderB.totalGross + manualAmountPaid); + + await expectLedger(dashboard, event.eventId, { + attendees: '4', + products_sold: '4', + gross_sales: grossSales, + orders: '3', + }); + + await api.cancelOrder(event.eventId, orderB.orderId); + + await expectLedger(dashboard, event.eventId, { + attendees: '3', + products_sold: '3', + gross_sales: grossSales, + orders: '2', + }); + + const attendeeId = await api.findAttendeeIdByPublicId(event.eventId, orderA.attendees[0].publicId); + await api.updateAttendeeStatus(event.eventId, attendeeId, 'CANCELLED'); + + await expectLedger(dashboard, event.eventId, { + attendees: '2', + products_sold: '3', + orders: '2', + }); + + await api.updateAttendeeStatus(event.eventId, attendeeId, 'ACTIVE'); + + await expectLedger(dashboard, event.eventId, { + attendees: '3', + products_sold: '3', + gross_sales: grossSales, + orders: '2', + }); + + const product = await api.getProduct(event.eventId, event.productId); + expect(product.prices?.[0]?.quantity_sold).toBe(3); + }); + + test('a recurring event keeps per-occurrence stats separate and the event total is their sum', async ({ + authedPage, + api, + account, + publicApi, + }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 2, price: 0 }); + const [first, second] = event.occurrences; + const dashboard = new EventDashboardPage(authedPage); + + await createCompletedOrder(publicApi, event, { quantity: 2, eventOccurrenceId: first.id }); + const secondOrder = await createCompletedOrder(publicApi, event, { quantity: 1, eventOccurrenceId: second.id }); + + await expectLedger(dashboard, event.eventId, { attendees: '3', products_sold: '3', orders: '2' }); + await expectLedger(dashboard, event.eventId, { attendees: '2', products_sold: '2', orders: '1' }, first.id); + await expectLedger(dashboard, event.eventId, { attendees: '1', products_sold: '1', orders: '1' }, second.id); + + const secondOrderId = await api.findOrderIdByShortId(event.eventId, secondOrder.orderShortId); + await api.cancelOrder(event.eventId, secondOrderId); + + await expectLedger(dashboard, event.eventId, { attendees: '2', products_sold: '2', orders: '1' }); + await expectLedger(dashboard, event.eventId, { attendees: '2', products_sold: '2', orders: '1' }, first.id); + await expectLedger(dashboard, event.eventId, { attendees: '0', products_sold: '0', orders: '0' }, second.id); + }); +}); diff --git a/frontend/src/components/common/KpiGrid/index.tsx b/frontend/src/components/common/KpiGrid/index.tsx index ca0ee92db8..e435861c53 100644 --- a/frontend/src/components/common/KpiGrid/index.tsx +++ b/frontend/src/components/common/KpiGrid/index.tsx @@ -29,6 +29,7 @@ interface KpiCellProps { sparkline?: number[]; delta?: KpiCellDelta | null; isLoading?: boolean; + testId?: string; } const formatPercent = (percent: number, sign: '+' | '-') => { @@ -36,10 +37,10 @@ const formatPercent = (percent: number, sign: '+' | '-') => { return `${sign}${abs}%`; }; -export const KpiCell = ({label, value, sparkline, delta, isLoading = false}: KpiCellProps) => { +export const KpiCell = ({label, value, sparkline, delta, isLoading = false, testId}: KpiCellProps) => { if (isLoading) { return ( -
+
{label}
@@ -72,10 +73,10 @@ export const KpiCell = ({label, value, sparkline, delta, isLoading = false}: Kpi } return ( -
+
{label}
-
{value}
+
{value}
{sparkline && sparkline.length > 0 ? ( (