['2026-05-11 10:00:00', true], 'Right on the edge (Monday 14:00)' => ['2026-05-11 14:00:00', true], 'After limit (Monday 14:01)' => ['2026-05-11 14:01:00', false], ]); dataset('example_2_deadlines', [ 'Before nominal limit (Monday 11:00)' => ['2026-05-04 11:00:00', true], 'After nominal limit (Monday 15:00)' => ['2026-05-04 15:00:00', false], ]); dataset('example_3_deadlines', [ 'Before limit custom (Wednesday 22:00)' => ['2026-04-29 22:00:00', true], 'After limit custom (Thursday 00:01)' => ['2026-04-30 00:01:00', false], ]); dataset('next_pickup_scenarios', [ 'Monday 10:00' => ['2026-05-11 10:00:00', '2026-05-15 14:00:00'], 'Friday 10:00 (Before cutoff hour)' => ['2026-05-15 10:00:00', '2026-05-15 14:00:00'], 'Friday 15:00 (After cutoff hour)' => ['2026-05-15 15:00:00', '2026-05-22 14:00:00'], ]); dataset('current_cutoff_scenarios', [ 'Monday 10:00' => ['2026-05-11 10:00:00', '2026-05-11 14:00:00'], 'Tuesday 10:00' => ['2026-05-12 10:00:00', '2026-05-11 14:00:00'], ]); describe('Pickup Scheduling Service', function (): void { it( 'can sets Friday\'s visibility based on the standard deadline', function (string $mockCurrentTime, bool $expectedVisibility): void { // GIVEN $now = CarbonImmutable::parse($mockCurrentTime); $until = $now->addWeeks(2); $shop = createTestShop(1); $service = createServiceWithExceptions($now); // WHEN $results = $service->getTimeSlotsFor($shop, $until); // THEN $targetFriday = '2026-05-15'; $hasFridaySlot = collect($results) ->contains(fn (PickupTimeSlot $slot) => Date::createFromInterface($slot->getAt()) ->isSameDay($targetFriday)); // THEN expect($hasFridaySlot)->toBe($expectedVisibility); } )->with('nominal_deadlines'); it('can remove pickup day if its a hollyday without replacement', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-04 10:00:00'); $until = $now->addDays(10); $shop = createTestShop(1); $exceptions = [ createExceptionPivot(date: '2026-05-08', replacementDate: null, orderDeadlineAt: null), ]; $service = createServiceWithExceptions($now, $exceptions); // WHEN $results = $service->getTimeSlotsFor($shop, $until); // THEN expect($results)->toBeEmpty(); }); it( 'it can replace the holiday pickup day with another day if ordered before the default deadline', function (string $mockCurrentTime, bool $expectedVisibility): void { // GIVEN $now = CarbonImmutable::parse($mockCurrentTime); $until = $now->addDays(10); $shop = createTestShop(1); $exceptions = [ createExceptionPivot(date: '2026-05-08', replacementDate: '2026-05-07', orderDeadlineAt: null), ]; $service = createServiceWithExceptions($now, $exceptions); // WHEN $results = $service->getTimeSlotsFor($shop, $until); // THEN $targetReplacementDay = '2026-05-07'; $hasThursdaySlot = collect($results) ->contains(fn (PickupTimeSlot $slot) => Date::createFromInterface($slot->getAt()) ->isSameDay($targetReplacementDay)); // THEN expect($hasThursdaySlot)->toBe($expectedVisibility); } )->with('example_2_deadlines'); it( 'can replace the holiday pickup day with another day if ordered before the custom deadline', function (string $mockCurrentTime, bool $expectedVisibility): void { // GIVEN $now = CarbonImmutable::parse($mockCurrentTime); $until = $now->addDays(15); $shop = createTestShop(1); $exceptions = [ createExceptionPivot(date: '2026-05-08', replacementDate: '2026-05-05', orderDeadlineAt: '2026-04-29 23:59:00'), ]; $service = createServiceWithExceptions($now, $exceptions); // WHEN $results = $service->getTimeSlotsFor($shop, $until); // THEN $targetReplacementDay = '2026-05-05'; $hasTuesdaySlot = collect($results) ->contains(fn (PickupTimeSlot $slot) => Date::createFromInterface($slot->getAt()) ->isSameDay($targetReplacementDay)); // THEN expect($hasTuesdaySlot)->toBe($expectedVisibility); } )->with('example_3_deadlines'); it('cannot get timeslots except for a futur date', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-15 12:00:00'); $pastUntilDate = $now->subDay(); $shop = createTestShop(1); $service = createServiceWithExceptions($now); // WHEN // THEN expect(fn () => $service->getTimeSlotsFor($shop, $pastUntilDate)) ->toThrow(\InvalidArgumentException::class, 'Cannot get time slots for past days'); }); it('can return multiple chronological pickup days for a long period', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-11 10:00:00'); // Monday $until = $now->addWeeks(3); $shop = createTestShop(1); $service = createServiceWithExceptions($now); // WHEN $results = $service->getTimeSlotsFor($shop, $until); // THEN expect($results)->toHaveCount(3); // THEN expect(Date::createFromInterface($results[0]->getAt())->isSameDay('2026-05-15'))->toBeTrue(); // THEN expect(Date::createFromInterface($results[1]->getAt())->isSameDay('2026-05-22'))->toBeTrue(); // THEN expect(Date::createFromInterface($results[2]->getAt())->isSameDay('2026-05-29'))->toBeTrue(); }); it('ignores pickup exceptions that do not fall on a Friday', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-11 10:00:00'); // Monday $until = $now->addDays(10); $shop = createTestShop(1); $exceptions = [ createExceptionPivot( date: '2026-05-12', // Friday replacementDate: '2026-05-14', // Thursday orderDeadlineAt: null ), ]; $service = createServiceWithExceptions($now, $exceptions); // WHEN $results = $service->getTimeSlotsFor($shop, $until); // THEN expect($results)->toHaveCount(1); // THEN expect(Date::createFromInterface($results[0]->getAt())->isSameDay('2026-05-15'))->toBeTrue(); }); it('excludes the current day if the order is placed on pickup day morning', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-15 08:00:00'); // Friday $until = $now->addWeeks(2); $shop = createTestShop(1); $service = createServiceWithExceptions($now); // WHEN $results = $service->getTimeSlotsFor($shop, $until); // THEN $hasTodaySlot = collect($results) ->contains(fn (PickupTimeSlot $slot) => Date::createFromInterface($slot->getAt()) ->isSameDay('2026-05-15')); // THEN expect($hasTodaySlot)->toBeFalse(); }); it('calculates nextPickup correctly depending on current day and time', function (string $mockCurrentTime, string $expectedPickupTime): void { // GIVEN $now = CarbonImmutable::parse($mockCurrentTime); $service = createServiceWithExceptions($now); // WHEN $pickup = $service->nextPickup(); // THEN expect($pickup->format('Y-m-d H:i:s'))->toBe($expectedPickupTime); })->with('next_pickup_scenarios'); it('calculates currentCutoff correctly', function (string $mockCurrentTime, string $expectedCutoffTime): void { // GIVEN $now = CarbonImmutable::parse($mockCurrentTime); $service = createServiceWithExceptions($now); // WHEN $cutoff = $service->currentCutoff(); // THEN expect($cutoff->format('Y-m-d H:i:s'))->toBe($expectedCutoffTime); })->with('current_cutoff_scenarios'); it('returns the pickup day', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-04 10:00:00'); $service = createServiceWithExceptions($now); // WHEN $pickupDay = $service->getPickupDay(); // THEN expect($pickupDay->value)->toBe(WeekDay::Friday->value); }); it('gets period from pickup day', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-04 10:00:00'); // Monday 10:00 $service = createServiceWithExceptions($now); // WHEN $period = $service->getPeriodFromPickUpDay(); // next pickup is 2026-05-08 14:00:00 // default deadline is 2026-05-04 14:00:00 // THEN expect($period->getStartDate()->format('Y-m-d H:i:s'))->toBe('2026-05-04 14:00:00') ->and($period->getEndDate()->format('Y-m-d H:i:s'))->toBe('2026-05-08 23:59:59'); }); it('gets period for pickup date', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-04 10:00:00'); $service = createServiceWithExceptions($now); $pickupDate = CarbonImmutable::parse('2026-05-15 14:00:00'); // WHEN $period = $service->getPeriodForPickupDate($pickupDate); // THEN expect($period->getStartDate()->format('Y-m-d H:i:s'))->toBe('2026-05-11 14:00:00') ->and($period->getEndDate()->format('Y-m-d H:i:s'))->toBe('2026-05-15 23:59:59'); }); it('gets editable pickup exceptions for shop', function (): void { // GIVEN $now = CarbonImmutable::parse('2026-05-04 10:00:00'); $exception1 = new PickupExceptionOnDate( id: new PickupExceptionId(Uuid::uuid7()), date: CarbonImmutable::parse('2026-05-08'), // Friday label: 'Test 1', repeatable: false, repeatFrequency: RepeatFrequency::YEARLY, autoUpdated: false ); $exception2 = new PickupExceptionOnDate( id: new PickupExceptionId(Uuid::uuid7()), date: CarbonImmutable::parse('2026-05-09'), // Saturday label: 'Test 2', repeatable: false, repeatFrequency: RepeatFrequency::YEARLY, autoUpdated: false ); $shopException = new ShopPickupOverride( exception: $exception1, replacementDate: null, orderDeadlineAt: null ); $repo = \Mockery::mock(PickupExceptionRepository::class); $repo->shouldReceive('getAllExceptions')->andReturn([$exception1, $exception2]); $repo->shouldReceive('getExceptionsAssociatedWithShop')->andReturn([$shopException]); $service = new class ($repo, $now) extends PickupSchedulingService { public function __construct(PickupExceptionRepository $repo, private readonly CarbonImmutable $now) { parent::__construct($repo); } protected function getNow(): CarbonImmutable { return $this->now; } }; $shop = createTestShop(1); // WHEN $editable = $service->getEditablePickupExceptionsFor($shop); // THEN expect($editable)->toHaveCount(2); // Test 1: is Friday, associated -> checked, replacement null // THEN expect($editable[0]->pickupException->id->value)->toBeInstanceOf(UuidInterface::class) ->and($editable[0]->isPickupDay)->toBeTrue() ->and($editable[0]->missingSubsitute)->toBeTrue() ->and($editable[0]->passedDeadline)->toBeFalse(); // Test 2: not Friday, not associated // THEN expect($editable[1]->pickupException->id->value)->toBeInstanceOf(UuidInterface::class) ->and($editable[1]->isPickupDay)->toBeFalse() ->and($editable[1]->missingSubsitute)->toBeFalse() ->and($editable[1]->passedDeadline)->toBeFalse(); }); }); /** * @param array $exceptions */ function createServiceWithExceptions(CarbonImmutable $now, array $exceptions = []): PickupSchedulingService { /** @var \Mockery\MockInterface&\Domain\Ports\Repositories\PickupExceptionRepository $repo */ $repo = \Mockery::mock(PickupExceptionRepository::class); $repo->shouldReceive('getExceptionsForShop')->andReturn($exceptions); return new class ($repo, $now) extends PickupSchedulingService { public function __construct( PickupExceptionRepository $repo, private readonly CarbonImmutable $now ) { parent::__construct($repo); } #[\Override] protected function getNow(): CarbonImmutable { return $this->now; } }; } function createExceptionPivot(string $date, ?string $replacementDate = null, ?string $orderDeadlineAt = null): ShopPickupOverride { static $idCounter = 1; $exception = new PickupExceptionOnDate( id: new PickupExceptionId(Uuid::uuid7()), date: CarbonImmutable::parse($date), label: 'Test Exception', repeatable: false, repeatFrequency: RepeatFrequency::YEARLY, autoUpdated: false ); return new ShopPickupOverride( exception: $exception, replacementDate: $replacementDate ? CarbonImmutable::parse($replacementDate) : null, orderDeadlineAt: $orderDeadlineAt ? CarbonImmutable::parse($orderDeadlineAt) : null ); }