ldc-boutique-cnc-sable/tests/Unit/Domain/Services/PickupSchedulingServiceTest.php
2026-10-27 09:58:12 +01:00

420 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
);
}