0) { $productId = new ProductId(Uuid::fromString('019e9e37-f2a1-74ef-b9c2-66d222470128')); $basketProduct = new BasketProduct( id: $productId, name: 'Test Product', qty: $qty, unitPriceHt: Money::EUR(100), unitDiscount: Money::EUR(0), taxRate: TaxRate::TVA_20, weightGrams: 100, brand: null, incoId: new IncoId(123), orderUnit: OrderUnit::PCE, slug: 'test-product', picture: null, description: null, weightVariable: false, discountRate: DiscountRate::fromRatio(0.0), priceKgTtc: Money::EUR(120) ); $items[(string) $productId->value] = $basketProduct; } $basketId = new BasketId(Uuid::fromString('019e9e37-f2a1-74ef-b9c2-66d222470128')); return new Basket($basketId, $items); } function createTestBuilder(bool $completed = false): OrderBuilder { $basket = createTestBasket(1); $shopperId = new ShopperId(Uuid::uuid7()); $builder = OrderBuilder::fromBasket($basket, $shopperId); if ($completed) { $shopId = new ShopId(Uuid::uuid7()); $timeSlot = new PickupTimeSlot(CarbonImmutable::create(2025, 1, 1), new DateInterval('PT1H')); $builder = $builder->withPickupTimeSlot($shopId, $timeSlot); } /** @var OrderBuilder $builder */ return $builder; } function createOrderTunnelService( ?BasketRepository $basketRepository = null, ?ProductRepository $productRepository = null, ?ShopRepository $shopRepository = null, ?ShopperRepository $shopperRepository = null, ?PickupExceptionRepository $pickupExceptionRepository = null, ?OrderBuilderSessionManager $orderBuilderSessionManager = null, ?OrderRepository $orderRepository = null, ?TransactionManager $transactionManager = null, ?DomainEventDispatcher $eventDispatcher = null, ): OrderTunnelService { if ($basketRepository === null) { $basketRepository = mock(BasketRepository::class); $basketRepository->shouldReceive('persist')->byDefault(); } if ($productRepository === null) { $productRepository = mock(ProductRepository::class); $productRepository->shouldReceive('getBasketProduct')->andReturnUsing(function ($id, $qty) { return new BasketProduct( id: $id, name: 'Test Product', qty: $qty, unitPriceHt: Money::EUR(100), unitDiscount: Money::EUR(0), taxRate: TaxRate::TVA_20, weightGrams: 100, brand: null, incoId: new IncoId(123), orderUnit: OrderUnit::PCE, slug: 'test-product', picture: null, description: null, weightVariable: false, discountRate: DiscountRate::fromRatio(0.0), priceKgTtc: Money::EUR(120) ); })->byDefault(); } $shopRepository ??= mock(ShopRepository::class); $shopperRepository ??= mock(ShopperRepository::class); $pickupExceptionRepository ??= mock(PickupExceptionRepository::class); $orderBuilderSessionManager ??= mock(OrderBuilderSessionManager::class); $orderRepository ??= mock(OrderRepository::class); $transactionManager ??= mock(TransactionManager::class); $eventDispatcher ??= mock(DomainEventDispatcher::class); $sessionService = \Mockery::mock(SessionService::class); $sessionService->shouldReceive('get')->andReturn(new SessionId(Uuid::uuid7()))->byDefault(); $employeeStatisticsService = \Mockery::mock(EmployeeStatisticsService::class); $employeeStatisticsService->shouldReceive('getWeeklyTotalForEmployee')->andReturn(Money::EUR(0))->byDefault(); $basketService = new BasketService($basketRepository, $productRepository, $sessionService); $shopperService = new ShopperService($shopRepository, $shopperRepository); $pickupService = new PickupSchedulingService($pickupExceptionRepository); $orderService = new OrderService($orderRepository); $orderLimitPolicy = new CustomerOrderLimitPolicy(500000, 500000); return new OrderTunnelService( $basketService, $eventDispatcher, $shopperService, $orderBuilderSessionManager, $pickupService, $orderRepository, $orderLimitPolicy, $transactionManager, $shopRepository, $shopperRepository, $orderService, $employeeStatisticsService ); } describe('OrderTunnelService', function (): void { it('validates basket is valid when it has items', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(2)); $service = createOrderTunnelService(basketRepository: $basketRepo); // WHEN $isValid = $service->hasValidBasket(); // THEN expect($isValid)->toBeTrue(); }); it('invalidates basket when it is empty', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(0)); $service = createOrderTunnelService(basketRepository: $basketRepo); // WHEN $isValid = $service->hasValidBasket(); // THEN expect($isValid)->toBeFalse(); }); it('allows access to REVIEW step when basket is valid', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(1)); $orderBuilderRepository = mock(OrderBuilderSessionManager::class); $orderBuilderRepository->shouldReceive('get')->andReturn(null); $service = createOrderTunnelService( basketRepository: $basketRepo, orderBuilderSessionManager: $orderBuilderRepository ); // WHEN $canAccess = $service->canAccessStep(OrderTunnelStep::REVIEW); // THEN expect($canAccess)->toBeTrue(); }); it('denies access to REVIEW step when basket is empty', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(0)); $orderBuilderRepository = mock(OrderBuilderSessionManager::class); $orderBuilderRepository->shouldReceive('get')->andReturn(null); $service = createOrderTunnelService( basketRepository: $basketRepo, orderBuilderSessionManager: $orderBuilderRepository ); // WHEN $canAccess = $service->canAccessStep(OrderTunnelStep::REVIEW); // THEN expect($canAccess)->toBeFalse(); }); it('allows access to BOOKING step when builder is from basket and basket is valid', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(1)); $builder = createTestBuilder(); $orderBuilderRepository = mock(OrderBuilderSessionManager::class); $orderBuilderRepository->shouldReceive('get')->andReturn($builder); $service = createOrderTunnelService( basketRepository: $basketRepo, orderBuilderSessionManager: $orderBuilderRepository ); // WHEN $canAccess = $service->canAccessStep(OrderTunnelStep::BOOKING); // THEN expect($canAccess)->toBeTrue(); }); it('denies access to BOOKING step when builder is missing', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(1)); $orderBuilderRepository = mock(OrderBuilderSessionManager::class); $orderBuilderRepository->shouldReceive('get')->andReturn(null); $service = createOrderTunnelService( basketRepository: $basketRepo, orderBuilderSessionManager: $orderBuilderRepository ); // WHEN $canAccess = $service->canAccessStep(OrderTunnelStep::BOOKING); // THEN expect($canAccess)->toBeFalse(); }); it('allows access to CONFIRMATION step when builder is completed and basket is valid', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(1)); $builder = createTestBuilder(completed: true); $orderBuilderRepository = mock(OrderBuilderSessionManager::class); $orderBuilderRepository->shouldReceive('get')->andReturn($builder); $service = createOrderTunnelService( basketRepository: $basketRepo, orderBuilderSessionManager: $orderBuilderRepository ); // WHEN $canAccess = $service->canAccessStep(OrderTunnelStep::CONFIRMATION); // THEN expect($canAccess)->toBeTrue(); }); it('denies access to CONFIRMATION step when builder is not completed', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(1)); $builder = createTestBuilder(completed: false); $orderBuilderRepository = mock(OrderBuilderSessionManager::class); $orderBuilderRepository->shouldReceive('get')->andReturn($builder); $service = createOrderTunnelService( basketRepository: $basketRepo, orderBuilderSessionManager: $orderBuilderRepository ); // WHEN $canAccess = $service->canAccessStep(OrderTunnelStep::CONFIRMATION); // THEN expect($canAccess)->toBeFalse(); }); it('denies access to CONFIRMATION step when builder is missing', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->andReturn(createTestBasket(1)); $orderBuilderRepository = mock(OrderBuilderSessionManager::class); $orderBuilderRepository->shouldReceive('get')->andReturn(null); $service = createOrderTunnelService( basketRepository: $basketRepo, orderBuilderSessionManager: $orderBuilderRepository ); // WHEN $canAccess = $service->canAccessStep(OrderTunnelStep::CONFIRMATION); // THEN expect($canAccess)->toBeFalse(); }); it('gets shop for customer successfully', function (): void { // GIVEN $shopperId = new ShopperId(Uuid::uuid7()); $shopper = new Shopper( id: $shopperId, email: new Email('test@test.com'), matricule: new Matricule('1234567'), nom: 'Doe', prenom: 'John', startDate: CarbonImmutable::now()->subYear(), endDate: null, etablissement: new ShopperEstablishmentCode('ETAB01'), site: new ShopperSiteCode('SITEA'), service: new ShopperServiceCode('101') ); $shopId = new ShopId(Uuid::uuid7()); $shop = new Shop( id: $shopId, name: 'Shop', address: 'Address', postcode: '75000', city: 'Paris' ); $shopperRepo = mock(ShopperRepository::class); $shopperRepo->shouldReceive('find')->with($shopperId)->andReturn($shopper); $shopRepo = mock(ShopRepository::class); $shopRepo->shouldReceive('findExceptionShopFor')->with($shopper->matricule)->andReturn(null); $shopRepo->shouldReceive('findShopForSiteAndService')->with($shopper->site, $shopper->service)->andReturn(null); $shopRepo->shouldReceive('findShopForSiteAndService')->with($shopper->site, null)->andReturn(null); $shopRepo->shouldReceive('getDefaultShop')->andReturn($shop); $service = createOrderTunnelService( shopRepository: $shopRepo, shopperRepository: $shopperRepo ); // WHEN $result = $service->getShopForCustomer($shopperId); // THEN expect($result)->toBe($shop); }); it('throws exception when get shop for customer fails', function (): void { // GIVEN $shopperId = new ShopperId(Uuid::uuid7()); $shopperRepo = mock(ShopperRepository::class); $shopperRepo->shouldReceive('find')->with($shopperId)->andReturn(null); $service = createOrderTunnelService(shopperRepository: $shopperRepo); // WHEN $action = fn () => $service->getShopForCustomer($shopperId); // THEN expect($action)->toThrow(\UnexpectedValueException::class); }); it('gets available time slots', function (): void { // GIVEN $shopId = new ShopId(Uuid::uuid7()); $shop = new Shop($shopId, 'Shop', 'Address', '75000', 'Paris'); $pickupExceptionRepo = mock(PickupExceptionRepository::class); $pickupExceptionRepo->shouldReceive('getExceptionsForShop')->andReturn([]); $service = createOrderTunnelService(pickupExceptionRepository: $pickupExceptionRepo); // WHEN $slots = $service->getAvailableTimeSlots($shop); // THEN expect($slots)->toBeArray(); }); it('initializes order from basket', function (): void { // GIVEN $basket = createTestBasket(1); $customerId = new ShopperId(Uuid::uuid7()); $orderBuilderRepo = mock(OrderBuilderSessionManager::class); $orderBuilderRepo->shouldReceive('save')->once(); $service = createOrderTunnelService(orderBuilderSessionManager: $orderBuilderRepo); // WHEN $service->initializeFromBasket($basket, $customerId); // THEN expect(true)->toBeTrue(); }); it('gets builder from basket or fail', function (): void { // GIVEN $builder = createTestBuilder(); $orderBuilderRepo = mock(OrderBuilderSessionManager::class); $orderBuilderRepo->shouldReceive('get')->once()->andReturn($builder); $service = createOrderTunnelService(orderBuilderSessionManager: $orderBuilderRepo); // WHEN $result = $service->getBuilderFromBasketOrFail(); // THEN expect($result)->toBe($builder); }); it('throws exception if builder not from basket', function (): void { // GIVEN $orderBuilderRepo = mock(OrderBuilderSessionManager::class); $orderBuilderRepo->shouldReceive('get')->once()->andReturn(null); $service = createOrderTunnelService(orderBuilderSessionManager: $orderBuilderRepo); // WHEN $action = fn () => $service->getBuilderFromBasketOrFail(); // THEN expect($action)->toThrow(MissingOrderBuilderException::class); }); it('gets completed builder or fail', function (): void { // GIVEN $builder = createTestBuilder(completed: true); $orderBuilderRepo = mock(OrderBuilderSessionManager::class); $orderBuilderRepo->shouldReceive('get')->once()->andReturn($builder); $service = createOrderTunnelService(orderBuilderSessionManager: $orderBuilderRepo); // WHEN $result = $service->getBuilderCompletedOrFail(); // THEN expect($result)->toBe($builder); }); it('throws exception if builder not completed', function (): void { // GIVEN $builder = createTestBuilder(completed: false); $orderBuilderRepo = mock(OrderBuilderSessionManager::class); $orderBuilderRepo->shouldReceive('get')->once()->andReturn($builder); $service = createOrderTunnelService(orderBuilderSessionManager: $orderBuilderRepo); // WHEN $action = fn () => $service->getBuilderCompletedOrFail(); // THEN expect($action)->toThrow(MissingOrderBuilderException::class); }); it('applies time slot', function (): void { // GIVEN $builder = createTestBuilder(completed: false); $shop = new Shop(new ShopId(Uuid::uuid7()), 'Shop', 'Address', '75000', 'Paris'); $selectedDate = CarbonImmutable::create(2025, 1, 1); $ts1 = new PickupTimeSlot($selectedDate, new DateInterval('PT1H')); $ts2 = new PickupTimeSlot(CarbonImmutable::create(2025, 1, 2), new DateInterval('PT1H')); $availableSlots = [$ts1, $ts2]; $orderBuilderRepo = mock(OrderBuilderSessionManager::class); $orderBuilderRepo->shouldReceive('save')->once(); $service = createOrderTunnelService(orderBuilderSessionManager: $orderBuilderRepo); // WHEN $matched = $service->applyTimeSlot($builder, $shop, $selectedDate, $availableSlots); // THEN expect($matched)->toBe($ts1); }); it('returns null if no matching time slot', function (): void { // GIVEN $builder = createTestBuilder(completed: false); $shop = new Shop(new ShopId(Uuid::uuid7()), 'Shop', 'Address', '75000', 'Paris'); $selectedDate = CarbonImmutable::create(2025, 1, 1); $ts1 = new PickupTimeSlot(CarbonImmutable::create(2025, 1, 2), new DateInterval('PT1H')); $availableSlots = [$ts1]; $service = createOrderTunnelService(); // WHEN $matched = $service->applyTimeSlot($builder, $shop, $selectedDate, $availableSlots); // THEN expect($matched)->toBeNull(); }); it('finalizes order', function (): void { // GIVEN $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->once()->andReturn(createTestBasket(1)); $basketRepo->shouldReceive('persist')->once(); $basketRepo->shouldReceive('delete')->once(); $builder = createTestBuilder(completed: true); $shopperId = $builder->getShopperId(); $shopper = new Shopper( id: $shopperId, email: new Email('test@test.com'), matricule: new Matricule('1234567'), nom: 'Doe', prenom: 'John', startDate: CarbonImmutable::now()->subYear(), endDate: null, etablissement: new ShopperEstablishmentCode('ETAB01'), site: new ShopperSiteCode('SITEA'), service: new ShopperServiceCode('101') ); $shopperRepo = mock(ShopperRepository::class); $shopperRepo->shouldReceive('getByIdOrFail')->with(\Mockery::type(ShopperId::class))->andReturn($shopper); $shopId = clone clone $builder->getShopId(); $shop = new Shop($shopId, 'Shop', 'Address', '75000', 'Paris'); $shopRepo = mock(ShopRepository::class); $shopRepo->shouldReceive('find')->andReturn($shop); // any with shopId is fine $orderRepo = mock(OrderRepository::class); $orderRepo->shouldReceive('getWeeklyTotalForEmployee')->andReturn(Money::EUR(5000)); $orderRepo->shouldReceive('persist')->once(); $transactionManager = mock(TransactionManager::class); $transactionManager->shouldReceive('execute')->once()->andReturnUsing(function ($callback) { return $callback(); }); $eventDispatcher = mock(DomainEventDispatcher::class); $eventDispatcher->shouldReceive('dispatch')->once(); $builderRepo = mock(OrderBuilderSessionManager::class); $builderRepo->shouldReceive('clear')->once(); $service = createOrderTunnelService( basketRepository: $basketRepo, shopRepository: $shopRepo, shopperRepository: $shopperRepo, orderBuilderSessionManager: $builderRepo, orderRepository: $orderRepo, transactionManager: $transactionManager, eventDispatcher: $eventDispatcher ); // WHEN $order = $service->finalizeOrder($builder); // THEN expect($order)->toBeInstanceOf(Order::class); expect($order->employee->matricule->value)->toBe('1234567'); expect($order->deliveryAddress->name)->toBe('Shop'); }); it('throws exception if shop not found during finalize order', function (): void { // GIVEN $builder = createTestBuilder(completed: true); $shopperId = clone clone $builder->getShopperId(); $shopper = new Shopper( id: $shopperId, email: new Email('test@test.com'), matricule: new Matricule('1234567'), nom: 'Doe', prenom: 'John', startDate: CarbonImmutable::now()->subYear(), endDate: null, etablissement: new ShopperEstablishmentCode('ETAB01'), site: new ShopperSiteCode('SITEA'), service: new ShopperServiceCode('101') ); $shopperRepo = mock(ShopperRepository::class); $shopperRepo->shouldReceive('getByIdOrFail')->with(\Mockery::type(ShopperId::class))->andReturn($shopper); $shopRepo = mock(ShopRepository::class); $shopRepo->shouldReceive('find')->andReturn(null); $orderRepo = mock(OrderRepository::class); $orderRepo->shouldReceive('getWeeklyTotalForEmployee')->andReturn(Money::EUR(5000)); $transactionManager = mock(TransactionManager::class); $transactionManager->shouldReceive('execute')->once()->andReturnUsing(function ($callback) { return $callback(); }); $service = createOrderTunnelService( shopRepository: $shopRepo, shopperRepository: $shopperRepo, orderRepository: $orderRepo, transactionManager: $transactionManager ); // WHEN $action = fn () => $service->finalizeOrder($builder); // THEN expect($action)->toThrow(\DomainException::class); }); it('revalidates basket prices from product repository when finalizing order', function (): void { // GIVEN $builder = createTestBuilder(completed: true); $shopperId = $builder->getShopperId(); $shopper = new Shopper( id: $shopperId, email: new Email('test@test.com'), matricule: new Matricule('1234567'), nom: 'Doe', prenom: 'John', startDate: CarbonImmutable::now()->subYear(), endDate: null, etablissement: new ShopperEstablishmentCode('ETAB01'), site: new ShopperSiteCode('SITEA'), service: new ShopperServiceCode('101') ); $shopperRepo = mock(ShopperRepository::class); $shopperRepo->shouldReceive('getByIdOrFail')->with(\Mockery::type(ShopperId::class))->andReturn($shopper); $shopId = clone $builder->getShopId(); $shop = new Shop($shopId, 'Shop', 'Address', '75000', 'Paris'); $shopRepo = mock(ShopRepository::class); $shopRepo->shouldReceive('find')->andReturn($shop); $orderRepo = mock(OrderRepository::class); $orderRepo->shouldReceive('getWeeklyTotalForEmployee')->andReturn(Money::EUR(5000)); $orderRepo->shouldReceive('persist')->once(); $transactionManager = mock(TransactionManager::class); $transactionManager->shouldReceive('execute')->once()->andReturnUsing(function ($callback) { return $callback(); }); $eventDispatcher = mock(DomainEventDispatcher::class); $eventDispatcher->shouldReceive('dispatch')->once(); $builderRepo = mock(OrderBuilderSessionManager::class); $builderRepo->shouldReceive('clear')->once(); $basketRepo = mock(BasketRepository::class); $basketRepo->shouldReceive('find')->once()->andReturn(createTestBasket(1)); $basketRepo->shouldReceive('persist')->once(); $basketRepo->shouldReceive('delete')->once(); $productRepo = mock(ProductRepository::class); $productRepo->shouldReceive('getBasketProduct')->once()->andReturnUsing(function ($id, $qty) { return new BasketProduct( id: $id, name: 'Updated Product Name', qty: $qty, unitPriceHt: Money::EUR(250), unitDiscount: Money::EUR(50), taxRate: TaxRate::TVA_20, weightGrams: 200, brand: null, incoId: new IncoId(123), orderUnit: OrderUnit::PCE, slug: 'updated-product', picture: null, description: null, weightVariable: false, discountRate: DiscountRate::fromRatio(0.0), priceKgTtc: Money::EUR(240) ); }); $service = createOrderTunnelService( basketRepository: $basketRepo, productRepository: $productRepo, shopRepository: $shopRepo, shopperRepository: $shopperRepo, orderBuilderSessionManager: $builderRepo, orderRepository: $orderRepo, transactionManager: $transactionManager, eventDispatcher: $eventDispatcher ); // WHEN $order = $service->finalizeOrder($builder); // THEN expect($order)->toBeInstanceOf(Order::class); }); });