From 89854cba4db34dde2908907386c6aa0c4bc55b07 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Wed, 14 Oct 2026 17:21:14 +0200 Subject: [PATCH 1/2] feat: :sparkles: add use case create printable coupon + tests --- .../Create/CreatePrintableCoupon.php | 100 ++++ .../UseCases/PrintableCoupon/Create/Input.php | 20 + .../PrintableCoupon/Create/Output.php | 23 + src/Domain/Models/Entities/Coupon.php | 34 ++ .../ValueObjects/Campaign/CampaignCode.php | 4 +- .../Coupon/CouponAvailabilityCalendar.php | 11 +- src/Domain/Models/ValueObjects/Period.php | 21 +- src/Domain/Models/ValueObjects/PrintRules.php | 26 +- .../Ports/Repositories/CampaignRepository.php | 6 + .../PrintableCouponRepository.php | 14 + .../Create/CreatePrintableCouponTest.php | 429 ++++++++++++++++++ .../Models/ValueObjects/PrintRulesTest.php | 53 ++- 12 files changed, 710 insertions(+), 31 deletions(-) create mode 100644 src/Application/UseCases/PrintableCoupon/Create/CreatePrintableCoupon.php create mode 100644 src/Application/UseCases/PrintableCoupon/Create/Input.php create mode 100644 src/Application/UseCases/PrintableCoupon/Create/Output.php create mode 100644 tests/Unit/Application/UseCases/PrintableCoupon/Create/CreatePrintableCouponTest.php diff --git a/src/Application/UseCases/PrintableCoupon/Create/CreatePrintableCoupon.php b/src/Application/UseCases/PrintableCoupon/Create/CreatePrintableCoupon.php new file mode 100644 index 0000000..76e7d1a --- /dev/null +++ b/src/Application/UseCases/PrintableCoupon/Create/CreatePrintableCoupon.php @@ -0,0 +1,100 @@ + */ +final readonly class CreatePrintableCoupon implements UseCase, Transactional +{ + public function __construct( + private PrintableCouponRepository $printableCouponRepository, + private CouponRepository $couponRepository, + private CampaignRepository $campaignRepository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $campaign = $this->campaignRepository->findByCode($input->campaignCode); + if (! $campaign instanceof Campaign) { + throw new \DomainException(sprintf('Campaign from code "%s" not found.', $input->campaignCode->value)); + } + if (! $campaign->publication) { + throw new \DomainException('Campaign must be published.'); + } + + $coupon = $this->couponRepository->findByCode($input->couponCode); + if (! $coupon instanceof Coupon) { + throw new \DomainException(sprintf('Coupon code "%s" not found.', $input->couponCode->value)); + } + + $campaignCoupon = $this->campaignRepository->findCampaignCoupon($campaign->code, $coupon->code); + if (! $campaignCoupon instanceof CampaignCoupon) { + throw new \DomainException('Coupon must be associated to the associated campaign coupon.'); + } + + $period = $coupon->getActivePeriod(); + + $this->checkPrintRulesFromCampaignCoupon($campaign, $campaignCoupon, $coupon, $input->crmCustomerHashcode); + + $printableCoupon = PrintableCoupon::create( + campaignCode: $campaign->code, + couponCode: $coupon->code, + couponName: $coupon->name, + crmCustomerHashcode: $input->crmCustomerHashcode, + distributableUntil: $period->end, + ); + + $this->printableCouponRepository->save($printableCoupon); + + return new Output( + campaignCode: $printableCoupon->campaignCode, + couponCode: $printableCoupon->couponCode, + couponName: $printableCoupon->couponName, + crmCustomerHashcode: $printableCoupon->crmCustomerHashcode, + distributableUntil: $printableCoupon->distributableUntil, + ); + } + + public function checkPrintRulesFromCampaignCoupon( + Campaign $campaign, + CampaignCoupon $campaignCoupon, + Coupon $coupon, + CrmHashcode $crmCustomerHashcode, + ): void { + if ($campaignCoupon->printRules instanceof PrintRules) { + $countPrintedCoupon = $this->printableCouponRepository->countPrintedCouponForCustomer( + $campaign->code, + $coupon->code, + $crmCustomerHashcode, + ); + $campaignCoupon->printRules->hasPrintLimitNotReached($countPrintedCoupon); + + $lastPrintedCoupon = $this->printableCouponRepository->findLastPrintedCouponForCustomer( + $campaign->code, + $coupon->code, + $crmCustomerHashcode, + ); + $diff = $lastPrintedCoupon->printedAt()->diff(new \DateTime()); + $campaignCoupon->printRules->hasReprintPeriodExpired($diff->days); + } + } +} diff --git a/src/Application/UseCases/PrintableCoupon/Create/Input.php b/src/Application/UseCases/PrintableCoupon/Create/Input.php new file mode 100644 index 0000000..209b011 --- /dev/null +++ b/src/Application/UseCases/PrintableCoupon/Create/Input.php @@ -0,0 +1,20 @@ +validityInformations, $this->legalInformations, ); + $this->validateCalendarPeriodsWithinDiffusionPeriod(); } public function id(): CouponId @@ -182,4 +184,36 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier updatedAt: DatesHelper::now(), ); } + + public function getActivePeriod(): Period + { + $currentDate = new \DateTime(); + $diffusionPeriod = $this->diffusion->period; + $period = $this->availabilityCalendar?->isActiveAt($currentDate) + ? $this->availabilityCalendar->getCurrentPeriod($currentDate) + ?? $diffusionPeriod + : $diffusionPeriod; + + if (! $period->hasStarted()) { + throw new \DomainException('The printing period has not yet begun.'); + } + + if (! $period->hasEnded()) { + throw new \DomainException('The printing period has expired.'); + } + + return $period; + } + + public function validateCalendarPeriodsWithinDiffusionPeriod(): void + { + if ($this->availabilityCalendar instanceof CouponAvailabilityCalendar) { + $diffusionPeriod = $this->diffusion->period; + foreach ($this->availabilityCalendar->periods as $calendarPeriod) { + if (! $diffusionPeriod->inRange($calendarPeriod->start) || ! $diffusionPeriod->inRange($calendarPeriod->end)) { + throw new \DomainException('Calendar period must be in diffusion period.'); + } + } + } + } } diff --git a/src/Domain/Models/ValueObjects/Campaign/CampaignCode.php b/src/Domain/Models/ValueObjects/Campaign/CampaignCode.php index afe38a9..33a855b 100644 --- a/src/Domain/Models/ValueObjects/Campaign/CampaignCode.php +++ b/src/Domain/Models/ValueObjects/Campaign/CampaignCode.php @@ -7,9 +7,9 @@ namespace Domain\Models\ValueObjects\Campaign; final readonly class CampaignCode { public function __construct( - public string $code, + public string $value, ) { - if (mb_trim($this->code) === '') { + if (mb_trim($this->value) === '') { throw new \DomainException('Campaign code cannot be empty.'); } } diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php b/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php index 90de3ff..a1f5ad4 100644 --- a/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php +++ b/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php @@ -10,8 +10,10 @@ use DateTime; final readonly class CouponAvailabilityCalendar implements Equatable { + /** + * @param array $periods + */ public function __construct( - /** @var Period[] */ public array $periods, ) { if ($this->periods === []) { @@ -27,7 +29,7 @@ final readonly class CouponAvailabilityCalendar implements Equatable public function isActiveAt(DateTime $in): bool { - return array_any($this->periods, fn ($period): bool => $in >= $period->start && $in <= $period->end); + return array_any($this->periods, fn ($period): bool => $period->inRange($in)); } public function beginsAt(): DateTime @@ -40,6 +42,11 @@ final readonly class CouponAvailabilityCalendar implements Equatable return max(array_map(fn (Period $period): DateTime => $period->end, $this->periods)); } + public function getCurrentPeriod(DateTime $in): ?Period + { + return array_first(array_filter($this->periods, fn ($period): bool => $period->inRange($in))); + } + #[\Override] public function equals(mixed $other): bool { diff --git a/src/Domain/Models/ValueObjects/Period.php b/src/Domain/Models/ValueObjects/Period.php index c189079..4a68005 100644 --- a/src/Domain/Models/ValueObjects/Period.php +++ b/src/Domain/Models/ValueObjects/Period.php @@ -4,16 +4,31 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects; -use DateTime; +use Shared\Helpers\DatesHelper; final readonly class Period { public function __construct( - public DateTime $start, - public DateTime $end, + public \DateTime $start, + public \DateTime $end, ) { if ($this->end < $this->start) { throw new \DomainException('Period end date cannot be before its start date.'); } } + + public function hasStarted(): bool + { + return $this->start <= DatesHelper::now(); + } + + public function hasEnded(): bool + { + return $this->end >= DatesHelper::now(); + } + + public function inRange(\DateTime $date): bool + { + return $date >= $this->start && $date <= $this->end; + } } diff --git a/src/Domain/Models/ValueObjects/PrintRules.php b/src/Domain/Models/ValueObjects/PrintRules.php index f915889..ec81030 100644 --- a/src/Domain/Models/ValueObjects/PrintRules.php +++ b/src/Domain/Models/ValueObjects/PrintRules.php @@ -11,15 +11,33 @@ final readonly class PrintRules public ?int $cooldownDays = null, ) { if (null === $this->maxPerCustomer && null === $this->cooldownDays) { - throw new \InvalidArgumentException('PrintRules require at least a max per customer or a cooldown days.'); + throw new \DomainException('PrintRules require at least a max per customer or a cooldown days.'); } if (null !== $this->maxPerCustomer && $this->maxPerCustomer <= 0) { - throw new \InvalidArgumentException('Max per customer must be greater than 0.'); + throw new \DomainException('Max per customer must be greater than 0.'); } - if (null !== $this->cooldownDays && $this->cooldownDays <= 0) { - throw new \InvalidArgumentException('Cooldown days must be greater than 0.'); + if (null !== $this->cooldownDays && $this->cooldownDays < 30) { + throw new \DomainException('Cooldown days must be greater than 30.'); + } + + if ($this->maxPerCustomer && $this->cooldownDays) { + throw new \DomainException('No more than one print rule.'); + } + } + + public function hasPrintLimitNotReached(int $countPrintedCoupon): void + { + if ($this->maxPerCustomer && $this->maxPerCustomer < $countPrintedCoupon) { + throw new \DomainException('Maximum number of prints reached.'); + } + } + + public function hasReprintPeriodExpired(int $diffDays): void + { + if ($this->cooldownDays && $diffDays < $this->cooldownDays) { + throw new \DomainException('Reprint period has not expired.'); } } } diff --git a/src/Domain/Ports/Repositories/CampaignRepository.php b/src/Domain/Ports/Repositories/CampaignRepository.php index 4618258..98445e7 100644 --- a/src/Domain/Ports/Repositories/CampaignRepository.php +++ b/src/Domain/Ports/Repositories/CampaignRepository.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Domain\Ports\Repositories; use Domain\Models\Entities\Campaign; +use Domain\Models\ValueObjects\Campaign\CampaignCode; +use Domain\Models\ValueObjects\Campaign\CampaignCoupon; use Domain\Models\ValueObjects\Coupon\CouponCode; use Shared\DDD\Ports\Repository; @@ -19,4 +21,8 @@ interface CampaignRepository extends Repository * @return array */ public function findByCouponCode(CouponCode $couponCode): array; + + public function findByCode(CampaignCode $code): ?Campaign; + + public function findCampaignCoupon(CampaignCode $campaignCode, CouponCode $couponCode): ?CampaignCoupon; } diff --git a/src/Domain/Ports/Repositories/PrintableCouponRepository.php b/src/Domain/Ports/Repositories/PrintableCouponRepository.php index 4ead8e6..b53d27b 100644 --- a/src/Domain/Ports/Repositories/PrintableCouponRepository.php +++ b/src/Domain/Ports/Repositories/PrintableCouponRepository.php @@ -6,8 +6,10 @@ namespace Domain\Ports\Repositories; use Domain\Models\Entities\PrintableCoupon; use Domain\Models\ValueObjects\Campaign\CampaignCode; +use Domain\Models\ValueObjects\Coupon\CouponCode; use Domain\Models\ValueObjects\CrmHashcode; use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortCriteria; +use Domain\Models\ValueObjects\PrintableCoupon\PrintedCoupon; use Shared\DDD\Ports\Repository; use Shared\ValueObjects\Pagination\Paginated; use Shared\ValueObjects\Pagination\Pagination; @@ -27,5 +29,17 @@ interface PrintableCouponRepository extends Repository ?PrintableCouponSortCriteria $sortCriteria = null, ): Paginated; + public function countPrintedCouponForCustomer( + CampaignCode $campaignCode, + CouponCode $couponCode, + CrmHashcode $crmCustomerHashcode, + ): int; + + public function findLastPrintedCouponForCustomer( + CampaignCode $campaignCode, + CouponCode $couponCode, + CrmHashcode $crmCustomerHashcode, + ): PrintedCoupon; + public function save(PrintableCoupon $printableCoupon): void; } diff --git a/tests/Unit/Application/UseCases/PrintableCoupon/Create/CreatePrintableCouponTest.php b/tests/Unit/Application/UseCases/PrintableCoupon/Create/CreatePrintableCouponTest.php new file mode 100644 index 0000000..3cf50bb --- /dev/null +++ b/tests/Unit/Application/UseCases/PrintableCoupon/Create/CreatePrintableCouponTest.php @@ -0,0 +1,429 @@ +modify('-1 month'); + $couponDiffusionEnd = new DateTime()->modify('+1 month'); + $makeCoupon = fn (array $overrides = []): Coupon => Coupon::create( + code: $couponCode, + name: $couponName, + amount: new MoneyAmount(new Money(Currency::EUR, 100)), + picture: new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobile_thumbnail.jpg'), + ), + diffusion: new CouponDiffusion( + new Period( + $overrides['diffusionStart'] ?? $couponDiffusionStart, + $overrides['diffusionEnd'] ?? $couponDiffusionEnd, + ), + ), + validityInformations: new CouponValidityInformationArray([ + new CouponValidityInformation( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', + Country::FRANCE, + ), + ]), + legalInformations: new CouponLegalInformationArray([ + new CouponLegalInformation( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', + Country::FRANCE, + ), + ]), + processingCode: new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + ]), + brandCode: $brandCode, + createdBy: ActorId::generate(), + products: new CouponProductArray([ + new IncoCode(35784) , + new IncoCode(15522), + ]), + movements: new CouponMovementArray([ + new CouponMovement(1000) + ]), + availabilityCalendar: $overrides['availabilityCalendar'] ?? null, + ); + $printRules = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null): ?PrintRules => ($maxPerCustomer || $cooldownDays) ? new PrintRules( + maxPerCustomer: $maxPerCustomer, + cooldownDays: $cooldownDays, + ) : null; + $campaignCoupon = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null): CampaignCoupon => new CampaignCoupon( + $couponCode, + $printRules($maxPerCustomer, $cooldownDays), + ); + $makeCampaign = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null, bool $published = true): Campaign => Campaign::create( + code: $campaignCode, + name: new CampaignName('Campaign name'), + publication: $published, + printAllAtOnce: true, + coupons: new CampaignCouponArray([ + $campaignCoupon($maxPerCustomer, $cooldownDays), + ]), + brandCode: $brandCode, + actorId: ActorId::generate(), + ); + $printedCoupon = new PrintedCoupon( + id: PrintedCouponId::generate(), + marketingCode: new MarketingDirectCode('0000000000001'), + printableCode: PrintableCouponCode::generate(), + couponCode: $couponCode, + campaignCode: $campaignCode, + customer: $crmCustomerHashcode, + printedAt: DatesHelper::now(), + ); + $makeInput = fn (): Input => new Input( + campaignCode: $campaignCode, + couponCode: $couponCode, + crmCustomerHashcode: $crmCustomerHashcode, + ); + + it('creates a printable coupon', function () use ($makeInput, $crmCustomerHashcode, $makeCampaign, $makeCoupon, $campaignCoupon): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(); + $coupon = $makeCoupon(); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + $campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code) + ->andReturn($campaignCoupon()); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + $printableCouponRepository->shouldReceive('save')->once()->with(\Mockery::type(PrintableCoupon::class)); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->campaignCode)->toEqual($campaign->code) + ->and($result->couponCode)->toEqual($coupon->code) + ->and($result->crmCustomerHashcode)->toEqual($crmCustomerHashcode) + ->and($result->distributableUntil)->toEqual($coupon->diffusion->period->end); + }); + + it('rejects a printable coupon when campaign not found', function () use ($makeInput, $makeCampaign): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn(null); + + $couponRepository = \Mockery::mock(CouponRepository::class); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, sprintf('Campaign from code "%s" not found.', $input->campaignCode->value)); + }); + + it('rejects a printable coupon when campaign is not published', function () use ($makeInput, $makeCampaign): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(published: false); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + + $couponRepository = \Mockery::mock(CouponRepository::class); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Campaign must be published.'); + }); + + it('rejects a printable coupon when coupon is not found', function () use ($makeInput, $makeCampaign, $makeCoupon): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(); + $coupon = $makeCoupon(); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn(null); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, sprintf('Coupon code "%s" not found.', $input->couponCode->value)); + }); + + it('rejects a printable coupon when campaign coupon is not found', function () use ($makeInput, $makeCampaign, $makeCoupon): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(); + $coupon = $makeCoupon(); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + $campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code) + ->andReturn(null); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Coupon must be associated to the associated campaign coupon.'); + }); + + it('rejects a printable coupon when outside of diffusion start', function () use ($makeInput, $makeCampaign, $makeCoupon, $campaignCoupon): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(); + $coupon = $makeCoupon(['diffusionStart' => new DateTime()->modify('+1 day')]); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + $campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code) + ->andReturn($campaignCoupon()); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'The printing period has not yet begun.'); + }); + + it('rejects a printable coupon when outside of diffusion end', function () use ($makeInput, $makeCampaign, $makeCoupon, $campaignCoupon): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(); + $coupon = $makeCoupon(['diffusionEnd' => new DateTime()->modify('-1 day')]); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + $campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code) + ->andReturn($campaignCoupon()); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'The printing period has expired.'); + }); + + it('creates a printable coupon when calendar period is inside diffusion period', function () use ( + $makeInput, + $crmCustomerHashcode, + $makeCampaign, + $makeCoupon, + $campaignCoupon, + ): void { + // GIVEN + $calendarStart = new DateTime()->modify('-1 day'); + $calendarEnd = new DateTime()->modify('+1 day'); + $input = $makeInput(); + $campaign = $makeCampaign(); + $coupon = $makeCoupon([ + 'availabilityCalendar' => new CouponAvailabilityCalendar([ + new Period( + start: $calendarStart, + end: $calendarEnd, + ), + ]), + ]); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + $campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code) + ->andReturn($campaignCoupon()); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + $printableCouponRepository->shouldReceive('save')->once()->with(\Mockery::type(PrintableCoupon::class)); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->campaignCode)->toEqual($campaign->code) + ->and($result->crmCustomerHashcode)->toEqual($crmCustomerHashcode) + ->and($result->distributableUntil)->toEqual($calendarEnd); + }); + + it('rejects a printable coupon when maximum number of prints reached', function () use ( + $makeInput, + $crmCustomerHashcode, + $makeCampaign, + $makeCoupon, + $campaignCoupon, + ): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(maxPerCustomer: 1); + $coupon = $makeCoupon(); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + $campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code) + ->andReturn($campaignCoupon(maxPerCustomer: 1)); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + $printableCouponRepository->shouldReceive('countPrintedCouponForCustomer')->once()->with( + $campaign->code, + $coupon->code, + $crmCustomerHashcode, + )->andReturn(5); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Maximum number of prints reached.'); + }); + + it('rejects a printable coupon when reprint period has expired', function () use ( + $makeInput, + $crmCustomerHashcode, + $makeCampaign, + $makeCoupon, + $campaignCoupon, + $printedCoupon, + ): void { + // GIVEN + $input = $makeInput(); + $campaign = $makeCampaign(cooldownDays: 30); + $coupon = $makeCoupon(); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign); + $campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code) + ->andReturn($campaignCoupon(cooldownDays: 30)); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class); + $printableCouponRepository->shouldReceive('countPrintedCouponForCustomer')->once()->with( + $campaign->code, + $coupon->code, + $crmCustomerHashcode, + )->andReturn(5); + $printableCouponRepository->shouldReceive('findLastPrintedCouponForCustomer')->once()->with( + $campaign->code, + $coupon->code, + $crmCustomerHashcode, + )->andReturn($printedCoupon); + + $useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Reprint period has not expired.'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php index e84fd8e..7a7ca4b 100644 --- a/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php @@ -16,17 +16,16 @@ describe('print rules', function (): void { $minCooldownDays = 1; $zeroCooldownDays = 0; - it('creates print rules with both fields set', function () use ($maxPerCustomer, $cooldownDays): void { + it('rejects print rules with both fields set', function () use ($maxPerCustomer, $cooldownDays): void { // GIVEN / WHEN - $printRules = new PrintRules( + $printRules = fn (): PrintRules => new PrintRules( maxPerCustomer: $maxPerCustomer, cooldownDays: $cooldownDays ); // THEN - expect($printRules)->toBeInstanceOf(PrintRules::class) - ->and($printRules->maxPerCustomer)->toBe($maxPerCustomer) - ->and($printRules->cooldownDays)->toBe($cooldownDays); + expect($printRules) + ->toThrow(\DomainException::class, 'No more than one print rule.'); }); it('creates print rules with only maxPerCustomer set', function () use ($maxPerCustomer): void { @@ -54,32 +53,46 @@ describe('print rules', function (): void { it('rejects print rules without any field set', function (): void { // WHEN / THEN expect(fn (): PrintRules => new PrintRules()) - ->toThrow(\InvalidArgumentException::class, 'PrintRules require at least a max per customer or a cooldown days.'); - }); - - it('accepts the minimum value of 1 for both fields', function () use ($minMaxPerCustomer, $minCooldownDays): void { - // GIVEN / WHEN - $printRules = new PrintRules( - maxPerCustomer: $minMaxPerCustomer, - cooldownDays: $minCooldownDays - ); - - // THEN - expect($printRules->maxPerCustomer)->toBe($minMaxPerCustomer) - ->and($printRules->cooldownDays)->toBe($minCooldownDays); + ->toThrow(\DomainException::class, 'PrintRules require at least a max per customer or a cooldown days.'); }); it('rejects a maxPerCustomer equal to zero', function () use ($zeroMaxPerCustomer): void { // GIVEN / WHEN / THEN expect(fn (): PrintRules => new PrintRules( maxPerCustomer: $zeroMaxPerCustomer - ))->toThrow(\InvalidArgumentException::class, 'Max per customer must be greater than 0.'); + ))->toThrow(\DomainException::class, 'Max per customer must be greater than 0.'); }); it('rejects a cooldownDays equal to zero', function () use ($zeroCooldownDays): void { // GIVEN / WHEN / THEN expect(fn (): PrintRules => new PrintRules( cooldownDays: $zeroCooldownDays - ))->toThrow(\InvalidArgumentException::class, 'Cooldown days must be greater than 0.'); + ))->toThrow(\DomainException::class, 'Cooldown days must be greater than 30.'); + }); + + it('rejects print rule when max per customer limit has reached', function () use ($maxPerCustomer, $minMaxPerCustomer): void { + // GIVEN + $printRules = new PrintRules( + maxPerCustomer: $minMaxPerCustomer + ); + + // WHEN + $action = fn () => $printRules->hasPrintLimitNotReached($maxPerCustomer); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Maximum number of prints reached.'); + }); + + it('rejects print rule when cooldown days period has expired', function () use ($cooldownDays, $minCooldownDays): void { + // GIVEN + $printRules = new PrintRules( + cooldownDays: $cooldownDays + ); + + // WHEN + $action = fn () => $printRules->hasReprintPeriodExpired($minCooldownDays); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Reprint period has not expired.'); }); }); From 937b516242391fbd670038d5ea7ef49d1e74c8c3 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Thu, 15 Oct 2026 15:40:13 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=F0=9F=9A=A8=20resolve=20linters=20e?= =?UTF-8?q?rrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCases/PrintableCoupon/List/ListPrintableCoupons.php | 7 +++++-- src/Domain/Models/Entities/Brand.php | 1 - src/Domain/Models/Entities/Campaign.php | 1 - src/Domain/Models/Entities/CampaignRequest.php | 1 - src/Domain/Models/Entities/Coupon.php | 1 - src/Domain/Models/Entities/PrintableCoupon.php | 1 - .../Application/UseCases/Coupon/Find/FindCouponTest.php | 3 ++- tests/Unit/Domain/Models/Entities/CouponTest.php | 2 +- .../Models/ValueObjects/Campaign/CampaignCodeTest.php | 2 +- 9 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/Application/UseCases/PrintableCoupon/List/ListPrintableCoupons.php b/src/Application/UseCases/PrintableCoupon/List/ListPrintableCoupons.php index 0769cf9..694dce6 100644 --- a/src/Application/UseCases/PrintableCoupon/List/ListPrintableCoupons.php +++ b/src/Application/UseCases/PrintableCoupon/List/ListPrintableCoupons.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Application\UseCases\PrintableCoupon\List; +use Domain\Models\Entities\Coupon; use Domain\Models\Entities\PrintableCoupon; use Domain\Models\ValueObjects\Coupon\CouponPicture; use Domain\Ports\Repositories\CouponRepository; @@ -46,8 +47,10 @@ final readonly class ListPrintableCoupons implements UseCase continue; } - $coupon = $this->couponRepository->findByCode($couponCode) - ?? throw new \LogicException(sprintf('Coupon "%s" referenced by a printable coupon does not exist.', $couponCode->value)); + $coupon = $this->couponRepository->findByCode($couponCode); + if (!$coupon instanceof Coupon) { + throw new \LogicException(sprintf('Coupon "%s" referenced by a printable coupon does not exist.', $couponCode->value)); + } $picturesByCouponCode[$couponCode->value] = $coupon->picture; } diff --git a/src/Domain/Models/Entities/Brand.php b/src/Domain/Models/Entities/Brand.php index 945c139..52a9f65 100644 --- a/src/Domain/Models/Entities/Brand.php +++ b/src/Domain/Models/Entities/Brand.php @@ -59,7 +59,6 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier logo: $logo, rcs: $rcs, contact: $contact, - updatedAt: null, ); } diff --git a/src/Domain/Models/Entities/Campaign.php b/src/Domain/Models/Entities/Campaign.php index f61db2a..9e23da9 100644 --- a/src/Domain/Models/Entities/Campaign.php +++ b/src/Domain/Models/Entities/Campaign.php @@ -65,7 +65,6 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier coupons: $coupons, brandCode: $brandCode, actorId: $actorId, - updatedAt: null, ); } diff --git a/src/Domain/Models/Entities/CampaignRequest.php b/src/Domain/Models/Entities/CampaignRequest.php index c873da6..3fad464 100644 --- a/src/Domain/Models/Entities/CampaignRequest.php +++ b/src/Domain/Models/Entities/CampaignRequest.php @@ -71,7 +71,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent campaignType: $campaignType, requestedCoupon: $requestedCoupon, status: CampaignRequestStatus::CREATED, - updatedAt: null, ); } diff --git a/src/Domain/Models/Entities/Coupon.php b/src/Domain/Models/Entities/Coupon.php index f190dde..ca23ff2 100644 --- a/src/Domain/Models/Entities/Coupon.php +++ b/src/Domain/Models/Entities/Coupon.php @@ -104,7 +104,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier products: $products, movements: $movements, availabilityCalendar: $availabilityCalendar, - updatedAt: null, ); } diff --git a/src/Domain/Models/Entities/PrintableCoupon.php b/src/Domain/Models/Entities/PrintableCoupon.php index 0337ebd..8ad7c4e 100644 --- a/src/Domain/Models/Entities/PrintableCoupon.php +++ b/src/Domain/Models/Entities/PrintableCoupon.php @@ -58,7 +58,6 @@ final readonly class PrintableCoupon implements AggregateRoot, HasTimestampIdent couponName: $couponName, crmCustomerHashcode: $crmCustomerHashcode, distributableUntil: $distributableUntil, - updatedAt: null, ); } diff --git a/tests/Unit/Application/UseCases/Coupon/Find/FindCouponTest.php b/tests/Unit/Application/UseCases/Coupon/Find/FindCouponTest.php index eb64946..4ef0ce1 100644 --- a/tests/Unit/Application/UseCases/Coupon/Find/FindCouponTest.php +++ b/tests/Unit/Application/UseCases/Coupon/Find/FindCouponTest.php @@ -8,6 +8,7 @@ use Application\UseCases\Coupon\Find\FindCoupon; use Application\UseCases\Coupon\Find\Input; use Application\UseCases\Coupon\Find\Output\CouponFound; use Application\UseCases\Coupon\Find\Output\CouponNotFound; +use Application\UseCases\Coupon\Find\Output\Output; use DateTime; use Domain\Models\Entities\Brand; use Domain\Models\Entities\Campaign; @@ -180,7 +181,7 @@ describe('find coupon use case', function (): void { $useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository); // WHEN / THEN - expect(fn () => $useCase->execute(new Input(code: $coupon->code))) + expect(fn (): Output => $useCase->execute(new Input(code: $coupon->code))) ->toThrow(\DomainException::class); }); diff --git a/tests/Unit/Domain/Models/Entities/CouponTest.php b/tests/Unit/Domain/Models/Entities/CouponTest.php index 91d1aa7..19cdfc3 100644 --- a/tests/Unit/Domain/Models/Entities/CouponTest.php +++ b/tests/Unit/Domain/Models/Entities/CouponTest.php @@ -45,7 +45,7 @@ describe('coupon', function (): void { new PicturePath('files/thumbnail.jpg'), new PicturePath('files/mobileThumbnail.jpg'), ); - $diffusion = new CouponDiffusion(new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01'))); + $diffusion = new CouponDiffusion(new Period(new DateTime('2026-01-01'), new DateTime('2026-12-31'))); $validityInformations = new CouponValidityInformationArray([ new CouponValidityInformation( 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php index 59bc1b3..39f6ca9 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php @@ -17,7 +17,7 @@ describe('campaign code', function (): void { $name = new CampaignCode($code); // THEN - expect($name->code)->toBe($code); + expect($name->value)->toBe($code); }); it('rejects invalid campaign code', function () use ($emptyCode): void {