421 lines
15 KiB
PHP
421 lines
15 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
use Carbon\CarbonImmutable;
|
||
|
|
use Carbon\WeekDay;
|
||
|
|
use Domain\Enums\RepeatFrequency;
|
||
|
|
use Domain\Models\Entities\PickupExceptionOnDate;
|
||
|
|
use Domain\Models\Entities\Shop;
|
||
|
|
use Domain\Models\ValueObjects\Entities\PickupExceptionId;
|
||
|
|
use Domain\Models\ValueObjects\Entities\ShopId;
|
||
|
|
use Domain\Models\ValueObjects\PickupTimeSlot;
|
||
|
|
use Domain\Models\ValueObjects\ShopPickupOverride;
|
||
|
|
use Domain\Ports\Repositories\PickupExceptionRepository;
|
||
|
|
use Domain\Services\PickupSchedulingService;
|
||
|
|
use Illuminate\Support\Facades\Date;
|
||
|
|
use Ramsey\Uuid\Uuid;
|
||
|
|
use Ramsey\Uuid\UuidInterface;
|
||
|
|
|
||
|
|
function createTestShop(int $id = 1): Shop
|
||
|
|
{
|
||
|
|
return new Shop(
|
||
|
|
id: new ShopId(Uuid::uuid7()),
|
||
|
|
name: 'Test Shop',
|
||
|
|
address: '1 rue de Paris',
|
||
|
|
postcode: '75000',
|
||
|
|
city: 'Paris'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
dataset('nominal_deadlines', [
|
||
|
|
'Before limit (Monday 10:00)' => ['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<int, \Domain\Models\ValueObjects\ShopPickupOverride> $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
|
||
|
|
);
|
||
|
|
}
|