diff --git a/src/Application/UseCases/Brand/Create/CreateBrand.php b/src/Application/UseCases/Brand/Create/CreateBrand.php index 7eb070f..c653255 100644 --- a/src/Application/UseCases/Brand/Create/CreateBrand.php +++ b/src/Application/UseCases/Brand/Create/CreateBrand.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Application\UseCases\Brand\Create; -use Domain\Exceptions\BrandCodeAlreadyExistsException; use Domain\Models\Entities\Brand; use Domain\Ports\Repositories\BrandRepository; use Domain\Ports\Services\PictureStorage; @@ -28,7 +27,7 @@ final readonly class CreateBrand implements UseCase, Transactional Assert::isInstanceOf($input, Input::class); if ($this->repository->existsByCode($input->code)) { - throw new BrandCodeAlreadyExistsException($input->code); + throw new \DomainException(sprintf('Brand code "%s" already exists.', $input->code->value)); } $logoPath = $this->pictureStorage->store($input->logo); @@ -49,7 +48,6 @@ final readonly class CreateBrand implements UseCase, Transactional logo: $brand->logo, rcs: $brand->rcs, contact: $brand->contact, - createdAt: $brand->createdAt(), ); } } diff --git a/src/Application/UseCases/Brand/Create/Output.php b/src/Application/UseCases/Brand/Create/Output.php index fbf416f..e931182 100644 --- a/src/Application/UseCases/Brand/Create/Output.php +++ b/src/Application/UseCases/Brand/Create/Output.php @@ -19,7 +19,6 @@ final readonly class Output implements UseCaseOutput public PicturePath $logo, public Rcs $rcs, public Email $contact, - public \DateTimeInterface $createdAt, ) { } } diff --git a/src/Application/UseCases/Brand/Find/FindBrand.php b/src/Application/UseCases/Brand/Find/FindBrand.php index 90d0ccc..caf39b1 100644 --- a/src/Application/UseCases/Brand/Find/FindBrand.php +++ b/src/Application/UseCases/Brand/Find/FindBrand.php @@ -39,6 +39,7 @@ final readonly class FindBrand implements UseCase rcs: $brand->rcs, contact: $brand->contact, createdAt: $brand->createdAt(), + updatedAt: $brand->updatedAt(), ); } } diff --git a/src/Application/UseCases/Brand/Find/Output/BrandFound.php b/src/Application/UseCases/Brand/Find/Output/BrandFound.php index 2540207..60a04f5 100644 --- a/src/Application/UseCases/Brand/Find/Output/BrandFound.php +++ b/src/Application/UseCases/Brand/Find/Output/BrandFound.php @@ -19,6 +19,7 @@ final readonly class BrandFound implements Output public Rcs $rcs, public Email $contact, public \DateTimeInterface $createdAt, + public ?\DateTimeInterface $updatedAt, ) { } } diff --git a/src/Application/UseCases/Brand/Update/Output/BrandUpdated.php b/src/Application/UseCases/Brand/Update/Output/BrandUpdated.php index bd094ca..d5ce362 100644 --- a/src/Application/UseCases/Brand/Update/Output/BrandUpdated.php +++ b/src/Application/UseCases/Brand/Update/Output/BrandUpdated.php @@ -18,8 +18,6 @@ final readonly class BrandUpdated implements Output public PicturePath $logo, public Rcs $rcs, public Email $contact, - public \DateTimeInterface $createdAt, - public ?\DateTimeInterface $updatedAt = null, ) { } } diff --git a/src/Application/UseCases/Brand/Update/UpdateBrand.php b/src/Application/UseCases/Brand/Update/UpdateBrand.php index f894176..4bbda6a 100644 --- a/src/Application/UseCases/Brand/Update/UpdateBrand.php +++ b/src/Application/UseCases/Brand/Update/UpdateBrand.php @@ -56,8 +56,6 @@ final readonly class UpdateBrand implements UseCase, Transactional logo: $brand->logo, rcs: $brand->rcs, contact: $brand->contact, - createdAt: $brand->createdAt(), - updatedAt: $brand->updatedAt(), ); } } diff --git a/src/Application/UseCases/CampaignRequest/Create/CreateCampaignRequest.php b/src/Application/UseCases/CampaignRequest/Create/CreateCampaignRequest.php index 9dc1b30..1050bce 100644 --- a/src/Application/UseCases/CampaignRequest/Create/CreateCampaignRequest.php +++ b/src/Application/UseCases/CampaignRequest/Create/CreateCampaignRequest.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Application\UseCases\CampaignRequest\Create; -use Domain\Exceptions\BrandNotFoundException; use Domain\Models\Entities\CampaignRequest; use Domain\Ports\Repositories\BrandRepository; use Domain\Ports\Repositories\CampaignRequestRepository; @@ -27,8 +26,8 @@ final readonly class CreateCampaignRequest implements UseCase, Transactional { Assert::isInstanceOf($input, Input::class); - if (!$this->brandRepository->existsByCode($input->brandCode)) { - throw new BrandNotFoundException($input->brandCode); + if (! $this->brandRepository->existsByCode($input->brandCode)) { + throw new \DomainException(sprintf('Brand with code "%s" was not found.', $input->brandCode->value)); } $campaignRequest = CampaignRequest::create( @@ -49,7 +48,6 @@ final readonly class CreateCampaignRequest implements UseCase, Transactional campaignType: $campaignRequest->campaignType, requestedCoupon: $campaignRequest->requestedCoupon, status: $campaignRequest->status, - createdAt: $campaignRequest->createdAt(), ); } } diff --git a/src/Application/UseCases/CampaignRequest/Create/Output.php b/src/Application/UseCases/CampaignRequest/Create/Output.php index 29ffce6..9571784 100644 --- a/src/Application/UseCases/CampaignRequest/Create/Output.php +++ b/src/Application/UseCases/CampaignRequest/Create/Output.php @@ -23,7 +23,6 @@ final readonly class Output implements UseCaseOutput public CampaignRequestType $campaignType, public RequestedCoupon $requestedCoupon, public CampaignRequestStatus $status, - public \DateTimeInterface $createdAt, ) { } } diff --git a/src/Application/UseCases/CampaignRequest/Find/FindCampaignRequest.php b/src/Application/UseCases/CampaignRequest/Find/FindCampaignRequest.php index 0acbbef..2361ab6 100644 --- a/src/Application/UseCases/CampaignRequest/Find/FindCampaignRequest.php +++ b/src/Application/UseCases/CampaignRequest/Find/FindCampaignRequest.php @@ -7,7 +7,6 @@ namespace Application\UseCases\CampaignRequest\Find; use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestFound; use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestNotFound; use Application\UseCases\CampaignRequest\Find\Output\Output; -use Domain\Exceptions\BrandNotFoundException; use Domain\Models\Entities\Brand; use Domain\Models\Entities\CampaignRequest; use Domain\Ports\Repositories\BrandRepository; @@ -39,7 +38,7 @@ final readonly class FindCampaignRequest implements UseCase $brand = $this->brandRepository->findByCode($campaignRequest->brandCode); if (! $brand instanceof Brand) { - throw new BrandNotFoundException($campaignRequest->brandCode); + throw new \DomainException(sprintf('Brand with code "%s" was not found.', $campaignRequest->brandCode->value)); } return new CampaignRequestFound( diff --git a/src/Application/UseCases/CampaignRequest/Reject/Output/CampaignRequestRejected.php b/src/Application/UseCases/CampaignRequest/Reject/Output/CampaignRequestRejected.php index dcf3a78..2b2f595 100644 --- a/src/Application/UseCases/CampaignRequest/Reject/Output/CampaignRequestRejected.php +++ b/src/Application/UseCases/CampaignRequest/Reject/Output/CampaignRequestRejected.php @@ -14,7 +14,6 @@ final readonly class CampaignRequestRejected implements Output public CampaignRequestId $id, public CampaignRequestStatus $status, public RejectionReason $rejectionReason, - public ?\DateTimeInterface $updatedAt = null ) { } } diff --git a/src/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequest.php b/src/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequest.php index ac717f3..7f79818 100644 --- a/src/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequest.php +++ b/src/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequest.php @@ -41,7 +41,6 @@ final readonly class RejectCampaignRequest implements UseCase, Transactional id: $campaignRequest->id(), status: $campaignRequest->status, rejectionReason:$campaignRequest->rejectionReason(), - updatedAt: $campaignRequest->updatedAt(), ); } } diff --git a/src/Application/UseCases/Coupon/Create/CreateCoupon.php b/src/Application/UseCases/Coupon/Create/CreateCoupon.php new file mode 100644 index 0000000..0881f68 --- /dev/null +++ b/src/Application/UseCases/Coupon/Create/CreateCoupon.php @@ -0,0 +1,72 @@ + */ +final readonly class CreateCoupon implements UseCase, Transactional +{ + public function __construct( + private CouponRepository $couponRepository, + private BrandRepository $brandRepository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + if (! $this->brandRepository->existsByCode($input->brandCode)) { + throw new \DomainException(sprintf('Brand code "%s" was not found.', $input->brandCode->value)); + } + + if ($this->couponRepository->existsByCode($input->code)) { + throw new \DomainException(sprintf('Coupon code "%s" already exists.', $input->code->value)); + } + + $coupon = Coupon::create( + code: $input->code, + name: $input->name, + amount: $input->amount, + picture: $input->picture, + diffusion: $input->diffusion, + validityInformations: $input->validityInformations, + legalInformations: $input->legalInformations, + processingCode: $input->processingCode, + brandCode: $input->brandCode, + createdBy: $input->createdBy, + products: $input->products, + movements: $input->movements, + availabilityCalendar: $input->availabilityCalendar, + ); + + $this->couponRepository->save($coupon); + + return new Output( + id: $coupon->id, + code: $coupon->code, + name: $coupon->name, + amount: $coupon->amount, + picture: $coupon->picture, + diffusion: $coupon->diffusion, + validityInformations: $coupon->validityInformations, + legalInformations: $coupon->legalInformations, + processingCode: $coupon->processingCode, + brandCode: $coupon->brandCode, + createdBy: $coupon->createdBy, + products: $coupon->products, + movements: $coupon->movements, + availabilityCalendar: $coupon->availabilityCalendar, + ); + } +} diff --git a/src/Application/UseCases/Coupon/Create/Input.php b/src/Application/UseCases/Coupon/Create/Input.php new file mode 100644 index 0000000..9fc1bb4 --- /dev/null +++ b/src/Application/UseCases/Coupon/Create/Input.php @@ -0,0 +1,40 @@ + */ +final readonly class FindCoupon implements UseCase +{ + public function __construct( + private CouponRepository $couponRepository, + private BrandRepository $brandRepository, + private CampaignRepository $campaignRepository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $coupon = $this->couponRepository->findByCode($input->code); + + if (! $coupon instanceof Coupon) { + return new CouponNotFound(); + } + if (! $this->brandRepository->existsByCode($coupon->brandCode)) { + throw new \DomainException(sprintf('Brand code "%s" was not found.', $coupon->brandCode->value)); + } + + $campaigns = $this->campaignRepository->findByCouponCode($coupon->code); + + return new CouponFound( + code: $coupon->code, + name: $coupon->name, + amount: $coupon->amount, + picture: $coupon->picture, + diffusion: $coupon->diffusion, + availabilityCalendar: $coupon->availabilityCalendar, + validityInformations: $coupon->validityInformations, + legalInformations: $coupon->legalInformations, + processingCode: $coupon->processingCode, + brandCode: $coupon->brandCode, + createdBy: $coupon->createdBy, + products: $coupon->products, + movements: $coupon->movements, + quantity: $coupon->quantity(), + campaigns: $campaigns, + createdAt: $coupon->createdAt(), + updatedAt: $coupon->updatedAt(), + ); + } +} diff --git a/src/Application/UseCases/Coupon/Find/Input.php b/src/Application/UseCases/Coupon/Find/Input.php new file mode 100644 index 0000000..37782f2 --- /dev/null +++ b/src/Application/UseCases/Coupon/Find/Input.php @@ -0,0 +1,16 @@ + $campaigns + */ + + public function __construct( + public CouponCode $code, + public CouponName $name, + public MoneyAmount $amount, + public CouponPicture $picture, + public CouponDiffusion $diffusion, + public ?CouponAvailabilityCalendar $availabilityCalendar, + public CouponValidityInformationArray $validityInformations, + public CouponLegalInformationArray $legalInformations, + public ProcessingCodeArray $processingCode, + public BrandCode $brandCode, + public ActorId $createdBy, + public CouponProductArray $products, + public CouponMovementArray $movements, + public int $quantity, + public array $campaigns, + public \DateTimeInterface $createdAt, + public ?\DateTimeInterface $updatedAt, + ) { + } +} diff --git a/src/Application/UseCases/Coupon/Find/Output/CouponNotFound.php b/src/Application/UseCases/Coupon/Find/Output/CouponNotFound.php new file mode 100644 index 0000000..2e9d3bf --- /dev/null +++ b/src/Application/UseCases/Coupon/Find/Output/CouponNotFound.php @@ -0,0 +1,9 @@ + */ +final readonly class ListCoupons implements UseCase +{ + public function __construct( + private CouponRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $result = $this->repository->findAll( + pagination: $input->pagination, + sortCriteria: $input->sort, + brandCode: $input->brandCode, + ); + + $items = array_map( + static fn (Coupon $coupon): CouponListItem => new CouponListItem( + code: $coupon->code, + name: $coupon->name, + brandCode: $coupon->brandCode, + picture: $coupon->picture, + diffusion: $coupon->diffusion, + availabilityCalendar: $coupon->availabilityCalendar, + ), + $result->getItems(), + ); + + return new Output( + coupons: new Paginated( + pagination: $result->getPagination(), + items: $items, + total: $result->getTotal(), + ), + ); + } +} diff --git a/src/Application/UseCases/Coupon/List/Output.php b/src/Application/UseCases/Coupon/List/Output.php new file mode 100644 index 0000000..27dc8db --- /dev/null +++ b/src/Application/UseCases/Coupon/List/Output.php @@ -0,0 +1,17 @@ + $coupons */ + public function __construct( + public Paginated $coupons, + ) { + } +} diff --git a/src/Application/UseCases/Coupon/Update/Input.php b/src/Application/UseCases/Coupon/Update/Input.php new file mode 100644 index 0000000..421b17a --- /dev/null +++ b/src/Application/UseCases/Coupon/Update/Input.php @@ -0,0 +1,29 @@ +picture instanceof CouponPicture + || $this->validityInformations instanceof CouponValidityInformationArray + || $this->legalInformations instanceof CouponLegalInformationArray, + 'At least one field must be provided to update the coupon.', + ); + } +} diff --git a/src/Application/UseCases/Coupon/Update/Output/CouponNotFound.php b/src/Application/UseCases/Coupon/Update/Output/CouponNotFound.php new file mode 100644 index 0000000..aeae2df --- /dev/null +++ b/src/Application/UseCases/Coupon/Update/Output/CouponNotFound.php @@ -0,0 +1,9 @@ + */ +final readonly class UpdateCoupon implements UseCase, Transactional +{ + public function __construct( + private CouponRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $coupon = $this->repository->findByCode($input->code); + + if (! $coupon instanceof Coupon) { + return new CouponNotFound(); + } + + if ($input->picture instanceof CouponPicture) { + $coupon = $coupon->changePicture($input->picture); + } + + if ( + $input->validityInformations instanceof CouponValidityInformationArray + || $input->legalInformations instanceof CouponLegalInformationArray + ) { + $coupon = $coupon->changeInformations( + $input->validityInformations ?? $coupon->validityInformations, + $input->legalInformations ?? $coupon->legalInformations, + ); + } + + $this->repository->save($coupon); + + return new CouponUpdated( + code: $coupon->code, + name: $coupon->name, + amount: $coupon->amount, + picture: $coupon->picture, + diffusion: $coupon->diffusion, + availabilityCalendar: $coupon->availabilityCalendar, + validityInformations: $coupon->validityInformations, + legalInformations: $coupon->legalInformations, + processingCode: $coupon->processingCode, + brandCode: $coupon->brandCode, + createdBy: $coupon->createdBy, + products: $coupon->products, + movements: $coupon->movements, + ); + } +} 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 @@ + */ +final readonly class ListPrintableCoupons implements UseCase +{ + public function __construct( + private PrintableCouponRepository $repository, + private CouponRepository $couponRepository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $result = $this->repository->findAllAvailable( + pagination: $input->pagination, + campaignCode: $input->campaignCode, + crmCustomerHashcode: $input->crmCustomerHashcode, + sortCriteria: $input->sortCriteria, + ); + + $printableCoupons = $result->getItems(); + + /** @var array $picturesByCouponCode */ + $picturesByCouponCode = []; + + foreach ($printableCoupons as $printableCoupon) { + $couponCode = $printableCoupon->couponCode; + + if (isset($picturesByCouponCode[$couponCode->value])) { + continue; + } + + $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; + } + + $items = array_map( + static fn (PrintableCoupon $printableCoupon): PrintableCouponListItem => new PrintableCouponListItem( + code: $printableCoupon->code, + couponCode: $printableCoupon->couponCode, + couponName: $printableCoupon->couponName, + couponPicture: $picturesByCouponCode[$printableCoupon->couponCode->value], + distributableUntil: $printableCoupon->distributableUntil, + ), + $printableCoupons, + ); + + return new Output( + printableCoupons: new Paginated( + pagination: $result->getPagination(), + items: $items, + total: $result->getTotal(), + ), + ); + } +} diff --git a/src/Application/UseCases/PrintableCoupon/List/Output.php b/src/Application/UseCases/PrintableCoupon/List/Output.php new file mode 100644 index 0000000..c6ac9ed --- /dev/null +++ b/src/Application/UseCases/PrintableCoupon/List/Output.php @@ -0,0 +1,17 @@ + $printableCoupons */ + public function __construct( + public Paginated $printableCoupons, + ) { + } +} diff --git a/src/Application/UseCases/PrintableCoupon/List/PrintableCouponListItem.php b/src/Application/UseCases/PrintableCoupon/List/PrintableCouponListItem.php new file mode 100644 index 0000000..31e3a64 --- /dev/null +++ b/src/Application/UseCases/PrintableCoupon/List/PrintableCouponListItem.php @@ -0,0 +1,22 @@ +value)); - } -} diff --git a/src/Domain/Exceptions/BrandNotFoundException.php b/src/Domain/Exceptions/BrandNotFoundException.php deleted file mode 100644 index eee250c..0000000 --- a/src/Domain/Exceptions/BrandNotFoundException.php +++ /dev/null @@ -1,19 +0,0 @@ -value)); - } -} diff --git a/src/Domain/Models/Entities/Actor.php b/src/Domain/Models/Entities/Actor.php index fecbafa..3e3c7de 100644 --- a/src/Domain/Models/Entities/Actor.php +++ b/src/Domain/Models/Entities/Actor.php @@ -8,6 +8,7 @@ use Domain\Models\ValueObjects\Actor\ActorDisplayName; use Domain\Models\ValueObjects\Actor\ActorId; use Shared\DDD\Models\AggregateRoot; use Shared\DDD\Models\HasTimestampIdentifier; +use Shared\Helpers\DatesHelper; /** * @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Actor\ActorId> @@ -39,12 +40,11 @@ final readonly class Actor implements AggregateRoot, HasTimestampIdentifier public static function hydrate( ActorId $id, ActorDisplayName $displayName, - ?\DateTimeInterface $updatedAt = null, ): self { return new self( id: $id, displayName: $displayName, - updatedAt: $updatedAt, + updatedAt: DatesHelper::now(), ); } } diff --git a/src/Domain/Models/Entities/Brand.php b/src/Domain/Models/Entities/Brand.php index 00cd6bc..52a9f65 100644 --- a/src/Domain/Models/Entities/Brand.php +++ b/src/Domain/Models/Entities/Brand.php @@ -51,7 +51,6 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier PicturePath $logo, Rcs $rcs, Email $contact, - ?\DateTimeInterface $updatedAt = null, ): Brand { return new self( id: BrandId::generate(), @@ -60,7 +59,6 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier logo: $logo, rcs: $rcs, contact: $contact, - updatedAt: $updatedAt, ); } @@ -71,7 +69,6 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier PicturePath $logo, Rcs $rcs, Email $contact, - ?\DateTimeInterface $updatedAt = null, ): self { return new self( id: $id, @@ -80,7 +77,7 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier logo: $logo, rcs: $rcs, contact: $contact, - updatedAt: $updatedAt, + updatedAt: DatesHelper::now(), ); } diff --git a/src/Domain/Models/Entities/Campaign.php b/src/Domain/Models/Entities/Campaign.php index a52ae3a..9e23da9 100644 --- a/src/Domain/Models/Entities/Campaign.php +++ b/src/Domain/Models/Entities/Campaign.php @@ -7,36 +7,29 @@ namespace Domain\Models\Entities; use Domain\Models\ValueObjects\Actor\ActorId; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Campaign\CampaignCode; -use Domain\Models\ValueObjects\Campaign\CampaignCoupon; +use Domain\Models\ValueObjects\Campaign\CampaignCouponArray; use Domain\Models\ValueObjects\Campaign\CampaignId; use Domain\Models\ValueObjects\Campaign\CampaignName; use Shared\DDD\Models\AggregateRoot; use Shared\DDD\Models\HasTimestampIdentifier; +use Shared\Helpers\DatesHelper; /** * @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Campaign\CampaignId> */ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier { - /** - * @param array $coupons - */ private function __construct( public CampaignId $id, public CampaignCode $code, public CampaignName $name, public bool $publication, public bool $printAllAtOnce, - public array $coupons, + public CampaignCouponArray $coupons, public BrandCode $brandCode, public ActorId $actorId, public ?\DateTimeInterface $updatedAt = null, ) { - foreach ($this->coupons as $coupon) { - if (!$coupon instanceof CampaignCoupon) { - throw new \DomainException('Campaign coupons must be CampaignCoupon instances.'); - } - } } public function id(): CampaignId @@ -54,18 +47,14 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier return $this->updatedAt; } - /** - * @param array $coupons - */ public static function create( CampaignCode $code, CampaignName $name, bool $publication, bool $printAllAtOnce, - array $coupons, + CampaignCouponArray $coupons, BrandCode $brandCode, ActorId $actorId, - ?\DateTimeInterface $updatedAt = null, ): Campaign { return new self( id: CampaignId::generate(), @@ -76,23 +65,18 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier coupons: $coupons, brandCode: $brandCode, actorId: $actorId, - updatedAt: $updatedAt, ); } - /** - * @param array $coupons - */ public static function hydrate( CampaignId $id, CampaignCode $code, CampaignName $name, bool $publication, bool $printAllAtOnce, - array $coupons, + CampaignCouponArray $coupons, BrandCode $brandCode, ActorId $actorId, - ?\DateTimeInterface $updatedAt = null, ): self { return new self( id: $id, @@ -103,7 +87,7 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier coupons: $coupons, brandCode: $brandCode, actorId: $actorId, - updatedAt: $updatedAt, + updatedAt: DatesHelper::now(), ); } } diff --git a/src/Domain/Models/Entities/CampaignRequest.php b/src/Domain/Models/Entities/CampaignRequest.php index b4e7882..3fad464 100644 --- a/src/Domain/Models/Entities/CampaignRequest.php +++ b/src/Domain/Models/Entities/CampaignRequest.php @@ -62,7 +62,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent Applicant $applicant, CampaignRequestType $campaignType, RequestedCoupon $requestedCoupon, - ?\DateTimeInterface $updatedAt = null, ): self { return new self( id: CampaignRequestId::generate(), @@ -72,8 +71,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent campaignType: $campaignType, requestedCoupon: $requestedCoupon, status: CampaignRequestStatus::CREATED, - rejectionReason: null, - updatedAt: $updatedAt, ); } @@ -86,7 +83,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent RequestedCoupon $requestedCoupon, CampaignRequestStatus $status, ?RejectionReason $rejectionReason = null, - ?\DateTimeInterface $updatedAt = null, ): self { return new self( id: $id, @@ -97,7 +93,7 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent requestedCoupon: $requestedCoupon, status: $status, rejectionReason: $rejectionReason, - updatedAt: $updatedAt, + updatedAt: DatesHelper::now(), ); } diff --git a/src/Domain/Models/Entities/Coupon.php b/src/Domain/Models/Entities/Coupon.php index f879cd9..ca23ff2 100644 --- a/src/Domain/Models/Entities/Coupon.php +++ b/src/Domain/Models/Entities/Coupon.php @@ -8,30 +8,27 @@ use Domain\Models\ValueObjects\Actor\ActorId; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar; use Domain\Models\ValueObjects\Coupon\CouponCode; +use Domain\Models\ValueObjects\Coupon\CouponCountryCoverage; use Domain\Models\ValueObjects\Coupon\CouponDiffusion; use Domain\Models\ValueObjects\Coupon\CouponId; -use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; -use Domain\Models\ValueObjects\Coupon\CouponValidity; -use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; -use Domain\Models\ValueObjects\CouponPicture; -use Domain\Models\ValueObjects\GS1; -use Domain\Models\ValueObjects\IncoCode; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponMovementArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\MoneyAmount; -use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\Period; +use Domain\Models\ValueObjects\ProcessingCodeArray; use Shared\DDD\Models\AggregateRoot; use Shared\DDD\Models\HasTimestampIdentifier; +use Shared\Helpers\DatesHelper; /** * @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Coupon\CouponId> */ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier { - /** - * @param array $couponValidityInformation - * @param array $couponLegalInformation - * @param array $products - */ private function __construct( public CouponId $id, public CouponCode $code, @@ -39,28 +36,22 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier public MoneyAmount $amount, public CouponPicture $picture, public CouponDiffusion $diffusion, - public CouponValidity $validity, - public array $couponValidityInformation, - public array $couponLegalInformation, - public ProcessingCode $processingCode, + public CouponValidityInformationArray $validityInformations, + public CouponLegalInformationArray $legalInformations, + public ProcessingCodeArray $processingCode, public BrandCode $brandCode, public ActorId $createdBy, - public array $products = [], + public CouponProductArray $products, + public CouponMovementArray $movements, public ?CouponAvailabilityCalendar $availabilityCalendar = null, - public ?GS1 $gs1 = null, public ?\DateTimeInterface $updatedAt = null, ) { - foreach ($this->couponValidityInformation as $validityInformation) { - if (!$validityInformation instanceof CouponValidityInformation) { - throw new \DomainException('Coupon validity information must be CouponValidityInformation instances.'); - } - } - - foreach ($this->couponLegalInformation as $legalInformation) { - if (!$legalInformation instanceof CouponLegalInformation) { - throw new \DomainException('Coupon legal information must be CouponLegalInformation instances.'); - } - } + CouponCountryCoverage::assert( + $this->processingCode, + $this->validityInformations, + $this->legalInformations, + ); + $this->validateCalendarPeriodsWithinDiffusionPeriod(); } public function id(): CouponId @@ -78,27 +69,25 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier return $this->updatedAt; } - /** - * @param array $couponValidityInformation - * @param array $couponLegalInformation - * @param array $products - */ + public function quantity(): int + { + return $this->movements->quantity(); + } + public static function create( CouponCode $code, CouponName $name, MoneyAmount $amount, CouponPicture $picture, CouponDiffusion $diffusion, - CouponValidity $validity, - array $couponValidityInformation, - array $couponLegalInformation, - ProcessingCode $processingCode, + CouponValidityInformationArray $validityInformations, + CouponLegalInformationArray $legalInformations, + ProcessingCodeArray $processingCode, BrandCode $brandCode, ActorId $createdBy, - array $products = [], + CouponProductArray $products, + CouponMovementArray $movements, ?CouponAvailabilityCalendar $availabilityCalendar = null, - ?GS1 $gs1 = null, - ?\DateTimeInterface $updatedAt = null, ): Coupon { return new self( id: CouponId::generate(), @@ -107,24 +96,17 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier amount: $amount, picture: $picture, diffusion: $diffusion, - validity: $validity, - couponValidityInformation: $couponValidityInformation, - couponLegalInformation: $couponLegalInformation, + validityInformations: $validityInformations, + legalInformations: $legalInformations, processingCode: $processingCode, brandCode: $brandCode, createdBy: $createdBy, products: $products, + movements: $movements, availabilityCalendar: $availabilityCalendar, - gs1: $gs1, - updatedAt: $updatedAt, ); } - /** - * @param array $couponValidityInformation - * @param array $couponLegalInformation - * @param array $products - */ public static function hydrate( CouponId $id, CouponCode $code, @@ -132,16 +114,14 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier MoneyAmount $amount, CouponPicture $picture, CouponDiffusion $diffusion, - CouponValidity $validity, - array $couponValidityInformation, - array $couponLegalInformation, - ProcessingCode $processingCode, + CouponValidityInformationArray $validityInformations, + CouponLegalInformationArray $legalInformations, + ProcessingCodeArray $processingCode, BrandCode $brandCode, ActorId $createdBy, - array $products = [], + CouponProductArray $products, + CouponMovementArray $movements, ?CouponAvailabilityCalendar $availabilityCalendar = null, - ?GS1 $gs1 = null, - ?\DateTimeInterface $updatedAt = null, ): self { return new self( id: $id, @@ -150,16 +130,89 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier amount: $amount, picture: $picture, diffusion: $diffusion, - validity: $validity, - couponValidityInformation: $couponValidityInformation, - couponLegalInformation: $couponLegalInformation, + validityInformations: $validityInformations, + legalInformations: $legalInformations, processingCode: $processingCode, brandCode: $brandCode, createdBy: $createdBy, products: $products, + movements: $movements, availabilityCalendar: $availabilityCalendar, - gs1: $gs1, - updatedAt: $updatedAt, + updatedAt: DatesHelper::now(), ); } + + public function changePicture(CouponPicture $picture): self + { + return new self( + id: $this->id, + code: $this->code, + name: $this->name, + amount: $this->amount, + picture: $picture, + diffusion: $this->diffusion, + validityInformations: $this->validityInformations, + legalInformations: $this->legalInformations, + processingCode: $this->processingCode, + brandCode: $this->brandCode, + createdBy: $this->createdBy, + products: $this->products, + movements: $this->movements, + availabilityCalendar: $this->availabilityCalendar, + updatedAt: DatesHelper::now(), + ); + } + + public function changeInformations(CouponValidityInformationArray $validityInformations, CouponLegalInformationArray $legalInformations): self + { + return new self( + id: $this->id, + code: $this->code, + name: $this->name, + amount: $this->amount, + picture: $this->picture, + diffusion: $this->diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $this->processingCode, + brandCode: $this->brandCode, + createdBy: $this->createdBy, + products: $this->products, + movements: $this->movements, + availabilityCalendar: $this->availabilityCalendar, + 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/Entities/PrintableCoupon.php b/src/Domain/Models/Entities/PrintableCoupon.php new file mode 100644 index 0000000..8ad7c4e --- /dev/null +++ b/src/Domain/Models/Entities/PrintableCoupon.php @@ -0,0 +1,82 @@ + + */ +final readonly class PrintableCoupon implements AggregateRoot, HasTimestampIdentifier +{ + private function __construct( + public PrintableCouponCode $code, + public CampaignCode $campaignCode, + public CouponCode $couponCode, + public CouponName $couponName, + public CrmHashcode $crmCustomerHashcode, + public \DateTimeInterface $distributableUntil, + public ?\DateTimeInterface $updatedAt = null, + ) { + } + + public function id(): PrintableCouponCode + { + return $this->code; + } + + public function createdAt(): \DateTimeInterface + { + return $this->code->toDateTime(); + } + + public function updatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + public static function create( + CampaignCode $campaignCode, + CouponCode $couponCode, + CouponName $couponName, + CrmHashcode $crmCustomerHashcode, + \DateTimeInterface $distributableUntil, + ): self { + return new self( + code: PrintableCouponCode::generate(), + campaignCode: $campaignCode, + couponCode: $couponCode, + couponName: $couponName, + crmCustomerHashcode: $crmCustomerHashcode, + distributableUntil: $distributableUntil, + ); + } + + public static function hydrate( + PrintableCouponCode $code, + CampaignCode $campaignCode, + CouponCode $couponCode, + CouponName $couponName, + CrmHashcode $crmCustomerHashcode, + \DateTimeInterface $distributableUntil, + ): self { + return new self( + code: $code, + campaignCode: $campaignCode, + couponCode: $couponCode, + couponName: $couponName, + crmCustomerHashcode: $crmCustomerHashcode, + distributableUntil: $distributableUntil, + updatedAt: DatesHelper::now(), + ); + } +} diff --git a/src/Domain/Models/Entities/Stock.php b/src/Domain/Models/Entities/Stock.php index 06cd24d..6343baf 100644 --- a/src/Domain/Models/Entities/Stock.php +++ b/src/Domain/Models/Entities/Stock.php @@ -5,12 +5,13 @@ declare(strict_types=1); namespace Domain\Models\Entities; use Domain\Models\ValueObjects\Campaign\CampaignCode; -use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\Coupon\CouponCode; use Domain\Models\ValueObjects\Stock\StockAdjustment; use Domain\Models\ValueObjects\Stock\StockAmount; use Domain\Models\ValueObjects\Stock\StockId; use Shared\DDD\Models\AggregateRoot; use Shared\DDD\Models\HasTimestampIdentifier; +use Shared\Helpers\DatesHelper; /** * @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Stock\StockId> @@ -21,7 +22,7 @@ final readonly class Stock implements AggregateRoot, HasTimestampIdentifier public StockId $id, public StockAmount $stockAmount, public CampaignCode $campaignCode, - public ProcessingCode $processingCode, + public CouponCode $couponCode, /** @var array $stockAdjustment */ public array $stockAdjustment, public ?\DateTimeInterface $updatedAt = null, @@ -55,17 +56,16 @@ final readonly class Stock implements AggregateRoot, HasTimestampIdentifier StockId $id, StockAmount $stockAmount, CampaignCode $campaignCode, - ProcessingCode $processingCode, + CouponCode $couponCode, array $stockAdjustment = [], - ?\DateTimeInterface $updatedAt = null, ): self { return new self( id: $id, stockAmount: $stockAmount, campaignCode: $campaignCode, - processingCode: $processingCode, + couponCode: $couponCode, stockAdjustment: $stockAdjustment, - updatedAt: $updatedAt, + updatedAt: DatesHelper::now(), ); } } 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/Campaign/CampaignCoupon.php b/src/Domain/Models/ValueObjects/Campaign/CampaignCoupon.php index ba56632..5841a0f 100644 --- a/src/Domain/Models/ValueObjects/Campaign/CampaignCoupon.php +++ b/src/Domain/Models/ValueObjects/Campaign/CampaignCoupon.php @@ -4,17 +4,13 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects\Campaign; -use Domain\Models\ValueObjects\Coupon\CouponQuantity; +use Domain\Models\ValueObjects\Coupon\CouponCode; use Domain\Models\ValueObjects\PrintRules; -use Domain\Models\ValueObjects\ProcessingCode; -use Shared\Enums\Country; final readonly class CampaignCoupon { public function __construct( - public ProcessingCode $processingCode, - public CouponQuantity $quantity, - public Country $availableIn, + public CouponCode $couponCode, public ?PrintRules $printRules = null, ) { } diff --git a/src/Domain/Models/ValueObjects/Campaign/CampaignCouponArray.php b/src/Domain/Models/ValueObjects/Campaign/CampaignCouponArray.php new file mode 100644 index 0000000..b312f93 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Campaign/CampaignCouponArray.php @@ -0,0 +1,23 @@ +value === []) { + throw new \DomainException('Coupons requires at least one CampaignCoupon.'); + } + + foreach ($this->value as $coupon) { + if (! $coupon instanceof CampaignCoupon) { + throw new \DomainException('Coupons must be CampaignCoupon instances.'); + } + } + } +} diff --git a/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFilters.php b/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFilters.php index 5978375..ca192a4 100644 --- a/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFilters.php +++ b/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFilters.php @@ -12,7 +12,7 @@ final readonly class CampaignRequestFilters public ?BrandCode $brandCode = null, public ?CampaignRequestStatus $status = null, ) { - if (null === $this->brandCode && null === $this->status) { + if (!$this->brandCode instanceof BrandCode && !$this->status instanceof CampaignRequestStatus) { throw new \InvalidArgumentException('CampaignRequestFilters require at least a brand code or a status.'); } } diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php b/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php index 718ff59..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 === []) { @@ -19,7 +21,7 @@ final readonly class CouponAvailabilityCalendar implements Equatable } foreach ($this->periods as $period) { - if (!$period instanceof Period) { + if (! $period instanceof Period) { throw new \DomainException('CouponAvailabilityCalendar periods must be Period instances.'); } } @@ -27,23 +29,22 @@ final readonly class CouponAvailabilityCalendar implements Equatable public function isActiveAt(DateTime $in): bool { - foreach ($this->periods as $period) { - if ($in >= $period->start && $in <= $period->end) { - return true; - } - } - - return false; + return array_any($this->periods, fn ($period): bool => $period->inRange($in)); } public function beginsAt(): DateTime { - return min(array_map(fn (Period $period) => $period->start, $this->periods)); + return min(array_map(fn (Period $period): DateTime => $period->start, $this->periods)); } public function endsAt(): DateTime { - return max(array_map(fn (Period $period) => $period->end, $this->periods)); + 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] diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponCountryCoverage.php b/src/Domain/Models/ValueObjects/Coupon/CouponCountryCoverage.php new file mode 100644 index 0000000..b64b92a --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponCountryCoverage.php @@ -0,0 +1,55 @@ +value as $processingCode) { + $country = $processingCode->availableIn; + + if (! self::hasInformationFor($country, $validityInformations)) { + throw new \DomainException(sprintf('A coupon requires validity information for %s.', $country->name)); + } + + if (! self::hasInformationFor($country, $legalInformations)) { + throw new \DomainException(sprintf('A coupon requires legal information for %s.', $country->name)); + } + } + + self::countDiffusionCountries($validityInformations, $processingCodes); + self::countDiffusionCountries($legalInformations, $processingCodes); + } + + private static function hasInformationFor( + Country $country, + CouponValidityInformationArray | CouponLegalInformationArray $informations, + ): bool { + return array_any( + $informations->value, + fn (CouponValidityInformation | CouponLegalInformation $information): bool => $information->forLocale === $country, + ); + } + + private static function countDiffusionCountries( + CouponValidityInformationArray | CouponLegalInformationArray $informations, + ProcessingCodeArray $processingCodes, + ): void { + if (count($informations->value) !== count($processingCodes->value)) { + throw new \DomainException('A coupon requires exactly one information per diffusion country.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponLegalInformation.php b/src/Domain/Models/ValueObjects/Coupon/CouponLegalInformation.php index e93b9ad..3baea56 100644 --- a/src/Domain/Models/ValueObjects/Coupon/CouponLegalInformation.php +++ b/src/Domain/Models/ValueObjects/Coupon/CouponLegalInformation.php @@ -10,7 +10,7 @@ final readonly class CouponLegalInformation { public function __construct( public string $legalText, - public Country $forLocale + public Country $forLocale, ) { if (mb_trim($this->legalText) === '') { throw new \DomainException('Legal text cannot be empty.'); diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponLegalInformationArray.php b/src/Domain/Models/ValueObjects/Coupon/CouponLegalInformationArray.php new file mode 100644 index 0000000..0925404 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponLegalInformationArray.php @@ -0,0 +1,45 @@ +value === []) { + throw new \DomainException('CouponLegalInformations requires at least one CouponLegalInformation.'); + } + + $hasFrance = false; + $countries = []; + + foreach ($this->value as $couponLegalInformation) { + if (! $couponLegalInformation instanceof CouponLegalInformation) { + throw new \DomainException('Value of CouponLegalInformationArray must be CouponLegalInformation instances.'); + } + + if (isset($countries[$couponLegalInformation->forLocale->name])) { + throw new \DomainException(sprintf( + 'Only one CouponLegalInformation is allowed per country, duplicate for %s.', + $couponLegalInformation->forLocale->name, + )); + } + + $countries[$couponLegalInformation->forLocale->name] = true; + + if ($couponLegalInformation->forLocale === Country::FRANCE) { + $hasFrance = true; + } + } + + if (! $hasFrance) { + throw new \DomainException('CouponLegalInformations requires a CouponLegalInformation for France.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponMovement.php b/src/Domain/Models/ValueObjects/Coupon/CouponMovement.php new file mode 100644 index 0000000..a620908 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponMovement.php @@ -0,0 +1,17 @@ +createdAt = $createdAt ?? new \DateTimeImmutable(); + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponMovementArray.php b/src/Domain/Models/ValueObjects/Coupon/CouponMovementArray.php new file mode 100644 index 0000000..20ffd65 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponMovementArray.php @@ -0,0 +1,28 @@ +value === []) { + throw new \DomainException('CouponMovementArray requires at least one CouponMovement.'); + } + + foreach ($this->value as $couponMovement) { + if (! $couponMovement instanceof CouponMovement) { + throw new \DomainException('Value of CouponMovementArray must be CouponMovement instances.'); + } + } + } + + public function quantity(): int + { + return array_sum(array_column($this->value, 'quantity')); + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponName.php b/src/Domain/Models/ValueObjects/Coupon/CouponName.php new file mode 100644 index 0000000..56ec622 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponName.php @@ -0,0 +1,22 @@ + 200) { + throw new \DomainException('Coupon name exceeds the maximum allowed length.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponPicture.php b/src/Domain/Models/ValueObjects/Coupon/CouponPicture.php new file mode 100644 index 0000000..21f5e61 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponPicture.php @@ -0,0 +1,57 @@ +value, PATHINFO_EXTENSION)); + + if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) { + throw new \DomainException(sprintf('Coupon picture "%s" must be a JPG file.', $picture)); + } + } + } + + public static function create( + PicturePath $decoration, + PicturePath $thumbnail, + PicturePath $mobileThumbnail, + ): self { + $picture = new self($decoration, $thumbnail, $mobileThumbnail); + + $size = is_file($decoration->value) ? getimagesize($decoration->value) : false; + + if ($size === false) { + throw new \DomainException(sprintf('Unable to read the decoration picture "%s".', $decoration)); + } + + [$width, $height] = $size; + + if ($width !== self::DECORATION_WIDTH || $height !== self::DECORATION_HEIGHT) { + throw new \DomainException(sprintf( + 'The decoration picture must be %d x %d px, %d x %d px given.', + self::DECORATION_WIDTH, + self::DECORATION_HEIGHT, + $width, + $height, + )); + } + + return $picture; + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponProductArray.php b/src/Domain/Models/ValueObjects/Coupon/CouponProductArray.php new file mode 100644 index 0000000..60d3005 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponProductArray.php @@ -0,0 +1,21 @@ +value as $product) { + if (! $product instanceof IncoCode) { + throw new \DomainException('Value of CouponProductArray must be IncoCode instances.'); + } + } + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponQuantity.php b/src/Domain/Models/ValueObjects/Coupon/CouponQuantity.php deleted file mode 100644 index d2f5bb0..0000000 --- a/src/Domain/Models/ValueObjects/Coupon/CouponQuantity.php +++ /dev/null @@ -1,16 +0,0 @@ -quantity <= 0) { - throw new \DomainException('CouponQuantity quantity must be positive.'); - } - } -} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponSortCriteria.php b/src/Domain/Models/ValueObjects/Coupon/CouponSortCriteria.php new file mode 100644 index 0000000..8385df0 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponSortCriteria.php @@ -0,0 +1,14 @@ +days <= 0) { - throw new \DomainException('Coupon Validity days must be positive.'); - } - } - - #[\Override] - public function equals(mixed $other): bool - { - return $other instanceof self && $this->days === $other->days && $this->until == $other->until; - } -} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponValidityInformationArray.php b/src/Domain/Models/ValueObjects/Coupon/CouponValidityInformationArray.php new file mode 100644 index 0000000..d1a9ff8 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponValidityInformationArray.php @@ -0,0 +1,45 @@ +value === []) { + throw new \DomainException('CouponValidityInformations requires at least one CouponValidityInformation.'); + } + + $hasFrance = false; + $countries = []; + + foreach ($this->value as $couponValidityInformation) { + if (! $couponValidityInformation instanceof CouponValidityInformation) { + throw new \DomainException('Value of CouponValidityInformationArray must be CouponValidityInformation instances.'); + } + + if (isset($countries[$couponValidityInformation->forLocale->name])) { + throw new \DomainException(sprintf( + 'Only one CouponValidityInformation is allowed per country, duplicate for %s.', + $couponValidityInformation->forLocale->name, + )); + } + + $countries[$couponValidityInformation->forLocale->name] = true; + + if ($couponValidityInformation->forLocale === Country::FRANCE) { + $hasFrance = true; + } + } + + if (! $hasFrance) { + throw new \DomainException('CouponValidityInformations requires a CouponValidityInformation for France.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/CouponPicture.php b/src/Domain/Models/ValueObjects/CouponPicture.php deleted file mode 100644 index a85f12f..0000000 --- a/src/Domain/Models/ValueObjects/CouponPicture.php +++ /dev/null @@ -1,15 +0,0 @@ -value === $other->value; + } +} diff --git a/src/Domain/Models/ValueObjects/GS1.php b/src/Domain/Models/ValueObjects/GS1.php deleted file mode 100644 index 1f646a2..0000000 --- a/src/Domain/Models/ValueObjects/GS1.php +++ /dev/null @@ -1,31 +0,0 @@ -value) === '') { - throw new \DomainException('GS1 value cannot be empty.'); - } - } - - #[\Override] - public function equals(mixed $other): bool - { - return $other instanceof self && $this->value === $other->value && $this->format === $other->format; - } - - public function __toString(): string - { - return $this->value; - } -} diff --git a/src/Domain/Models/ValueObjects/IncoCode.php b/src/Domain/Models/ValueObjects/IncoCode.php index e830ad6..0055e87 100644 --- a/src/Domain/Models/ValueObjects/IncoCode.php +++ b/src/Domain/Models/ValueObjects/IncoCode.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects; -use Shared\DDD\Models\Equatable; - -final readonly class IncoCode implements Equatable +final readonly class IncoCode { /** * @throws \DomainException @@ -18,10 +16,4 @@ final readonly class IncoCode implements Equatable throw new \DomainException('Inco Code must be a positive integer.'); } } - - #[\Override] - public function equals(mixed $other): bool - { - return $other instanceof self && $this->value === $other->value; - } } 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/Models/ValueObjects/PrintableCoupon/MarketingDirectCode.php b/src/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCode.php new file mode 100644 index 0000000..d21e226 --- /dev/null +++ b/src/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCode.php @@ -0,0 +1,47 @@ +value) !== 1) { + throw new \DomainException( + 'Marketing Direct Code must be exactly 13 numeric characters, e.g. 5425003944130.', + ); + } + } + + public static function fromInt(int $sequence): self + { + if ($sequence < 0) { + throw new \DomainException('Marketing Direct sequence must be positive.'); + } + + return new self(str_pad((string) $sequence, 13, '0', STR_PAD_LEFT)); + } + + public function toInt(): int + { + return (int) $this->value; + } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof self && $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCode.php b/src/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCode.php new file mode 100644 index 0000000..6e0291a --- /dev/null +++ b/src/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCode.php @@ -0,0 +1,16 @@ +id->toDateTime(); + } +} diff --git a/src/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponId.php b/src/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponId.php new file mode 100644 index 0000000..b04d121 --- /dev/null +++ b/src/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponId.php @@ -0,0 +1,16 @@ +value) === '') { - throw new \DomainException('Coupon name cannot be empty.'); + throw new \DomainException('Unprinted reason cannot be empty.'); } } } diff --git a/src/Domain/Models/ValueObjects/ProcessingCode.php b/src/Domain/Models/ValueObjects/ProcessingCode.php index 97dd7ff..bad00ff 100644 --- a/src/Domain/Models/ValueObjects/ProcessingCode.php +++ b/src/Domain/Models/ValueObjects/ProcessingCode.php @@ -5,14 +5,20 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects; use Shared\DDD\Models\Equatable; +use Shared\Enums\Country; final readonly class ProcessingCode implements \Stringable, Equatable { + private const string PATTERN = '/^\d{13}\z/'; + public function __construct( public string $value, + public Country $availableIn, ) { - if (mb_trim($this->value) === '') { - throw new \DomainException('Processing Code value cannot be empty.'); + if (preg_match(self::PATTERN, $this->value) !== 1) { + throw new \DomainException( + 'Processing Code must be exactly 13 numeric characters, e.g. 5425003944130.', + ); } } diff --git a/src/Domain/Models/ValueObjects/ProcessingCodeArray.php b/src/Domain/Models/ValueObjects/ProcessingCodeArray.php new file mode 100644 index 0000000..e95d1fe --- /dev/null +++ b/src/Domain/Models/ValueObjects/ProcessingCodeArray.php @@ -0,0 +1,45 @@ +value === []) { + throw new \DomainException('ProcessingCodes requires at least one ProcessingCode.'); + } + + $hasFrance = false; + $countries = []; + + foreach ($this->value as $processingCode) { + if (! $processingCode instanceof ProcessingCode) { + throw new \DomainException('Value of ProcessingCodeArray must be ProcessingCode instances.'); + } + + if (isset($countries[$processingCode->availableIn->name])) { + throw new \DomainException(sprintf( + 'Only one ProcessingCode is allowed per country, duplicate for %s.', + $processingCode->availableIn->name, + )); + } + + $countries[$processingCode->availableIn->name] = true; + + if ($processingCode->availableIn === Country::FRANCE) { + $hasFrance = true; + } + } + + if (! $hasFrance) { + throw new \DomainException('ProcessingCodes requires a ProcessingCode for France.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCoupon.php b/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCoupon.php index c5bfbab..d589b30 100644 --- a/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCoupon.php +++ b/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCoupon.php @@ -4,58 +4,35 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects\RequestedCoupon; -use Domain\Models\ValueObjects\Coupon\CouponValidity; -use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; -use Domain\Models\ValueObjects\GS1; -use Domain\Models\ValueObjects\IncoCode; +use Domain\Models\ValueObjects\Coupon\CouponCountryCoverage; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\MoneyAmount; use Domain\Models\ValueObjects\Period; use Domain\Models\ValueObjects\PrintRules; -use Domain\Models\ValueObjects\ProcessingCode; -use Shared\Enums\Country; +use Domain\Models\ValueObjects\ProcessingCodeArray; final readonly class RequestedCoupon { - /** - * @param array $couponValidityInformation - * @param array $products - */ public function __construct( - public CouponName $name, + public CouponName $couponName, public RequestedCouponQuantity $quantity, public Period $diffusion, public MoneyAmount $moneyAmount, - public ProcessingCode $processingCode, - public CouponValidity $couponValidity, - public array $couponValidityInformation, - public array $products = [], + public CouponPicture $couponPicture, + public ProcessingCodeArray $couponProcessingCodes, + public CouponValidityInformationArray $couponValidityInformations, + public CouponLegalInformationArray $couponLegalInformations, + public CouponProductArray $couponProducts, public ?PrintRules $printRules = null, - public ?GS1 $gs1 = null, ) { - foreach ($this->couponValidityInformation as $validityInformation) { - if (!$validityInformation instanceof CouponValidityInformation) { - throw new \DomainException('Coupon validity information must be CouponValidityInformation instances.'); - } - } - - if (!$this->hasValidityInformationFor(Country::FRANCE)) { - throw new \DomainException('A coupon requires validity information for France.'); - } - - if ($this->gs1 instanceof GS1 && !$this->hasValidityInformationFor(Country::BENELUX)) { - throw new \DomainException('A GS1 coupon requires validity information for Benelux.'); - } - } - - private function hasValidityInformationFor(Country $country): bool - { - foreach ($this->couponValidityInformation as $validityInformation) { - if ($validityInformation->forLocale === $country) { - return true; - } - } - - return false; + CouponCountryCoverage::assert( + $this->couponProcessingCodes, + $this->couponValidityInformations, + $this->couponLegalInformations, + ); } } diff --git a/src/Domain/Ports/Repositories/CampaignRepository.php b/src/Domain/Ports/Repositories/CampaignRepository.php index a042630..98445e7 100644 --- a/src/Domain/Ports/Repositories/CampaignRepository.php +++ b/src/Domain/Ports/Repositories/CampaignRepository.php @@ -4,6 +4,10 @@ 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; /** @@ -11,4 +15,14 @@ use Shared\DDD\Ports\Repository; */ interface CampaignRepository extends Repository { + /** + * Returns the campaigns that currently contain the given coupon + * + * @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/CouponRepository.php b/src/Domain/Ports/Repositories/CouponRepository.php index 458302c..4473bd8 100644 --- a/src/Domain/Ports/Repositories/CouponRepository.php +++ b/src/Domain/Ports/Repositories/CouponRepository.php @@ -4,11 +4,31 @@ declare(strict_types=1); namespace Domain\Ports\Repositories; +use Domain\Models\Entities\Coupon; +use Domain\Models\ValueObjects\Brand\BrandCode; +use Domain\Models\ValueObjects\Coupon\CouponCode; +use Domain\Models\ValueObjects\Coupon\CouponSortCriteria; use Shared\DDD\Ports\Repository; +use Shared\ValueObjects\Pagination\Paginated; +use Shared\ValueObjects\Pagination\Pagination; /** * @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\Coupon\CouponId, \Domain\Models\Entities\Coupon> */ interface CouponRepository extends Repository { + public function existsByCode(CouponCode $code): bool; + + public function findByCode(CouponCode $code): ?Coupon; + + /** + * @return Paginated + */ + public function findAll( + Pagination $pagination, + ?CouponSortCriteria $sortCriteria = null, + ?BrandCode $brandCode = null, + ): Paginated; + + public function save(Coupon $brand): void; } diff --git a/src/Domain/Ports/Repositories/PrintableCouponRepository.php b/src/Domain/Ports/Repositories/PrintableCouponRepository.php new file mode 100644 index 0000000..b53d27b --- /dev/null +++ b/src/Domain/Ports/Repositories/PrintableCouponRepository.php @@ -0,0 +1,45 @@ + + */ +interface PrintableCouponRepository extends Repository +{ + /** + * @return Paginated + */ + public function findAllAvailable( + Pagination $pagination, + CampaignCode $campaignCode, + CrmHashcode $crmCustomerHashcode, + ?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/src/Shared/Helpers/DatesHelper.php b/src/Shared/Helpers/DatesHelper.php index 8c1a0ca..021df21 100644 --- a/src/Shared/Helpers/DatesHelper.php +++ b/src/Shared/Helpers/DatesHelper.php @@ -36,10 +36,10 @@ final class DatesHelper $now = self::now(); return match (true) { - $from !== null && $to !== null => $now->between($from, $to), - $from !== null => $now->greaterThanOrEqualTo($from), - $to !== null => $now->lessThanOrEqualTo($to), - default => false, + $from instanceof CarbonInterface && $to instanceof CarbonInterface => $now->between($from, $to), + $from instanceof CarbonInterface => $now->greaterThanOrEqualTo($from), + $to instanceof CarbonInterface => $now->lessThanOrEqualTo($to), + default => false, }; } @@ -87,7 +87,7 @@ final class DatesHelper break; default: try { - $output = (new \DateTimeImmutable($datetime, new \DateTimeZone('UTC')))->setTimezone(new \DateTimeZone('UTC')); + $output = new \DateTimeImmutable($datetime, new \DateTimeZone('UTC'))->setTimezone(new \DateTimeZone('UTC')); } catch (\Exception) { throw InvalidDateFormatException::forDateString($datetime); } @@ -96,21 +96,21 @@ final class DatesHelper return $output; } - protected static function parseFrench(string $datetime): \DateTimeInterface + private static function parseFrench(string $datetime): \DateTimeInterface { $dt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $datetime . ' 00:00:00', new \DateTimeZone('UTC')); return $dt ?: self::parseFallback($datetime); } - protected static function parseFrenchInverted(string $datetime): \DateTimeInterface + private static function parseFrenchInverted(string $datetime): \DateTimeInterface { $dt = \DateTimeImmutable::createFromFormat('d-m-Y H:i:s', $datetime . ' 00:00:00', new \DateTimeZone('UTC')); return $dt ?: self::parseFallback($datetime); } - protected static function parseRfc3339(string $datetime): \DateTimeInterface + private static function parseRfc3339(string $datetime): \DateTimeInterface { $dt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::RFC3339, $datetime); if ($dt !== false) { @@ -122,17 +122,17 @@ final class DatesHelper return $dt ? $dt->setTimezone(new \DateTimeZone('UTC')) : self::parseFallback($datetime); } - protected static function parseIso8601(string $datetime): \DateTimeInterface + private static function parseIso8601(string $datetime): \DateTimeInterface { $dt = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $datetime); return $dt ? $dt->setTimezone(new \DateTimeZone('UTC')) : self::parseFallback($datetime); } - protected static function parseFallback(string $datetime): \DateTimeInterface + private static function parseFallback(string $datetime): \DateTimeInterface { try { - return (new \DateTimeImmutable($datetime, new \DateTimeZone('UTC')))->setTimezone(new \DateTimeZone('UTC')); + return new \DateTimeImmutable($datetime, new \DateTimeZone('UTC'))->setTimezone(new \DateTimeZone('UTC')); } catch (\Throwable) { throw InvalidDateFormatException::forDateString($datetime); } diff --git a/src/Shared/ValueObjects/Pagination/Paginated.php b/src/Shared/ValueObjects/Pagination/Paginated.php index 84d4a09..add80dd 100644 --- a/src/Shared/ValueObjects/Pagination/Paginated.php +++ b/src/Shared/ValueObjects/Pagination/Paginated.php @@ -9,7 +9,7 @@ final readonly class Paginated { /** @param T[] $items */ public function __construct( - private readonly Pagination $pagination, + private Pagination $pagination, private array $items, private int $total ) { diff --git a/src/Shared/ValueObjects/Pagination/Pagination.php b/src/Shared/ValueObjects/Pagination/Pagination.php index 5b38a80..599885e 100644 --- a/src/Shared/ValueObjects/Pagination/Pagination.php +++ b/src/Shared/ValueObjects/Pagination/Pagination.php @@ -9,7 +9,7 @@ use Shared\DDD\Models\Equatable; /** * Immutable value object representing a pagination range. */ -final readonly class Pagination implements Equatable +final readonly class Pagination implements Equatable, \Stringable { /** ERR_TOO_MANY_RECORDS fixed to 10k */ public const int MAX_PAGE_SIZE = PageSize::MAX_PAGE_SIZE; diff --git a/tests/Fixtures/Coupon/decoration.jpg b/tests/Fixtures/Coupon/decoration.jpg new file mode 100644 index 0000000..271a50f Binary files /dev/null and b/tests/Fixtures/Coupon/decoration.jpg differ diff --git a/tests/Fixtures/Coupon/invalid_decoration.jpg b/tests/Fixtures/Coupon/invalid_decoration.jpg new file mode 100644 index 0000000..1a1f44d Binary files /dev/null and b/tests/Fixtures/Coupon/invalid_decoration.jpg differ diff --git a/tests/Unit/Application/UseCases/Brand/Create/CreateBrandTest.php b/tests/Unit/Application/UseCases/Brand/Create/CreateBrandTest.php index aeaa717..d7e81b6 100644 --- a/tests/Unit/Application/UseCases/Brand/Create/CreateBrandTest.php +++ b/tests/Unit/Application/UseCases/Brand/Create/CreateBrandTest.php @@ -7,7 +7,6 @@ namespace Tests\Unit\Application\UseCases\Brand\Create; use Application\UseCases\Brand\Create\CreateBrand; use Application\UseCases\Brand\Create\Input; use Application\UseCases\Brand\Create\Output; -use Domain\Exceptions\BrandCodeAlreadyExistsException; use Domain\Models\Entities\Brand; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Brand\BrandName; @@ -77,7 +76,7 @@ describe('create brand use case validation rules', function (): void { $action = fn (): Output => $useCase->execute($input); // THEN - expect($action)->toThrow(BrandCodeAlreadyExistsException::class); + expect($action)->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php b/tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php index e18077e..3558819 100644 --- a/tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php +++ b/tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php @@ -21,16 +21,16 @@ use Shared\ValueObjects\Rcs; covers(UpdateBrand::class, Input::class, BrandUpdated::class, BrandNotFound::class); -$existingBrand = static fn (BrandCode $code): Brand => Brand::hydrate( - BrandId::generate(), - $code, - new BrandName('Name'), - new PicturePath('files/picture-old.jpg'), - new Rcs('RCS PARIS 111 222 333'), - new Email('contact-old@example.com'), -); +describe('update brand use case', function (): void { + $existingBrand = static fn (BrandCode $code): Brand => Brand::hydrate( + BrandId::generate(), + $code, + new BrandName('Name'), + new PicturePath('files/picture-old.jpg'), + new Rcs('RCS PARIS 111 222 333'), + new Email('contact-old@example.com'), + ); -describe('update brand use case', function () use ($existingBrand): void { it('updates every provided field', function () use ($existingBrand): void { // GIVEN $code = new BrandCode('AAA01'); @@ -47,8 +47,7 @@ describe('update brand use case', function () use ($existingBrand): void { && $saved->rcs === $newRcs && $saved->contact === $newContact && $saved->code === $brand->code - && $saved->name === $brand->name - && $saved->updatedAt !== null, + && $saved->name === $brand->name, )); $useCase = new UpdateBrand($repository); @@ -58,8 +57,10 @@ describe('update brand use case', function () use ($existingBrand): void { // THEN expect($result)->toBeInstanceOf(BrandUpdated::class) - ->and($result->code)->toBe($brand->code) - ->and($result->updatedAt)->not->toBeNull(); + ->and($result->logo)->toBe($newLogo) + ->and($result->rcs)->toBe($newRcs) + ->and($result->contact)->toBe($newContact) + ->and($result->code)->toBe($brand->code); }); it('updates only the contact and leaves logo/rcs untouched', function () use ($existingBrand): void { @@ -76,8 +77,7 @@ describe('update brand use case', function () use ($existingBrand): void { && $saved->logo === $brand->logo && $saved->rcs === $brand->rcs && $saved->code === $brand->code - && $saved->name === $brand->name - && $saved->updatedAt !== null, + && $saved->name === $brand->name, )); $useCase = new UpdateBrand($repository); @@ -87,8 +87,8 @@ describe('update brand use case', function () use ($existingBrand): void { // THEN expect($result)->toBeInstanceOf(BrandUpdated::class) - ->and($result->code)->toBe($brand->code) - ->and($result->updatedAt)->not->toBeNull(); + ->and($result->contact)->toBe($newContact) + ->and($result->code)->toBe($brand->code); }); }); diff --git a/tests/Unit/Application/UseCases/CampaignRequest/Create/CreateCampaignRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/Create/CreateCampaignRequestTest.php index efd146e..4bb30ed 100644 --- a/tests/Unit/Application/UseCases/CampaignRequest/Create/CreateCampaignRequestTest.php +++ b/tests/Unit/Application/UseCases/CampaignRequest/Create/CreateCampaignRequestTest.php @@ -8,21 +8,26 @@ use Application\UseCases\CampaignRequest\Create\CreateCampaignRequest; use Application\UseCases\CampaignRequest\Create\Input; use Application\UseCases\CampaignRequest\Create\Output; use DateTime; -use Domain\Exceptions\BrandNotFoundException; use Domain\Models\Entities\CampaignRequest; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Campaign\CampaignName; use Domain\Models\ValueObjects\CampaignRequest\Applicant; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType; -use Domain\Models\ValueObjects\Coupon\CouponValidity; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\IncoCode; use Domain\Models\ValueObjects\MoneyAmount; use Domain\Models\ValueObjects\Period; +use Domain\Models\ValueObjects\PicturePath; use Domain\Models\ValueObjects\PrintRules; use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\ProcessingCodeArray; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity; use Domain\Ports\Repositories\BrandRepository; @@ -34,33 +39,45 @@ use Shared\ValueObjects\Money; covers(CreateCampaignRequest::class, Input::class, Output::class); -beforeEach(function (): void { - $this->input = new Input( +describe('create campaign request use case', function (): void { + $makeInput = fn (): Input => new Input( brandCode: new BrandCode('AAA01'), name: new CampaignName('Campaign name'), applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')), campaignType: CampaignRequestType::CLUB, requestedCoupon: new RequestedCoupon( - name: new CouponName('Coupon A'), + couponName: new CouponName('Coupon A'), quantity: new RequestedCouponQuantity(100), diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)), - processingCode: new ProcessingCode('PC1'), - couponValidity: new CouponValidity(30, new DateTime('2026-02-01')), - couponValidityInformation: [ + couponPicture: new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobileThumbnail.jpg') + ), + couponProcessingCodes: new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('4006381333931', Country::BENELUX), + ]), + couponValidityInformations: new CouponValidityInformationArray([ new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), - ], - products: [new IncoCode(35784), new IncoCode(15522)], + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponLegalInformations: new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]), printRules: new PrintRules(maxPerCustomer: 3) ), ); -}); -describe('create campaign request use case', function (): void { - it('creates a campaign request when the brand exists', function (): void { + it('creates a campaign request when the brand exists', function () use ($makeInput): void { // GIVEN + $input = $makeInput(); + $brandRepository = \Mockery::mock(BrandRepository::class); - $brandRepository->shouldReceive('existsByCode')->once()->with($this->input->brandCode)->andReturn(true); + $brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(true); $campaignRequestRepository = \Mockery::mock(CampaignRequestRepository::class); $campaignRequestRepository->shouldReceive('save')->once()->with(\Mockery::type(CampaignRequest::class)); @@ -68,20 +85,20 @@ describe('create campaign request use case', function (): void { $useCase = new CreateCampaignRequest($campaignRequestRepository, $brandRepository); // WHEN - $result = $useCase->execute($this->input); + $result = $useCase->execute($input); // THEN expect($result)->toBeInstanceOf(Output::class) ->and($result->status)->toBe(CampaignRequestStatus::CREATED) - ->and($result->brandCode)->toBe($this->input->brandCode); + ->and($result->brandCode)->toBe($input->brandCode); }); -}); -describe('create campaign request use case validation rules', function (): void { - it('throws when the brand does not exist', function (): void { + it('throws when the brand does not exist', function () use ($makeInput): void { // GIVEN + $input = $makeInput(); + $brandRepository = \Mockery::mock(BrandRepository::class); - $brandRepository->shouldReceive('existsByCode')->once()->with($this->input->brandCode)->andReturn(false); + $brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(false); $campaignRequestRepository = \Mockery::mock(CampaignRequestRepository::class); $campaignRequestRepository->shouldNotReceive('save'); @@ -89,9 +106,9 @@ describe('create campaign request use case validation rules', function (): void $useCase = new CreateCampaignRequest($campaignRequestRepository, $brandRepository); // WHEN - $action = fn (): Output => $useCase->execute($this->input); + $action = fn (): Output => $useCase->execute($input); // THEN - expect($action)->toThrow(BrandNotFoundException::class); + expect($action)->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Application/UseCases/CampaignRequest/Find/FindCampaignRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/Find/FindCampaignRequestTest.php index 912dc8d..dd00697 100644 --- a/tests/Unit/Application/UseCases/CampaignRequest/Find/FindCampaignRequestTest.php +++ b/tests/Unit/Application/UseCases/CampaignRequest/Find/FindCampaignRequestTest.php @@ -10,8 +10,6 @@ use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestFound; use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestNotFound; use Application\UseCases\CampaignRequest\Find\Output\Output; use DateTime; -use Domain\Enums\BarcodeFormat; -use Domain\Exceptions\BrandNotFoundException; use Domain\Models\Entities\Brand; use Domain\Models\Entities\CampaignRequest; use Domain\Models\ValueObjects\Brand\BrandCode; @@ -23,16 +21,20 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType; use Domain\Models\ValueObjects\CampaignRequest\RejectionReason; -use Domain\Models\ValueObjects\Coupon\CouponValidity; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; -use Domain\Models\ValueObjects\GS1; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\IncoCode; use Domain\Models\ValueObjects\MoneyAmount; use Domain\Models\ValueObjects\Period; use Domain\Models\ValueObjects\PicturePath; use Domain\Models\ValueObjects\PrintRules; use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\ProcessingCodeArray; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity; use Domain\Ports\Repositories\BrandRepository; @@ -47,44 +49,52 @@ use Shared\ValueObjects\Rcs; covers(FindCampaignRequest::class, Input::class, CampaignRequestFound::class, CampaignRequestNotFound::class); describe('find campaign request use case', function (): void { - beforeEach(function (): void { - $this->requestedCoupon = new RequestedCoupon( - new CouponName('Coupon A'), - new RequestedCouponQuantity(100), - new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), - new MoneyAmount(new Money(Currency::EUR, 300)), - new ProcessingCode('PC1'), - new CouponValidity(30, new DateTime('2026-02-01')), - [ - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) - ], - [new IncoCode(35784), new IncoCode(15522)], - new PrintRules(maxPerCustomer: 3), - new GS1('01234567890128', BarcodeFormat::EAN13) - ); - }); + $requestedCoupon = new RequestedCoupon( + couponName: new CouponName('Coupon A'), + quantity: new RequestedCouponQuantity(100), + diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), + moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)), + couponPicture: new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobileThumbnail.jpg') + ), + couponProcessingCodes: new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('4006381333931', Country::BENELUX), + ]), + couponValidityInformations: new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponLegalInformations: new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]), + printRules: new PrintRules(maxPerCustomer: 3), + ); - it('finds a campaign request by its id, along with its brand', function (): void { + it('finds a campaign request by its id, along with its brand', function () use ($requestedCoupon): void { // GIVEN $id = CampaignRequestId::generate(); $brandCode = new BrandCode('AAA01'); $campaignRequest = CampaignRequest::hydrate( - $id, - $brandCode, - new CampaignName('Campaign A'), - new Applicant('John', 'Doe', new Email('john.doe@example.com')), - CampaignRequestType::CLUB, - $this->requestedCoupon, - CampaignRequestStatus::CREATED, + id: $id, + brandCode: $brandCode, + name: new CampaignName('Campaign A'), + applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')), + campaignType: CampaignRequestType::CLUB, + requestedCoupon: $requestedCoupon, + status: CampaignRequestStatus::CREATED, ); $brand = Brand::hydrate( - BrandId::generate(), - $brandCode, - new BrandName('Brand A'), - new PicturePath('brands/logo.jpg'), - new Rcs('RCS PARIS 111 222 333'), - new Email('contact@example.com'), + id: BrandId::generate(), + code: $brandCode, + name: new BrandName('Brand A'), + logo: new PicturePath('brands/logo.jpg'), + rcs: new Rcs('RCS PARIS 111 222 333'), + contact: new Email('contact@example.com'), ); $repository = \Mockery::mock(CampaignRequestRepository::class); @@ -109,20 +119,20 @@ describe('find campaign request use case', function (): void { ->and($result->rejectionReason)->toBeNull(); }); - it('finds a rejected campaign request with its rejection reason', function (): void { + it('finds a rejected campaign request with its rejection reason', function () use ($requestedCoupon): void { // GIVEN $id = CampaignRequestId::generate(); $code = new BrandCode('AAA01'); $rejectionReason = new RejectionReason('Missing legal documents.'); $campaignRequest = CampaignRequest::hydrate( - $id, - $code, - new CampaignName('Campaign A'), - new Applicant('John', 'Doe', new Email('john.doe@example.com')), - CampaignRequestType::CLUB, - $this->requestedCoupon, - CampaignRequestStatus::REJECTED, - $rejectionReason, + id: $id, + brandCode: $code, + name: new CampaignName('Campaign A'), + applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')), + campaignType: CampaignRequestType::CLUB, + requestedCoupon: $requestedCoupon, + status: CampaignRequestStatus::REJECTED, + rejectionReason: $rejectionReason, ); $repository = \Mockery::mock(CampaignRequestRepository::class); @@ -131,12 +141,12 @@ describe('find campaign request use case', function (): void { $brandRepository = \Mockery::mock(BrandRepository::class); $brandRepository->shouldReceive('findByCode')->once()->with($code)->andReturn( Brand::hydrate( - BrandId::generate(), - $code, - new BrandName('Brand A'), - new PicturePath('brands/logo.jpg'), - new Rcs('RCS PARIS 111 222 333'), - new Email('contact@example.com'), + id: BrandId::generate(), + code: $code, + name: new BrandName('Brand A'), + logo: new PicturePath('brands/logo.jpg'), + rcs: new Rcs('RCS PARIS 111 222 333'), + contact: new Email('contact@example.com'), ) ); @@ -170,18 +180,18 @@ describe('find campaign request use case', function (): void { expect($result)->toBeInstanceOf(CampaignRequestNotFound::class); }); - it('throws when the campaign request references a brand that no longer exists', function (): void { + it('throws when the campaign request references a brand that no longer exists', function () use ($requestedCoupon): void { // GIVEN $id = CampaignRequestId::generate(); $brandCode = new BrandCode('AAA01'); $campaignRequest = CampaignRequest::hydrate( - $id, - $brandCode, - new CampaignName('Campaign A'), - new Applicant('John', 'Doe', new Email('john.doe@example.com')), - CampaignRequestType::CLUB, - $this->requestedCoupon, - CampaignRequestStatus::CREATED, + id: $id, + brandCode: $brandCode, + name: new CampaignName('Campaign A'), + applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')), + campaignType: CampaignRequestType::CLUB, + requestedCoupon: $requestedCoupon, + status: CampaignRequestStatus::CREATED, ); $repository = \Mockery::mock(CampaignRequestRepository::class); @@ -196,7 +206,7 @@ describe('find campaign request use case', function (): void { $action = fn (): Output => $useCase->execute(new Input(id: $id)); // THEN - expect($action)->toThrow(BrandNotFoundException::class); + expect($action)->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Application/UseCases/CampaignRequest/List/ListCampaignsRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/List/ListCampaignsRequestTest.php index cfb1914..e849099 100644 --- a/tests/Unit/Application/UseCases/CampaignRequest/List/ListCampaignsRequestTest.php +++ b/tests/Unit/Application/UseCases/CampaignRequest/List/ListCampaignsRequestTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Application\UseCases\CampaignRequest\List; use DateTime; -use Domain\Enums\BarcodeFormat; use Domain\Models\Entities\CampaignRequest; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Campaign\CampaignName; @@ -17,15 +16,20 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortCriteria; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortField; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType; -use Domain\Models\ValueObjects\Coupon\CouponValidity; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; -use Domain\Models\ValueObjects\GS1; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\IncoCode; use Domain\Models\ValueObjects\MoneyAmount; use Domain\Models\ValueObjects\Period; +use Domain\Models\ValueObjects\PicturePath; use Domain\Models\ValueObjects\PrintRules; use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\ProcessingCodeArray; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity; use Domain\Ports\Repositories\CampaignRequestRepository; @@ -39,69 +43,87 @@ use Shared\ValueObjects\Pagination\Pagination; covers(ListCampaignsRequest::class, Input::class, Output::class, CampaignRequestListItem::class); describe('list campaigns request use case', function (): void { - beforeEach(function (): void { - $this->couponA = new RequestedCoupon( - new CouponName('Coupon A'), - new RequestedCouponQuantity(100), - new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), - new MoneyAmount(new Money(Currency::EUR, 300)), - new ProcessingCode('PC1'), - new CouponValidity(30, new DateTime('2026-02-01')), - [ - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) - ], - [new IncoCode(35784), new IncoCode(15522)], - new PrintRules(maxPerCustomer: 3), - new GS1('01234567890128', BarcodeFormat::EAN13) - ); + $couponA = new RequestedCoupon( + couponName: new CouponName('Coupon A'), + quantity: new RequestedCouponQuantity(100), + diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), + moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)), + couponPicture: new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobileThumbnail.jpg') + ), + couponProcessingCodes: new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('4006381333931', Country::BENELUX), + ]), + couponValidityInformations: new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponLegalInformations: new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]), + printRules: new PrintRules(maxPerCustomer: 3), + ); - $this->couponB = new RequestedCoupon( - new CouponName('Coupon B'), - new RequestedCouponQuantity(100), - new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), - new MoneyAmount(new Money(Currency::EUR, 300)), - new ProcessingCode('PC2'), - new CouponValidity(30, new DateTime('2026-02-01')), - [ - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) - ], - [new IncoCode(24514), new IncoCode(46259)], - new PrintRules(maxPerCustomer: 3), - new GS1('01234567890128', BarcodeFormat::EAN13) - ); + $couponB = new RequestedCoupon( + couponName: new CouponName('Coupon B'), + quantity: new RequestedCouponQuantity(100), + diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), + moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)), + couponPicture: new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobileThumbnail.jpg') + ), + couponProcessingCodes: new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('4006381333931', Country::BENELUX), + ]), + couponValidityInformations: new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponLegalInformations: new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]), + printRules: new PrintRules(maxPerCustomer: 3), + ); - $this->pagination = Pagination::firstPage(); + $pagination = Pagination::firstPage(); - $this->sort = new CampaignRequestSortCriteria( - clauses: [ - new CampaignRequestSortClause(field: CampaignRequestSortField::CreatedAt, direction: \SortDirection::Descending), - ] - ); - }); + $sort = new CampaignRequestSortCriteria( + clauses: [ + new CampaignRequestSortClause(field: CampaignRequestSortField::CreatedAt, direction: \SortDirection::Descending), + ] + ); - it('maps repository campaigns request to list items, preserving pagination and sort', function (): void { + it('maps repository campaigns request to list items, preserving pagination and sort', function () use ($couponA, $couponB, $pagination): void { // GIVEN $campaignRequest1 = CampaignRequest::hydrate( - CampaignRequestId::generate(), - new BrandCode('AAA01'), - new CampaignName('Campaign A'), - new Applicant('John', 'Doe', new Email('john.doe@example.com')), - CampaignRequestType::CLUB, - $this->couponA, - CampaignRequestStatus::CREATED, + id: CampaignRequestId::generate(), + brandCode: new BrandCode('AAA01'), + name: new CampaignName('Campaign A'), + applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')), + campaignType: CampaignRequestType::CLUB, + requestedCoupon: $couponA, + status: CampaignRequestStatus::CREATED, ); $campaignRequest2 = CampaignRequest::hydrate( - CampaignRequestId::generate(), - new BrandCode('AAA02'), - new CampaignName('Campaign B'), - new Applicant('John', 'Doe', new Email('john.doe@example.com')), - CampaignRequestType::CLUB, - $this->couponB, - CampaignRequestStatus::CREATED, + id: CampaignRequestId::generate(), + brandCode: new BrandCode('AAA02'), + name: new CampaignName('Campaign B'), + applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')), + campaignType: CampaignRequestType::CLUB, + requestedCoupon: $couponB, + status: CampaignRequestStatus::CREATED, ); - $paginated = new Paginated($this->pagination, [$campaignRequest1, $campaignRequest2], 2); + $paginated = new Paginated($pagination, [$campaignRequest1, $campaignRequest2], 2); $sort = new CampaignRequestSortCriteria( clauses: [ @@ -110,12 +132,12 @@ describe('list campaigns request use case', function (): void { ] ); $filters = new CampaignRequestFilters($campaignRequest2->brandCode, CampaignRequestStatus::CREATED); - $input = new Input(pagination: $this->pagination, sort: $sort, filters: $filters); + $input = new Input(pagination: $pagination, sort: $sort, filters: $filters); $repository = \Mockery::mock(CampaignRequestRepository::class); $repository->shouldReceive('findAll') ->once() - ->with($this->pagination, CampaignRequestSortCriteria::class, CampaignRequestFilters::class) + ->with($pagination, CampaignRequestSortCriteria::class, CampaignRequestFilters::class) ->andReturn($paginated); $useCase = new ListCampaignsRequest($repository); @@ -135,29 +157,29 @@ describe('list campaigns request use case', function (): void { ->and($items[0]->campaignType)->toBe($campaignRequest1->campaignType) ->and($items[0]->status)->toBe($campaignRequest1->status) ->and($result->campaignsRequest->getTotal())->toBe(2) - ->and($result->campaignsRequest->getPagination())->toBe($this->pagination); + ->and($result->campaignsRequest->getPagination())->toBe($pagination); }); - it('lists campaigns request with filters', function (): void { + it('lists campaigns request with filters', function () use ($couponA, $pagination, $sort): void { // GIVEN $campaignRequest = CampaignRequest::hydrate( - CampaignRequestId::generate(), - new BrandCode('AAA01'), - new CampaignName('Campaign A'), - new Applicant('John', 'Doe', new Email('john.doe@example.com')), - CampaignRequestType::CLUB, - $this->couponA, - CampaignRequestStatus::CREATED, + id: CampaignRequestId::generate(), + brandCode: new BrandCode('AAA01'), + name: new CampaignName('Campaign A'), + applicant: new Applicant('John', 'Doe', new Email('john.doe@example.com')), + campaignType: CampaignRequestType::CLUB, + requestedCoupon: $couponA, + status: CampaignRequestStatus::CREATED, ); - $paginated = new Paginated($this->pagination, [$campaignRequest], 1); + $paginated = new Paginated($pagination, [$campaignRequest], 1); $filters = new CampaignRequestFilters($campaignRequest->brandCode, CampaignRequestStatus::CREATED); - $input = new Input(pagination: $this->pagination, sort: $this->sort, filters: $filters); + $input = new Input(pagination: $pagination, sort: $sort, filters: $filters); $repository = \Mockery::mock(CampaignRequestRepository::class); $repository->shouldReceive('findAll') ->once() - ->with($this->pagination, $this->sort, $filters) + ->with($pagination, $sort, $filters) ->andReturn($paginated); $useCase = new ListCampaignsRequest($repository); @@ -173,10 +195,10 @@ describe('list campaigns request use case', function (): void { ->and($items[0]->name)->toBe($campaignRequest->name) ->and($items[0]->status)->toBe($campaignRequest->status) ->and($result->campaignsRequest->getTotal())->toBe(1) - ->and($result->campaignsRequest->getPagination())->toBe($this->pagination); + ->and($result->campaignsRequest->getPagination())->toBe($pagination); }); - it('lists campaigns request with sort but without filters', function (): void { + it('lists campaigns request with sort but without filters', function () use ($couponA, $couponB, $pagination, $sort): void { // GIVEN $campaignRequest1 = CampaignRequest::hydrate( CampaignRequestId::generate(), @@ -184,7 +206,7 @@ describe('list campaigns request use case', function (): void { new CampaignName('Campaign A'), new Applicant('John', 'Doe', new Email('john.doe@example.com')), CampaignRequestType::CLUB, - $this->couponA, + $couponA, CampaignRequestStatus::CREATED, ); $campaignRequest2 = CampaignRequest::hydrate( @@ -193,22 +215,22 @@ describe('list campaigns request use case', function (): void { new CampaignName('Campaign B'), new Applicant('John', 'Doe', new Email('john.doe@example.com')), CampaignRequestType::CLUB, - $this->couponB, + $couponB, CampaignRequestStatus::CREATED, ); - $paginated = new Paginated($this->pagination, [$campaignRequest2, $campaignRequest1], 2); + $paginated = new Paginated($pagination, [$campaignRequest2, $campaignRequest1], 2); $sort = new CampaignRequestSortCriteria( clauses: [ new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Descending), ] ); - $input = new Input(pagination: $this->pagination, sort: $sort); + $input = new Input(pagination: $pagination, sort: $sort); $repository = \Mockery::mock(CampaignRequestRepository::class); $repository->shouldReceive('findAll') ->once() - ->with($this->pagination, $sort, null) + ->with($pagination, $sort, null) ->andReturn($paginated); $useCase = new ListCampaignsRequest($repository); @@ -222,18 +244,18 @@ describe('list campaigns request use case', function (): void { ->and($items[0]->brandCode)->toBe($campaignRequest2->brandCode) ->and($items[1]->brandCode)->toBe($campaignRequest1->brandCode) ->and($result->campaignsRequest->getTotal())->toBe(2) - ->and($result->campaignsRequest->getPagination())->toBe($this->pagination); + ->and($result->campaignsRequest->getPagination())->toBe($pagination); }); - it('returns an empty list without error when there are no campaigns request', function (): void { + it('returns an empty list without error when there are no campaigns request', function () use ($pagination, $sort): void { // GIVEN - $paginated = new Paginated($this->pagination, [], 0); - $input = new Input(pagination: $this->pagination, sort: $this->sort); + $paginated = new Paginated($pagination, [], 0); + $input = new Input(pagination: $pagination, sort: $sort); $repository = \Mockery::mock(CampaignRequestRepository::class); $repository->shouldReceive('findAll') ->once() - ->with($this->pagination, $this->sort, null) + ->with($pagination, $sort, null) ->andReturn($paginated); $useCase = new ListCampaignsRequest($repository); diff --git a/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php index 5cc0ccc..60af83d 100644 --- a/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php +++ b/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php @@ -11,7 +11,6 @@ use Application\UseCases\CampaignRequest\Reject\Output\Output; use Application\UseCases\CampaignRequest\Reject\RejectCampaignRequest; use Carbon\CarbonImmutable; use DateTime; -use Domain\Enums\BarcodeFormat; use Domain\Models\Entities\CampaignRequest; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Campaign\CampaignName; @@ -20,15 +19,20 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType; use Domain\Models\ValueObjects\CampaignRequest\RejectionReason; -use Domain\Models\ValueObjects\Coupon\CouponValidity; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; -use Domain\Models\ValueObjects\GS1; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\IncoCode; use Domain\Models\ValueObjects\MoneyAmount; use Domain\Models\ValueObjects\Period; +use Domain\Models\ValueObjects\PicturePath; use Domain\Models\ValueObjects\PrintRules; use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\ProcessingCodeArray; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity; use Domain\Ports\Repositories\CampaignRequestRepository; @@ -40,31 +44,39 @@ use Shared\ValueObjects\Money; covers(RejectCampaignRequest::class, Input::class, CampaignRequestRejected::class, CampaignRequestNotFound::class); describe('reject campaign request use case', function (): void { - beforeEach(function (): void { - $this->requestedCoupon = new RequestedCoupon( - new CouponName('Coupon A'), - new RequestedCouponQuantity(100), - new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), - new MoneyAmount(new Money(Currency::EUR, 300)), - new ProcessingCode('PC1'), - new CouponValidity(30, new DateTime('2026-02-01')), - [ - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) - ], - [new IncoCode(35784), new IncoCode(15522)], - new PrintRules(maxPerCustomer: 3), - new GS1('01234567890128', BarcodeFormat::EAN13) - ); - }); + $requestedCoupon = new RequestedCoupon( + couponName: new CouponName('Coupon A'), + quantity: new RequestedCouponQuantity(100), + diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), + moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)), + couponPicture: new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobileThumbnail.jpg') + ), + couponProcessingCodes: new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('4006381333931', Country::BENELUX), + ]), + couponValidityInformations: new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponLegalInformations: new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]), + printRules: new PrintRules(maxPerCustomer: 3), + ); afterEach(function (): void { CarbonImmutable::setTestNow(); }); - it('rejects a campaign request and persists it', function (): void { + it('rejects a campaign request and persists it', function () use ($requestedCoupon): void { // GIVEN - CarbonImmutable::setTestNow('2026-06-15 12:00:00'); + CarbonImmutable::setTestNow('2026-06-15 12:00:00 UTC'); $id = CampaignRequestId::generate(); $campaignRequest = CampaignRequest::hydrate( $id, @@ -72,7 +84,7 @@ describe('reject campaign request use case', function (): void { new CampaignName('Campaign A'), new Applicant('John', 'Doe', new Email('john.doe@example.com')), CampaignRequestType::CLUB, - $this->requestedCoupon, + $requestedCoupon, CampaignRequestStatus::CREATED, ); $reason = new RejectionReason('Missing legal documents.'); @@ -81,8 +93,7 @@ describe('reject campaign request use case', function (): void { $repository->shouldReceive('findById')->once()->with($id)->andReturn($campaignRequest); $repository->shouldReceive('save')->once()->with(\Mockery::on( fn (CampaignRequest $saved): bool => $saved->status === CampaignRequestStatus::REJECTED - && $saved->rejectionReason === $reason - && $saved->updatedAt !== null, + && $saved->rejectionReason === $reason, )); $useCase = new RejectCampaignRequest($repository); @@ -94,8 +105,7 @@ describe('reject campaign request use case', function (): void { expect($result)->toBeInstanceOf(CampaignRequestRejected::class) ->and($result->id)->toBe($id) ->and($result->status)->toBe(CampaignRequestStatus::REJECTED) - ->and($result->rejectionReason)->toBe($reason) - ->and($result->updatedAt->format('Y-m-d H:i:s'))->toBe('2026-06-15 12:00:00'); + ->and($result->rejectionReason)->toBe($reason); }); it('returns CampaignRequestNotFound when the id is not found', function (): void { @@ -115,7 +125,7 @@ describe('reject campaign request use case', function (): void { expect($result)->toBeInstanceOf(CampaignRequestNotFound::class); }); - it('propagates the domain exception when the campaign request cannot be rejected', function (): void { + it('propagates the domain exception when the campaign request cannot be rejected', function () use ($requestedCoupon): void { // GIVEN $id = CampaignRequestId::generate(); $campaignRequest = CampaignRequest::hydrate( @@ -124,7 +134,7 @@ describe('reject campaign request use case', function (): void { new CampaignName('Campaign A'), new Applicant('John', 'Doe', new Email('john.doe@example.com')), CampaignRequestType::CLUB, - $this->requestedCoupon, + $requestedCoupon, CampaignRequestStatus::TRANSFORMED, ); @@ -142,19 +152,4 @@ describe('reject campaign request use case', function (): void { // THEN expect($action)->toThrow(\DomainException::class, 'Only a created campaign request can be rejected.'); }); - - it('never touches the repository when a rejection reason is missing', function (): void { - // GIVEN - $repository = \Mockery::mock(CampaignRequestRepository::class); - $repository->shouldNotReceive('findById'); - $repository->shouldNotReceive('save'); - - $useCase = new RejectCampaignRequest($repository); - - // WHEN - $action = fn (): Output => $useCase->execute(new Input(id: CampaignRequestId::generate())); - - // THEN - expect($action)->toThrow(\ArgumentCountError::class); - }); }); diff --git a/tests/Unit/Application/UseCases/Coupon/Create/CreateCouponTest.php b/tests/Unit/Application/UseCases/Coupon/Create/CreateCouponTest.php new file mode 100644 index 0000000..5e71918 --- /dev/null +++ b/tests/Unit/Application/UseCases/Coupon/Create/CreateCouponTest.php @@ -0,0 +1,343 @@ + new Input(...array_merge([ + 'code' => new CouponCode('COU01'), + 'name' => new CouponName('Coupon name'), + 'amount' => new MoneyAmount(new Money(Currency::EUR, 100)), + 'picture' => new CouponPicture( + new PicturePath('files/product.jpg'), + 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')), + ), + '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' => new BrandCode('TST89'), + 'createdBy' => ActorId::generate(), + 'products' => new CouponProductArray(), + 'movements' => new CouponMovementArray([ + new CouponMovement(100), + ]), + 'availabilityCalendar' => null, + ], $overrides)); + + it('creates a coupon when the brand exists and the code is unique', function () use ($makeInput): void { + // GIVEN + $input = $makeInput(); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(true); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('existsByCode')->once()->with($input->code)->andReturn(false); + $couponRepository->shouldReceive('save')->once()->with(\Mockery::type(Coupon::class)); + + $useCase = new CreateCoupon($couponRepository, $brandRepository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->id)->toBeInstanceOf(CouponId::class) + ->and($result->code)->toEqual($input->code) + ->and($result->name)->toEqual($input->name) + ->and($result->amount)->toEqual($input->amount) + ->and($result->picture)->toEqual($input->picture) + ->and($result->diffusion)->toEqual($input->diffusion) + ->and($result->validityInformations)->toEqual($input->validityInformations) + ->and($result->legalInformations)->toEqual($input->legalInformations) + ->and($result->processingCode)->toEqual($input->processingCode) + ->and($result->brandCode)->toEqual($input->brandCode) + ->and($result->createdBy)->toEqual($input->createdBy) + ->and($result->products)->toEqual($input->products) + ->and($result->movements)->toEqual($input->movements) + ->and($result->availabilityCalendar)->toBeNull(); + }); + + it('throws when the brand does not exist', function () use ($makeInput): void { + // GIVEN + $input = $makeInput(); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(false); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldNotReceive('existsByCode'); + $couponRepository->shouldNotReceive('save'); + + $useCase = new CreateCoupon($couponRepository, $brandRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Brand code "TST89" was not found.'); + }); + + it('throws when the coupon code already exists', function () use ($makeInput): void { + // GIVEN + $input = $makeInput(); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->with($input->brandCode)->andReturn(true); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('existsByCode')->once()->with($input->code)->andReturn(true); + $couponRepository->shouldNotReceive('save'); + + $useCase = new CreateCoupon($couponRepository, $brandRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Coupon code "COU01" already exists.'); + }); + + it('creates a coupon with an availability calendar', function () use ($makeInput): void { + // GIVEN + $calendar = new CouponAvailabilityCalendar([ + new Period(new DateTime('2026-01-05'), new DateTime('2026-01-15')), + new Period(new DateTime('2026-01-20'), new DateTime('2026-01-25')), + ]); + $input = $makeInput(['availabilityCalendar' => $calendar]); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->andReturn(true); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('existsByCode')->once()->andReturn(false); + $couponRepository->shouldReceive('save')->once()->with(\Mockery::on( + fn (Coupon $saved): bool => $saved->availabilityCalendar === $calendar, + )); + + $useCase = new CreateCoupon($couponRepository, $brandRepository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result->availabilityCalendar)->toBe($calendar); + }); + + it('creates a GS1 coupon with France and Benelux', function () use ($makeInput): void { + // GIVEN + $processingCode = new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('5425003944147', Country::BENELUX), + ]); + $validityInformations = new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX), + ]); + $legalInformations = new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX), + ]); + $input = $makeInput([ + 'processingCode' => $processingCode, + 'validityInformations' => $validityInformations, + 'legalInformations' => $legalInformations, + ]); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->andReturn(true); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('existsByCode')->once()->andReturn(false); + $couponRepository->shouldReceive('save')->once()->with(\Mockery::type(Coupon::class)); + + $useCase = new CreateCoupon($couponRepository, $brandRepository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result->processingCode)->toBe($processingCode) + ->and($result->validityInformations)->toBe($validityInformations) + ->and($result->legalInformations)->toBe($legalInformations); + }); + + it('rejects a coupon that targets only benelux', function () use ($makeInput): void { + // GIVEN / WHEN + $action = fn (): Input => $makeInput([ + 'processingCode' => new ProcessingCodeArray([ + new ProcessingCode('5425003944147', Country::BENELUX), + ]), + 'validityInformations' => new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX), + ]), + 'legalInformations' => new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX), + ]), + ]); + + // THEN + expect($action)->toThrow(\DomainException::class); + }); + + it('rejects a benelux processing code without benelux legal information', function () use ($makeInput): void { + // GIVEN + $input = $makeInput([ + 'processingCode' => new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('5425003944147', Country::BENELUX), + ]), + 'validityInformations' => new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX), + ]), + ]); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->andReturn(true); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('existsByCode')->andReturn(false); + $couponRepository->shouldNotReceive('save'); + + $useCase = new CreateCoupon($couponRepository, $brandRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'A coupon requires legal information for BENELUX.'); + }); + + it('rejects a benelux processing code without benelux validity information', function () use ($makeInput): void { + // GIVEN + $input = $makeInput([ + 'processingCode' => new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('5425003944147', Country::BENELUX), + ]), + 'legalInformations' => new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX), + ]), + ]); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->andReturn(true); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('existsByCode')->andReturn(false); + $couponRepository->shouldNotReceive('save'); + + $useCase = new CreateCoupon($couponRepository, $brandRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\DomainException::class, 'A coupon requires validity information for BENELUX.'); + }); + + it('rejects two processing codes for the same country', function () use ($makeInput): void { + // GIVEN / WHEN + $action = fn (): Input => $makeInput([ + 'processingCode' => new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('5425003944147', Country::FRANCE), + ]), + ]); + + // THEN + expect($action)->toThrow(\DomainException::class); + }); + + it('rejects two legal informations for the same country', function () use ($makeInput): void { + // GIVEN / WHEN + $action = fn (): Input => $makeInput([ + 'legalInformations' => new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + ]), + ]); + + // THEN + expect($action)->toThrow(\DomainException::class); + }); + + it('rejects two validity informations for the same country', function () use ($makeInput): void { + // GIVEN / WHEN + $action = fn (): Input => $makeInput([ + 'validityInformations' => new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + ]), + ]); + + // THEN + expect($action)->toThrow(\DomainException::class); + }); +}); + +describe('create coupon use case assertions', function (): void { + it('rejects an input of the wrong type', function (): void { + // GIVEN + $useCase = new CreateCoupon( + \Mockery::mock(CouponRepository::class), + \Mockery::mock(BrandRepository::class), + ); + $invalidInput = new class () implements BaseInput + { + }; + + // WHEN + $action = fn (): Output => $useCase->execute($invalidInput); + + // THEN + expect($action)->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/tests/Unit/Application/UseCases/Coupon/Find/FindCouponTest.php b/tests/Unit/Application/UseCases/Coupon/Find/FindCouponTest.php new file mode 100644 index 0000000..4ef0ce1 --- /dev/null +++ b/tests/Unit/Application/UseCases/Coupon/Find/FindCouponTest.php @@ -0,0 +1,208 @@ +code, + createdBy: ActorId::generate(), + products: new CouponProductArray([ + new IncoCode(35784), + new IncoCode(15522), + ]), + movements: new CouponMovementArray([ + new CouponMovement(1000) + ]), + ); + $campaign = Campaign::create( + code: new CampaignCode('CMP01'), + name: new CampaignName('Campaign name'), + publication: true, + printAllAtOnce: true, + coupons: new CampaignCouponArray([ + new CampaignCoupon($coupon->code), + ]), + brandCode: $brand->code, + actorId: ActorId::generate(), + ); + + it('finds a coupon by its code', function () use ($coupon, $campaign): void { + // GIVEN + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->with($coupon->brandCode)->andReturnTrue(); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCouponCode')->once()->with($coupon->code)->andReturn([$campaign]); + + $useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository); + + // WHEN + $result = $useCase->execute(new Input(code: $coupon->code)); + + // THEN + expect($result)->toBeInstanceOf(CouponFound::class) + ->and($result->code)->toBe($coupon->code) + ->and($result->name)->toBe($coupon->name) + ->and($result->amount)->toBe($coupon->amount) + ->and($result->picture)->toBe($coupon->picture) + ->and($result->diffusion)->toBe($coupon->diffusion) + ->and($result->availabilityCalendar)->toBe($coupon->availabilityCalendar) + ->and($result->validityInformations)->toBe($coupon->validityInformations) + ->and($result->legalInformations)->toBe($coupon->legalInformations) + ->and($result->processingCode)->toBe($coupon->processingCode) + ->and($result->brandCode)->toBe($coupon->brandCode) + ->and($result->createdBy)->toBe($coupon->createdBy) + ->and($result->products)->toBe($coupon->products) + ->and($result->movements)->toBe($coupon->movements) + ->and($result->quantity)->toBe($coupon->quantity()) + ->and($result->campaigns)->toBe([$campaign]) + ->and($result->createdAt)->toBeInstanceOf(\DateTimeInterface::class) + ->and($result->updatedAt)->toBeNull(); + }); + + it('returns CouponNotFound when the code is not found', function (): void { + // GIVEN + $code = new CouponCode('COU02'); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($code)->andReturn(null); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldNotReceive('existsByCode'); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldNotReceive('findByCouponCode'); + + $useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository); + + // WHEN + $result = $useCase->execute(new Input(code: $code)); + + // THEN + expect($result)->toBeInstanceOf(CouponNotFound::class); + }); + + it('throws when the brand of the coupon is not found', function () use ($coupon): void { + // GIVEN + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->with($coupon->brandCode)->andReturnFalse(); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldNotReceive('findByCouponCode'); + + $useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository); + + // WHEN / THEN + expect(fn (): Output => $useCase->execute(new Input(code: $coupon->code))) + ->toThrow(\DomainException::class); + }); + + it('finds a coupon not attached to any campaign', function () use ($coupon): void { + // GIVEN + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->with($coupon->brandCode)->andReturnTrue(); + + $campaignRepository = \Mockery::mock(CampaignRepository::class); + $campaignRepository->shouldReceive('findByCouponCode')->once()->with($coupon->code)->andReturn([]); + + $useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository); + + // WHEN + $result = $useCase->execute(new Input(code: $coupon->code)); + + // THEN + expect($result)->toBeInstanceOf(CouponFound::class) + ->and($result->campaigns)->toBe([]); + }); +}); diff --git a/tests/Unit/Application/UseCases/Coupon/List/ListCouponTest.php b/tests/Unit/Application/UseCases/Coupon/List/ListCouponTest.php new file mode 100644 index 0000000..ce75463 --- /dev/null +++ b/tests/Unit/Application/UseCases/Coupon/List/ListCouponTest.php @@ -0,0 +1,228 @@ +shouldReceive('findAll') + ->once() + ->with($pagination, CouponSortCriteria::class, $brandCode) + ->andReturn($paginated); + + $useCase = new ListCoupons($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class); + + $items = $result->coupons->getItems(); + expect($items)->toHaveCount(2) + ->and($items[0])->toBeInstanceOf(CouponListItem::class) + ->and($items[0]->code)->toBe($coupon1->code) + ->and($items[0]->name)->toBe($coupon1->name) + ->and($items[0]->brandCode)->toBe($coupon1->brandCode) + ->and($items[0]->picture)->toBe($coupon1->picture) + ->and($items[0]->diffusion)->toBe($coupon1->diffusion) + ->and($items[0]->availabilityCalendar)->toBe($coupon1->availabilityCalendar) + ->and($items[1]->code)->toBe($coupon2->code) + ->and($items[1]->brandCode)->toBe($coupon2->brandCode) + ->and($items[1]->availabilityCalendar)->toBeNull() + ->and($result->coupons->getTotal())->toBe(2) + ->and($result->coupons->getPagination())->toBe($pagination); + }); + + it('maps repository coupons to list items, preserving specific pagination and sort', function () use ( + $coupon1, + $coupon2, + $pagination, + ): void { + // GIVEN + $paginated = new Paginated($pagination, [$coupon2, $coupon1], 2); + $sort = new CouponSortCriteria(field: CouponSortField::BrandCode, direction: \SortDirection::Descending); + $input = new Input(pagination: $pagination, sort: $sort); + + $repository = \Mockery::mock(CouponRepository::class); + $repository->shouldReceive('findAll') + ->once() + ->with($pagination, CouponSortCriteria::class, null) + ->andReturn($paginated); + + $useCase = new ListCoupons($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class); + + $items = $result->coupons->getItems(); + expect($items)->toHaveCount(2) + ->and($items[1])->toBeInstanceOf(CouponListItem::class) + ->and($items[1]->code)->toBe($coupon1->code) + ->and($items[1]->name)->toBe($coupon1->name) + ->and($items[1]->brandCode)->toBe($coupon1->brandCode) + ->and($items[1]->picture)->toBe($coupon1->picture) + ->and($items[1]->diffusion)->toBe($coupon1->diffusion) + ->and($items[1]->availabilityCalendar)->toBe($coupon1->availabilityCalendar) + ->and($items[0]->code)->toBe($coupon2->code) + ->and($items[0]->brandCode)->toBe($coupon2->brandCode) + ->and($items[0]->availabilityCalendar)->toBeNull() + ->and($result->coupons->getTotal())->toBe(2) + ->and($result->coupons->getPagination())->toBe($pagination); + }); + + it('returns an empty list without error when there are no coupons', function (): void { + // GIVEN + $pagination = Pagination::firstPage(); + $paginated = new Paginated($pagination, [], 0); + $input = new Input(pagination: $pagination); + + $repository = \Mockery::mock(CouponRepository::class); + $repository->shouldReceive('findAll') + ->once() + ->with($pagination, null, null) + ->andReturn($paginated); + + $useCase = new ListCoupons($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result->coupons->getItems())->toBe([]) + ->and($result->coupons->getTotal())->toBe(0); + }); +}); + +describe('list coupons use case assertions', function (): void { + it('rejects an input of the wrong type', function (): void { + // GIVEN + $repository = \Mockery::mock(CouponRepository::class); + $useCase = new ListCoupons($repository); + $invalidInput = new class () implements BaseInput + { + }; + + // WHEN + $action = fn (): Output => $useCase->execute($invalidInput); + + // THEN + expect($action)->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/tests/Unit/Application/UseCases/Coupon/Update/UpdateCouponTest.php b/tests/Unit/Application/UseCases/Coupon/Update/UpdateCouponTest.php new file mode 100644 index 0000000..fd45a6c --- /dev/null +++ b/tests/Unit/Application/UseCases/Coupon/Update/UpdateCouponTest.php @@ -0,0 +1,191 @@ + Coupon::hydrate( + id: CouponId::generate(), + code: new CouponCode('COU01'), + name: new CouponName('Coupon name'), + amount: new MoneyAmount(new Money(Currency::EUR, 100)), + picture: new CouponPicture( + new PicturePath('files/decoration-old.jpg'), + new PicturePath('files/thumbnail-old.jpg'), + new PicturePath('files/mobile-thumbnail-old.jpg'), + ), + diffusion: new CouponDiffusion( + new Period( + new DateTime('2026-01-01'), + new DateTime('2026-02-01'), + ), + ), + 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: new BrandCode('AAA01'), + createdBy: ActorId::generate(), + products: new CouponProductArray([ + new IncoCode(35784), + new IncoCode(15522), + ]), + movements: new CouponMovementArray([ + new CouponMovement(1000), + ]), + ); + + it('updates the picture and keeps the other fields', function () use ($makeCoupon): void { + // GIVEN + $coupon = $makeCoupon(); + $newPicture = new CouponPicture( + new PicturePath('files/decoration-new.jpg'), + new PicturePath('files/thumbnail-new.jpg'), + new PicturePath('files/mobile-thumbnail-new.jpg'), + ); + $input = new Input(code: $coupon->code, picture: $newPicture); + + $repository = \Mockery::mock(CouponRepository::class); + $repository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + $repository->shouldReceive('save')->once()->with(\Mockery::on( + fn (Coupon $saved): bool => $saved->picture === $newPicture + && $saved->id === $coupon->id + && $saved->code === $coupon->code + && $saved->name === $coupon->name + && $saved->amount === $coupon->amount + && $saved->diffusion === $coupon->diffusion + && $saved->updatedAt instanceof \DateTimeInterface, + )); + + $useCase = new UpdateCoupon($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(CouponUpdated::class) + ->and($result->code)->toBe($coupon->code) + ->and($result->picture)->toBe($newPicture); + }); + + it('returns CouponNotFound when the coupon does not exist', function (): void { + // GIVEN + $code = new CouponCode('COU01'); + $input = new Input( + code: $code, + picture: new CouponPicture( + new PicturePath('files/decoration-new.jpg'), + new PicturePath('files/thumbnail-new.jpg'), + new PicturePath('files/mobile-thumbnail-new.jpg'), + ), + ); + + $repository = \Mockery::mock(CouponRepository::class); + $repository->shouldReceive('findByCode')->once()->with($code)->andReturn(null); + $repository->shouldNotReceive('save'); + + $useCase = new UpdateCoupon($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(CouponNotFound::class); + }); + + it('updates the legal and validity information and keeps the other fields', function () use ($makeCoupon): void { + // GIVEN + $coupon = $makeCoupon(); + $newValidityInformations = new CouponValidityInformationArray([ + new CouponValidityInformation('New validity information.', Country::FRANCE), + ]); + $newLegalInformations = new CouponLegalInformationArray([ + new CouponLegalInformation('New legal information.', Country::FRANCE), + ]); + $input = new Input( + code: $coupon->code, + validityInformations: $newValidityInformations, + legalInformations: $newLegalInformations, + ); + + $repository = \Mockery::mock(CouponRepository::class); + $repository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon); + $repository->shouldReceive('save')->once()->with(\Mockery::on( + fn (Coupon $saved): bool => $saved->validityInformations === $newValidityInformations + && $saved->legalInformations === $newLegalInformations + && $saved->id === $coupon->id + && $saved->name === $coupon->name + && $saved->amount === $coupon->amount + && $saved->picture === $coupon->picture + && $saved->diffusion === $coupon->diffusion + && $saved->movements === $coupon->movements + && $saved->updatedAt instanceof \DateTimeInterface, + )); + + $useCase = new UpdateCoupon($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(CouponUpdated::class) + ->and($result->validityInformations)->toBe($newValidityInformations) + ->and($result->legalInformations)->toBe($newLegalInformations); + }); + + it('rejects an input with nothing to update', function (): void { + // GIVEN + $code = new CouponCode('COU01'); + + // WHEN + $action = fn (): Input => new Input(code: $code); + + // THEN + expect($action)->toThrow(\InvalidArgumentException::class); + }); +}); 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/Application/UseCases/PrintableCoupon/List/ListPrintableCouponTest.php b/tests/Unit/Application/UseCases/PrintableCoupon/List/ListPrintableCouponTest.php new file mode 100644 index 0000000..59e6113 --- /dev/null +++ b/tests/Unit/Application/UseCases/PrintableCoupon/List/ListPrintableCouponTest.php @@ -0,0 +1,277 @@ + new CouponPicture( + new PicturePath("files/{$name}.jpg"), + new PicturePath("files/{$name}-thumbnail.jpg"), + new PicturePath("files/{$name}-mobile.jpg"), + ); + + $createInput = fn (PrintableCouponSortCriteria | null $sortCriteria = null): Input => new Input( + pagination: Pagination::firstPage(), + campaignCode: new CampaignCode('AAA01'), + crmCustomerHashcode: new CrmHashcode(Uuid::uuid4()), + sortCriteria: $sortCriteria ?? new PrintableCouponSortCriteria( + field: PrintableCouponSortField::CreatedAt, + direction: \SortDirection::Descending, + ), + ); + + $createPrintableCoupon = fn (CouponCode $couponCode): PrintableCoupon => PrintableCoupon::hydrate( + code: PrintableCouponCode::generate(), + campaignCode: new CampaignCode('AAA01'), + couponCode: $couponCode, + couponName: new CouponName('Coupon name'), + crmCustomerHashcode: new CrmHashcode(Uuid::uuid4()), + distributableUntil: new DateTime('2027-01-01'), + ); + + $createCoupon = fn (CouponCode $code, CouponPicture $picture): Coupon => Coupon::create( + code: $code, + name: new CouponName('Coupon name'), + amount: new MoneyAmount(new Money(Currency::EUR, 100)), + picture: $picture, + diffusion: new CouponDiffusion( + new Period( + new DateTime('2026-01-01'), + new DateTime('2026-02-01'), + ), + ), + 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: new BrandCode('AAA01'), + createdBy: ActorId::generate(), + products: new CouponProductArray([ + new IncoCode(35784), + new IncoCode(15522), + ]), + movements: new CouponMovementArray([ + new CouponMovement(1000) + ]), + ); + + it('returns the printable coupons with the picture of their coupon', function () use ( + $createInput, + $createPicture, + $createCoupon, + $createPrintableCoupon, + ): void { + // GIVEN + $input = $createInput(); + $picture = $createPicture('picture'); + $couponCode = new CouponCode('AAA01'); + $printable = $createPrintableCoupon($couponCode); + $coupon = $createCoupon($couponCode, $picture); + + $paginated = new Paginated(pagination: $input->pagination, items: [$printable], total: 1); + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable') + ->once() + ->with($input->pagination, $input->campaignCode, $input->crmCustomerHashcode, $input->sortCriteria) + ->andReturn($paginated); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode') + ->once() + ->with($printable->couponCode) + ->andReturn($coupon); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $output = $useCase->execute($input); + + // THEN + $items = $output->printableCoupons->getItems(); + + expect($items)->toHaveCount(1) + ->and($output->printableCoupons->getTotal())->toBe(1) + ->and($items[0]->code)->toBe($printable->code) + ->and($items[0]->couponCode)->toBe($printable->couponCode) + ->and($items[0]->couponName)->toBe($printable->couponName) + ->and($items[0]->couponPicture)->toBe($picture) + ->and($items[0]->distributableUntil)->toBe($printable->distributableUntil); + }); + + it('forwards the sort criteria and keeps the order returned by the repository', function () use ( + $createInput, + $createPicture, + $createCoupon, + $createPrintableCoupon, + ): void { + // GIVEN + $sortCriteria = new PrintableCouponSortCriteria( + field: PrintableCouponSortField::Name, + direction: \SortDirection::Descending, + ); + $input = $createInput($sortCriteria); + + $code1 = new CouponCode('AAA01'); + $code2 = new CouponCode('BBB02'); + $picture1 = $createPicture('picture-1'); + $picture2 = $createPicture('picture-2'); + + $printableCoupon1 = $createPrintableCoupon($code1); + $printableCoupon2 = $createPrintableCoupon($code2); + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable') + ->once() + ->with($input->pagination, $input->campaignCode, $input->crmCustomerHashcode, $sortCriteria) + ->andReturn(new Paginated(pagination: $input->pagination, items: [$printableCoupon2, $printableCoupon1], total: 2)); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->with($code2)->once() + ->andReturn($createCoupon($code2, $picture2)); + $couponRepository->shouldReceive('findByCode')->with($code1)->once() + ->andReturn($createCoupon($code1, $picture1)); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $items = $useCase->execute($input)->printableCoupons->getItems(); + + // THEN + expect($items)->toHaveCount(2) + ->and($items[0]->code)->toBe($printableCoupon2->code) + ->and($items[0]->couponName)->toBe($printableCoupon2->couponName) + ->and($items[0]->couponPicture)->toBe($picture2) + ->and($items[1]->code)->toBe($printableCoupon1->code) + ->and($items[1]->couponName)->toBe($printableCoupon1->couponName) + ->and($items[1]->couponPicture)->toBe($picture1); + }); + + it('returns an empty list without error when there is no printable coupon', function () use ($createInput): void { + // GIVEN + $input = $createInput(); + $paginated = new Paginated(pagination: $input->pagination, items: [], total: 0); + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable')->once()->andReturn($paginated); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldNotReceive('findByCode'); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $output = $useCase->execute($input); + + // THEN + expect($output->printableCoupons->getItems())->toBe([]) + ->and($output->printableCoupons->getTotal())->toBe(0); + }); + + it('loads a shared coupon only once', function () use ( + $createInput, + $createPicture, + $createPrintableCoupon, + $createCoupon, + ): void { + // GIVEN + $input = $createInput(); + $picture = $createPicture('picture'); + $couponCode = new CouponCode('AAA01'); + $items = [$createPrintableCoupon($couponCode), $createPrintableCoupon($couponCode)]; + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable') + ->once() + ->andReturn(new Paginated(pagination: $input->pagination, items: $items, total: 2)); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode') + ->once() + ->andReturn($createCoupon($couponCode, $picture)); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $output = $useCase->execute($input); + + // THEN + expect($output->printableCoupons->getItems())->toHaveCount(2); + }); + + it('throws when the coupon of a printable coupon does not exist', function () use ($createInput, $createPrintableCoupon): void { + // GIVEN + $input = $createInput(); + $printable = $createPrintableCoupon(new CouponCode('BBB02')); + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable') + ->once() + ->andReturn(new Paginated(pagination: $input->pagination, items: [$printable], total: 1)); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->andReturn(null); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\LogicException::class); + }); +}); diff --git a/tests/Unit/Domain/Enums/BarcodeFormatTest.php b/tests/Unit/Domain/Enums/BarcodeFormatTest.php deleted file mode 100644 index d1af8e0..0000000 --- a/tests/Unit/Domain/Enums/BarcodeFormatTest.php +++ /dev/null @@ -1,23 +0,0 @@ -value)->toBe('ean') - ->and(BarcodeFormat::CODE128->value)->toBe('code128') - ->and(BarcodeFormat::GS1_128->value)->toBe('gs1'); - }); - - it('exposes its case name', function (): void { - // THEN - expect(BarcodeFormat::EAN13->name)->toBe('EAN13'); - }); -}); diff --git a/tests/Unit/Domain/Exceptions/BrandCodeAlreadyExistsExceptionTest.php b/tests/Unit/Domain/Exceptions/BrandCodeAlreadyExistsExceptionTest.php deleted file mode 100644 index 9ead53c..0000000 --- a/tests/Unit/Domain/Exceptions/BrandCodeAlreadyExistsExceptionTest.php +++ /dev/null @@ -1,24 +0,0 @@ -getMessage())->toBe('Brand code "AAA01" already exists.') - ->and($exception)->toBeInstanceOf(\DomainException::class); - }); -}); diff --git a/tests/Unit/Domain/Models/Entities/ActorTest.php b/tests/Unit/Domain/Models/Entities/ActorTest.php index cda40c1..f49881e 100644 --- a/tests/Unit/Domain/Models/Entities/ActorTest.php +++ b/tests/Unit/Domain/Models/Entities/ActorTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\Entities; +use DateTimeInterface; use Domain\Models\Entities\Actor; use Domain\Models\ValueObjects\Actor\ActorDisplayName; use Domain\Models\ValueObjects\Actor\ActorId; @@ -12,50 +13,32 @@ use Ramsey\Uuid\Uuid; covers(Actor::class); describe('actor', function (): void { - beforeEach(function (): void { - $this->displayName = new ActorDisplayName('John Doe'); - }); + $displayName = new ActorDisplayName('John Doe'); - it('hydrates an actor', function (): void { + it('hydrates an actor', function () use ($displayName): void { // GIVEN $id = ActorId::generate(); // WHEN $actor = Actor::hydrate( $id, - $this->displayName + $displayName, ); // THEN expect($actor->id())->toBe($id) - ->and($actor->displayName)->toBe($this->displayName) - ->and($actor->updatedAt)->toBeNull() - ->and($actor->updatedAt())->toBeNull(); + ->and($actor->displayName)->toBe($displayName) + ->and($actor->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($actor->updatedAt())->toBeInstanceOf(DateTimeInterface::class); }); - it('hydrates an actor with an explicit updatedAt date', function (): void { - // GIVEN - $updatedAt = new \DateTimeImmutable('2026-06-15 12:00:00', new \DateTimeZone('UTC')); - - // WHEN - $actor = Actor::hydrate( - ActorId::generate(), - $this->displayName, - $updatedAt - ); - - // THEN - expect($actor->updatedAt)->toBe($updatedAt) - ->and($actor->updatedAt())->toBe($updatedAt); - }); - - it('returns the creation date extracted from the id', function (): void { + it('returns the creation date extracted from the id', function () use ($displayName): void { // GIVEN $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $id = new ActorId(Uuid::uuid7($expected)); $actor = Actor::hydrate( $id, - $this->displayName + $displayName, ); // WHEN diff --git a/tests/Unit/Domain/Models/Entities/BrandTest.php b/tests/Unit/Domain/Models/Entities/BrandTest.php index 1e3737b..57d03fb 100644 --- a/tests/Unit/Domain/Models/Entities/BrandTest.php +++ b/tests/Unit/Domain/Models/Entities/BrandTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\Entities; use Carbon\CarbonImmutable; +use DateTimeInterface; use Domain\Models\Entities\Brand; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Brand\BrandId; @@ -17,119 +18,96 @@ use Shared\ValueObjects\Rcs; covers(Brand::class); describe('brand', function (): void { - beforeEach(function (): void { - $this->code = new BrandCode('AAA01'); - $this->name = new BrandName('Name'); - $this->logo = new PicturePath('files/picture.jpg'); - $this->rcs = new Rcs('RCS PARIS 111 222 333'); - $this->contact = new Email('contact@example.com'); - }); + $code = new BrandCode('AAA01'); + $name = new BrandName('Name'); + $logo = new PicturePath('files/picture.jpg'); + $rcs = new Rcs('RCS PARIS 111 222 333'); + $contact = new Email('contact@example.com'); afterEach(function (): void { CarbonImmutable::setTestNow(); }); - it('creates a brand', function (): void { + it('creates a brand', function () use ($code, $name, $logo, $rcs, $contact): void { // GIVEN $id = BrandId::generate(); // WHEN $brand = Brand::hydrate( $id, - $this->code, - $this->name, - $this->logo, - $this->rcs, - $this->contact + $code, + $name, + $logo, + $rcs, + $contact, ); // THEN expect($brand->id())->toBe($id) - ->and($brand->code)->toBe($this->code) - ->and($brand->name)->toBe($this->name) - ->and($brand->logo)->toBe($this->logo) - ->and($brand->rcs)->toBe($this->rcs) - ->and($brand->contact)->toBe($this->contact) - ->and($brand->updatedAt)->toBeNull() - ->and($brand->updatedAt())->toBeNull(); + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logo) + ->and($brand->rcs)->toBe($rcs) + ->and($brand->contact)->toBe($contact) + ->and($brand->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($brand->updatedAt())->toBeInstanceOf(DateTimeInterface::class); }); - it('creates a brand with an explicit updatedAt date', function (): void { - // GIVEN - $id = BrandId::generate(); - $updatedAt = new \DateTimeImmutable('2026-06-15 12:00:00', new \DateTimeZone('UTC')); - - // WHEN - $brand = Brand::hydrate( - $id, - $this->code, - $this->name, - $this->logo, - $this->rcs, - $this->contact, - $updatedAt - ); - - // THEN - expect($brand->updatedAt)->toBe($updatedAt) - ->and($brand->updatedAt())->toBe($updatedAt); - }); - - it('creates a new brand with a freshly generated id', function (): void { + it('creates a new brand with a freshly generated id', function () use ($code, $name, $logo, $rcs, $contact): void { // GIVEN / WHEN $brand = Brand::create( - $this->code, - $this->name, - $this->logo, - $this->rcs, - $this->contact + $code, + $name, + $logo, + $rcs, + $contact, ); // THEN expect($brand->id())->toBeInstanceOf(BrandId::class) - ->and($brand->code)->toBe($this->code) - ->and($brand->name)->toBe($this->name) - ->and($brand->logo)->toBe($this->logo) - ->and($brand->rcs)->toBe($this->rcs) - ->and($brand->contact)->toBe($this->contact) - ->and($brand->updatedAt)->toBeNull() + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logo) + ->and($brand->rcs)->toBe($rcs) + ->and($brand->contact)->toBe($contact) + ->and($brand->createdAt())->toBeInstanceOf(DateTimeInterface::class) ->and($brand->updatedAt())->toBeNull(); }); - it('generates a different id for each created brand', function (): void { + it('generates a different id for each created brand', function () use ($code, $name, $logo, $rcs, $contact): void { // GIVEN / WHEN $first = Brand::create( - $this->code, - $this->name, - $this->logo, - $this->rcs, - $this->contact + $code, + $name, + $logo, + $rcs, + $contact, ); $second = Brand::create( - $this->code, - $this->name, - $this->logo, - $this->rcs, - $this->contact + $code, + $name, + $logo, + $rcs, + $contact, ); // THEN expect($first->id()->equals($second->id()))->toBeFalse(); }); - it('updates a brand logo and sets updatedAt timestamp', function (): void { + it('updates a brand logo and sets updatedAt timestamp', function () use ($code, $name, $rcs, $contact): void { // GIVEN - CarbonImmutable::setTestNow('2026-06-15 12:00:00'); + CarbonImmutable::setTestNow('2026-06-15 12:00:00 UTC'); $id = BrandId::generate(); $logoOld = new PicturePath('files/picture-old.jpg'); $logoNew = new PicturePath('files/picture-new.jpg'); $brand = Brand::hydrate( $id, - $this->code, - $this->name, + $code, + $name, $logoOld, - $this->rcs, - $this->contact + $rcs, + $contact, ); // WHEN @@ -137,28 +115,29 @@ describe('brand', function (): void { // THEN expect($brand->id())->toBe($id) - ->and($brand->code)->toBe($this->code) - ->and($brand->name)->toBe($this->name) + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) ->and($brand->logo)->toBe($logoNew) - ->and($brand->rcs)->toBe($this->rcs) - ->and($brand->contact)->toBe($this->contact) - ->and($brand->updatedAt)->not->toBeNull() - ->and($brand->updatedAt()?->format('Y-m-d H:i:s'))->toBe('2026-06-15 12:00:00'); + ->and($brand->rcs)->toBe($rcs) + ->and($brand->contact)->toBe($contact) + ->and($brand->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($brand->updatedAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($brand->updatedAt()->format('Y-m-d H:i:s'))->toBe('2026-06-15 12:00:00'); }); - it('updates a brand rcs and sets updatedAt timestamp', function (): void { + it('updates a brand rcs and sets updatedAt timestamp', function () use ($code, $name, $logo, $contact): void { // GIVEN - CarbonImmutable::setTestNow('2026-06-15 14:30:00'); + CarbonImmutable::setTestNow('2026-06-15 14:30:00 UTC'); $id = BrandId::generate(); $rcsOld = new Rcs('RCS PARIS 111 222 333'); $rcsNew = new Rcs('RCS NANTES 444 555 666'); $brand = Brand::hydrate( $id, - $this->code, - $this->name, - $this->logo, + $code, + $name, + $logo, $rcsOld, - $this->contact + $contact, ); // WHEN @@ -166,28 +145,29 @@ describe('brand', function (): void { // THEN expect($brand->id())->toBe($id) - ->and($brand->code)->toBe($this->code) - ->and($brand->name)->toBe($this->name) - ->and($brand->logo)->toBe($this->logo) + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logo) ->and($brand->rcs)->toBe($rcsNew) - ->and($brand->contact)->toBe($this->contact) - ->and($brand->updatedAt)->not->toBeNull() - ->and($brand->updatedAt()?->format('Y-m-d H:i:s'))->toBe('2026-06-15 14:30:00'); + ->and($brand->contact)->toBe($contact) + ->and($brand->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($brand->updatedAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($brand->updatedAt()->format('Y-m-d H:i:s'))->toBe('2026-06-15 14:30:00'); }); - it('updates a brand contact and sets updatedAt timestamp', function (): void { + it('updates a brand contact and sets updatedAt timestamp', function () use ($code, $name, $logo, $rcs): void { // GIVEN - CarbonImmutable::setTestNow('2026-06-15 16:45:00'); + CarbonImmutable::setTestNow('2026-06-15 16:45:00 UTC'); $id = BrandId::generate(); $contactOld = new Email('contact-old@example.com'); $contactNew = new Email('contact-new@example.com'); $brand = Brand::hydrate( $id, - $this->code, - $this->name, - $this->logo, - $this->rcs, - $contactOld + $code, + $name, + $logo, + $rcs, + $contactOld, ); // WHEN @@ -195,26 +175,27 @@ describe('brand', function (): void { // THEN expect($brand->id())->toBe($id) - ->and($brand->code)->toBe($this->code) - ->and($brand->name)->toBe($this->name) - ->and($brand->logo)->toBe($this->logo) - ->and($brand->rcs)->toBe($this->rcs) + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logo) + ->and($brand->rcs)->toBe($rcs) ->and($brand->contact)->toBe($contactNew) - ->and($brand->updatedAt)->not->toBeNull() - ->and($brand->updatedAt()?->format('Y-m-d H:i:s'))->toBe('2026-06-15 16:45:00'); + ->and($brand->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($brand->updatedAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($brand->updatedAt()->format('Y-m-d H:i:s'))->toBe('2026-06-15 16:45:00'); }); - it('returns the creation date extracted from the id', function (): void { + it('returns the creation date extracted from the id', function () use ($code, $name, $logo, $rcs, $contact): void { // GIVEN $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $id = new BrandId(Uuid::uuid7($expected)); $brand = Brand::hydrate( $id, - $this->code, - $this->name, - $this->logo, - $this->rcs, - $this->contact + $code, + $name, + $logo, + $rcs, + $contact, ); // WHEN diff --git a/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php b/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php index e1b936f..9637393 100644 --- a/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php +++ b/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php @@ -6,7 +6,7 @@ namespace Tests\Unit\Domain\Models\Entities; use Carbon\CarbonImmutable; use DateTime; -use Domain\Enums\BarcodeFormat; +use DateTimeInterface; use Domain\Models\Entities\CampaignRequest; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Campaign\CampaignName; @@ -15,15 +15,20 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus; use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType; use Domain\Models\ValueObjects\CampaignRequest\RejectionReason; -use Domain\Models\ValueObjects\Coupon\CouponValidity; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; -use Domain\Models\ValueObjects\GS1; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\IncoCode; use Domain\Models\ValueObjects\MoneyAmount; use Domain\Models\ValueObjects\Period; +use Domain\Models\ValueObjects\PicturePath; use Domain\Models\ValueObjects\PrintRules; use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\ProcessingCodeArray; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity; use Ramsey\Uuid\Uuid; @@ -35,114 +40,114 @@ use Shared\ValueObjects\Money; covers(CampaignRequest::class); describe('campaign request', function (): void { - beforeEach(function (): void { - $this->requestedCoupon = new RequestedCoupon( - new CouponName('Coupon A'), - new RequestedCouponQuantity(100), - new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), - new MoneyAmount(new Money(Currency::EUR, 300)), - new ProcessingCode('PC1'), - new CouponValidity(30, new DateTime('2026-02-01')), - [ - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), - new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) - ], - [new IncoCode(35784), new IncoCode(15522)], - new PrintRules(maxPerCustomer: 3), - new GS1('01234567890128', BarcodeFormat::EAN13) - ); - $this->code = new BrandCode('AAA01'); - $this->name = new CampaignName('Campaign A'); - $this->applicant = new Applicant('John', 'Doe', new Email('john.doe@example.com')); - $this->campaignType = CampaignRequestType::CLUB; - $this->requestedCoupon = $this->requestedCoupon; - $this->status = CampaignRequestStatus::CREATED; - $this->transformedStatus = CampaignRequestStatus::TRANSFORMED; - $this->rejectedStatus = CampaignRequestStatus::REJECTED; - $this->rejectionReason = new RejectionReason('Missing legal documents.'); - }); + $requestedCoupon = new RequestedCoupon( + couponName: new CouponName('Coupon A'), + quantity: new RequestedCouponQuantity(100), + diffusion: new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), + moneyAmount: new MoneyAmount(new Money(Currency::EUR, 300)), + couponPicture: new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobileThumbnail.jpg') + ), + couponProcessingCodes: new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + new ProcessingCode('4006381333931', Country::BENELUX), + ]), + couponValidityInformations: new CouponValidityInformationArray([ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponLegalInformations: new CouponLegalInformationArray([ + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX) + ]), + couponProducts: new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]), + printRules: new PrintRules(maxPerCustomer: 3), + ); + $code = new BrandCode('AAA01'); + $name = new CampaignName('Campaign A'); + $applicant = new Applicant('John', 'Doe', new Email('john.doe@example.com')); + $campaignType = CampaignRequestType::CLUB; + $status = CampaignRequestStatus::CREATED; + $transformedStatus = CampaignRequestStatus::TRANSFORMED; + $rejectedStatus = CampaignRequestStatus::REJECTED; + $rejectionReason = new RejectionReason('Missing legal documents.'); afterEach(function (): void { CarbonImmutable::setTestNow(); }); - it('hydrates a campaign request with all its fields', function (): void { + it('hydrates a campaign request with all its fields', function () use ($code, $name, $applicant, $campaignType, $requestedCoupon, $status): void { // GIVEN $id = CampaignRequestId::generate(); // WHEN $campaignRequest = CampaignRequest::hydrate( $id, - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->status, + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $status, ); // THEN expect($campaignRequest->id())->toBe($id) - ->and($campaignRequest->brandCode)->toBe($this->code) - ->and($campaignRequest->name)->toBe($this->name) - ->and($campaignRequest->applicant)->toBe($this->applicant) - ->and($campaignRequest->campaignType)->toBe($this->campaignType) - ->and($campaignRequest->requestedCoupon)->toBe($this->requestedCoupon) - ->and($campaignRequest->status)->toBe($this->status) - ->and($campaignRequest->updatedAt)->toBeNull() - ->and($campaignRequest->updatedAt())->toBeNull(); + ->and($campaignRequest->brandCode)->toBe($code) + ->and($campaignRequest->name)->toBe($name) + ->and($campaignRequest->applicant)->toBe($applicant) + ->and($campaignRequest->campaignType)->toBe($campaignType) + ->and($campaignRequest->requestedCoupon)->toBe($requestedCoupon) + ->and($campaignRequest->status)->toBe($status) + ->and($campaignRequest->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($campaignRequest->updatedAt())->toBeInstanceOf(DateTimeInterface::class); }); - it('hydrates a campaign request with an explicit updatedAt date', function (): void { - // GIVEN - $updatedAt = new \DateTimeImmutable('2026-06-15 12:00:00', new \DateTimeZone('UTC')); - - // WHEN - $campaignRequest = CampaignRequest::hydrate( - CampaignRequestId::generate(), - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->status, - null, - $updatedAt, - ); - - // THEN - expect($campaignRequest->updatedAt)->toBe($updatedAt) - ->and($campaignRequest->updatedAt())->toBe($updatedAt); - }); - - it('hydrates a rejected campaign request with a rejection reason', function (): void { + it('hydrates a rejected campaign request with a rejection reason', function () use ( + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $rejectedStatus, + $rejectionReason, + ): void { // GIVEN / WHEN $campaignRequest = CampaignRequest::hydrate( CampaignRequestId::generate(), - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->rejectedStatus, - $this->rejectionReason, + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $rejectedStatus, + $rejectionReason, ); // THEN - expect($campaignRequest->status)->toBe($this->rejectedStatus) - ->and($campaignRequest->rejectionReason)->toBe($this->rejectionReason); + expect($campaignRequest->status)->toBe($rejectedStatus) + ->and($campaignRequest->rejectionReason)->toBe($rejectionReason); }); - it('rejects a rejected campaign request without a rejection reason', function (): void { + it('rejects a rejected campaign request without a rejection reason', function () use ( + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $rejectedStatus, + ): void { // GIVEN / WHEN $action = fn (): CampaignRequest => CampaignRequest::hydrate( CampaignRequestId::generate(), - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->rejectedStatus, + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $rejectedStatus, ); // THEN @@ -152,17 +157,25 @@ describe('campaign request', function (): void { ); }); - it('rejects a non-rejected campaign request with a rejection reason', function (): void { + it('rejects a non-rejected campaign request with a rejection reason', function () use ( + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $status, + $rejectionReason, + ): void { // WHEN $action = fn (): CampaignRequest => CampaignRequest::hydrate( CampaignRequestId::generate(), - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->status, - $this->rejectionReason, + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $status, + $rejectionReason, ); // THEN @@ -172,64 +185,88 @@ describe('campaign request', function (): void { ); }); - it('rejects a campaign request and sets the rejection reason and updatedAt', function (): void { + it('rejects a campaign request and sets the rejection reason and updatedAt', function () use ( + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $status, + $rejectionReason, + ): void { // GIVEN - CarbonImmutable::setTestNow('2026-06-15 12:00:00'); + CarbonImmutable::setTestNow('2026-06-15 12:00:00 UTC'); $campaignRequest = CampaignRequest::hydrate( CampaignRequestId::generate(), - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->status, + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $status, ); // WHEN - $rejected = $campaignRequest->reject($this->rejectionReason); + $rejected = $campaignRequest->reject($rejectionReason); // THEN expect($rejected->status)->toBe(CampaignRequestStatus::REJECTED) - ->and($rejected->rejectionReason)->toBe($this->rejectionReason) + ->and($rejected->rejectionReason)->toBe($rejectionReason) ->and($rejected->id())->toBe($campaignRequest->id()) ->and($rejected->brandCode)->toBe($campaignRequest->brandCode) ->and($rejected->name)->toBe($campaignRequest->name) ->and($rejected->applicant)->toBe($campaignRequest->applicant) ->and($rejected->campaignType)->toBe($campaignRequest->campaignType) ->and($rejected->requestedCoupon)->toBe($campaignRequest->requestedCoupon) - ->and($rejected->updatedAt)->not->toBeNull() - ->and($rejected->updatedAt()?->format('Y-m-d H:i:s'))->toBe('2026-06-15 12:00:00'); + ->and($rejected->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($rejected->updatedAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($rejected->updatedAt()->format('Y-m-d H:i:s'))->toBe('2026-06-15 12:00:00'); }); - it('rejects rejecting a transformed campaign request', function (): void { + it('rejects rejecting a transformed campaign request', function () use ( + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $transformedStatus, + $rejectionReason, + ): void { // GIVEN $campaignRequest = CampaignRequest::hydrate( CampaignRequestId::generate(), - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->transformedStatus, + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $transformedStatus, ); // WHEN - $action = fn (): CampaignRequest => $campaignRequest->reject($this->rejectionReason); + $action = fn (): CampaignRequest => $campaignRequest->reject($rejectionReason); // THEN expect($action)->toThrow(\DomainException::class, 'Only a created campaign request can be rejected.'); }); - it('rejects rejecting an already rejected campaign request', function (): void { + it('rejects rejecting an already rejected campaign request', function () use ( + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $rejectedStatus, + ): void { // GIVEN $campaignRequest = CampaignRequest::hydrate( CampaignRequestId::generate(), - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->rejectedStatus, + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $rejectedStatus, new RejectionReason('First reason.'), ); @@ -240,18 +277,18 @@ describe('campaign request', function (): void { expect($action)->toThrow(\DomainException::class, 'Only a created campaign request can be rejected.'); }); - it('returns the creation date extracted from the id', function (): void { + it('returns the creation date extracted from the id', function () use ($code, $name, $applicant, $campaignType, $requestedCoupon, $status): void { // GIVEN $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $id = new CampaignRequestId(Uuid::uuid7($expected)); $campaignRequest = CampaignRequest::hydrate( $id, - $this->code, - $this->name, - $this->applicant, - $this->campaignType, - $this->requestedCoupon, - $this->status, + $code, + $name, + $applicant, + $campaignType, + $requestedCoupon, + $status, ); // WHEN diff --git a/tests/Unit/Domain/Models/Entities/CampaignTest.php b/tests/Unit/Domain/Models/Entities/CampaignTest.php index 403200c..ad796d5 100644 --- a/tests/Unit/Domain/Models/Entities/CampaignTest.php +++ b/tests/Unit/Domain/Models/Entities/CampaignTest.php @@ -5,176 +5,161 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\Entities; use Carbon\CarbonImmutable; -use Shared\Enums\Country; +use DateTimeInterface; use Domain\Models\Entities\Campaign; use Domain\Models\ValueObjects\Actor\ActorId; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Campaign\CampaignCode; use Domain\Models\ValueObjects\Campaign\CampaignCoupon; +use Domain\Models\ValueObjects\Campaign\CampaignCouponArray; use Domain\Models\ValueObjects\Campaign\CampaignId; use Domain\Models\ValueObjects\Campaign\CampaignName; -use Domain\Models\ValueObjects\Coupon\CouponQuantity; +use Domain\Models\ValueObjects\Coupon\CouponCode; use Domain\Models\ValueObjects\PrintRules; -use Domain\Models\ValueObjects\ProcessingCode; use Ramsey\Uuid\Uuid; covers(Campaign::class); describe('campaign', function (): void { - beforeEach(function (): void { - $this->code = new CampaignCode('Camp100'); - $this->name = new CampaignName('Name'); - $this->publication = \boolval(0); - $this->printAllAtOnce = \boolval(1); - $this->coupons = [ - new CampaignCoupon( - new ProcessingCode('PC1'), - new CouponQuantity(100), - Country::FRANCE, - new PrintRules(maxPerCustomer: 3) - ) - ]; - $this->brandCode = new BrandCode('AAA01'); - $this->actorId = ActorId::generate(); - }); + $code = new CampaignCode('Camp100'); + $name = new CampaignName('Name'); + $publication = \boolval(0); + $printAllAtOnce = \boolval(1); + $coupons = new CampaignCouponArray([ + new CampaignCoupon( + new CouponCode('AAA01'), + new PrintRules(maxPerCustomer: 3) + ) + ]); + $brandCode = new BrandCode('AAA01'); + $actorId = ActorId::generate(); afterEach(function (): void { CarbonImmutable::setTestNow(); }); - it('creates a campaign', function (): void { + it('creates a campaign', function () use ( + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId, + ): void { // GIVEN $id = CampaignId::generate(); // WHEN $campaign = Campaign::hydrate( $id, - $this->code, - $this->name, - $this->publication, - $this->printAllAtOnce, - $this->coupons, - $this->brandCode, - $this->actorId + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId ); // THEN expect($campaign->id())->toBe($id) - ->and($campaign->code)->toBe($this->code) - ->and($campaign->name)->toBe($this->name) - ->and($campaign->publication)->toBe($this->publication) - ->and($campaign->printAllAtOnce)->toBe($this->printAllAtOnce) - ->and($campaign->coupons)->toBe($this->coupons) - ->and($campaign->brandCode)->toBe($this->brandCode) - ->and($campaign->updatedAt)->toBeNull() - ->and($campaign->updatedAt())->toBeNull(); + ->and($campaign->code)->toBe($code) + ->and($campaign->name)->toBe($name) + ->and($campaign->publication)->toBe($publication) + ->and($campaign->printAllAtOnce)->toBe($printAllAtOnce) + ->and($campaign->coupons)->toBe($coupons) + ->and($campaign->brandCode)->toBe($brandCode) + ->and($campaign->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($campaign->updatedAt())->toBeInstanceOf(DateTimeInterface::class); }); - it('creates a campaign without coupon', function (): void { - // GIVEN - $id = CampaignId::generate(); - - // WHEN - $campaign = Campaign::hydrate( - $id, - $this->code, - $this->name, - $this->publication, - $this->printAllAtOnce, - [], - $this->brandCode, - $this->actorId - ); - - // THEN - expect($campaign->coupons)->toBe([]); - }); - - it('creates a coupon with an explicit updatedAt date', function (): void { - // GIVEN - $id = CampaignId::generate(); - $updatedAt = new \DateTimeImmutable('2026-09-22 10:00:00', new \DateTimeZone('UTC')); - - // WHEN - $campaign = Campaign::hydrate( - $id, - $this->code, - $this->name, - $this->publication, - $this->printAllAtOnce, - $this->coupons, - $this->brandCode, - $this->actorId, - $updatedAt - ); - - // THEN - expect($campaign->updatedAt)->toBe($updatedAt) - ->and($campaign->updatedAt())->toBe($updatedAt); - }); - - it('creates a new campaign with a freshly generated id', function (): void { + it('creates a new campaign with a freshly generated id', function () use ( + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId, + ): void { // GIVEN / WHEN $campaign = Campaign::create( - $this->code, - $this->name, - $this->publication, - $this->printAllAtOnce, - $this->coupons, - $this->brandCode, - $this->actorId + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId ); // THEN expect($campaign->id())->toBeInstanceOf(CampaignId::class) - ->and($campaign->code)->toBe($this->code) - ->and($campaign->name)->toBe($this->name) - ->and($campaign->publication)->toBe($this->publication) - ->and($campaign->printAllAtOnce)->toBe($this->printAllAtOnce) - ->and($campaign->coupons)->toBe($this->coupons) - ->and($campaign->brandCode)->toBe($this->brandCode) - ->and($campaign->updatedAt)->toBeNull() + ->and($campaign->code)->toBe($code) + ->and($campaign->name)->toBe($name) + ->and($campaign->publication)->toBe($publication) + ->and($campaign->printAllAtOnce)->toBe($printAllAtOnce) + ->and($campaign->coupons)->toBe($coupons) + ->and($campaign->brandCode)->toBe($brandCode) + ->and($campaign->createdAt())->toBeInstanceOf(DateTimeInterface::class) ->and($campaign->updatedAt())->toBeNull(); }); - it('generates a different id for each created campaign', function (): void { + it('generates a different id for each created campaign', function () use ( + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId, + ): void { // GIVEN / WHEN $firstCampaign = Campaign::create( - $this->code, - $this->name, - $this->publication, - $this->printAllAtOnce, - $this->coupons, - $this->brandCode, - $this->actorId + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId ); $secondCampaign = Campaign::create( - $this->code, - $this->name, - $this->publication, - $this->printAllAtOnce, - $this->coupons, - $this->brandCode, - $this->actorId + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId ); // THEN expect($firstCampaign->id()->equals($secondCampaign->id()))->toBeFalse(); }); - it('returns the creation date extracted from the id', function (): void { + it('returns the creation date extracted from the id', function () use ( + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId, + ): void { // GIVEN $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $id = new CampaignId(Uuid::uuid7($expected)); $campaign = Campaign::hydrate( $id, - $this->code, - $this->name, - $this->publication, - $this->printAllAtOnce, - $this->coupons, - $this->brandCode, - $this->actorId + $code, + $name, + $publication, + $printAllAtOnce, + $coupons, + $brandCode, + $actorId ); // WHEN @@ -184,21 +169,20 @@ describe('campaign', function (): void { expect($result->format('Y-m-d H:i:s.v'))->toBe($expected->format('Y-m-d H:i:s.v')); }); - it('rejects a campaign with a non-coupon element', function (): void { + it('rejects a campaign with a non-coupon element', function () use ($code, $name, $publication, $printAllAtOnce, $brandCode, $actorId): void { // GIVEN - $id = CampaignId::generate(); - $coupons = ['PC1']; + $id = CampaignId::generate(); // WHEN / THEN expect(fn (): Campaign => Campaign::hydrate( $id, - $this->code, - $this->name, - $this->publication, - $this->printAllAtOnce, - $coupons, - $this->brandCode, - $this->actorId - ))->toThrow(\DomainException::class, 'Campaign coupons must be CampaignCoupon instances.'); + $code, + $name, + $publication, + $printAllAtOnce, + new CampaignCouponArray(['PC1']), + $brandCode, + $actorId + ))->toThrow(\DomainException::class, 'Coupons must be CampaignCoupon instances.'); }); }); diff --git a/tests/Unit/Domain/Models/Entities/CouponTest.php b/tests/Unit/Domain/Models/Entities/CouponTest.php index 6b54f95..19cdfc3 100644 --- a/tests/Unit/Domain/Models/Entities/CouponTest.php +++ b/tests/Unit/Domain/Models/Entities/CouponTest.php @@ -6,8 +6,7 @@ namespace Tests\Unit\Domain\Models\Entities; use Carbon\CarbonImmutable; use DateTime; -use Domain\Enums\BarcodeFormat; -use Shared\Enums\Country; +use DateTimeInterface; use Domain\Models\Entities\Coupon; use Domain\Models\ValueObjects\Actor\ActorId; use Domain\Models\ValueObjects\Brand\BrandCode; @@ -16,418 +15,407 @@ use Domain\Models\ValueObjects\Coupon\CouponCode; use Domain\Models\ValueObjects\Coupon\CouponDiffusion; use Domain\Models\ValueObjects\Coupon\CouponId; use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; -use Domain\Models\ValueObjects\Coupon\CouponValidity; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponMovement; +use Domain\Models\ValueObjects\Coupon\CouponMovementArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; -use Domain\Models\ValueObjects\CouponPicture; -use Domain\Models\ValueObjects\GS1; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\IncoCode; use Domain\Models\ValueObjects\MoneyAmount; use Domain\Models\ValueObjects\Period; use Domain\Models\ValueObjects\PicturePath; use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\ProcessingCodeArray; use Ramsey\Uuid\Uuid; +use Shared\Enums\Country; use Shared\Enums\Currency; use Shared\ValueObjects\Money; covers(Coupon::class); describe('coupon', function (): void { - beforeEach(function (): void { - $this->code = new CouponCode('COU01'); - $this->name = new CouponName('Name'); - $this->amount = new MoneyAmount(new Money(Currency::EUR, 1)); - $this->picture = new CouponPicture( - new PicturePath('files/product.jpg'), - new PicturePath('files/thumbnail.jpg'), - new PicturePath('files/inactive_thumbnail.jpg') - ); - $this->diffusion = new CouponDiffusion(new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01'))); - $this->validity = new CouponValidity(30, new DateTime('2026-02-01')); - $this->couponValidityInformation = [ - new CouponValidityInformation( - 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', - Country::FRANCE - ) - ]; - $this->couponLegalInformation = [ - new CouponLegalInformation( - 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', - Country::FRANCE - ) - ]; - $this->processingCode = new ProcessingCode('5425003944130'); - $this->brandCode = new BrandCode('AAA01'); - $this->createdBy = ActorId::generate(); - $this->products = [new IncoCode(35784), new IncoCode(15522)]; - $this->availabilityCalendar = new CouponAvailabilityCalendar([new Period(new DateTime('2026-08-25'), new DateTime('2026-10-31'))]); - $this->gs1 = new GS1('110101010', BarcodeFormat::EAN13); - }); + $code = new CouponCode('AAA01'); + $name = new CouponName('Name'); + $amount = new MoneyAmount(new Money(Currency::EUR, 1)); + $picture = new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobileThumbnail.jpg'), + ); + $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.', + 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 = new BrandCode('AAA01'); + $createdBy = ActorId::generate(); + $products = new CouponProductArray([new IncoCode(35784), new IncoCode(15522)]); + $movements = new CouponMovementArray([new CouponMovement(100)]); + $availabilityCalendar = new CouponAvailabilityCalendar([ + new Period( + new DateTime('2026-08-25'), + new DateTime('2026-10-31'), + )]); afterEach(function (): void { CarbonImmutable::setTestNow(); }); - it('creates a coupon', function (): void { + it('creates a coupon', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $validityInformations, + $legalInformations, + $processingCode, + $brandCode, + $createdBy, + $products, + $movements, + $availabilityCalendar, + ): void { // GIVEN $id = CouponId::generate(); // WHEN $coupon = Coupon::hydrate( - $id, - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - $this->couponValidityInformation, - $this->couponLegalInformation, - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, + id: $id, + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: $movements, + availabilityCalendar: $availabilityCalendar, ); // THEN expect($coupon->id())->toBe($id) - ->and($coupon->code)->toBe($this->code) - ->and($coupon->name)->toBe($this->name) - ->and($coupon->amount)->toBe($this->amount) - ->and($coupon->picture)->toBe($this->picture) - ->and($coupon->diffusion)->toBe($this->diffusion) - ->and($coupon->validity)->toBe($this->validity) - ->and($coupon->couponValidityInformation)->toBe($this->couponValidityInformation) - ->and($coupon->couponLegalInformation)->toBe($this->couponLegalInformation) - ->and($coupon->processingCode)->toBe($this->processingCode) - ->and($coupon->brandCode)->toBe($this->brandCode) - ->and($coupon->createdBy)->toBe($this->createdBy) - ->and($coupon->products)->toBe($this->products) - ->and($coupon->availabilityCalendar)->toBe($this->availabilityCalendar) - ->and($coupon->gs1)->toBe($this->gs1) - ->and($coupon->updatedAt)->toBeNull() - ->and($coupon->updatedAt())->toBeNull(); + ->and($coupon->code)->toBe($code) + ->and($coupon->name)->toBe($name) + ->and($coupon->amount)->toBe($amount) + ->and($coupon->picture)->toBe($picture) + ->and($coupon->diffusion)->toBe($diffusion) + ->and($coupon->validityInformations)->toBe($validityInformations) + ->and($coupon->legalInformations)->toBe($legalInformations) + ->and($coupon->processingCode)->toBe($processingCode) + ->and($coupon->brandCode)->toBe($brandCode) + ->and($coupon->createdBy)->toBe($createdBy) + ->and($coupon->products)->toBe($products) + ->and($coupon->availabilityCalendar)->toBe($availabilityCalendar) + ->and($coupon->movements)->toBe($movements) + ->and($coupon->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($coupon->updatedAt())->toBeInstanceOf(DateTimeInterface::class); }); - it('hydrates a coupon without gs1', function (): void { + it('hydrates a coupon without availability calendar', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $validityInformations, + $legalInformations, + $processingCode, + $brandCode, + $createdBy, + $products, + $movements, + ): void { // GIVEN $id = CouponId::generate(); // WHEN $coupon = Coupon::hydrate( id: $id, - code: $this->code, - name: $this->name, - amount: $this->amount, - picture: $this->picture, - diffusion: $this->diffusion, - validity: $this->validity, - couponValidityInformation: $this->couponValidityInformation, - couponLegalInformation: $this->couponLegalInformation, - processingCode: $this->processingCode, - brandCode: $this->brandCode, - createdBy: $this->createdBy, - products: $this->products, - availabilityCalendar: $this->availabilityCalendar - ); - - // THEN - expect($coupon->products)->toBe($this->products) - ->and($coupon->availabilityCalendar)->toBe($this->availabilityCalendar) - ->and($coupon->gs1)->toBeNull(); - }); - - it('hydrates a coupon without availability calendar', function (): void { - // GIVEN - $id = CouponId::generate(); - - // WHEN - $coupon = Coupon::hydrate( - id: $id, - code: $this->code, - name: $this->name, - amount: $this->amount, - picture: $this->picture, - diffusion: $this->diffusion, - validity: $this->validity, - couponValidityInformation: $this->couponValidityInformation, - couponLegalInformation: $this->couponLegalInformation, - processingCode: $this->processingCode, - brandCode: $this->brandCode, - createdBy: $this->createdBy, - products: $this->products, - gs1: $this->gs1 + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: $movements, ); // THEN expect($coupon->availabilityCalendar)->toBeNull() - ->and($coupon->products)->toBe($this->products) - ->and($coupon->gs1)->toBe($this->gs1); + ->and($coupon->products)->toBe($products); }); - it('hydrates a coupon without products', function (): void { + it('hydrates a coupon without products', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $validityInformations, + $legalInformations, + $processingCode, + $brandCode, + $createdBy, + $movements, + $availabilityCalendar, + ): void { // GIVEN $id = CouponId::generate(); // WHEN $coupon = Coupon::hydrate( id: $id, - code: $this->code, - name: $this->name, - amount: $this->amount, - picture: $this->picture, - diffusion: $this->diffusion, - validity: $this->validity, - couponValidityInformation: $this->couponValidityInformation, - couponLegalInformation: $this->couponLegalInformation, - processingCode: $this->processingCode, - brandCode: $this->brandCode, - createdBy: $this->createdBy, - availabilityCalendar: $this->availabilityCalendar, - gs1: $this->gs1 + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: new CouponProductArray(), + movements: $movements, + availabilityCalendar: $availabilityCalendar, ); // THEN - expect($coupon->products)->toBe([]) - ->and($coupon->availabilityCalendar)->toBe($this->availabilityCalendar) - ->and($coupon->gs1)->toBe($this->gs1); + expect($coupon->products->value)->toBe([]) + ->and($coupon->availabilityCalendar)->toBe($availabilityCalendar); }); - it('hydrates a coupon with only required fields', function (): void { + it('throws when validity information contains an invalid item', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $legalInformations, + $processingCode, + $brandCode, + $createdBy, + $products, + $movements, + $availabilityCalendar, + ): void { // GIVEN $id = CouponId::generate(); - // WHEN - $coupon = Coupon::hydrate( + // WHEN / THEN + expect(fn (): Coupon => Coupon::hydrate( id: $id, - code: $this->code, - name: $this->name, - amount: $this->amount, - picture: $this->picture, - diffusion: $this->diffusion, - validity: $this->validity, - couponValidityInformation: $this->couponValidityInformation, - couponLegalInformation: $this->couponLegalInformation, - processingCode: $this->processingCode, - brandCode: $this->brandCode, - createdBy: $this->createdBy, - ); - - // THEN - expect($coupon->id())->toBe($id) - ->and($coupon->products)->toBe([]) - ->and($coupon->availabilityCalendar)->toBeNull() - ->and($coupon->gs1)->toBeNull() - ->and($coupon->updatedAt)->toBeNull() - ->and($coupon->updatedAt())->toBeNull(); + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: new CouponValidityInformationArray(['Not a CouponValidityInformation']), + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: $movements, + availabilityCalendar: $availabilityCalendar, + ))->toThrow(\DomainException::class, 'Value of CouponValidityInformationArray must be CouponValidityInformation instances.'); }); - it('accepts empty validity and legal information lists', function (): void { - // GIVEN - $id = CouponId::generate(); - - // WHEN - $coupon = Coupon::hydrate( - $id, - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - [], - [], - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, - ); - - // THEN - expect($coupon->couponValidityInformation)->toBe([]) - ->and($coupon->couponLegalInformation)->toBe([]); - }); - - it('throws when validity information contains an invalid item', function (): void { + it('throws when legal information contains an invalid item', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $validityInformations, + $processingCode, + $brandCode, + $createdBy, + $products, + $movements, + $availabilityCalendar, + ): void { // GIVEN $id = CouponId::generate(); // WHEN / THEN - expect(fn () => Coupon::hydrate( - $id, - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - ['Not a CouponValidityInformation'], - $this->couponLegalInformation, - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, - ))->toThrow(\DomainException::class, 'Coupon validity information must be CouponValidityInformation instances.'); + expect(fn (): Coupon => Coupon::hydrate( + id: $id, + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: new CouponLegalInformationArray(['Not a CouponLegalInformation']), + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: $movements, + availabilityCalendar: $availabilityCalendar, + ))->toThrow(\DomainException::class, 'Value of CouponLegalInformationArray must be CouponLegalInformation instances.'); }); - it('throws when legal information contains an invalid item', function (): void { - // GIVEN - $id = CouponId::generate(); - - // WHEN / THEN - expect(fn () => Coupon::hydrate( - $id, - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - $this->couponValidityInformation, - ['Not a CouponLegalInformation'], - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, - ))->toThrow(\DomainException::class, 'Coupon legal information must be CouponLegalInformation instances.'); - }); - - it('hydrates a coupon with an explicit updatedAt date', function (): void { - // GIVEN - $id = CouponId::generate(); - $updatedAt = new \DateTimeImmutable('2026-09-22 10:00:00', new \DateTimeZone('UTC')); - - // WHEN - $coupon = Coupon::hydrate( - $id, - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - $this->couponValidityInformation, - $this->couponLegalInformation, - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, - $updatedAt - ); - - // THEN - expect($coupon->updatedAt)->toBe($updatedAt) - ->and($coupon->updatedAt())->toBe($updatedAt); - }); - - it('creates a new coupon with a freshly generated id', function (): void { + it('creates a new coupon with a freshly generated id', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $validityInformations, + $legalInformations, + $processingCode, + $brandCode, + $createdBy, + $products, + $movements, + $availabilityCalendar, + ): void { // GIVEN / WHEN $coupon = Coupon::create( - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - $this->couponValidityInformation, - $this->couponLegalInformation, - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: $movements, + availabilityCalendar: $availabilityCalendar, ); // THEN expect($coupon->id())->toBeInstanceOf(CouponId::class) - ->and($coupon->code)->toBe($this->code) - ->and($coupon->name)->toBe($this->name) - ->and($coupon->amount)->toBe($this->amount) - ->and($coupon->picture)->toBe($this->picture) - ->and($coupon->diffusion)->toBe($this->diffusion) - ->and($coupon->validity)->toBe($this->validity) - ->and($coupon->couponValidityInformation)->toBe($this->couponValidityInformation) - ->and($coupon->couponLegalInformation)->toBe($this->couponLegalInformation) - ->and($coupon->processingCode)->toBe($this->processingCode) - ->and($coupon->brandCode)->toBe($this->brandCode) - ->and($coupon->createdBy)->toBe($this->createdBy) - ->and($coupon->products)->toBe($this->products) - ->and($coupon->availabilityCalendar)->toBe($this->availabilityCalendar) - ->and($coupon->gs1)->toBe($this->gs1) - ->and($coupon->updatedAt)->toBeNull() + ->and($coupon->code)->toBe($code) + ->and($coupon->name)->toBe($name) + ->and($coupon->amount)->toBe($amount) + ->and($coupon->picture)->toBe($picture) + ->and($coupon->diffusion)->toBe($diffusion) + ->and($coupon->validityInformations)->toBe($validityInformations) + ->and($coupon->legalInformations)->toBe($legalInformations) + ->and($coupon->processingCode)->toBe($processingCode) + ->and($coupon->brandCode)->toBe($brandCode) + ->and($coupon->createdBy)->toBe($createdBy) + ->and($coupon->products)->toBe($products) + ->and($coupon->movements)->toBe($movements) + ->and($coupon->availabilityCalendar)->toBe($availabilityCalendar) + ->and($coupon->createdAt())->toBeInstanceOf(DateTimeInterface::class) ->and($coupon->updatedAt())->toBeNull(); }); - it('generates a different id for each created coupon', function (): void { + it('generates a different id for each created coupon', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $validityInformations, + $legalInformations, + $processingCode, + $brandCode, + $createdBy, + $products, + $movements, + $availabilityCalendar, + ): void { // GIVEN / WHEN $firstCoupon = Coupon::create( - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - $this->couponValidityInformation, - $this->couponLegalInformation, - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: $movements, + availabilityCalendar: $availabilityCalendar, ); $secondCoupon = Coupon::create( - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - $this->couponValidityInformation, - $this->couponLegalInformation, - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: $movements, + availabilityCalendar: $availabilityCalendar, ); // THEN expect($firstCoupon->id()->equals($secondCoupon->id()))->toBeFalse(); }); - it('returns the creation date extracted from the id', function (): void { + it('returns the creation date extracted from the id', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $validityInformations, + $legalInformations, + $processingCode, + $brandCode, + $createdBy, + $products, + $movements, + $availabilityCalendar, + ): void { // GIVEN $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $id = new CouponId(Uuid::uuid7($expected)); $coupon = Coupon::hydrate( $id, - $this->code, - $this->name, - $this->amount, - $this->picture, - $this->diffusion, - $this->validity, - $this->couponValidityInformation, - $this->couponLegalInformation, - $this->processingCode, - $this->brandCode, - $this->createdBy, - $this->products, - $this->availabilityCalendar, - $this->gs1, + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: $movements, + availabilityCalendar: $availabilityCalendar, ); // WHEN @@ -436,4 +424,43 @@ describe('coupon', function (): void { // THEN expect($result->format('Y-m-d H:i:s.v'))->toBe($expected->format('Y-m-d H:i:s.v')); }); + + it('computes the quantity from its movements', function () use ( + $code, + $name, + $amount, + $picture, + $diffusion, + $validityInformations, + $legalInformations, + $processingCode, + $brandCode, + $createdBy, + $products, + ): void { + // GIVEN + $coupon = Coupon::create( + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validityInformations: $validityInformations, + legalInformations: $legalInformations, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + movements: new CouponMovementArray([ + new CouponMovement(1000), + new CouponMovement(-200), + ]), + ); + + // WHEN + $quantity = $coupon->quantity(); + + // THEN + expect($quantity)->toBe(800); + }); }); diff --git a/tests/Unit/Domain/Models/Entities/PrintableCouponTest.php b/tests/Unit/Domain/Models/Entities/PrintableCouponTest.php new file mode 100644 index 0000000..85dbf7f --- /dev/null +++ b/tests/Unit/Domain/Models/Entities/PrintableCouponTest.php @@ -0,0 +1,123 @@ +campaignCode)->toBe($campaignCode) + ->and($printable->couponCode)->toBe($couponCode) + ->and($printable->couponName)->toBe($couponName) + ->and($printable->crmCustomerHashcode)->toBe($crmCustomerHashcode) + ->and($printable->distributableUntil)->toBe($distributableUntil) + ->and($printable->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($printable->updatedAt())->toBeNull(); + }); + + it('generates a new code on each creation', function () use ( + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ): void { + // GIVEN / WHEN + $first = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil); + $second = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil); + + // THEN + expect($first->code)->toBeInstanceOf(PrintableCouponCode::class) + ->and($first->code->equals($second->code))->toBeFalse(); + }); + + it('hydrates a printable coupon with the given values', function () use ( + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ): void { + // GIVEN + $code = PrintableCouponCode::generate(); + + // WHEN + $printable = PrintableCoupon::hydrate( + $code, + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ); + + // THEN + expect($printable->code)->toBe($code) + ->and($printable->campaignCode)->toBe($campaignCode) + ->and($printable->couponCode)->toBe($couponCode) + ->and($printable->couponName)->toBe($couponName) + ->and($printable->crmCustomerHashcode)->toBe($crmCustomerHashcode) + ->and($printable->distributableUntil)->toBe($distributableUntil) + ->and($printable->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($printable->updatedAt())->toBeInstanceOf(DateTimeInterface::class); + }); + + it('returns the creation date extracted from the id', function () use ( + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ): void { + // GIVEN + $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); + $code = new PrintableCouponCode(Uuid::uuid7($expected)); + $printableCoupon = PrintableCoupon::hydrate( + $code, + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ); + + // WHEN + $result = $printableCoupon->createdAt(); + + // THEN + expect($result->format('Y-m-d H:i:s.v'))->toBe($expected->format('Y-m-d H:i:s.v')); + }); +}); diff --git a/tests/Unit/Domain/Models/Entities/StockTest.php b/tests/Unit/Domain/Models/Entities/StockTest.php index a30b6d7..11659f6 100644 --- a/tests/Unit/Domain/Models/Entities/StockTest.php +++ b/tests/Unit/Domain/Models/Entities/StockTest.php @@ -4,10 +4,11 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\Entities; +use DateTimeInterface; use Domain\Models\Entities\Stock; use Domain\Models\ValueObjects\Actor\ActorId; use Domain\Models\ValueObjects\Campaign\CampaignCode; -use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\Coupon\CouponCode; use Domain\Models\ValueObjects\Stock\StockAdjustment; use Domain\Models\ValueObjects\Stock\StockAmount; use Domain\Models\ValueObjects\Stock\StockId; @@ -16,40 +17,38 @@ use Ramsey\Uuid\Uuid; covers(Stock::class); describe('stock', function (): void { - beforeEach(function (): void { - $this->stockAmount = new StockAmount(10); - $this->campaignCode = new CampaignCode('AAA01'); - $this->processingCode = new ProcessingCode('PC1'); - $this->stockAdjustment = [new StockAdjustment(5, 'Inventory correction', ActorId::generate())]; - }); + $stockAmount = new StockAmount(10); + $campaignCode = new CampaignCode('AAA01'); + $couponCode = new CouponCode('AAA01'); + $stockAdjustment = [new StockAdjustment(5, 'Inventory correction', ActorId::generate())]; - it('hydrates a stock', function (): void { + it('hydrates a stock', function () use ($stockAmount, $campaignCode, $couponCode, $stockAdjustment): void { // GIVEN $id = StockId::generate(); // WHEN $stock = Stock::hydrate( $id, - $this->stockAmount, - $this->campaignCode, - $this->processingCode, - $this->stockAdjustment + $stockAmount, + $campaignCode, + $couponCode, + $stockAdjustment, ); // THEN expect($stock->id())->toBe($id) - ->and($stock->stockAmount)->toBe($this->stockAmount) - ->and($stock->campaignCode)->toBe($this->campaignCode) - ->and($stock->processingCode)->toBe($this->processingCode) - ->and($stock->stockAdjustment)->toBe($this->stockAdjustment) - ->and($stock->updatedAt)->toBeNull() - ->and($stock->updatedAt())->toBeNull(); + ->and($stock->stockAmount)->toBe($stockAmount) + ->and($stock->campaignCode)->toBe($campaignCode) + ->and($stock->couponCode)->toBe($couponCode) + ->and($stock->stockAdjustment)->toBe($stockAdjustment) + ->and($stock->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($stock->updatedAt())->toBeInstanceOf(DateTimeInterface::class); }); - it('hydrates a stock with several stock adjustments', function (): void { + it('hydrates a stock with several stock adjustments', function () use ($stockAmount, $campaignCode, $couponCode): void { // GIVEN - $id = StockId::generate(); - $stockAdjustment = [ + $id = StockId::generate(); + $severalAdjustments = [ new StockAdjustment(5, 'Inventory correction', ActorId::generate()), new StockAdjustment(10, 'Inventory correction', ActorId::generate()), ]; @@ -57,88 +56,69 @@ describe('stock', function (): void { // WHEN $stock = Stock::hydrate( $id, - $this->stockAmount, - $this->campaignCode, - $this->processingCode, - $stockAdjustment, + $stockAmount, + $campaignCode, + $couponCode, + $severalAdjustments, ); // THEN expect($stock->id())->toBe($id) - ->and($stock->stockAmount)->toBe($this->stockAmount) - ->and($stock->campaignCode)->toBe($this->campaignCode) - ->and($stock->processingCode)->toBe($this->processingCode) - ->and($stock->stockAdjustment)->toBe($stockAdjustment) - ->and($stock->updatedAt)->toBeNull() - ->and($stock->updatedAt())->toBeNull(); + ->and($stock->stockAmount)->toBe($stockAmount) + ->and($stock->campaignCode)->toBe($campaignCode) + ->and($stock->couponCode)->toBe($couponCode) + ->and($stock->stockAdjustment)->toBe($severalAdjustments) + ->and($stock->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($stock->updatedAt())->toBeInstanceOf(DateTimeInterface::class); }); - it('hydrates a stock without stock adjustement', function (): void { + it('hydrates a stock without stock adjustement', function () use ($stockAmount, $campaignCode, $couponCode): void { // GIVEN $id = StockId::generate(); // WHEN $stock = Stock::hydrate( $id, - $this->stockAmount, - $this->campaignCode, - $this->processingCode + $stockAmount, + $campaignCode, + $couponCode, ); // THEN expect($stock->id())->toBe($id) - ->and($stock->stockAmount)->toBe($this->stockAmount) - ->and($stock->campaignCode)->toBe($this->campaignCode) - ->and($stock->processingCode)->toBe($this->processingCode) + ->and($stock->stockAmount)->toBe($stockAmount) + ->and($stock->campaignCode)->toBe($campaignCode) + ->and($stock->couponCode)->toBe($couponCode) ->and($stock->stockAdjustment)->toBe([]) - ->and($stock->updatedAt)->toBeNull() - ->and($stock->updatedAt())->toBeNull(); + ->and($stock->createdAt())->toBeInstanceOf(DateTimeInterface::class) + ->and($stock->updatedAt())->toBeInstanceOf(DateTimeInterface::class); }); - it('rejects a stock with a non-stock-adjustment element', function (): void { + it('rejects a stock with a non-stock-adjustment element', function () use ($stockAmount, $campaignCode, $couponCode): void { // GIVEN - $id = StockId::generate(); - $stockAdjustment = [new StockAmount(5)]; + $id = StockId::generate(); + $invalidAdjustment = [new StockAmount(5)]; // WHEN / THEN expect(fn (): Stock => Stock::hydrate( $id, - $this->stockAmount, - $this->campaignCode, - $this->processingCode, - $stockAdjustment, + $stockAmount, + $campaignCode, + $couponCode, + $invalidAdjustment, ))->toThrow(\DomainException::class, 'Stock adjustment must be StockAdjustment instances.'); }); - it('hydrates a stock with an explicit updatedAt date', function (): void { - // GIVEN - $updatedAt = new \DateTimeImmutable('2026-06-15 12:00:00', new \DateTimeZone('UTC')); - - // WHEN - $stock = Stock::hydrate( - StockId::generate(), - $this->stockAmount, - $this->campaignCode, - $this->processingCode, - $this->stockAdjustment, - $updatedAt, - ); - - // THEN - expect($stock->updatedAt)->toBe($updatedAt) - ->and($stock->updatedAt())->toBe($updatedAt); - }); - - it('returns the creation date extracted from the id', function (): void { + it('returns the creation date extracted from the id', function () use ($stockAmount, $campaignCode, $couponCode, $stockAdjustment): void { // GIVEN $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $id = new StockId(Uuid::uuid7($expected)); $stock = Stock::hydrate( $id, - $this->stockAmount, - $this->campaignCode, - $this->processingCode, - $this->stockAdjustment + $stockAmount, + $campaignCode, + $couponCode, + $stockAdjustment ); // WHEN diff --git a/tests/Unit/Domain/Models/ValueObjects/Actor/ActorDisplayNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Actor/ActorDisplayNameTest.php index 82b09a3..13a8cc6 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Actor/ActorDisplayNameTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Actor/ActorDisplayNameTest.php @@ -9,25 +9,22 @@ use Domain\Models\ValueObjects\Actor\ActorDisplayName; covers(ActorDisplayName::class); describe('actor displayname', function (): void { - beforeEach(function (): void { - $this->value = 'Johnny'; - $this->emptyValue = ''; - }); + $value = 'Johnny'; + $emptyValue = ''; - it('creates a valid actor displayname', function (): void { + it('creates a valid actor displayname', function () use ($value): void { // GIVEN / WHEN $name = new ActorDisplayName( - $this->value + $value ); // THEN - expect($name->value)->toBe($this->value); + expect($name->value)->toBe($value); }); - it('rejects invalid actor displayname', function (): void { + it('rejects invalid actor displayname', function () use ($emptyValue): void { // GIVEN / WHEN / THEN - expect(fn (): ActorDisplayName => new ActorDisplayName( - $this->emptyValue - ))->toThrow(\DomainException::class); + expect(fn (): ActorDisplayName => new ActorDisplayName($emptyValue)) + ->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php index 41a3762..c2f96aa 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php @@ -9,26 +9,21 @@ use Domain\Models\ValueObjects\Brand\BrandName; covers(BrandName::class); describe('brand name', function (): void { - beforeEach(function (): void { - $this->value = 'Name'; - $this->emptyValue = ''; - }); + $value = 'Name'; + $emptyValue = ''; - it('creates a valid brand name', function (): void { + it('creates a valid brand name', function () use ($value): void { // GIVEN / WHEN - $name = new BrandName( - $this->value - ); + $name = new BrandName($value); // THEN - expect($name->value)->toBe($this->value); + expect($name->value)->toBe($value); }); - it('rejects invalid brand name', function (): void { + it('rejects invalid brand name', function () use ($emptyValue): void { // GIVEN / WHEN / THEN - expect(fn (): BrandName => new BrandName( - $this->emptyValue - ))->toThrow(\DomainException::class); + expect(fn (): BrandName => new BrandName($emptyValue)) + ->toThrow(\DomainException::class); }); it('rejects a name that exceeds the maximum allowed length', function (): void { diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandSortCriteriaTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandSortCriteriaTest.php new file mode 100644 index 0000000..e9e946e --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandSortCriteriaTest.php @@ -0,0 +1,34 @@ +field)->toBe(BrandSortField::Name) + ->and($criteria->direction)->toBe(\SortDirection::Ascending); + }); + + it('keeps the given field and direction', function (): void { + // GIVEN + $field = BrandSortField::Name; + $direction = \SortDirection::Descending; + + // WHEN + $criteria = new BrandSortCriteria($field, $direction); + + // THEN + expect($criteria->field)->toBe($field) + ->and($criteria->direction)->toBe($direction); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php index 939cefd..39f6ca9 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php @@ -9,23 +9,20 @@ use Domain\Models\ValueObjects\Campaign\CampaignCode; covers(CampaignCode::class); describe('campaign code', function (): void { - beforeEach(function (): void { - $this->code = 'Name'; - $this->emptyCode = ''; - }); + $code = 'Name'; + $emptyCode = ''; - it('creates a valid campaign code', function (): void { + it('creates a valid campaign code', function () use ($code): void { // GIVEN / WHEN - $name = new CampaignCode($this->code); + $name = new CampaignCode($code); // THEN - expect($name->code)->toBe($this->code); + expect($name->value)->toBe($code); }); - it('rejects invalid campaign code', function (): void { + it('rejects invalid campaign code', function () use ($emptyCode): void { // GIVEN / WHEN / THEN - expect(fn (): CampaignCode => new CampaignCode( - $this->emptyCode - ))->toThrow(\DomainException::class); + expect(fn (): CampaignCode => new CampaignCode($emptyCode)) + ->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponArrayTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponArrayTest.php new file mode 100644 index 0000000..8daae6e --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponArrayTest.php @@ -0,0 +1,57 @@ +value)->toBe($coupons); + }); + + it('rejects an empty array', function (): void { + // GIVEN / WHEN + $action = fn (): CampaignCouponArray => new CampaignCouponArray([]); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Coupons requires at least one CampaignCoupon.'); + }); + + it('rejects a non-coupon element', function (): void { + // GIVEN / WHEN + $action = fn (): CampaignCouponArray => new CampaignCouponArray(['PC1']); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Coupons must be CampaignCoupon instances.'); + }); + + it('rejects a non-coupon element mixed with valid coupons', function () use ($coupon): void { + // GIVEN + $coupons = [$coupon, 'PC1']; + + // WHEN + $action = fn (): CampaignCouponArray => new CampaignCouponArray($coupons); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Coupons must be CampaignCoupon instances.'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponTest.php index e700f2a..9be21d3 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponTest.php @@ -5,78 +5,34 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\ValueObjects\Campaign; use Domain\Models\ValueObjects\Campaign\CampaignCoupon; -use Domain\Models\ValueObjects\Coupon\CouponQuantity; +use Domain\Models\ValueObjects\Coupon\CouponCode; use Domain\Models\ValueObjects\PrintRules; -use Domain\Models\ValueObjects\ProcessingCode; -use Shared\Enums\Country; covers(CampaignCoupon::class); describe('campaign coupon', function (): void { - beforeEach(function (): void { - $this->processingCode = new ProcessingCode('PC1'); - $this->quantity = new CouponQuantity(10); - $this->availableIn = Country::FRANCE; - $this->printRules = new PrintRules(maxPerCustomer: 3); - }); + $couponCode = new CouponCode('AAA01'); + $printRules = new PrintRules(maxPerCustomer: 3); - it('creates a valid campaign coupon', function (): void { + it('creates a valid campaign coupon', function () use ($couponCode, $printRules): void { // GIVEN / WHEN $campaignCoupon = new CampaignCoupon( - $this->processingCode, - $this->quantity, - $this->availableIn, - $this->printRules + couponCode: $couponCode, + printRules: $printRules, ); // THEN - expect($campaignCoupon->processingCode)->toBe($this->processingCode) - ->and($campaignCoupon->quantity)->toBe($this->quantity) - ->and($campaignCoupon->availableIn)->toBe($this->availableIn) - ->and($campaignCoupon->printRules)->toBe($this->printRules); + expect($campaignCoupon->couponCode)->toBe($couponCode) + ->and($campaignCoupon->printRules)->toBe($printRules); }); - it('creates a valid campaign coupon whitout warning threshold quantity', function (): void { + it('creates a valid campaign coupon whitout print rules', function () use ($couponCode): void { // GIVEN / WHEN $campaignCoupon = new CampaignCoupon( - processingCode: $this->processingCode, - quantity: $this->quantity, - availableIn: $this->availableIn, - printRules: $this->printRules + couponCode: $couponCode, ); // THEN - expect($campaignCoupon->processingCode)->toBe($this->processingCode) - ->and($campaignCoupon->quantity)->toBe($this->quantity) - ->and($campaignCoupon->availableIn)->toBe($this->availableIn) - ->and($campaignCoupon->printRules)->toBe($this->printRules); - }); - - it('creates a valid campaign coupon whitout print rules', function (): void { - // GIVEN / WHEN - $campaignCoupon = new CampaignCoupon( - processingCode: $this->processingCode, - quantity: $this->quantity, - availableIn: $this->availableIn, - ); - - // THEN - expect($campaignCoupon->processingCode)->toBe($this->processingCode) - ->and($campaignCoupon->quantity)->toBe($this->quantity) - ->and($campaignCoupon->availableIn)->toBe($this->availableIn); - }); - - it('creates a valid campaign coupon whitout warning threshold quantity and print rules', function (): void { - // GIVEN / WHEN - $campaignCoupon = new CampaignCoupon( - processingCode: $this->processingCode, - quantity: $this->quantity, - availableIn: $this->availableIn - ); - - // THEN - expect($campaignCoupon->processingCode)->toBe($this->processingCode) - ->and($campaignCoupon->quantity)->toBe($this->quantity) - ->and($campaignCoupon->availableIn)->toBe($this->availableIn); + expect($campaignCoupon->couponCode)->toBe($couponCode); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignNameTest.php index ad4af96..770ee44 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignNameTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignNameTest.php @@ -9,23 +9,20 @@ use Domain\Models\ValueObjects\Campaign\CampaignName; covers(CampaignName::class); describe('campaign name', function (): void { - beforeEach(function (): void { - $this->value = 'Name'; - $this->emptyValue = ''; - }); + $value = 'Name'; + $emptyValue = ''; - it('creates a valid campaign name', function (): void { + it('creates a valid campaign name', function () use ($value): void { // GIVEN / WHEN - $name = new CampaignName($this->value); + $name = new CampaignName($value); // THEN - expect($name->value)->toBe($this->value); + expect($name->value)->toBe($value); }); - it('rejects invalid campaign name', function (): void { + it('rejects invalid campaign name', function () use ($emptyValue): void { // GIVEN / WHEN / THEN - expect(fn (): CampaignName => new CampaignName( - $this->emptyValue - ))->toThrow(\DomainException::class); + expect(fn (): CampaignName => new CampaignName($emptyValue)) + ->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/ApplicantTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/ApplicantTest.php index 730b5c6..ee75550 100644 --- a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/ApplicantTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/ApplicantTest.php @@ -10,33 +10,31 @@ use Shared\ValueObjects\Email; covers(Applicant::class); describe('applicant', function (): void { - beforeEach(function (): void { - $this->firstname = 'John'; - $this->lastname = 'Doe'; - $this->email = new Email('john.doe@example.com'); - }); + $firstname = 'John'; + $lastname = 'Doe'; + $email = new Email('john.doe@example.com'); - it('creates a valid applicant', function (): void { + it('creates a valid applicant', function () use ($firstname, $lastname, $email): void { // GIVEN / WHEN $applicant = new Applicant( - $this->firstname, - $this->lastname, - $this->email + $firstname, + $lastname, + $email, ); // THEN expect($applicant)->toBeInstanceOf(Applicant::class) - ->and($applicant->firstname)->toBe($this->firstname) - ->and($applicant->lastname)->toBe($this->lastname) - ->and($applicant->email)->toBe($this->email); + ->and($applicant->firstname)->toBe($firstname) + ->and($applicant->lastname)->toBe($lastname) + ->and($applicant->email)->toBe($email); }); - it('rejects an empty or whitespace-only firstname', function (string $firstname): void { + it('rejects an empty or whitespace-only firstname', function (string $invalidFirstname) use ($lastname, $email): void { // WHEN $action = fn (): Applicant => new Applicant( - $firstname, - $this->lastname, - $this->email + $invalidFirstname, + $lastname, + $email, ); // THEN @@ -46,42 +44,42 @@ describe('applicant', function (): void { 'spaces' => ' ', ]); - it('rejects a firstname that exceeds the maximum allowed length', function (): void { + it('rejects a firstname that exceeds the maximum allowed length', function () use ($lastname, $email): void { // GIVEN - $firstname = str_repeat('a', 201); + $tooLongFirstname = str_repeat('a', 201); // WHEN $action = fn (): Applicant => new Applicant( - $firstname, - $this->lastname, - $this->email + $tooLongFirstname, + $lastname, + $email, ); // THEN expect($action)->toThrow(\DomainException::class, 'Applicant firstname exceeds the maximum allowed length.'); }); - it('accepts a firstname of exactly the maximum length', function (): void { + it('accepts a firstname of exactly the maximum length', function () use ($lastname, $email): void { // GIVEN - $firstname = str_repeat('a', 200); + $maxLengthFirstname = str_repeat('a', 200); // WHEN $applicant = new Applicant( - $firstname, - $this->lastname, - $this->email + $maxLengthFirstname, + $lastname, + $email, ); // THEN expect(mb_strlen($applicant->firstname))->toBe(200); }); - it('rejects an empty or whitespace-only lastname', function (string $lastname): void { + it('rejects an empty or whitespace-only lastname', function (string $invalidLastname) use ($firstname, $email): void { // WHEN $action = fn (): Applicant => new Applicant( - $this->firstname, - $lastname, - $this->email + $firstname, + $invalidLastname, + $email, ); // THEN @@ -91,30 +89,30 @@ describe('applicant', function (): void { 'spaces' => ' ', ]); - it('rejects a lastname that exceeds the maximum allowed length', function (): void { + it('rejects a lastname that exceeds the maximum allowed length', function () use ($firstname, $email): void { // GIVEN - $lastname = str_repeat('a', 201); + $tooLongLastname = str_repeat('a', 201); // WHEN $action = fn (): Applicant => new Applicant( - $this->firstname, - $lastname, - $this->email + $firstname, + $tooLongLastname, + $email, ); // THEN expect($action)->toThrow(\DomainException::class, 'Applicant lastname exceeds the maximum allowed length.'); }); - it('accepts a lastname of exactly the maximum length', function (): void { + it('accepts a lastname of exactly the maximum length', function () use ($firstname, $email): void { // GIVEN - $lastname = str_repeat('a', 200); + $maxLengthLastname = str_repeat('a', 200); // WHEN $applicant = new Applicant( - $this->firstname, - $lastname, - $this->email + $firstname, + $maxLengthLastname, + $email, ); // THEN diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFiltersTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFiltersTest.php index b85bdb3..a8ca643 100644 --- a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFiltersTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFiltersTest.php @@ -11,43 +11,41 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus; covers(CampaignRequestFilters::class); describe('campaign request filters', function (): void { - beforeEach(function (): void { - $this->brandCode = new BrandCode('AAA01'); - $this->status = CampaignRequestStatus::CREATED; - }); + $brandCode = new BrandCode('AAA01'); + $status = CampaignRequestStatus::CREATED; - it('creates filters with both fields set', function (): void { + it('creates filters with both fields set', function () use ($brandCode, $status): void { // GIVEN / WHEN $filters = new CampaignRequestFilters( - brandCode: $this->brandCode, - status: $this->status + brandCode: $brandCode, + status: $status, ); // THEN - expect($filters->brandCode)->toBe($this->brandCode) - ->and($filters->status)->toBe($this->status); + expect($filters->brandCode)->toBe($brandCode) + ->and($filters->status)->toBe($status); }); - it('creates filters with only brandCode set', function (): void { + it('creates filters with only brandCode set', function () use ($brandCode): void { // GIVEN / WHEN $filters = new CampaignRequestFilters( - brandCode: $this->brandCode + brandCode: $brandCode, ); // THEN - expect($filters->brandCode)->toBe($this->brandCode) + expect($filters->brandCode)->toBe($brandCode) ->and($filters->status)->toBeNull(); }); - it('creates filters with only status set', function (): void { + it('creates filters with only status set', function () use ($status): void { // GIVEN / WHEN $filters = new CampaignRequestFilters( - status: $this->status + status: $status, ); // THEN expect($filters->brandCode)->toBeNull() - ->and($filters->status)->toBe($this->status); + ->and($filters->status)->toBe($status); }); it('rejects filters without any field set', function (): void { diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortClauseTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortClauseTest.php new file mode 100644 index 0000000..f78c0ea --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortClauseTest.php @@ -0,0 +1,25 @@ +field)->toBe($field) + ->and($clause->direction)->toBe($direction); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortCriteriaTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortCriteriaTest.php index af31c54..42260c6 100644 --- a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortCriteriaTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortCriteriaTest.php @@ -11,48 +11,42 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortField; covers(CampaignRequestSortCriteria::class); describe('campaign request sort criteria', function (): void { - beforeEach(function (): void { - $this->singleClauses = [ - new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending), - ]; - $this->clauses = [ - new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending), - new CampaignRequestSortClause(field: CampaignRequestSortField::Status, direction: \SortDirection::Descending), - ]; - $this->invalidClauses = [CampaignRequestSortField::BrandCode]; - $this->emptyClauses = []; - }); + $singleClauses = [ + new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending), + ]; + $clauses = [ + new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending), + new CampaignRequestSortClause(field: CampaignRequestSortField::Status, direction: \SortDirection::Descending), + ]; + $invalidClauses = [CampaignRequestSortField::BrandCode]; + $emptyClauses = []; - it('creates a valid campaign request sort criteria with one clause', function (): void { + it('creates a valid campaign request sort criteria with one clause', function () use ($singleClauses): void { // GIVEN / WHEN - $campaignRequestSortCriteria = new CampaignRequestSortCriteria( - $this->singleClauses - ); + $campaignRequestSortCriteria = new CampaignRequestSortCriteria($singleClauses); // THEN - expect($campaignRequestSortCriteria->clauses)->toBe($this->singleClauses); + expect($campaignRequestSortCriteria->clauses)->toBe($singleClauses); }); - it('creates a valid campaign request sort criteria with several clauses', function (): void { + it('creates a valid campaign request sort criteria with several clauses', function () use ($clauses): void { // GIVEN / WHEN - $campaignRequestSortCriteria = new CampaignRequestSortCriteria( - $this->clauses - ); + $campaignRequestSortCriteria = new CampaignRequestSortCriteria($clauses); // THEN - expect($campaignRequestSortCriteria->clauses)->toBe($this->clauses); + expect($campaignRequestSortCriteria->clauses)->toBe($clauses); }); - it('rejects a campaign request sort criteria without clause', function (): void { + it('rejects a campaign request sort criteria without clause', function () use ($emptyClauses): void { // GIVEN / WHEN / THEN - expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($this->emptyClauses)) + expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($emptyClauses)) ->toThrow(\DomainException::class, 'Campaign Request Sort Criteria requires at least one clause.'); }); - it('rejects a campaign request sort criteria with a non-clause element', function (): void { + it('rejects a campaign request sort criteria with a non-clause element', function () use ($invalidClauses): void { // GIVEN / WHEN / THEN - expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($this->invalidClauses)) + expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($invalidClauses)) ->toThrow(\DomainException::class, 'CampaignRequestSortCriteria clauses must be CampaignRequestSortClause instances.'); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/RejectionReasonTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/RejectionReasonTest.php index f72656f..ee1ddff 100644 --- a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/RejectionReasonTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/RejectionReasonTest.php @@ -9,22 +9,20 @@ use Domain\Models\ValueObjects\CampaignRequest\RejectionReason; covers(RejectionReason::class); describe('rejection reason', function (): void { - beforeEach(function (): void { - $this->value = 'Missing legal documents.'; - }); + $value = 'Missing legal documents.'; - it('creates a valid rejection reason', function (): void { + it('creates a valid rejection reason', function () use ($value): void { // GIVEN / WHEN - $reason = new RejectionReason($this->value); + $reason = new RejectionReason($value); // THEN expect($reason)->toBeInstanceOf(RejectionReason::class) - ->and($reason->value)->toBe($this->value); + ->and($reason->value)->toBe($value); }); - it('rejects an empty or whitespace-only reason', function (string $value): void { + it('rejects an empty or whitespace-only reason', function (string $invalidValue): void { // WHEN - $action = fn (): RejectionReason => new RejectionReason($value); + $action = fn (): RejectionReason => new RejectionReason($invalidValue); // THEN expect($action)->toThrow(\DomainException::class, 'Rejection reason cannot be empty.'); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendarTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendarTest.php index 829915c..5f05e45 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendarTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendarTest.php @@ -11,49 +11,41 @@ use DateTime; covers(CouponAvailabilityCalendar::class); describe('coupon availability calendar', function (): void { - beforeEach(function (): void { - $this->singlePeriods = [ - new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), - ]; - $this->periods = [ - new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), - new Period(new DateTime('2026-03-01'), new DateTime('2026-04-01')), - ]; - $this->invalidPeriods = ['2026-08-25', '2026-10-31']; - $this->emptyPeriods = []; - }); + $singlePeriods = [ + new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), + ]; + $periods = [ + new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), + new Period(new DateTime('2026-03-01'), new DateTime('2026-04-01')), + ]; + $invalidPeriods = ['2026-08-25', '2026-10-31']; + $emptyPeriods = []; - it('creates a valid coupon availability calendar with one period', function (): void { + it('creates a valid coupon availability calendar with one period', function () use ($singlePeriods): void { // GIVEN / WHEN - $couponAvailabilityCalendar = new CouponAvailabilityCalendar( - $this->singlePeriods - ); + $couponAvailabilityCalendar = new CouponAvailabilityCalendar($singlePeriods); // THEN - expect($couponAvailabilityCalendar->periods)->toBe($this->singlePeriods); + expect($couponAvailabilityCalendar->periods)->toBe($singlePeriods); }); - it('creates a valid coupon availability calendar with several periods', function (): void { + it('creates a valid coupon availability calendar with several periods', function () use ($periods): void { // GIVEN / WHEN - $couponAvailabilityCalendar = new CouponAvailabilityCalendar( - $this->periods - ); + $couponAvailabilityCalendar = new CouponAvailabilityCalendar($periods); // THEN - expect($couponAvailabilityCalendar->periods)->toBe($this->periods); + expect($couponAvailabilityCalendar->periods)->toBe($periods); }); - it('rejects a coupon availability calendar without period', function (): void { + it('rejects a coupon availability calendar without period', function () use ($emptyPeriods): void { // GIVEN / WHEN / THEN - expect(fn (): CouponAvailabilityCalendar => new CouponAvailabilityCalendar( - $this->emptyPeriods - ))->toThrow(\DomainException::class); + expect(fn (): CouponAvailabilityCalendar => new CouponAvailabilityCalendar($emptyPeriods)) + ->toThrow(\DomainException::class); }); - it('rejects a coupon availability calendar with a non-period element', function (): void { + it('rejects a coupon availability calendar with a non-period element', function () use ($invalidPeriods): void { // GIVEN / WHEN / THEN - expect(fn (): CouponAvailabilityCalendar => new CouponAvailabilityCalendar( - $this->invalidPeriods - ))->toThrow(\DomainException::class, 'CouponAvailabilityCalendar periods must be Period instances.'); + expect(fn (): CouponAvailabilityCalendar => new CouponAvailabilityCalendar($invalidPeriods)) + ->toThrow(\DomainException::class, 'CouponAvailabilityCalendar periods must be Period instances.'); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponCountryCoverageTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponCountryCoverageTest.php new file mode 100644 index 0000000..47c19d2 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponCountryCoverageTest.php @@ -0,0 +1,121 @@ + CouponCountryCoverage::assert( + new ProcessingCodeArray([$franceCode]), + new CouponValidityInformationArray([$franceValidity]), + new CouponLegalInformationArray([$franceLegal]), + ); + + // THEN + expect($action)->not->toThrow(\DomainException::class); + }); + + it('accepts a coupon fully covered for france and benelux', function () use ( + $franceCode, + $beneluxCode, + $franceValidity, + $beneluxValidity, + $franceLegal, + $beneluxLegal, + ): void { + // GIVEN / WHEN + $action = fn () => CouponCountryCoverage::assert( + new ProcessingCodeArray([$franceCode, $beneluxCode]), + new CouponValidityInformationArray([$franceValidity, $beneluxValidity]), + new CouponLegalInformationArray([$franceLegal, $beneluxLegal]), + ); + + // THEN + expect($action)->not->toThrow(\DomainException::class); + }); + + it('rejects a benelux processing code without benelux validity information', function () use ( + $franceCode, + $beneluxCode, + $franceValidity, + $franceLegal, + $beneluxLegal, + ): void { + // GIVEN / WHEN + $action = fn () => CouponCountryCoverage::assert( + new ProcessingCodeArray([$franceCode, $beneluxCode]), + new CouponValidityInformationArray([$franceValidity]), + new CouponLegalInformationArray([$franceLegal, $beneluxLegal]), + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'A coupon requires validity information for BENELUX.'); + }); + + it('rejects a benelux processing code without benelux legal information', function () use ( + $franceCode, + $beneluxCode, + $franceValidity, + $beneluxValidity, + $franceLegal, + ): void { + // GIVEN / WHEN + $action = fn () => CouponCountryCoverage::assert( + new ProcessingCodeArray([$franceCode, $beneluxCode]), + new CouponValidityInformationArray([$franceValidity, $beneluxValidity]), + new CouponLegalInformationArray([$franceLegal]), + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'A coupon requires legal information for BENELUX.'); + }); + + it('rejects information for a country where the coupon is not diffused', function () use ( + $franceCode, + $franceValidity, + $beneluxValidity, + $franceLegal, + ): void { + // GIVEN / WHEN + $action = fn () => CouponCountryCoverage::assert( + new ProcessingCodeArray([$franceCode]), + new CouponValidityInformationArray([$franceValidity, $beneluxValidity]), + new CouponLegalInformationArray([$franceLegal]), + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'A coupon requires exactly one information per diffusion country.'); + }); + + it('cannot be instantiated from the outside', function (): void { + // GIVEN + $reflection = new \ReflectionClass(CouponCountryCoverage::class); + $constructor = $reflection->getConstructor(); + + // WHEN + $constructor?->invoke($reflection->newInstanceWithoutConstructor()); + + // THEN + expect($constructor?->isPrivate())->toBeTrue(); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponDiffusionTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponDiffusionTest.php index 83929ee..b893d7b 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponDiffusionTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponDiffusionTest.php @@ -11,15 +11,13 @@ use DateTime; covers(CouponDiffusion::class); describe('coupon diffusion', function (): void { - beforeEach(function (): void { - $this->period = new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')); - }); + $period = new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')); - it('creates a valid coupon diffusion with one period', function (): void { + it('creates a valid coupon diffusion with one period', function () use ($period): void { // GIVEN / WHEN - $couponDiffusion = new CouponDiffusion($this->period); + $couponDiffusion = new CouponDiffusion($period); // THEN - expect($couponDiffusion->period)->toBe($this->period); + expect($couponDiffusion->period)->toBe($period); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationArrayTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationArrayTest.php new file mode 100644 index 0000000..dab8bcc --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationArrayTest.php @@ -0,0 +1,105 @@ +value)->toBe($informations); + }); + + it('accepts additional countries next to France', function () use ($franceInformation, $beneluxInformation): void { + // GIVEN + $informations = [$beneluxInformation, $franceInformation]; + + // WHEN + $array = new CouponLegalInformationArray($informations); + + // THEN + expect($array->value)->toBe($informations); + }); + + it('rejects an empty array', function (): void { + // GIVEN / WHEN + $action = fn (): CouponLegalInformationArray => new CouponLegalInformationArray([]); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'CouponLegalInformations requires at least one CouponLegalInformation.', + ); + }); + + it('rejects two legal informations for the same country', function () use ($franceInformation): void { + // GIVEN + $informations = [ + $franceInformation, + new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + ]; + + // WHEN + $action = fn (): CouponLegalInformationArray => new CouponLegalInformationArray($informations); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Only one CouponLegalInformation is allowed per country, duplicate for FRANCE.', + ); + }); + + it('rejects a non legal information element', function (): void { + // GIVEN / WHEN + $action = fn (): CouponLegalInformationArray => new CouponLegalInformationArray(['legal']); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of CouponLegalInformationArray must be CouponLegalInformation instances.', + ); + }); + + it('rejects a non legal information element mixed with valid ones', function () use ($franceInformation): void { + // GIVEN + $informations = [$franceInformation, 'legal']; + + // WHEN + $action = fn (): CouponLegalInformationArray => new CouponLegalInformationArray($informations); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of CouponLegalInformationArray must be CouponLegalInformation instances.', + ); + }); + + it('rejects an array without France legal information', function () use ($beneluxInformation): void { + // GIVEN + $informations = [$beneluxInformation]; + + // WHEN + $action = fn (): CouponLegalInformationArray => new CouponLegalInformationArray($informations); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'CouponLegalInformations requires a CouponLegalInformation for France.', + ); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationTest.php index 007b7d6..d404a4a 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationTest.php @@ -10,29 +10,39 @@ use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; covers(CouponLegalInformation::class); describe('coupon legal information', function (): void { - beforeEach(function (): void { - $this->legalText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; - $this->emptyLegalText = ''; - $this->forLocale = Country::FRANCE; - }); + $legalText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + $emptyLegalText = ''; + $forLocale = Country::FRANCE; - it('creates a valid coupon legal information', function (): void { + it('creates a valid coupon legal information FRANCE', function () use ($legalText, $forLocale): void { // GIVEN / WHEN $couponLegalInformation = new CouponLegalInformation( - $this->legalText, - $this->forLocale + $legalText, + $forLocale, ); // THEN - expect($couponLegalInformation->legalText)->toBe($this->legalText) - ->and($couponLegalInformation->forLocale)->toBe($this->forLocale); + expect($couponLegalInformation->legalText)->toBe($legalText) + ->and($couponLegalInformation->forLocale)->toBe($forLocale); }); - it('rejects invalid coupon legal information', function (): void { + it('creates a valid coupon legal information BENELUX', function () use ($legalText): void { + // GIVEN / WHEN + $couponLegalInformation = new CouponLegalInformation( + $legalText, + Country::BENELUX, + ); + + // THEN + expect($couponLegalInformation->legalText)->toBe($legalText) + ->and($couponLegalInformation->forLocale)->toBe(Country::BENELUX); + }); + + it('rejects invalid coupon legal information', function () use ($emptyLegalText, $forLocale): void { // GIVEN / WHEN / THEN expect(fn (): CouponLegalInformation => new CouponLegalInformation( - $this->emptyLegalText, - $this->forLocale + $emptyLegalText, + $forLocale, ))->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponMovementArrayTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponMovementArrayTest.php new file mode 100644 index 0000000..c152156 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponMovementArrayTest.php @@ -0,0 +1,61 @@ +value)->toBe($couponMovements); + }); + + it('rejects an empty array', function (): void { + // GIVEN / WHEN + $action = fn (): CouponMovementArray => new CouponMovementArray([]); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'CouponMovementArray requires at least one CouponMovement.', + ); + }); + + it('rejects a non coupon movement element', function (): void { + // GIVEN / WHEN + $action = fn (): CouponMovementArray => new CouponMovementArray(['movement']); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of CouponMovementArray must be CouponMovement instances.', + ); + }); + + it('rejects a non coupon movement element mixed with valid ones', function () use ($couponMovement): void { + // GIVEN + $couponMovements = [$couponMovement, 'movement']; + + // WHEN + $action = fn (): CouponMovementArray => new CouponMovementArray($couponMovements); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of CouponMovementArray must be CouponMovement instances.', + ); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponMovementTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponMovementTest.php new file mode 100644 index 0000000..7750d8e --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponMovementTest.php @@ -0,0 +1,47 @@ +quantity)->toBe($quantity) + ->and($movement->createdAt)->toBe($createdAt); + }); + + it('defaults the creation date to now', function (): void { + // GIVEN + $before = new \DateTimeImmutable(); + + // WHEN + $movement = new CouponMovement(5); + + // THEN + $after = new \DateTimeImmutable(); + + expect($movement->createdAt)->toBeInstanceOf(\DateTimeImmutable::class) + ->and($movement->createdAt >= $before)->toBeTrue() + ->and($movement->createdAt <= $after)->toBeTrue(); + }); + + it('accepts a negative quantity', function (): void { + // GIVEN / WHEN + $movement = new CouponMovement(-3); + + // THEN + expect($movement->quantity)->toBe(-3); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponNameTest.php new file mode 100644 index 0000000..1a8e560 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponNameTest.php @@ -0,0 +1,28 @@ +value)->toBe($value); + }); + + it('rejects invalid coupon name', function () use ($emptyValue): void { + // GIVEN / WHEN / THEN + expect(fn (): CouponName => new CouponName($emptyValue)) + ->toThrow(\DomainException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponPictureTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponPictureTest.php new file mode 100644 index 0000000..c8e2903 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponPictureTest.php @@ -0,0 +1,85 @@ +decoration)->toBe($decoration) + ->and($picture->thumbnail)->toBe($thumbnail) + ->and($picture->mobileThumbnail)->toBe($mobileThumbnail); + })->with(['jpg', 'jpeg']); + + it('refuses files that are not jpg', function (string $decoration, string $thumbnail, string $mobileThumbnail, string $invalid): void { + // GIVEN + $decorationPath = new PicturePath($decoration); + $thumbnailPath = new PicturePath($thumbnail); + $mobileThumbnailPath = new PicturePath($mobileThumbnail); + + // WHEN + $build = fn (): CouponPicture => new CouponPicture($decorationPath, $thumbnailPath, $mobileThumbnailPath); + + // THEN + expect($build)->toThrow(\DomainException::class, sprintf('Coupon picture "%s" must be a JPG file.', $invalid)); + })->with([ + 'decoration' => ['files/decoration.png', 'files/thumbnail.jpg', 'files/mobile_thumbnail.jpg', 'files/decoration.png'], + 'thumbnail' => ['files/decoration.jpg', 'files/thumbnail.webp', 'files/mobile_thumbnail.jpg', 'files/thumbnail.webp'], + 'mobile thumbnail' => ['files/decoration.jpg', 'files/thumbnail.jpg', 'files/mobile_thumbnail.png', 'files/mobile_thumbnail.png'], + ]); + + it('creates a picture when the decoration is 280 x 260', function (): void { + // GIVEN + $decoration = new PicturePath('tests/Fixtures/Coupon/decoration.jpg'); + $thumbnail = new PicturePath('files/thumbnail.jpg'); + $mobileThumbnail = new PicturePath('files/mobile_thumbnail.jpg'); + + // WHEN + $picture = CouponPicture::create($decoration, $thumbnail, $mobileThumbnail); + + // THEN + expect($picture->decoration)->toBe($decoration) + ->and($picture->thumbnail)->toBe($thumbnail) + ->and($picture->mobileThumbnail)->toBe($mobileThumbnail); + }); + + it('refuses a decoration with the wrong size', function (): void { + // GIVEN + $decoration = new PicturePath('tests/Fixtures/Coupon/invalid_decoration.jpg'); + $thumbnail = new PicturePath('files/thumbnail.jpg'); + $mobileThumbnail = new PicturePath('files/mobile_thumbnail.jpg'); + + // WHEN + $create = fn (): CouponPicture => CouponPicture::create($decoration, $thumbnail, $mobileThumbnail); + + // THEN + expect($create)->toThrow(\DomainException::class, 'The decoration picture must be 280 x 260 px'); + }); + + it('refuses a decoration file that does not exist', function (): void { + // GIVEN + $decoration = new PicturePath('files/missing.jpg'); + $thumbnail = new PicturePath('files/thumbnail.jpg'); + $mobileThumbnail = new PicturePath('files/mobile_thumbnail.jpg'); + + // WHEN + $create = fn (): CouponPicture => CouponPicture::create($decoration, $thumbnail, $mobileThumbnail); + + // THEN + expect($create)->toThrow(\DomainException::class, 'Unable to read the decoration picture "files/missing.jpg".'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponProcessingCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponProcessingCodeTest.php new file mode 100644 index 0000000..3302b48 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponProcessingCodeTest.php @@ -0,0 +1,83 @@ +value)->toBe($value) + ->and($code->availableIn)->toBe(Country::FRANCE); + }); + + it('rejects an invalid value', function (string $invalidValue): void { + // GIVEN / WHEN + $action = fn (): ProcessingCode => new ProcessingCode($invalidValue, Country::FRANCE); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Processing Code must be exactly 13 numeric characters'); + })->with([ + 'empty' => '', + 'too short' => '542500394413', + 'too long' => '54250039441301', + 'with letters' => 'ABC1234567890', + 'special char' => '542500394413!', + 'with space' => '5425 03944130', + 'trailing break' => "5425003944130\n", + ]); + + it('is equal to another processing code with the same value', function () use ($value): void { + // GIVEN + $code = new ProcessingCode($value, Country::FRANCE); + $other = new ProcessingCode($value, Country::FRANCE); + + // WHEN / THEN + expect($code->equals($other))->toBeTrue(); + }); + + it('ignores the country when comparing', function () use ($value): void { + // GIVEN + $france = new ProcessingCode($value, Country::FRANCE); + $benelux = new ProcessingCode($value, Country::BENELUX); + + // WHEN / THEN + expect($france->equals($benelux))->toBeTrue(); + }); + + it('is not equal to a processing code with a different value', function () use ($value): void { + // GIVEN + $code = new ProcessingCode($value, Country::FRANCE); + $other = new ProcessingCode('4006381333931', Country::FRANCE); + + // WHEN / THEN + expect($code->equals($other))->toBeFalse(); + }); + + it('is not equal to a value that is not a processing code', function () use ($value): void { + // GIVEN + $code = new ProcessingCode($value, Country::FRANCE); + + // WHEN / THEN + expect($code->equals($value))->toBeFalse() + ->and($code->equals(null))->toBeFalse(); + }); + + it('is converted to its value as a string', function () use ($value): void { + // GIVEN + $code = new ProcessingCode($value, Country::FRANCE); + + // WHEN / THEN + expect((string) $code)->toBe($value); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponProductArrayTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponProductArrayTest.php new file mode 100644 index 0000000..ac43e87 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponProductArrayTest.php @@ -0,0 +1,67 @@ +value)->toBe([]); + }); + + it('accepts an empty array explicitly', function (): void { + // GIVEN / WHEN + $array = new CouponProductArray([]); + + // THEN + expect($array->value)->toBe([]); + }); + + it('creates an array with valid products', function () use ($firstProduct, $secondProduct): void { + // GIVEN + $products = [$firstProduct, $secondProduct]; + + // WHEN + $array = new CouponProductArray($products); + + // THEN + expect($array->value)->toBe($products); + }); + + it('rejects a non product element', function (): void { + // GIVEN / WHEN + $action = fn (): CouponProductArray => new CouponProductArray([35784]); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of CouponProductArray must be IncoCode instances.', + ); + }); + + it('rejects a non product element mixed with valid ones', function () use ($firstProduct): void { + // GIVEN + $products = [$firstProduct, 35784]; + + // WHEN + $action = fn (): CouponProductArray => new CouponProductArray($products); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of CouponProductArray must be IncoCode instances.', + ); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponQuantityTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponQuantityTest.php deleted file mode 100644 index 35dd4b3..0000000 --- a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponQuantityTest.php +++ /dev/null @@ -1,48 +0,0 @@ -quantity = 10; - $this->zeroQuantity = 0; - $this->negativeQuantity = -1; - }); - - it('creates a valid coupon quantity', function (): void { - // GIVEN / WHEN - $couponQuantity = new CouponQuantity( - $this->quantity - ); - - // THEN - expect($couponQuantity)->toBeInstanceOf(CouponQuantity::class) - ->and($couponQuantity->quantity)->toBe($this->quantity); - }); - - it('rejects a quantity of zero', function (): void { - // WHEN - $action = fn (): CouponQuantity => new CouponQuantity( - $this->zeroQuantity - ); - - // THEN - expect($action)->toThrow(\DomainException::class, 'CouponQuantity quantity must be positive.'); - }); - - it('rejects a negative quantity', function (): void { - // WHEN - $action = fn (): CouponQuantity => new CouponQuantity( - $this->negativeQuantity - ); - - // THEN - expect($action)->toThrow(\DomainException::class, 'CouponQuantity quantity must be positive.'); - }); -}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationArrayTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationArrayTest.php new file mode 100644 index 0000000..c2b81fa --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationArrayTest.php @@ -0,0 +1,105 @@ +value)->toBe($informations); + }); + + it('accepts additional countries next to France', function () use ($franceInformation, $beneluxInformation): void { + // GIVEN + $informations = [$beneluxInformation, $franceInformation]; + + // WHEN + $array = new CouponValidityInformationArray($informations); + + // THEN + expect($array->value)->toBe($informations); + }); + + it('rejects an empty array', function (): void { + // GIVEN / WHEN + $action = fn (): CouponValidityInformationArray => new CouponValidityInformationArray([]); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'CouponValidityInformations requires at least one CouponValidityInformation.', + ); + }); + + it('rejects two legal informations for the same country', function () use ($franceInformation): void { + // GIVEN + $informations = [ + $franceInformation, + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + ]; + + // WHEN + $action = fn (): CouponValidityInformationArray => new CouponValidityInformationArray($informations); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Only one CouponValidityInformation is allowed per country, duplicate for FRANCE.', + ); + }); + + it('rejects a non validity information element', function (): void { + // GIVEN / WHEN + $action = fn (): CouponValidityInformationArray => new CouponValidityInformationArray(['validity']); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of CouponValidityInformationArray must be CouponValidityInformation instances.', + ); + }); + + it('rejects a non validity information element mixed with valid ones', function () use ($franceInformation): void { + // GIVEN + $informations = [$franceInformation, 'validity']; + + // WHEN + $action = fn (): CouponValidityInformationArray => new CouponValidityInformationArray($informations); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of CouponValidityInformationArray must be CouponValidityInformation instances.', + ); + }); + + it('rejects an array without France validity information', function () use ($beneluxInformation): void { + // GIVEN + $informations = [$beneluxInformation]; + + // WHEN + $action = fn (): CouponValidityInformationArray => new CouponValidityInformationArray($informations); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'CouponValidityInformations requires a CouponValidityInformation for France.', + ); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationTest.php index 952a9b2..35aa0e8 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationTest.php @@ -10,29 +10,27 @@ use Shared\Enums\Country; covers(CouponValidityInformation::class); describe('coupon validity information', function (): void { - beforeEach(function (): void { - $this->validityText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; - $this->emptyValidityText = ''; - $this->forLocale = Country::FRANCE; - }); + $validityText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + $emptyValidityText = ''; + $forLocale = Country::FRANCE; - it('creates a valid coupon validity information', function (): void { + it('creates a valid coupon validity information', function () use ($validityText, $forLocale): void { // GIVEN / WHEN $couponValidityInformation = new CouponValidityInformation( - $this->validityText, - $this->forLocale + $validityText, + $forLocale, ); // THEN - expect($couponValidityInformation->validityText)->toBe($this->validityText) - ->and($couponValidityInformation->forLocale)->toBe($this->forLocale); + expect($couponValidityInformation->validityText)->toBe($validityText) + ->and($couponValidityInformation->forLocale)->toBe($forLocale); }); - it('rejects invalid coupon validity information', function (): void { + it('rejects invalid coupon validity information', function () use ($emptyValidityText, $forLocale): void { // GIVEN / WHEN / THEN expect(fn (): CouponValidityInformation => new CouponValidityInformation( - $this->emptyValidityText, - $this->forLocale + $emptyValidityText, + $forLocale, ))->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityTest.php deleted file mode 100644 index 57c85c9..0000000 --- a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityTest.php +++ /dev/null @@ -1,38 +0,0 @@ -days = 30; - $this->zeroDays = 0; - $this->until = new DateTime('2026-11-30'); - }); - - it('creates a valid coupon validity', function (): void { - // GIVEN / WHEN - $couponValidity = new CouponValidity( - $this->days, - $this->until - ); - - // THEN - expect($couponValidity->days)->toBe($this->days) - ->and($couponValidity->until)->toBe($this->until); - }); - - it('rejects invalid coupon validity', function (): void { - // GIVEN / WHEN / THEN - expect(fn (): CouponValidity => new CouponValidity( - $this->zeroDays, - $this->until - ))->toThrow(\DomainException::class); - }); -}); diff --git a/tests/Unit/Domain/Models/ValueObjects/CouponNameTest.php b/tests/Unit/Domain/Models/ValueObjects/CouponNameTest.php deleted file mode 100644 index f1b0524..0000000 --- a/tests/Unit/Domain/Models/ValueObjects/CouponNameTest.php +++ /dev/null @@ -1,33 +0,0 @@ -value = 'Name'; - $this->emptyValue = ''; - }); - - it('creates a valid coupon name', function (): void { - // GIVEN / WHEN - $name = new CouponName( - $this->value - ); - - // THEN - expect($name->value)->toBe($this->value); - }); - - it('rejects invalid coupon name', function (): void { - // GIVEN / WHEN / THEN - expect(fn (): CouponName => new CouponName( - $this->emptyValue - ))->toThrow(\DomainException::class); - }); -}); diff --git a/tests/Unit/Domain/Models/ValueObjects/CrmHashcodeTest.php b/tests/Unit/Domain/Models/ValueObjects/CrmHashcodeTest.php new file mode 100644 index 0000000..7f515fc --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CrmHashcodeTest.php @@ -0,0 +1,49 @@ +value)->toBe($value); + }); + + it('is equal to a hashcode with the same value', function () use ($value): void { + // GIVEN + $hashcode = new CrmHashcode($value); + $other = new CrmHashcode($value); + + // WHEN / THEN + expect($hashcode->equals($other))->toBeTrue(); + }); + + it('is not equal to a hashcode with a different value', function () use ($value): void { + // GIVEN + $hashcode = new CrmHashcode($value); + $other = new CrmHashcode(Uuid::uuid4()); + + // WHEN / THEN + expect($hashcode->equals($other))->toBeFalse(); + }); + + it('is not equal to a value that is not a hashcode', function () use ($value): void { + // GIVEN + $hashcode = new CrmHashcode($value); + + // WHEN / THEN + expect($hashcode->equals($value))->toBeFalse() + ->and($hashcode->equals(null))->toBeFalse(); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/GS1Test.php b/tests/Unit/Domain/Models/ValueObjects/GS1Test.php deleted file mode 100644 index 1e56044..0000000 --- a/tests/Unit/Domain/Models/ValueObjects/GS1Test.php +++ /dev/null @@ -1,38 +0,0 @@ -value = '1010100101'; - $this->emptyValue = ''; - $this->format = BarcodeFormat::EAN13; - }); - - it('creates a valid gs1', function (): void { - // WHEN - $GS1 = new GS1( - $this->value, - $this->format - ); - - // THEN - expect($GS1->value)->toBe($this->value) - ->and($GS1->format)->toBe($this->format); - }); - - it('rejects invalid gs1', function (): void { - // GIVEN / WHEN / THEN - expect(fn (): GS1 => new GS1( - $this->emptyValue, - $this->format - ))->toThrow(\DomainException::class); - }); -}); diff --git a/tests/Unit/Domain/Models/ValueObjects/IncoCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/IncoCodeTest.php index 54b03ea..026260e 100644 --- a/tests/Unit/Domain/Models/ValueObjects/IncoCodeTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/IncoCodeTest.php @@ -9,25 +9,20 @@ use Domain\Models\ValueObjects\IncoCode; covers(IncoCode::class); describe('inco code', function (): void { - beforeEach(function (): void { - $this->value = 1010100101; - $this->zeroValue = 0; - }); + $value = 1010100101; + $zeroValue = 0; - it('creates a valid inco code', function (): void { + it('creates a valid inco code', function () use ($value): void { // GIVEN / WHEN - $incoCode = new IncoCode( - $this->value - ); + $incoCode = new IncoCode($value); // THEN - expect($incoCode->value)->toBe($this->value); + expect($incoCode->value)->toBe($value); }); - it('rejects invalid inco code', function (): void { + it('rejects invalid inco code', function () use ($zeroValue): void { // GIVEN / WHEN / THEN - expect(fn (): IncoCode => new IncoCode( - $this->zeroValue - ))->toThrow(\DomainException::class); + expect(fn (): IncoCode => new IncoCode($zeroValue)) + ->toThrow(\DomainException::class); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/MoneyAmountTest.php b/tests/Unit/Domain/Models/ValueObjects/MoneyAmountTest.php index 84ea7e2..ee12511 100644 --- a/tests/Unit/Domain/Models/ValueObjects/MoneyAmountTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/MoneyAmountTest.php @@ -11,36 +11,26 @@ use Shared\ValueObjects\Money; covers(MoneyAmount::class); describe('money amount', function (): void { - beforeEach(function (): void { - $this->money = new Money(Currency::EUR, 300); - $this->secondMoney = new Money(Currency::EUR, 200); - $this->minimumMoney = new Money(Currency::EUR, 1); - $this->zeroMoney = new Money(Currency::EUR, 0); - $this->aboveLimitMoney = new Money(Currency::EUR, 301); - }); + $money = new Money(Currency::EUR, 300); + $secondMoney = new Money(Currency::EUR, 200); + $minimumMoney = new Money(Currency::EUR, 1); + $zeroMoney = new Money(Currency::EUR, 0); + $aboveLimitMoney = new Money(Currency::EUR, 301); - it('wraps a Money value', function (): void { + it('wraps a Money value', function () use ($money): void { // GIVEN / WHEN - $amount = new MoneyAmount( - $this->money - ); + $amount = new MoneyAmount($money); // THEN expect($amount)->toBeInstanceOf(MoneyAmount::class) - ->and($amount->value)->toBe($this->money); + ->and($amount->value)->toBe($money); }); - it('implements equals by delegating to the wrapped Money', function (): void { + it('implements equals by delegating to the wrapped Money', function () use ($money, $secondMoney): void { // GIVEN / WHEN - $amount1 = new MoneyAmount( - $this->money - ); - $amount2 = new MoneyAmount( - $this->money - ); - $amount3 = new MoneyAmount( - $this->secondMoney - ); + $amount1 = new MoneyAmount($money); + $amount2 = new MoneyAmount($money); + $amount3 = new MoneyAmount($secondMoney); // THEN expect($amount1->equals($amount2))->toBeTrue() @@ -48,41 +38,39 @@ describe('money amount', function (): void { ->and($amount1->equals(null))->toBeFalse(); }); - it('converts to string by delegating to the wrapped Money', function (): void { + it('converts to string by delegating to the wrapped Money', function () use ($money): void { // GIVEN / WHEN - $amount = new MoneyAmount( - $this->money - ); + $amount = new MoneyAmount($money); // THEN expect((string) $amount)->toBe((string) $amount->value); }); - it('accepts the minimum amount of 0.01€', function (): void { + it('accepts the minimum amount of 0.01€', function () use ($minimumMoney): void { // GIVEN / WHEN - $amount = new MoneyAmount($this->minimumMoney); + $amount = new MoneyAmount($minimumMoney); // THEN - expect($amount->value)->toBe($this->minimumMoney); + expect($amount->value)->toBe($minimumMoney); }); - it('accepts the maximum amount of 3.00€', function (): void { + it('accepts the maximum amount of 3.00€', function () use ($money): void { // GIVEN / WHEN - $amount = new MoneyAmount($this->money); + $amount = new MoneyAmount($money); // THEN - expect($amount->value)->toBe($this->money); + expect($amount->value)->toBe($money); }); - it('throws when the amount is zero', function (): void { + it('throws when the amount is zero', function () use ($zeroMoney): void { // GIVEN / WHEN / THEN - expect(fn () => new MoneyAmount($this->zeroMoney)) + expect(fn (): MoneyAmount => new MoneyAmount($zeroMoney)) ->toThrow(\DomainException::class, 'Money amount must be between 0.01€ and 3.00€'); }); - it('throws when the amount is above 3.00€', function (): void { + it('throws when the amount is above 3.00€', function () use ($aboveLimitMoney): void { // GIVEN / WHEN / THEN - expect(fn () => new MoneyAmount($this->aboveLimitMoney)) + expect(fn (): MoneyAmount => new MoneyAmount($aboveLimitMoney)) ->toThrow(\DomainException::class, 'Money amount must be between 0.01€ and 3.00€'); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/PeriodTest.php b/tests/Unit/Domain/Models/ValueObjects/PeriodTest.php similarity index 60% rename from tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/PeriodTest.php rename to tests/Unit/Domain/Models/ValueObjects/PeriodTest.php index e161db9..444c536 100644 --- a/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/PeriodTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/PeriodTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Unit\Domain\Models\ValueObjects\RequestedCoupon; +namespace Tests\Unit\Domain\Models\ValueObjects; use Domain\Models\ValueObjects\Period; use DateTime; @@ -10,22 +10,17 @@ use DateTime; covers(Period::class); describe('period', function (): void { - beforeEach(function (): void { - $this->start = new DateTime('2026-01-01'); - $this->end = new DateTime('2026-01-31'); - }); + $start = new DateTime('2026-01-01'); + $end = new DateTime('2026-01-31'); - it('creates a valid period', function (): void { + it('creates a valid period', function () use ($start, $end): void { // GIVEN / WHEN - $period = new Period( - $this->start, - $this->end - ); + $period = new Period($start, $end); // THEN expect($period)->toBeInstanceOf(Period::class) - ->and($period->start)->toBe($this->start) - ->and($period->end)->toBe($this->end); + ->and($period->start)->toBe($start) + ->and($period->end)->toBe($end); }); it('accepts a period whose end equals its start', function (): void { @@ -43,7 +38,7 @@ describe('period', function (): void { it('throws when the end is before the start', function (): void { // GIVEN / WHEN / THEN - expect(fn () => new Period(new DateTime('2026-02-01'), new DateTime('2026-01-01'))) + expect(fn (): Period => new Period(new DateTime('2026-02-01'), new DateTime('2026-01-01'))) ->toThrow(\DomainException::class, 'Period end date cannot be before its start date.'); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php index d45e709..7a7ca4b 100644 --- a/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php @@ -9,79 +9,90 @@ use Domain\Models\ValueObjects\PrintRules; covers(PrintRules::class); describe('print rules', function (): void { - beforeEach(function (): void { - $this->maxPerCustomer = 3; - $this->minMaxPerCustomer = 1; - $this->zeroMaxPerCustomer = 0; - $this->cooldownDays = 30; - $this->minCooldownDays = 1; - $this->zeroCooldownDays = 0; - }); + $maxPerCustomer = 3; + $minMaxPerCustomer = 1; + $zeroMaxPerCustomer = 0; + $cooldownDays = 30; + $minCooldownDays = 1; + $zeroCooldownDays = 0; - it('creates print rules with both fields set', function (): void { + it('rejects print rules with both fields set', function () use ($maxPerCustomer, $cooldownDays): void { // GIVEN / WHEN - $printRules = new PrintRules( - maxPerCustomer: $this->maxPerCustomer, - cooldownDays: $this->cooldownDays + $printRules = fn (): PrintRules => new PrintRules( + maxPerCustomer: $maxPerCustomer, + cooldownDays: $cooldownDays ); // THEN - expect($printRules)->toBeInstanceOf(PrintRules::class) - ->and($printRules->maxPerCustomer)->toBe($this->maxPerCustomer) - ->and($printRules->cooldownDays)->toBe($this->cooldownDays); + expect($printRules) + ->toThrow(\DomainException::class, 'No more than one print rule.'); }); - it('creates print rules with only maxPerCustomer set', function (): void { + it('creates print rules with only maxPerCustomer set', function () use ($maxPerCustomer): void { // GIVEN / WHEN $printRules = new PrintRules( - maxPerCustomer: $this->maxPerCustomer + maxPerCustomer: $maxPerCustomer ); // THEN - expect($printRules->maxPerCustomer)->toBe($this->maxPerCustomer) + expect($printRules->maxPerCustomer)->toBe($maxPerCustomer) ->and($printRules->cooldownDays)->toBeNull(); }); - it('creates print rules with only cooldownDays set', function (): void { + it('creates print rules with only cooldownDays set', function () use ($cooldownDays): void { // GIVEN / WHEN $printRules = new PrintRules( - cooldownDays: $this->cooldownDays + cooldownDays: $cooldownDays ); // THEN expect($printRules->maxPerCustomer)->toBeNull() - ->and($printRules->cooldownDays)->toBe($this->cooldownDays); + ->and($printRules->cooldownDays)->toBe($cooldownDays); }); 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.'); + ->toThrow(\DomainException::class, 'PrintRules require at least a max per customer or a cooldown days.'); }); - it('accepts the minimum value of 1 for both fields', function (): void { - // GIVEN / WHEN + it('rejects a maxPerCustomer equal to zero', function () use ($zeroMaxPerCustomer): void { + // GIVEN / WHEN / THEN + expect(fn (): PrintRules => new PrintRules( + maxPerCustomer: $zeroMaxPerCustomer + ))->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(\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: $this->minMaxPerCustomer, - cooldownDays: $this->minCooldownDays + maxPerCustomer: $minMaxPerCustomer ); + // WHEN + $action = fn () => $printRules->hasPrintLimitNotReached($maxPerCustomer); + // THEN - expect($printRules->maxPerCustomer)->toBe($this->minMaxPerCustomer) - ->and($printRules->cooldownDays)->toBe($this->minCooldownDays); + expect($action)->toThrow(\DomainException::class, 'Maximum number of prints reached.'); }); - it('rejects a maxPerCustomer equal to zero', function (): void { - // GIVEN / WHEN / THEN - expect(fn (): PrintRules => new PrintRules( - maxPerCustomer: $this->zeroMaxPerCustomer - ))->toThrow(\InvalidArgumentException::class, 'Max per customer must be greater than 0.'); - }); + it('rejects print rule when cooldown days period has expired', function () use ($cooldownDays, $minCooldownDays): void { + // GIVEN + $printRules = new PrintRules( + cooldownDays: $cooldownDays + ); - it('rejects a cooldownDays equal to zero', function (): void { - // GIVEN / WHEN / THEN - expect(fn (): PrintRules => new PrintRules( - cooldownDays: $this->zeroCooldownDays - ))->toThrow(\InvalidArgumentException::class, 'Cooldown days must be greater than 0.'); + // WHEN + $action = fn () => $printRules->hasReprintPeriodExpired($minCooldownDays); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Reprint period has not expired.'); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCodeTest.php new file mode 100644 index 0000000..0bc9d6d --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCodeTest.php @@ -0,0 +1,32 @@ +value)->toBe($expected); + })->with([ + 'one' => [1, '0000000000001'], + 'zero' => [0, '0000000000000'], + 'full size' => [5425003944130, '5425003944130'], + ]); + + it('rejects a negative or too large sequence', function (int $sequence): void { + $action = fn (): MarketingDirectCode => MarketingDirectCode::fromInt($sequence); + + expect($action)->toThrow(\DomainException::class); + })->with([ + 'negative' => [-1], + 'too large' => [10_000_000_000_000], + ]); + + it('converts back to its integer', function (): void { + expect(MarketingDirectCode::fromInt(42)->toInt())->toBe(42); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCodeTest.php new file mode 100644 index 0000000..ab786fb --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCodeTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(PrintableCouponCode::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = PrintableCouponCode::generate(); + $second = PrintableCouponCode::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponIdTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponIdTest.php new file mode 100644 index 0000000..76c4f07 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponIdTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(PrintedCouponId::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = PrintedCouponId::generate(); + $second = PrintedCouponId::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponTest.php new file mode 100644 index 0000000..bae0a00 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponTest.php @@ -0,0 +1,62 @@ +id)->toBe($id) + ->and($printed->printableCode)->toBe($printableCode) + ->and($printed->couponCode)->toBe($couponCode) + ->and($printed->campaignCode)->toBe($campaignCode) + ->and($printed->customer)->toBe($customer) + ->and($printed->printedAt)->toBe($printedAt); + }); + + it('derives the printing date from its id', function () use ($marketingCode, $printableCode, $couponCode, $campaignCode, $customer): void { + // GIVEN + $id = PrintedCouponId::generate(); + $printed = new PrintedCoupon( + $id, + $marketingCode, + $printableCode, + $couponCode, + $campaignCode, + $customer, + new \DateTimeImmutable('2020-01-01'), + ); + + // WHEN + $date = $printed->printedAt(); + + // THEN + expect($date)->toEqual($id->toDateTime()) + ->and($date)->not->toEqual($printed->printedAt); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedCouponTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedCouponTest.php new file mode 100644 index 0000000..03d5789 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedCouponTest.php @@ -0,0 +1,69 @@ +printableCode)->toBe($printableCode) + ->and($unprinted->couponCode)->toBe($couponCode) + ->and($unprinted->campaignCode)->toBe($campaignCode) + ->and($unprinted->customer)->toBe($customer) + ->and($unprinted->unprintedReason)->toBe($reason); + }); + + it('keeps the reason as given without trimming it', function () use ($printableCode, $couponCode, $campaignCode, $customer): void { + // GIVEN + $reason = new UnprintedReason(' Printer failure '); + + // WHEN + $unprinted = new UnprintedCoupon($printableCode, $couponCode, $campaignCode, $customer, $reason); + + // THEN + expect($unprinted->unprintedReason)->toBe($reason); + }); + + it('rejects an empty reason', function (string $invalidReason) use ($printableCode, $couponCode, $campaignCode, $customer): void { + // GIVEN / WHEN + $action = fn (): UnprintedCoupon => new UnprintedCoupon( + $printableCode, + $couponCode, + $campaignCode, + $customer, + new UnprintedReason($invalidReason), + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Unprinted reason cannot be empty.'); + })->with([ + 'empty' => '', + 'spaces' => ' ', + 'tab' => "\t", + 'new lines' => "\n\r\n", + 'mixed spaces' => " \t\n ", + 'unicode spaces' => "\u{00A0}\u{2003}", + ]); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedReasonTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedReasonTest.php new file mode 100644 index 0000000..c4cf3f9 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedReasonTest.php @@ -0,0 +1,33 @@ +toBeInstanceOf(UnprintedReason::class) + ->and($reason->value)->toBe($value); + }); + + it('rejects an empty or whitespace-only reason', function (string $invalidValue): void { + // WHEN + $action = fn (): UnprintedReason => new UnprintedReason($invalidValue); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Unprinted reason cannot be empty.'); + })->with([ + 'empty' => '', + 'spaces' => ' ', + ]); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/ProcessingCode.php b/tests/Unit/Domain/Models/ValueObjects/ProcessingCode.php new file mode 100644 index 0000000..64b7681 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/ProcessingCode.php @@ -0,0 +1,45 @@ +value)->toBe($value); + }); + + it('creates a valid processing code BENELUX', function () use ($value): void { + // GIVEN / WHEN + $processingCode = new ProcessingCode( + $value, + Country::BENELUX, + ); + + // THEN + expect($processingCode->value)->toBe($value); + }); + + it('rejects invalid processing code', function () use ($emptyValue): void { + // GIVEN / WHEN / THEN + expect(fn (): ProcessingCode => new ProcessingCode( + $emptyValue, + Country::FRANCE, + ))->toThrow(\DomainException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeArrayTest.php b/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeArrayTest.php new file mode 100644 index 0000000..c71da19 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeArrayTest.php @@ -0,0 +1,106 @@ +value)->toBe($processingCodes); + }); + + it('accepts additional countries next to France', function () use ($franceCode, $beneluxCode): void { + // GIVEN + $processingCodes = [$beneluxCode, $franceCode]; + + // WHEN + $array = new ProcessingCodeArray($processingCodes); + + // THEN + expect($array->value)->toBe($processingCodes); + }); + + it('rejects an empty array', function (): void { + // GIVEN / WHEN + $action = fn (): ProcessingCodeArray => new ProcessingCodeArray([]); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'ProcessingCodes requires at least one ProcessingCode.', + ); + }); + + + it('rejects two legal informations for the same country', function () use ($franceCode): void { + // GIVEN + $informations = [ + $franceCode, + new ProcessingCode('5425003944130', Country::FRANCE), + ]; + + // WHEN + $action = fn (): ProcessingCodeArray => new ProcessingCodeArray($informations); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Only one ProcessingCode is allowed per country, duplicate for FRANCE.', + ); + }); + + it('rejects a non processing code element', function (): void { + // GIVEN / WHEN + $action = fn (): ProcessingCodeArray => new ProcessingCodeArray(['ABC1234567890']); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of ProcessingCodeArray must be ProcessingCode instances.', + ); + }); + + it('rejects a non processing code element mixed with valid ones', function () use ($franceCode): void { + // GIVEN + $processingCodes = [$franceCode, 'ABC1234567890']; + + // WHEN + $action = fn (): ProcessingCodeArray => new ProcessingCodeArray($processingCodes); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'Value of ProcessingCodeArray must be ProcessingCode instances.', + ); + }); + + it('rejects an array without France processing code', function () use ($beneluxCode): void { + // GIVEN + $processingCodes = [$beneluxCode]; + + // WHEN + $action = fn (): ProcessingCodeArray => new ProcessingCodeArray($processingCodes); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'ProcessingCodes requires a ProcessingCode for France.', + ); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeTest.php deleted file mode 100644 index 2946cd1..0000000 --- a/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeTest.php +++ /dev/null @@ -1,33 +0,0 @@ -value = '1010100101'; - $this->emptyValue = ''; - }); - - it('creates a valid processing code', function (): void { - // GIVEN / WHEN - $processingCode = new ProcessingCode( - $this->value - ); - - // THEN - expect($processingCode->value)->toBe($this->value); - }); - - it('rejects invalid processing code', function (): void { - // GIVEN / WHEN / THEN - expect(fn (): ProcessingCode => new ProcessingCode( - $this->emptyValue - ))->toThrow(\DomainException::class); - }); -}); diff --git a/tests/Unit/Domain/Models/ValueObjects/RawPictureTest.php b/tests/Unit/Domain/Models/ValueObjects/RawPictureTest.php index 8df5a9c..73e7858 100644 --- a/tests/Unit/Domain/Models/ValueObjects/RawPictureTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/RawPictureTest.php @@ -61,7 +61,7 @@ describe('raw picture', function (): void { it('measures size in raw bytes rather than characters', function (): void { // GIVEN - $halfLimit = (int) (RawPicturePath::MAX_SIZE_IN_BYTES / 2) + 1; + $halfLimit = RawPicturePath::MAX_SIZE_IN_BYTES / 2 + 1; $content = str_repeat('é', $halfLimit); // WHEN diff --git a/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantityTest.php b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantityTest.php index e5f835c..e6bfc09 100644 --- a/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantityTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantityTest.php @@ -9,38 +9,30 @@ use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity; covers(RequestedCouponQuantity::class); describe('quantity', function (): void { - beforeEach(function (): void { - $this->value = 10; - $this->zeroValue = 0; - $this->negativeValue = -1; - }); + $value = 10; + $zeroValue = 0; + $negativeValue = -1; - it('creates a valid quantity', function (): void { + it('creates a valid quantity', function () use ($value): void { // GIVEN / WHEN - $quantity = new RequestedCouponQuantity( - $this->value - ); + $quantity = new RequestedCouponQuantity($value); // THEN expect($quantity)->toBeInstanceOf(RequestedCouponQuantity::class) - ->and($quantity->value)->toBe($this->value); + ->and($quantity->value)->toBe($value); }); - it('rejects a quantity of zero', function (): void { + it('rejects a quantity of zero', function () use ($zeroValue): void { // GIVEN / WHEN - $action = fn (): RequestedCouponQuantity => new RequestedCouponQuantity( - $this->zeroValue - ); + $action = fn (): RequestedCouponQuantity => new RequestedCouponQuantity($zeroValue); // THEN expect($action)->toThrow(\DomainException::class, 'Quantity should be more than 0.'); }); - it('rejects a negative quantity', function (): void { + it('rejects a negative quantity', function () use ($negativeValue): void { // GIVEN / WHEN - $action = fn (): RequestedCouponQuantity => new RequestedCouponQuantity( - $this->negativeValue - ); + $action = fn (): RequestedCouponQuantity => new RequestedCouponQuantity($negativeValue); // THEN expect($action)->toThrow(\DomainException::class, 'Quantity should be more than 0.'); diff --git a/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponTest.php b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponTest.php index f55da32..392fa6a 100644 --- a/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponTest.php @@ -4,18 +4,21 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\ValueObjects\RequestedCoupon; -use Carbon\CarbonImmutable; use DateTime; -use Domain\Enums\BarcodeFormat; -use Domain\Models\ValueObjects\Coupon\CouponValidity; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformation; +use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray; +use Domain\Models\ValueObjects\Coupon\CouponName; +use Domain\Models\ValueObjects\Coupon\CouponPicture; +use Domain\Models\ValueObjects\Coupon\CouponProductArray; use Domain\Models\ValueObjects\Coupon\CouponValidityInformation; -use Domain\Models\ValueObjects\CouponName; -use Domain\Models\ValueObjects\GS1; +use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray; use Domain\Models\ValueObjects\IncoCode; use Domain\Models\ValueObjects\MoneyAmount; use Domain\Models\ValueObjects\Period; +use Domain\Models\ValueObjects\PicturePath; use Domain\Models\ValueObjects\PrintRules; use Domain\Models\ValueObjects\ProcessingCode; +use Domain\Models\ValueObjects\ProcessingCodeArray; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon; use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity; use Shared\Enums\Country; @@ -25,210 +28,265 @@ use Shared\ValueObjects\Money; covers(RequestedCoupon::class); describe('requested coupon', function (): void { - beforeEach(function (): void { - $this->name = new CouponName('Coupon A'); - $this->quantity = new RequestedCouponQuantity(100); - $this->diffusion = new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')); - $this->moneyAmount = new MoneyAmount(new Money(Currency::EUR, 300)); - $this->processingCode = new ProcessingCode('PC1'); - $this->couponValidity = new CouponValidity(30, new DateTime('2026-02-01')); - $this->franceValidityInformation = new CouponValidityInformation( - 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', - Country::FRANCE - ); - $this->beneluxValidityInformation = new CouponValidityInformation( - 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', - Country::BENELUX - ); - $this->couponValidityInformation = [ - $this->franceValidityInformation, - $this->beneluxValidityInformation, - ]; - $this->products = [new IncoCode(35784), new IncoCode(15522)]; - $this->printRules = new PrintRules(maxPerCustomer: 3); - $this->gs1 = new GS1('01234567890128', BarcodeFormat::EAN13); - }); + $couponName = new CouponName('Coupon A'); + $quantity = new RequestedCouponQuantity(100); + $diffusion = new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')); + $moneyAmount = new MoneyAmount(new Money(Currency::EUR, 300)); + $couponPicture = new CouponPicture( + new PicturePath('files/product.jpg'), + new PicturePath('files/thumbnail.jpg'), + new PicturePath('files/mobileThumbnail.jpg') + ); - afterEach(function (): void { - CarbonImmutable::setTestNow(); - }); + $franceProcessingCode = new ProcessingCode('5425003944130', Country::FRANCE); + $beneluxProcessingCode = new ProcessingCode('5425003944147', Country::BENELUX); + $couponProcessingCodes = new ProcessingCodeArray([ + $franceProcessingCode, + $beneluxProcessingCode, + ]); - it('hydrates a requested coupon with all its fields', function (): void { + $franceValidityInformation = new CouponValidityInformation( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', + Country::FRANCE + ); + $beneluxValidityInformation = new CouponValidityInformation( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', + Country::BENELUX + ); + $couponValidityInformations = new CouponValidityInformationArray([ + $franceValidityInformation, + $beneluxValidityInformation, + ]); + + $franceLegalInformation = new CouponLegalInformation( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', + Country::FRANCE + ); + $beneluxLegalInformation = new CouponLegalInformation( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', + Country::BENELUX + ); + $couponLegalInformations = new CouponLegalInformationArray([ + $franceLegalInformation, + $beneluxLegalInformation, + ]); + + $couponProducts = new CouponProductArray([ + new IncoCode(35784), + new IncoCode(15522), + ]); + $printRules = new PrintRules(maxPerCustomer: 3); + + it('hydrates a requested coupon with all its fields', function () use ( + $couponName, + $quantity, + $diffusion, + $moneyAmount, + $couponPicture, + $couponProcessingCodes, + $couponValidityInformations, + $couponLegalInformations, + $couponProducts, + $printRules, + ): void { // GIVEN / WHEN $coupon = new RequestedCoupon( - $this->name, - $this->quantity, - $this->diffusion, - $this->moneyAmount, - $this->processingCode, - $this->couponValidity, - $this->couponValidityInformation, - $this->products, - $this->printRules, - $this->gs1, + couponName: $couponName, + quantity: $quantity, + diffusion: $diffusion, + moneyAmount: $moneyAmount, + couponPicture: $couponPicture, + couponProcessingCodes: $couponProcessingCodes, + couponValidityInformations: $couponValidityInformations, + couponLegalInformations: $couponLegalInformations, + couponProducts: $couponProducts, + printRules: $printRules, ); // THEN - expect($coupon->name)->toBe($this->name) - ->and($coupon->quantity)->toBe($this->quantity) - ->and($coupon->diffusion)->toBe($this->diffusion) - ->and($coupon->moneyAmount)->toBe($this->moneyAmount) - ->and($coupon->processingCode)->toBe($this->processingCode) - ->and($coupon->couponValidity)->toBe($this->couponValidity) - ->and($coupon->couponValidityInformation)->toBe($this->couponValidityInformation) - ->and($coupon->products)->toBe($this->products) - ->and($coupon->printRules)->toBe($this->printRules) - ->and($coupon->gs1)->toBe($this->gs1); + expect($coupon->couponName)->toBe($couponName) + ->and($coupon->quantity)->toBe($quantity) + ->and($coupon->diffusion)->toBe($diffusion) + ->and($coupon->moneyAmount)->toBe($moneyAmount) + ->and($coupon->couponPicture)->toBe($couponPicture) + ->and($coupon->couponProcessingCodes)->toBe($couponProcessingCodes) + ->and($coupon->couponValidityInformations)->toBe($couponValidityInformations) + ->and($coupon->couponLegalInformations)->toBe($couponLegalInformations) + ->and($coupon->couponProducts)->toBe($couponProducts) + ->and($coupon->printRules)->toBe($printRules); }); - it('hydrates a requested coupon without products', function (): void { + it('hydrates a requested coupon without products', function () use ( + $couponName, + $quantity, + $diffusion, + $moneyAmount, + $couponPicture, + $couponProcessingCodes, + $couponValidityInformations, + $couponLegalInformations, + $printRules, + ): void { // GIVEN / WHEN $coupon = new RequestedCoupon( - name: $this->name, - quantity: $this->quantity, - diffusion: $this->diffusion, - moneyAmount: $this->moneyAmount, - processingCode: $this->processingCode, - couponValidity: $this->couponValidity, - couponValidityInformation: $this->couponValidityInformation, - printRules: $this->printRules, - gs1: $this->gs1 + couponName: $couponName, + quantity: $quantity, + diffusion: $diffusion, + moneyAmount: $moneyAmount, + couponPicture: $couponPicture, + couponProcessingCodes: $couponProcessingCodes, + couponValidityInformations: $couponValidityInformations, + couponLegalInformations: $couponLegalInformations, + couponProducts: new CouponProductArray(), + printRules: $printRules, ); // THEN - expect($coupon->products)->toBe([]) - ->and($coupon->printRules)->toBe($this->printRules) - ->and($coupon->gs1)->toBe($this->gs1); + expect($coupon->couponProducts->value)->toBe([]) + ->and($coupon->printRules)->toBe($printRules); }); - it('hydrates a requested coupon without print rules', function (): void { + it('hydrates a requested coupon without print rules', function () use ( + $couponName, + $quantity, + $diffusion, + $moneyAmount, + $couponPicture, + $couponProcessingCodes, + $couponValidityInformations, + $couponLegalInformations, + $couponProducts, + ): void { // GIVEN / WHEN $coupon = new RequestedCoupon( - name: $this->name, - quantity: $this->quantity, - diffusion: $this->diffusion, - moneyAmount: $this->moneyAmount, - processingCode: $this->processingCode, - couponValidity: $this->couponValidity, - couponValidityInformation: $this->couponValidityInformation, - products: $this->products, - gs1: $this->gs1 + couponName: $couponName, + quantity: $quantity, + diffusion: $diffusion, + moneyAmount: $moneyAmount, + couponPicture: $couponPicture, + couponProcessingCodes: $couponProcessingCodes, + couponValidityInformations: $couponValidityInformations, + couponLegalInformations: $couponLegalInformations, + couponProducts: $couponProducts, ); // THEN expect($coupon->printRules)->toBeNull() - ->and($coupon->products)->toBe($this->products) - ->and($coupon->gs1)->toBe($this->gs1); + ->and($coupon->couponProducts)->toBe($couponProducts); }); - it('hydrates a requested coupon without gs1', function (): void { + it('accepts a france only coupon', function () use ( + $couponName, + $quantity, + $diffusion, + $moneyAmount, + $couponPicture, + $franceProcessingCode, + $franceValidityInformation, + $franceLegalInformation, + $couponProducts, + ): void { // GIVEN / WHEN $coupon = new RequestedCoupon( - name: $this->name, - quantity: $this->quantity, - diffusion: $this->diffusion, - moneyAmount: $this->moneyAmount, - processingCode: $this->processingCode, - couponValidity: $this->couponValidity, - couponValidityInformation: $this->couponValidityInformation, - products: $this->products, - printRules: $this->printRules + couponName: $couponName, + quantity: $quantity, + diffusion: $diffusion, + moneyAmount: $moneyAmount, + couponPicture: $couponPicture, + couponProcessingCodes: new ProcessingCodeArray([ + $franceProcessingCode, + ]), + couponValidityInformations: new CouponValidityInformationArray([ + $franceValidityInformation, + ]), + couponLegalInformations: new CouponLegalInformationArray([ + $franceLegalInformation, + ]), + couponProducts: $couponProducts, ); // THEN - expect($coupon->gs1)->toBeNull() - ->and($coupon->products)->toBe($this->products) - ->and($coupon->printRules)->toBe($this->printRules); + expect($coupon)->toBeInstanceOf(RequestedCoupon::class); }); - it('hydrates a requested coupon with only required fields', function (): void { - // GIVEN / WHEN - $coupon = new RequestedCoupon( - name: $this->name, - quantity: $this->quantity, - diffusion: $this->diffusion, - moneyAmount: $this->moneyAmount, - processingCode: $this->processingCode, - couponValidity: $this->couponValidity, - couponValidityInformation: $this->couponValidityInformation, - ); - - // THEN - expect($coupon->products)->toBe([]) - ->and($coupon->printRules)->toBeNull() - ->and($coupon->gs1)->toBeNull(); - }); - - it('throws when there is no validity information for france', function (): void { + it('throws when there is no processing code for france', function () use ( + $couponName, + $quantity, + $diffusion, + $moneyAmount, + $couponPicture, + $beneluxProcessingCode, + $couponValidityInformations, + $couponLegalInformations, + $couponProducts, + ): void { // GIVEN / WHEN / THEN - expect(fn () => new RequestedCoupon( - name: $this->name, - quantity: $this->quantity, - diffusion: $this->diffusion, - moneyAmount: $this->moneyAmount, - processingCode: $this->processingCode, - couponValidity: $this->couponValidity, - couponValidityInformation: [], - ))->toThrow(\DomainException::class, 'A coupon requires validity information for France.'); + expect(fn (): RequestedCoupon => new RequestedCoupon( + couponName: $couponName, + quantity: $quantity, + diffusion: $diffusion, + moneyAmount: $moneyAmount, + couponPicture: $couponPicture, + couponProcessingCodes: new ProcessingCodeArray([ + $beneluxProcessingCode, + ]), + couponValidityInformations: $couponValidityInformations, + couponLegalInformations: $couponLegalInformations, + couponProducts: $couponProducts, + ))->toThrow(\DomainException::class, 'ProcessingCodes requires a ProcessingCode for France.'); }); - it('throws when validity information contains an invalid item', function (): void { + it('throws when a benelux processing code has no benelux validity information', function () use ( + $couponName, + $quantity, + $diffusion, + $moneyAmount, + $couponPicture, + $couponProcessingCodes, + $franceValidityInformation, + $couponLegalInformations, + $couponProducts, + ): void { // GIVEN / WHEN / THEN - expect(fn () => new RequestedCoupon( - $this->name, - $this->quantity, - $this->diffusion, - $this->moneyAmount, - $this->processingCode, - $this->couponValidity, - ['Not a CouponValidityInformation'], - $this->products, - $this->printRules, - $this->gs1 - ))->toThrow(\DomainException::class, 'Coupon validity information must be CouponValidityInformation instances.'); + expect(fn (): RequestedCoupon => new RequestedCoupon( + couponName: $couponName, + quantity: $quantity, + diffusion: $diffusion, + moneyAmount: $moneyAmount, + couponPicture: $couponPicture, + couponProcessingCodes: $couponProcessingCodes, + couponValidityInformations: new CouponValidityInformationArray([ + $franceValidityInformation, + ]), + couponLegalInformations: $couponLegalInformations, + couponProducts: $couponProducts, + ))->toThrow(\DomainException::class, 'A coupon requires validity information for BENELUX.'); }); - it('throws when a gs1 coupon has no benelux validity information', function (): void { + it('throws when a benelux processing code has no benelux legal information', function () use ( + $couponName, + $quantity, + $diffusion, + $moneyAmount, + $couponPicture, + $couponProcessingCodes, + $couponValidityInformations, + $franceLegalInformation, + $couponProducts, + ): void { // GIVEN / WHEN / THEN - expect(fn () => new RequestedCoupon( - name: $this->name, - quantity: $this->quantity, - diffusion: $this->diffusion, - moneyAmount: $this->moneyAmount, - processingCode: $this->processingCode, - couponValidity: $this->couponValidity, - couponValidityInformation: [$this->franceValidityInformation], - gs1: $this->gs1, - ))->toThrow(\DomainException::class, 'A GS1 coupon requires validity information for Benelux.'); - }); - - it('throws when a gs1 coupon has only benelux validity information', function (): void { - // GIVEN / WHEN / THEN - expect(fn () => new RequestedCoupon( - name: $this->name, - quantity: $this->quantity, - diffusion: $this->diffusion, - moneyAmount: $this->moneyAmount, - processingCode: $this->processingCode, - couponValidity: $this->couponValidity, - couponValidityInformation: [$this->beneluxValidityInformation], - gs1: $this->gs1, - ))->toThrow(\DomainException::class, 'A coupon requires validity information for France.'); - }); - - it('accepts a coupon without gs1 and without benelux validity information', function (): void { - // GIVEN / WHEN - $coupon = new RequestedCoupon( - name: $this->name, - quantity: $this->quantity, - diffusion: $this->diffusion, - moneyAmount: $this->moneyAmount, - processingCode: $this->processingCode, - couponValidity: $this->couponValidity, - couponValidityInformation: [$this->franceValidityInformation], - ); - - // THEN - expect($coupon->gs1)->toBeNull(); + expect(fn (): RequestedCoupon => new RequestedCoupon( + couponName: $couponName, + quantity: $quantity, + diffusion: $diffusion, + moneyAmount: $moneyAmount, + couponPicture: $couponPicture, + couponProcessingCodes: $couponProcessingCodes, + couponValidityInformations: $couponValidityInformations, + couponLegalInformations: new CouponLegalInformationArray([ + $franceLegalInformation, + ]), + couponProducts: $couponProducts, + ))->toThrow(\DomainException::class, 'A coupon requires legal information for BENELUX.'); }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Stock/StockAdjustmentTest.php b/tests/Unit/Domain/Models/ValueObjects/Stock/StockAdjustmentTest.php index 42441f1..c897668 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Stock/StockAdjustmentTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Stock/StockAdjustmentTest.php @@ -10,73 +10,71 @@ use Domain\Models\ValueObjects\Stock\StockAdjustment; covers(StockAdjustment::class); describe('stock adjustment', function (): void { - beforeEach(function (): void { - $this->quantity = 10; - $this->zeroQuantity = 0; - $this->negativeQuantity = -5; - $this->reason = 'Inventory correction'; - }); + $quantity = 10; + $zeroQuantity = 0; + $negativeQuantity = -5; + $reason = 'Inventory correction'; - it('creates a valid stock adjustment', function (): void { + it('creates a valid stock adjustment', function () use ($quantity, $reason): void { // GIVEN $actorId = ActorId::generate(); // WHEN $adjustment = new StockAdjustment( - $this->quantity, - $this->reason, - $actorId + $quantity, + $reason, + $actorId, ); // THEN expect($adjustment)->toBeInstanceOf(StockAdjustment::class) - ->and($adjustment->quantity)->toBe($this->quantity) - ->and($adjustment->reason)->toBe($this->reason) + ->and($adjustment->quantity)->toBe($quantity) + ->and($adjustment->reason)->toBe($reason) ->and($adjustment->actorId)->toBe($actorId) ->and($adjustment->createdAt)->toBeNull(); }); - it('creates a stock adjustment with an explicit createdAt date', function (): void { + it('creates a stock adjustment with an explicit createdAt date', function () use ($quantity, $reason): void { // GIVEN $actorId = ActorId::generate(); $createdAt = new \DateTimeImmutable('2026-06-15 12:00:00', new \DateTimeZone('UTC')); // WHEN $adjustment = new StockAdjustment( - $this->quantity, - $this->reason, + $quantity, + $reason, $actorId, - $createdAt + $createdAt, ); // THEN expect($adjustment->createdAt)->toBe($createdAt); }); - it('rejects a quantity of zero', function (): void { + it('rejects a quantity of zero', function () use ($zeroQuantity, $reason): void { // GIVEN $actorId = ActorId::generate(); // WHEN $action = fn (): StockAdjustment => new StockAdjustment( - $this->zeroQuantity, - $this->reason, - $actorId + $zeroQuantity, + $reason, + $actorId, ); // THEN expect($action)->toThrow(\DomainException::class, 'Stock Amount should be more than 0.'); }); - it('rejects a negative quantity', function (): void { + it('rejects a negative quantity', function () use ($negativeQuantity, $reason): void { // GIVEN $actorId = ActorId::generate(); // WHEN $action = fn (): StockAdjustment => new StockAdjustment( - $this->negativeQuantity, - $this->reason, - $actorId + $negativeQuantity, + $reason, + $actorId, ); // THEN diff --git a/tests/Unit/Domain/Models/ValueObjects/Stock/StockAmountTest.php b/tests/Unit/Domain/Models/ValueObjects/Stock/StockAmountTest.php index 59a7753..539a44d 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Stock/StockAmountTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Stock/StockAmountTest.php @@ -9,38 +9,30 @@ use Domain\Models\ValueObjects\Stock\StockAmount; covers(StockAmount::class); describe('stock amount', function (): void { - beforeEach(function (): void { - $this->value = 10; - $this->zeroValue = 0; - $this->negativeValue = -1; - }); + $value = 10; + $zeroValue = 0; + $negativeValue = -1; - it('creates a valid stock amount', function (): void { + it('creates a valid stock amount', function () use ($value): void { // GIVEN / WHEN - $amount = new StockAmount( - $this->value - ); + $amount = new StockAmount($value); // THEN expect($amount)->toBeInstanceOf(StockAmount::class) - ->and($amount->value)->toBe($this->value); + ->and($amount->value)->toBe($value); }); - it('accepts a zero value', function (): void { + it('accepts a zero value', function () use ($zeroValue): void { // GIVEN / WHEN - $amount = new StockAmount( - $this->zeroValue - ); + $amount = new StockAmount($zeroValue); // THEN - expect($amount->value)->toBe($this->zeroValue); + expect($amount->value)->toBe($zeroValue); }); - it('rejects a negative value', function (): void { + it('rejects a negative value', function () use ($negativeValue): void { // GIVEN / WHEN - $action = fn (): StockAmount => new StockAmount( - $this->negativeValue - ); + $action = fn (): StockAmount => new StockAmount($negativeValue); // THEN expect($action)->toThrow(\DomainException::class, 'Stock Amount should be positive.'); diff --git a/tests/Unit/Domain/Enums/CountryTest.php b/tests/Unit/Shared/Enums/CountryTest.php similarity index 57% rename from tests/Unit/Domain/Enums/CountryTest.php rename to tests/Unit/Shared/Enums/CountryTest.php index 5a6c7b8..e548d13 100644 --- a/tests/Unit/Domain/Enums/CountryTest.php +++ b/tests/Unit/Shared/Enums/CountryTest.php @@ -4,9 +4,10 @@ declare(strict_types=1); namespace Tests\Unit\Shared\Enums; +use Shared\DDD\Exceptions\UnknownCountryIsoCodeException; use Shared\Enums\Country; -covers(Country::class); +covers(Country::class, UnknownCountryIsoCodeException::class); describe('country', function (): void { it('has the expected cases with their backing values', function (): void { @@ -19,4 +20,12 @@ describe('country', function (): void { // THEN expect(Country::FRANCE->name)->toBe('FRANCE'); }); + + it('is a domain exception', function (): void { + // GIVEN / WHEN + $exception = UnknownCountryIsoCodeException::fromIsoCode('XX'); + + // THEN + expect($exception)->toBeInstanceOf(\DomainException::class); + }); }); diff --git a/tests/Unit/Shared/Helpers/DatesHelperTest.php b/tests/Unit/Shared/Helpers/DatesHelperTest.php index 9a683ab..e7ac93a 100644 --- a/tests/Unit/Shared/Helpers/DatesHelperTest.php +++ b/tests/Unit/Shared/Helpers/DatesHelperTest.php @@ -250,7 +250,7 @@ describe('dates helper', function (): void { $invalidInput = 'not-a-valid-date'; // WHEN / THEN - expect(fn () => DatesHelper::parseDateTime($invalidInput)) + expect(fn (): \DateTimeInterface => DatesHelper::parseDateTime($invalidInput)) ->toThrow(InvalidDateFormatException::class); }); @@ -259,7 +259,7 @@ describe('dates helper', function (): void { $emptyInput = ' '; // WHEN / THEN - expect(fn () => DatesHelper::parseDateTime($emptyInput)) + expect(fn (): \DateTimeInterface => DatesHelper::parseDateTime($emptyInput)) ->toThrow(InvalidDateFormatException::class); }); }); diff --git a/tests/Unit/Shared/Helpers/UuidHelperTest.php b/tests/Unit/Shared/Helpers/UuidHelperTest.php index 4e4d3ad..67985f5 100644 --- a/tests/Unit/Shared/Helpers/UuidHelperTest.php +++ b/tests/Unit/Shared/Helpers/UuidHelperTest.php @@ -11,7 +11,7 @@ use Ramsey\Uuid\Uuid; use Shared\Helpers\Exceptions\InvalidUuidException; use Shared\Helpers\UuidHelper; -covers(UuidHelper::class); +covers(UuidHelper::class, InvalidUuidException::class); describe('Uuid Helper Unit Tests', function (): void { it('prevents direct instantiation but covers constructor', function (): void { @@ -23,7 +23,7 @@ describe('Uuid Helper Unit Tests', function (): void { $constructor->invoke($reflection->newInstanceWithoutConstructor()); // THEN - expect(fn () => $reflection->newInstance()) + expect(fn (): UuidHelper => $reflection->newInstance()) ->toThrow(\ReflectionException::class); }); @@ -83,6 +83,7 @@ describe('Uuid Helper Unit Tests', function (): void { public function unserialize(string $data): void { + // Keep empty. } public function __serialize(): array @@ -92,6 +93,7 @@ describe('Uuid Helper Unit Tests', function (): void { public function __unserialize(array $data): void { + // Keep empty. } }; @@ -127,7 +129,15 @@ describe('Uuid Helper Unit Tests', function (): void { $uuidV4 = Uuid::uuid4(); // WHEN & THEN - expect(fn () => UuidHelper::toDateTime($uuidV4)) + expect(fn (): \DateTimeImmutable => UuidHelper::toDateTime($uuidV4)) ->toThrow(InvalidUuidException::class, 'Cannot extract datetime from non-V7 UUID'); }); + + it('is an invalid argument exception', function (): void { + // GIVEN / WHEN + $exception = InvalidUuidException::forValue('foo'); + + // THEN + expect($exception)->toBeInstanceOf(\InvalidArgumentException::class); + }); }); diff --git a/tests/Unit/Shared/ValueObjects/Pagination/PageNumberTest.php b/tests/Unit/Shared/ValueObjects/Pagination/PageNumberTest.php index f23a423..4dfdff5 100644 --- a/tests/Unit/Shared/ValueObjects/Pagination/PageNumberTest.php +++ b/tests/Unit/Shared/ValueObjects/Pagination/PageNumberTest.php @@ -46,7 +46,7 @@ describe('PageNumber Value Object Unit Tests', function (): void { it('rejects page number less than or equal to zero', function (int $invalidValue): void { // WHEN / THEN - expect(fn () => new PageNumber($invalidValue)) + expect(fn (): PageNumber => new PageNumber($invalidValue)) ->toThrow(\InvalidArgumentException::class, 'Page number must be greater than 0'); })->with([ 'zero' => [0], diff --git a/tests/Unit/Shared/ValueObjects/Pagination/PageSizeTest.php b/tests/Unit/Shared/ValueObjects/Pagination/PageSizeTest.php index fa96ee3..09cbdad 100644 --- a/tests/Unit/Shared/ValueObjects/Pagination/PageSizeTest.php +++ b/tests/Unit/Shared/ValueObjects/Pagination/PageSizeTest.php @@ -54,7 +54,7 @@ describe('PageSize Value Object Unit Tests', function (): void { it('rejects page size less than or equal to zero', function (int $invalidValue): void { // WHEN / THEN - expect(fn () => new PageSize($invalidValue)) + expect(fn (): PageSize => new PageSize($invalidValue)) ->toThrow(\InvalidArgumentException::class, 'Page size must be greater than 0'); })->with([ 'zero' => [0], @@ -64,7 +64,7 @@ describe('PageSize Value Object Unit Tests', function (): void { it('rejects page size exceeding maximum page size', function (): void { // WHEN / THEN - expect(fn () => new PageSize(PageSize::MAX_PAGE_SIZE + 1)) + expect(fn (): PageSize => new PageSize(PageSize::MAX_PAGE_SIZE + 1)) ->toThrow(\InvalidArgumentException::class, 'Page size must be less than or equal to 10000'); });