diff --git a/src/Application/UseCases/CampaignRequest/Create/CreateCampaignRequest.php b/src/Application/UseCases/CampaignRequest/Create/CreateCampaignRequest.php new file mode 100644 index 0000000..9dc1b30 --- /dev/null +++ b/src/Application/UseCases/CampaignRequest/Create/CreateCampaignRequest.php @@ -0,0 +1,55 @@ + */ +final readonly class CreateCampaignRequest implements UseCase, Transactional +{ + public function __construct( + private CampaignRequestRepository $campaignRequestRepository, + private BrandRepository $brandRepository + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + if (!$this->brandRepository->existsByCode($input->brandCode)) { + throw new BrandNotFoundException($input->brandCode); + } + + $campaignRequest = CampaignRequest::create( + brandCode: $input->brandCode, + name: $input->name, + applicant: $input->applicant, + campaignType: $input->campaignType, + requestedCoupon: $input->requestedCoupon, + ); + + $this->campaignRequestRepository->save($campaignRequest); + + return new Output( + id: $campaignRequest->id(), + brandCode: $campaignRequest->brandCode, + name: $campaignRequest->name, + applicant: $campaignRequest->applicant, + campaignType: $campaignRequest->campaignType, + requestedCoupon: $campaignRequest->requestedCoupon, + status: $campaignRequest->status, + createdAt: $campaignRequest->createdAt(), + ); + } +} diff --git a/src/Application/UseCases/CampaignRequest/Create/Input.php b/src/Application/UseCases/CampaignRequest/Create/Input.php new file mode 100644 index 0000000..bffc422 --- /dev/null +++ b/src/Application/UseCases/CampaignRequest/Create/Input.php @@ -0,0 +1,24 @@ + */ +final readonly class FindCampaignRequest implements UseCase +{ + public function __construct( + private CampaignRequestRepository $repository, + private BrandRepository $brandRepository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $campaignRequest = $this->repository->findById($input->id); + + if (! $campaignRequest instanceof CampaignRequest) { + return new CampaignRequestNotFound(); + } + + $brand = $this->brandRepository->findByCode($campaignRequest->brandCode); + + if (! $brand instanceof Brand) { + throw new BrandNotFoundException($campaignRequest->brandCode); + } + + return new CampaignRequestFound( + brand: $brand, + name: $campaignRequest->name, + applicant: $campaignRequest->applicant, + campaignType: $campaignRequest->campaignType, + requestedCoupon: $campaignRequest->requestedCoupon, + status: $campaignRequest->status, + rejectionReason: $campaignRequest->rejectionReason, + createdAt: $campaignRequest->createdAt(), + updatedAt: $campaignRequest->updatedAt(), + ); + } +} diff --git a/src/Application/UseCases/CampaignRequest/Find/Input.php b/src/Application/UseCases/CampaignRequest/Find/Input.php new file mode 100644 index 0000000..29221ad --- /dev/null +++ b/src/Application/UseCases/CampaignRequest/Find/Input.php @@ -0,0 +1,16 @@ + */ +final readonly class ListCampaignsRequest implements UseCase +{ + public function __construct( + private CampaignRequestRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $result = $this->repository->findAll( + pagination: $input->pagination, + sortCriteria: $input->sort, + filters: $input->filters + ); + + $items = array_map( + static fn (CampaignRequest $campaignRequest): CampaignRequestListItem => new CampaignRequestListItem( + brandCode: $campaignRequest->brandCode, + name: $campaignRequest->name, + applicant: $campaignRequest->applicant, + campaignType: $campaignRequest->campaignType, + status: $campaignRequest->status + ), + $result->getItems(), + ); + + return new Output( + campaignsRequest: new Paginated( + pagination: $result->getPagination(), + items: $items, + total: $result->getTotal(), + ), + ); + } +} diff --git a/src/Application/UseCases/CampaignRequest/List/Output.php b/src/Application/UseCases/CampaignRequest/List/Output.php new file mode 100644 index 0000000..ef1d456 --- /dev/null +++ b/src/Application/UseCases/CampaignRequest/List/Output.php @@ -0,0 +1,17 @@ + $campaignsRequest */ + public function __construct( + public Paginated $campaignsRequest, + ) { + } +} diff --git a/src/Application/UseCases/CampaignRequest/Reject/Input.php b/src/Application/UseCases/CampaignRequest/Reject/Input.php new file mode 100644 index 0000000..d1783ff --- /dev/null +++ b/src/Application/UseCases/CampaignRequest/Reject/Input.php @@ -0,0 +1,18 @@ + */ +final readonly class RejectCampaignRequest implements UseCase, Transactional +{ + public function __construct( + private CampaignRequestRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $campaignRequest = $this->repository->findById($input->id); + + if (! $campaignRequest instanceof CampaignRequest) { + return new CampaignRequestNotFound(); + } + + $campaignRequest = $campaignRequest->reject($input->reason); + + $this->repository->save($campaignRequest); + + return new CampaignRequestRejected( + id: $campaignRequest->id(), + status: $campaignRequest->status, + rejectionReason:$campaignRequest->rejectionReason(), + updatedAt: $campaignRequest->updatedAt(), + ); + } +} diff --git a/src/Domain/Enums/BarcodeFormat.php b/src/Domain/Enums/BarcodeFormat.php new file mode 100644 index 0000000..f648d9d --- /dev/null +++ b/src/Domain/Enums/BarcodeFormat.php @@ -0,0 +1,12 @@ +value)); + } +} diff --git a/src/Domain/Models/Entities/Actor.php b/src/Domain/Models/Entities/Actor.php new file mode 100644 index 0000000..fecbafa --- /dev/null +++ b/src/Domain/Models/Entities/Actor.php @@ -0,0 +1,50 @@ + + */ +final readonly class Actor implements AggregateRoot, HasTimestampIdentifier +{ + private function __construct( + public ActorId $id, + public ActorDisplayName $displayName, + public ?\DateTimeInterface $updatedAt = null, + ) { + } + + public function id(): ActorId + { + return $this->id; + } + + public function createdAt(): \DateTimeInterface + { + return $this->id->toDateTime(); + } + + public function updatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + public static function hydrate( + ActorId $id, + ActorDisplayName $displayName, + ?\DateTimeInterface $updatedAt = null, + ): self { + return new self( + id: $id, + displayName: $displayName, + updatedAt: $updatedAt, + ); + } +} diff --git a/src/Domain/Models/Entities/Campaign.php b/src/Domain/Models/Entities/Campaign.php new file mode 100644 index 0000000..a52ae3a --- /dev/null +++ b/src/Domain/Models/Entities/Campaign.php @@ -0,0 +1,109 @@ + + */ +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 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 + { + return $this->id; + } + + public function createdAt(): \DateTimeInterface + { + return $this->id->toDateTime(); + } + + public function updatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + /** + * @param array $coupons + */ + public static function create( + CampaignCode $code, + CampaignName $name, + bool $publication, + bool $printAllAtOnce, + array $coupons, + BrandCode $brandCode, + ActorId $actorId, + ?\DateTimeInterface $updatedAt = null, + ): Campaign { + return new self( + id: CampaignId::generate(), + code: $code, + name: $name, + publication: $publication, + printAllAtOnce: $printAllAtOnce, + 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, + BrandCode $brandCode, + ActorId $actorId, + ?\DateTimeInterface $updatedAt = null, + ): self { + return new self( + id: $id, + code: $code, + name: $name, + publication: $publication, + printAllAtOnce: $printAllAtOnce, + coupons: $coupons, + brandCode: $brandCode, + actorId: $actorId, + updatedAt: $updatedAt, + ); + } +} diff --git a/src/Domain/Models/Entities/CampaignRequest.php b/src/Domain/Models/Entities/CampaignRequest.php new file mode 100644 index 0000000..b4e7882 --- /dev/null +++ b/src/Domain/Models/Entities/CampaignRequest.php @@ -0,0 +1,131 @@ + + */ +final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdentifier +{ + private function __construct( + public CampaignRequestId $id, + public BrandCode $brandCode, + public CampaignName $name, + public Applicant $applicant, + public CampaignRequestType $campaignType, + public RequestedCoupon $requestedCoupon, + public CampaignRequestStatus $status, + public ?RejectionReason $rejectionReason = null, + public ?\DateTimeInterface $updatedAt = null, + ) { + if ($this->status === CampaignRequestStatus::REJECTED && ! $this->rejectionReason instanceof RejectionReason) { + throw new \DomainException('A rejection reason is required when the campaign request is rejected.'); + } + + if ($this->status !== CampaignRequestStatus::REJECTED && $this->rejectionReason instanceof RejectionReason) { + throw new \DomainException('A rejection reason can only be set when the campaign request is rejected.'); + } + } + + public function id(): CampaignRequestId + { + return $this->id; + } + + public function createdAt(): \DateTimeInterface + { + return $this->id->toDateTime(); + } + + public function updatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + public static function create( + BrandCode $brandCode, + CampaignName $name, + Applicant $applicant, + CampaignRequestType $campaignType, + RequestedCoupon $requestedCoupon, + ?\DateTimeInterface $updatedAt = null, + ): self { + return new self( + id: CampaignRequestId::generate(), + brandCode: $brandCode, + name: $name, + applicant: $applicant, + campaignType: $campaignType, + requestedCoupon: $requestedCoupon, + status: CampaignRequestStatus::CREATED, + rejectionReason: null, + updatedAt: $updatedAt, + ); + } + + public static function hydrate( + CampaignRequestId $id, + BrandCode $brandCode, + CampaignName $name, + Applicant $applicant, + CampaignRequestType $campaignType, + RequestedCoupon $requestedCoupon, + CampaignRequestStatus $status, + ?RejectionReason $rejectionReason = null, + ?\DateTimeInterface $updatedAt = null, + ): self { + return new self( + id: $id, + brandCode: $brandCode, + name: $name, + applicant: $applicant, + campaignType: $campaignType, + requestedCoupon: $requestedCoupon, + status: $status, + rejectionReason: $rejectionReason, + updatedAt: $updatedAt, + ); + } + + public function reject(RejectionReason $reason): self + { + if ($this->status !== CampaignRequestStatus::CREATED) { + throw new \DomainException('Only a created campaign request can be rejected.'); + } + + return new self( + id: $this->id, + brandCode: $this->brandCode, + name: $this->name, + applicant: $this->applicant, + campaignType: $this->campaignType, + requestedCoupon: $this->requestedCoupon, + status: CampaignRequestStatus::REJECTED, + rejectionReason: $reason, + updatedAt: DatesHelper::now(), + ); + } + + public function rejectionReason(): RejectionReason + { + if (! $this->rejectionReason instanceof RejectionReason) { + throw new \DomainException('Only a rejected campaign request has a rejection reason.'); + } + + return $this->rejectionReason; + } +} diff --git a/src/Domain/Models/Entities/Coupon.php b/src/Domain/Models/Entities/Coupon.php new file mode 100644 index 0000000..f879cd9 --- /dev/null +++ b/src/Domain/Models/Entities/Coupon.php @@ -0,0 +1,165 @@ + + */ +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, + public CouponName $name, + public MoneyAmount $amount, + public CouponPicture $picture, + public CouponDiffusion $diffusion, + public CouponValidity $validity, + public array $couponValidityInformation, + public array $couponLegalInformation, + public ProcessingCode $processingCode, + public BrandCode $brandCode, + public ActorId $createdBy, + public array $products = [], + 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.'); + } + } + } + + public function id(): CouponId + { + return $this->id; + } + + public function createdAt(): \DateTimeInterface + { + return $this->id->toDateTime(); + } + + public function updatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + /** + * @param array $couponValidityInformation + * @param array $couponLegalInformation + * @param array $products + */ + public static function create( + CouponCode $code, + CouponName $name, + MoneyAmount $amount, + CouponPicture $picture, + CouponDiffusion $diffusion, + CouponValidity $validity, + array $couponValidityInformation, + array $couponLegalInformation, + ProcessingCode $processingCode, + BrandCode $brandCode, + ActorId $createdBy, + array $products = [], + ?CouponAvailabilityCalendar $availabilityCalendar = null, + ?GS1 $gs1 = null, + ?\DateTimeInterface $updatedAt = null, + ): Coupon { + return new self( + id: CouponId::generate(), + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validity: $validity, + couponValidityInformation: $couponValidityInformation, + couponLegalInformation: $couponLegalInformation, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + availabilityCalendar: $availabilityCalendar, + gs1: $gs1, + updatedAt: $updatedAt, + ); + } + + /** + * @param array $couponValidityInformation + * @param array $couponLegalInformation + * @param array $products + */ + public static function hydrate( + CouponId $id, + CouponCode $code, + CouponName $name, + MoneyAmount $amount, + CouponPicture $picture, + CouponDiffusion $diffusion, + CouponValidity $validity, + array $couponValidityInformation, + array $couponLegalInformation, + ProcessingCode $processingCode, + BrandCode $brandCode, + ActorId $createdBy, + array $products = [], + ?CouponAvailabilityCalendar $availabilityCalendar = null, + ?GS1 $gs1 = null, + ?\DateTimeInterface $updatedAt = null, + ): self { + return new self( + id: $id, + code: $code, + name: $name, + amount: $amount, + picture: $picture, + diffusion: $diffusion, + validity: $validity, + couponValidityInformation: $couponValidityInformation, + couponLegalInformation: $couponLegalInformation, + processingCode: $processingCode, + brandCode: $brandCode, + createdBy: $createdBy, + products: $products, + availabilityCalendar: $availabilityCalendar, + gs1: $gs1, + updatedAt: $updatedAt, + ); + } +} diff --git a/src/Domain/Models/Entities/Stock.php b/src/Domain/Models/Entities/Stock.php new file mode 100644 index 0000000..06cd24d --- /dev/null +++ b/src/Domain/Models/Entities/Stock.php @@ -0,0 +1,71 @@ + + */ +final readonly class Stock implements AggregateRoot, HasTimestampIdentifier +{ + private function __construct( + public StockId $id, + public StockAmount $stockAmount, + public CampaignCode $campaignCode, + public ProcessingCode $processingCode, + /** @var array $stockAdjustment */ + public array $stockAdjustment, + public ?\DateTimeInterface $updatedAt = null, + ) { + foreach ($this->stockAdjustment as $adjustment) { + if (!$adjustment instanceof StockAdjustment) { + throw new \DomainException('Stock adjustment must be StockAdjustment instances.'); + } + } + } + + public function id(): StockId + { + return $this->id; + } + + public function createdAt(): \DateTimeInterface + { + return $this->id->toDateTime(); + } + + public function updatedAt(): ?\DateTimeInterface + { + return $this->updatedAt; + } + + /** + * @param array $stockAdjustment + */ + public static function hydrate( + StockId $id, + StockAmount $stockAmount, + CampaignCode $campaignCode, + ProcessingCode $processingCode, + array $stockAdjustment = [], + ?\DateTimeInterface $updatedAt = null, + ): self { + return new self( + id: $id, + stockAmount: $stockAmount, + campaignCode: $campaignCode, + processingCode: $processingCode, + stockAdjustment: $stockAdjustment, + updatedAt: $updatedAt, + ); + } +} diff --git a/src/Domain/Models/ValueObjects/Actor/ActorDisplayName.php b/src/Domain/Models/ValueObjects/Actor/ActorDisplayName.php new file mode 100644 index 0000000..d18b739 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Actor/ActorDisplayName.php @@ -0,0 +1,16 @@ +value) === '') { + throw new \DomainException('Display name cannot be empty.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Actor/ActorId.php b/src/Domain/Models/ValueObjects/Actor/ActorId.php new file mode 100644 index 0000000..48e6ee1 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Actor/ActorId.php @@ -0,0 +1,16 @@ +code) === '') { + 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 new file mode 100644 index 0000000..ba56632 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Campaign/CampaignCoupon.php @@ -0,0 +1,21 @@ +value) === '') { + throw new \DomainException('Campaign name cannot be empty.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/CampaignRequest/Applicant.php b/src/Domain/Models/ValueObjects/CampaignRequest/Applicant.php new file mode 100644 index 0000000..f333fe9 --- /dev/null +++ b/src/Domain/Models/ValueObjects/CampaignRequest/Applicant.php @@ -0,0 +1,36 @@ + 200) { + throw new \DomainException('Applicant firstname exceeds the maximum allowed length.'); + } + + $trimmedLastname = mb_trim($lastname); + + if ($trimmedLastname === '') { + throw new \DomainException('Applicant lastname cannot be empty.'); + } + + if (mb_strlen($trimmedLastname) > 200) { + throw new \DomainException('Applicant lastname exceeds the maximum allowed length.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFilters.php b/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFilters.php new file mode 100644 index 0000000..5978375 --- /dev/null +++ b/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFilters.php @@ -0,0 +1,19 @@ +brandCode && null === $this->status) { + throw new \InvalidArgumentException('CampaignRequestFilters require at least a brand code or a status.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestId.php b/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestId.php new file mode 100644 index 0000000..459d131 --- /dev/null +++ b/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestId.php @@ -0,0 +1,16 @@ +clauses === []) { + throw new \DomainException('Campaign Request Sort Criteria requires at least one clause.'); + } + + foreach ($this->clauses as $clause) { + if (!$clause instanceof CampaignRequestSortClause) { + throw new \DomainException('CampaignRequestSortCriteria clauses must be CampaignRequestSortClause instances.'); + } + } + } +} diff --git a/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortField.php b/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortField.php new file mode 100644 index 0000000..d44563f --- /dev/null +++ b/src/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortField.php @@ -0,0 +1,12 @@ +value) === '') { + throw new \DomainException('Rejection reason cannot be empty.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php b/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php new file mode 100644 index 0000000..718ff59 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendar.php @@ -0,0 +1,54 @@ +periods === []) { + throw new \DomainException('Diffusion requires at least one period.'); + } + + foreach ($this->periods as $period) { + if (!$period instanceof Period) { + throw new \DomainException('CouponAvailabilityCalendar periods must be Period instances.'); + } + } + } + + public function isActiveAt(DateTime $in): bool + { + foreach ($this->periods as $period) { + if ($in >= $period->start && $in <= $period->end) { + return true; + } + } + + return false; + } + + public function beginsAt(): DateTime + { + return min(array_map(fn (Period $period) => $period->start, $this->periods)); + } + + public function endsAt(): DateTime + { + return max(array_map(fn (Period $period) => $period->end, $this->periods)); + } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof self && $this->periods == $other->periods; + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponCode.php b/src/Domain/Models/ValueObjects/Coupon/CouponCode.php new file mode 100644 index 0000000..09a09c4 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponCode.php @@ -0,0 +1,26 @@ +legalText) === '') { + throw new \DomainException('Legal text cannot be empty.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponQuantity.php b/src/Domain/Models/ValueObjects/Coupon/CouponQuantity.php new file mode 100644 index 0000000..d2f5bb0 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponQuantity.php @@ -0,0 +1,16 @@ +quantity <= 0) { + throw new \DomainException('CouponQuantity quantity must be positive.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Coupon/CouponValidity.php b/src/Domain/Models/ValueObjects/Coupon/CouponValidity.php new file mode 100644 index 0000000..ef59020 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponValidity.php @@ -0,0 +1,26 @@ +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/CouponValidityInformation.php b/src/Domain/Models/ValueObjects/Coupon/CouponValidityInformation.php new file mode 100644 index 0000000..5ac7ff5 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Coupon/CouponValidityInformation.php @@ -0,0 +1,19 @@ +validityText) === '') { + throw new \DomainException('Validity text cannot be empty.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/CouponName.php b/src/Domain/Models/ValueObjects/CouponName.php new file mode 100644 index 0000000..a681b49 --- /dev/null +++ b/src/Domain/Models/ValueObjects/CouponName.php @@ -0,0 +1,16 @@ +value) === '') { + throw new \DomainException('Coupon name cannot be empty.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/CouponPicture.php b/src/Domain/Models/ValueObjects/CouponPicture.php new file mode 100644 index 0000000..a85f12f --- /dev/null +++ b/src/Domain/Models/ValueObjects/CouponPicture.php @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..e830ad6 --- /dev/null +++ b/src/Domain/Models/ValueObjects/IncoCode.php @@ -0,0 +1,27 @@ +value <= 0) { + 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/MoneyAmount.php b/src/Domain/Models/ValueObjects/MoneyAmount.php new file mode 100644 index 0000000..cb0b753 --- /dev/null +++ b/src/Domain/Models/ValueObjects/MoneyAmount.php @@ -0,0 +1,30 @@ +value->equals($other->value); + } + + public function __toString(): string + { + return (string) $this->value; + } +} diff --git a/src/Domain/Models/ValueObjects/Period.php b/src/Domain/Models/ValueObjects/Period.php new file mode 100644 index 0000000..c189079 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Period.php @@ -0,0 +1,19 @@ +end < $this->start) { + throw new \DomainException('Period end date cannot be before its start date.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/PicturePath.php b/src/Domain/Models/ValueObjects/PicturePath.php index b0844c4..a1b95ce 100644 --- a/src/Domain/Models/ValueObjects/PicturePath.php +++ b/src/Domain/Models/ValueObjects/PicturePath.php @@ -25,7 +25,7 @@ final readonly class PicturePath implements \Stringable, Equatable public function __construct( public string $value, ) { - $trimmed = trim($this->value); + $trimmed = mb_trim($this->value); if ($trimmed === '') { throw new \DomainException('Picture Path cannot be empty.'); diff --git a/src/Domain/Models/ValueObjects/PrintRules.php b/src/Domain/Models/ValueObjects/PrintRules.php new file mode 100644 index 0000000..f915889 --- /dev/null +++ b/src/Domain/Models/ValueObjects/PrintRules.php @@ -0,0 +1,25 @@ +maxPerCustomer && null === $this->cooldownDays) { + throw new \InvalidArgumentException('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.'); + } + + if (null !== $this->cooldownDays && $this->cooldownDays <= 0) { + throw new \InvalidArgumentException('Cooldown days must be greater than 0.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/ProcessingCode.php b/src/Domain/Models/ValueObjects/ProcessingCode.php new file mode 100644 index 0000000..97dd7ff --- /dev/null +++ b/src/Domain/Models/ValueObjects/ProcessingCode.php @@ -0,0 +1,29 @@ +value) === '') { + throw new \DomainException('Processing Code value cannot be empty.'); + } + } + + #[\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/RequestedCoupon/RequestedCoupon.php b/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCoupon.php new file mode 100644 index 0000000..8fc0c3c --- /dev/null +++ b/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCoupon.php @@ -0,0 +1,73 @@ + $couponValidityInformation + * @param array $products + */ + public function __construct( + public CouponName $name, + public RequestedCouponQuantity $quantity, + public Period $diffusion, + public MoneyAmount $moneyAmount, + public ProcessingCode $processingCode, + public CouponValidity $couponValidity, + public array $couponValidityInformation, + public array $products = [], + public ?PrintRules $printRules = null, + public ?GS1 $gs1 = null, + ) { + $money = $this->moneyAmount->value; + + foreach ($this->couponValidityInformation as $validityInformation) { + if (!$validityInformation instanceof CouponValidityInformation) { + throw new \DomainException('Coupon validity information must be CouponValidityInformation instances.'); + } + } + + if ($money->value < self::MIN_FACE_VALUE_IN_CENTS || $money->value > self::MAX_FACE_VALUE_IN_CENTS) { + throw new \DomainException(sprintf( + 'Coupon face value must be between 0.01€ and 3.00€, %s given.', + $money, + )); + } + + 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; + } +} diff --git a/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantity.php b/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantity.php new file mode 100644 index 0000000..9794fc2 --- /dev/null +++ b/src/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantity.php @@ -0,0 +1,16 @@ +value <= 0) { + throw new \DomainException('Quantity should be more than 0.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Stock/StockAdjustment.php b/src/Domain/Models/ValueObjects/Stock/StockAdjustment.php new file mode 100644 index 0000000..9e3a67d --- /dev/null +++ b/src/Domain/Models/ValueObjects/Stock/StockAdjustment.php @@ -0,0 +1,25 @@ +quantity <= 0) { + throw new \DomainException('Stock Amount should be more than 0.'); + } + + if (mb_trim($this->reason) === '') { + throw new \DomainException('Reason cannot be empty.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Stock/StockAmount.php b/src/Domain/Models/ValueObjects/Stock/StockAmount.php new file mode 100644 index 0000000..8fbac0c --- /dev/null +++ b/src/Domain/Models/ValueObjects/Stock/StockAmount.php @@ -0,0 +1,16 @@ +value < 0) { + throw new \DomainException('Stock Amount should be positive.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Stock/StockId.php b/src/Domain/Models/ValueObjects/Stock/StockId.php new file mode 100644 index 0000000..000e873 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Stock/StockId.php @@ -0,0 +1,16 @@ + + */ +interface CampaignRepository extends Repository +{ +} diff --git a/src/Domain/Ports/Repositories/CampaignRequestRepository.php b/src/Domain/Ports/Repositories/CampaignRequestRepository.php new file mode 100644 index 0000000..909202a --- /dev/null +++ b/src/Domain/Ports/Repositories/CampaignRequestRepository.php @@ -0,0 +1,31 @@ + + */ +interface CampaignRequestRepository extends Repository +{ + public function findById(CampaignRequestId $id): ?CampaignRequest; + + public function save(CampaignRequest $campaignRequest): void; + /** + * @return Paginated + */ + public function findAll( + Pagination $pagination, + ?CampaignRequestSortCriteria $sortCriteria = null, + ?CampaignRequestFilters $filters = null, + ): Paginated; +} diff --git a/src/Domain/Ports/Repositories/CouponRepository.php b/src/Domain/Ports/Repositories/CouponRepository.php new file mode 100644 index 0000000..458302c --- /dev/null +++ b/src/Domain/Ports/Repositories/CouponRepository.php @@ -0,0 +1,14 @@ + + */ +interface CouponRepository extends Repository +{ +} diff --git a/src/Shared/DDD/Exceptions/UnknownCountryIsoCodeException.php b/src/Shared/DDD/Exceptions/UnknownCountryIsoCodeException.php new file mode 100644 index 0000000..263fc8e --- /dev/null +++ b/src/Shared/DDD/Exceptions/UnknownCountryIsoCodeException.php @@ -0,0 +1,20 @@ +name}'"); + } +} diff --git a/src/Shared/DDD/Models/EntityId.php b/src/Shared/DDD/Models/EntityId.php index b776f46..537b407 100644 --- a/src/Shared/DDD/Models/EntityId.php +++ b/src/Shared/DDD/Models/EntityId.php @@ -9,7 +9,7 @@ abstract readonly class EntityId implements \Stringable, Equatable public function __construct( public string $value ) { - if (trim($value) === '') { + if (mb_trim($this->value) === '') { throw new \DomainException('EntityId value cannot be empty'); } } diff --git a/src/Shared/Enums/Country.php b/src/Shared/Enums/Country.php new file mode 100644 index 0000000..218dd84 --- /dev/null +++ b/src/Shared/Enums/Country.php @@ -0,0 +1,11 @@ +value < 0) { + throw new \DomainException('Money value cannot be negative.'); + } + } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof self + && $this->currency->value === $other->currency->value + && $this->value === $other->value; + } + + public function __toString(): string + { + return sprintf('%d %s', $this->value, $this->currency->name); + } +} diff --git a/src/Shared/ValueObjects/Rcs.php b/src/Shared/ValueObjects/Rcs.php index bef5e2b..3f54e2d 100644 --- a/src/Shared/ValueObjects/Rcs.php +++ b/src/Shared/ValueObjects/Rcs.php @@ -21,7 +21,7 @@ final readonly class Rcs implements \Stringable, Equatable public function __construct( public string $value, ) { - $trimmed = trim($this->value); + $trimmed = mb_trim($this->value); if ($trimmed === '') { throw new \DomainException('Rcs text cannot be empty.'); diff --git a/tests/Unit/Application/UseCases/CampaignRequest/Create/CreateCampaignRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/Create/CreateCampaignRequestTest.php new file mode 100644 index 0000000..efd146e --- /dev/null +++ b/tests/Unit/Application/UseCases/CampaignRequest/Create/CreateCampaignRequestTest.php @@ -0,0 +1,97 @@ +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'), + 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: [ + new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE), + ], + products: [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 { + // GIVEN + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->with($this->input->brandCode)->andReturn(true); + + $campaignRequestRepository = \Mockery::mock(CampaignRequestRepository::class); + $campaignRequestRepository->shouldReceive('save')->once()->with(\Mockery::type(CampaignRequest::class)); + + $useCase = new CreateCampaignRequest($campaignRequestRepository, $brandRepository); + + // WHEN + $result = $useCase->execute($this->input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->status)->toBe(CampaignRequestStatus::CREATED) + ->and($result->brandCode)->toBe($this->input->brandCode); + }); +}); + +describe('create campaign request use case validation rules', function (): void { + it('throws when the brand does not exist', function (): void { + // GIVEN + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('existsByCode')->once()->with($this->input->brandCode)->andReturn(false); + + $campaignRequestRepository = \Mockery::mock(CampaignRequestRepository::class); + $campaignRequestRepository->shouldNotReceive('save'); + + $useCase = new CreateCampaignRequest($campaignRequestRepository, $brandRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($this->input); + + // THEN + expect($action)->toThrow(BrandNotFoundException::class); + }); +}); diff --git a/tests/Unit/Application/UseCases/CampaignRequest/Find/FindCampaignRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/Find/FindCampaignRequestTest.php new file mode 100644 index 0000000..912dc8d --- /dev/null +++ b/tests/Unit/Application/UseCases/CampaignRequest/Find/FindCampaignRequestTest.php @@ -0,0 +1,219 @@ +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) + ); + }); + + it('finds a campaign request by its id, along with its brand', function (): 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, + ); + $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'), + ); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn($campaignRequest); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('findByCode')->once()->with($brandCode)->andReturn($brand); + + $useCase = new FindCampaignRequest($repository, $brandRepository); + + // WHEN + $result = $useCase->execute(new Input(id: $id)); + + // THEN + expect($result)->toBeInstanceOf(CampaignRequestFound::class) + ->and($result->brand)->toBe($brand) + ->and($result->name)->toBe($campaignRequest->name) + ->and($result->applicant)->toBe($campaignRequest->applicant) + ->and($result->campaignType)->toBe($campaignRequest->campaignType) + ->and($result->requestedCoupon)->toBe($campaignRequest->requestedCoupon) + ->and($result->status)->toBe($campaignRequest->status) + ->and($result->rejectionReason)->toBeNull(); + }); + + it('finds a rejected campaign request with its rejection reason', function (): 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, + ); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn($campaignRequest); + + $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'), + ) + ); + + $useCase = new FindCampaignRequest($repository, $brandRepository); + + // WHEN + $result = $useCase->execute(new Input(id: $id)); + + // THEN + expect($result)->toBeInstanceOf(CampaignRequestFound::class) + ->and($result->status)->toBe(CampaignRequestStatus::REJECTED) + ->and($result->rejectionReason)->toBe($rejectionReason); + }); + + it('returns CampaignRequestNotFound when the id is not found', function (): void { + // GIVEN + $id = CampaignRequestId::generate(); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn(null); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldNotReceive('findByCode'); + + $useCase = new FindCampaignRequest($repository, $brandRepository); + + // WHEN + $result = $useCase->execute(new Input(id: $id)); + + // THEN + expect($result)->toBeInstanceOf(CampaignRequestNotFound::class); + }); + + it('throws when the campaign request references a brand that no longer exists', function (): 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, + ); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn($campaignRequest); + + $brandRepository = \Mockery::mock(BrandRepository::class); + $brandRepository->shouldReceive('findByCode')->once()->with($brandCode)->andReturn(null); + + $useCase = new FindCampaignRequest($repository, $brandRepository); + + // WHEN + $action = fn (): Output => $useCase->execute(new Input(id: $id)); + + // THEN + expect($action)->toThrow(BrandNotFoundException::class); + }); +}); + +describe('find campaign request use case assertions', function (): void { + it('rejects an input of the wrong type', function (): void { + // GIVEN + $repository = \Mockery::mock(CampaignRequestRepository::class); + $brandRepository = \Mockery::mock(BrandRepository::class); + $useCase = new FindCampaignRequest($repository, $brandRepository); + $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/CampaignRequest/List/ListCampaignsRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/List/ListCampaignsRequestTest.php new file mode 100644 index 0000000..aa717b2 --- /dev/null +++ b/tests/Unit/Application/UseCases/CampaignRequest/List/ListCampaignsRequestTest.php @@ -0,0 +1,244 @@ +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) + ); + + $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) + ); + }); + + it('maps repository campaigns request to list items, preserving pagination and sort', function (): void { + // GIVEN + $pagination = Pagination::firstPage(); + $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, + ); + $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, + ); + $paginated = new Paginated($pagination, [$campaignRequest1, $campaignRequest2], 2); + + $sort = new CampaignRequestSortCriteria( + clauses: [ + new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending), + new CampaignRequestSortClause(field: CampaignRequestSortField::CreatedAt, direction: \SortDirection::Descending) + ] + ); + $filters = new CampaignRequestFilters($campaignRequest2->brandCode, CampaignRequestStatus::CREATED); + $input = new Input(pagination: $pagination, sort: $sort, filters: $filters); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findAll') + ->once() + ->with($pagination, CampaignRequestSortCriteria::class, CampaignRequestFilters::class) + ->andReturn($paginated); + + $useCase = new ListCampaignsRequest($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class); + + $items = $result->campaignsRequest->getItems(); + expect($items)->toHaveCount(2) + ->and($items[0])->toBeInstanceOf(CampaignRequestListItem::class) + ->and($items[0]->brandCode)->toBe($campaignRequest1->brandCode) + ->and($items[0]->name)->toBe($campaignRequest1->name) + ->and($items[0]->applicant)->toBe($campaignRequest1->applicant) + ->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($pagination); + }); + + it('lists campaigns request with filters but without sort', function (): void { + // GIVEN + $pagination = Pagination::firstPage(); + $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, + ); + $paginated = new Paginated($pagination, [$campaignRequest], 1); + + $filters = new CampaignRequestFilters($campaignRequest->brandCode, CampaignRequestStatus::CREATED); + $input = new Input(pagination: $pagination, filters: $filters); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findAll') + ->once() + ->with($pagination, null, $filters) + ->andReturn($paginated); + + $useCase = new ListCampaignsRequest($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + $items = $result->campaignsRequest->getItems(); + expect($items)->toHaveCount(1) + ->and($items[0])->toBeInstanceOf(CampaignRequestListItem::class) + ->and($items[0]->brandCode)->toBe($campaignRequest->brandCode) + ->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($pagination); + }); + + it('lists campaigns request with sort but without filters', function (): void { + // GIVEN + $pagination = Pagination::firstPage(); + $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, + ); + $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, + ); + $paginated = new Paginated($pagination, [$campaignRequest2, $campaignRequest1], 2); + + $sort = new CampaignRequestSortCriteria( + clauses: [ + new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Descending), + ] + ); + $input = new Input(pagination: $pagination, sort: $sort); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findAll') + ->once() + ->with($pagination, $sort, null) + ->andReturn($paginated); + + $useCase = new ListCampaignsRequest($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + $items = $result->campaignsRequest->getItems(); + expect($items)->toHaveCount(2) + ->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($pagination); + }); + + it('returns an empty list without error when there are no campaigns request', function (): void { + // GIVEN + $pagination = Pagination::firstPage(); + $paginated = new Paginated($pagination, [], 0); + $input = new Input(pagination: $pagination); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findAll') + ->once() + ->with($pagination, null, null) + ->andReturn($paginated); + + $useCase = new ListCampaignsRequest($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result->campaignsRequest->getItems())->toBe([]) + ->and($result->campaignsRequest->getTotal())->toBe(0); + }); +}); diff --git a/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php new file mode 100644 index 0000000..5cc0ccc --- /dev/null +++ b/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php @@ -0,0 +1,160 @@ +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) + ); + }); + + afterEach(function (): void { + CarbonImmutable::setTestNow(); + }); + + it('rejects a campaign request and persists it', function (): void { + // GIVEN + CarbonImmutable::setTestNow('2026-06-15 12:00:00'); + $id = CampaignRequestId::generate(); + $campaignRequest = CampaignRequest::hydrate( + $id, + new BrandCode('AAA01'), + new CampaignName('Campaign A'), + new Applicant('John', 'Doe', new Email('john.doe@example.com')), + CampaignRequestType::CLUB, + $this->requestedCoupon, + CampaignRequestStatus::CREATED, + ); + $reason = new RejectionReason('Missing legal documents.'); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $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, + )); + + $useCase = new RejectCampaignRequest($repository); + + // WHEN + $result = $useCase->execute(new Input(id: $id, reason: $reason)); + + // THEN + 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'); + }); + + it('returns CampaignRequestNotFound when the id is not found', function (): void { + // GIVEN + $id = CampaignRequestId::generate(); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn(null); + $repository->shouldNotReceive('save'); + + $useCase = new RejectCampaignRequest($repository); + + // WHEN + $result = $useCase->execute(new Input(id: $id, reason: new RejectionReason('Missing legal documents.'))); + + // THEN + expect($result)->toBeInstanceOf(CampaignRequestNotFound::class); + }); + + it('propagates the domain exception when the campaign request cannot be rejected', function (): void { + // GIVEN + $id = CampaignRequestId::generate(); + $campaignRequest = CampaignRequest::hydrate( + $id, + new BrandCode('AAA01'), + new CampaignName('Campaign A'), + new Applicant('John', 'Doe', new Email('john.doe@example.com')), + CampaignRequestType::CLUB, + $this->requestedCoupon, + CampaignRequestStatus::TRANSFORMED, + ); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn($campaignRequest); + $repository->shouldNotReceive('save'); + + $useCase = new RejectCampaignRequest($repository); + + // WHEN + $action = fn (): Output => $useCase->execute( + new Input(id: $id, reason: new RejectionReason('Missing legal documents.')) + ); + + // 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/Domain/Enums/BarcodeFormatTest.php b/tests/Unit/Domain/Enums/BarcodeFormatTest.php new file mode 100644 index 0000000..d1af8e0 --- /dev/null +++ b/tests/Unit/Domain/Enums/BarcodeFormatTest.php @@ -0,0 +1,23 @@ +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/Enums/Campaign/CampaignStatusTest.php b/tests/Unit/Domain/Enums/Campaign/CampaignStatusTest.php new file mode 100644 index 0000000..bcf43f5 --- /dev/null +++ b/tests/Unit/Domain/Enums/Campaign/CampaignStatusTest.php @@ -0,0 +1,23 @@ +value)->toBe('created') + ->and(CampaignRequestStatus::TRANSFORMED->value)->toBe('transformed') + ->and(CampaignRequestStatus::REJECTED->value)->toBe('rejected'); + }); + + it('exposes its case name', function (): void { + // THEN + expect(CampaignRequestStatus::CREATED->name)->toBe('CREATED'); + }); +}); diff --git a/tests/Unit/Domain/Enums/Campaign/CampaignTypeTest.php b/tests/Unit/Domain/Enums/Campaign/CampaignTypeTest.php new file mode 100644 index 0000000..737fccd --- /dev/null +++ b/tests/Unit/Domain/Enums/Campaign/CampaignTypeTest.php @@ -0,0 +1,22 @@ +value)->toBe('club') + ->and(CampaignRequestType::GAME->value)->toBe('game'); + }); + + it('exposes its case name', function (): void { + // THEN + expect(CampaignRequestType::CLUB->name)->toBe('CLUB'); + }); +}); diff --git a/tests/Unit/Domain/Enums/CountryTest.php b/tests/Unit/Domain/Enums/CountryTest.php new file mode 100644 index 0000000..5a6c7b8 --- /dev/null +++ b/tests/Unit/Domain/Enums/CountryTest.php @@ -0,0 +1,22 @@ +value)->toBe('france') + ->and(Country::BENELUX->value)->toBe('benelux'); + }); + + it('exposes its case name', function (): void { + // THEN + expect(Country::FRANCE->name)->toBe('FRANCE'); + }); +}); diff --git a/tests/Unit/Domain/Models/Entities/ActorTest.php b/tests/Unit/Domain/Models/Entities/ActorTest.php new file mode 100644 index 0000000..cda40c1 --- /dev/null +++ b/tests/Unit/Domain/Models/Entities/ActorTest.php @@ -0,0 +1,67 @@ +displayName = new ActorDisplayName('John Doe'); + }); + + it('hydrates an actor', function (): void { + // GIVEN + $id = ActorId::generate(); + + // WHEN + $actor = Actor::hydrate( + $id, + $this->displayName + ); + + // THEN + expect($actor->id())->toBe($id) + ->and($actor->displayName)->toBe($this->displayName) + ->and($actor->updatedAt)->toBeNull() + ->and($actor->updatedAt())->toBeNull(); + }); + + 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 { + // 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 + ); + + // WHEN + $result = $actor->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/BrandTest.php b/tests/Unit/Domain/Models/Entities/BrandTest.php index efcbf99..1e3737b 100644 --- a/tests/Unit/Domain/Models/Entities/BrandTest.php +++ b/tests/Unit/Domain/Models/Entities/BrandTest.php @@ -17,29 +17,39 @@ 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'); + }); + afterEach(function (): void { CarbonImmutable::setTestNow(); }); it('creates a brand', function (): void { // GIVEN - $id = BrandId::generate(); - $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'); + $id = BrandId::generate(); // WHEN - $brand = Brand::hydrate($id, $code, $name, $logo, $rcs, $contact); + $brand = Brand::hydrate( + $id, + $this->code, + $this->name, + $this->logo, + $this->rcs, + $this->contact + ); // THEN expect($brand->id())->toBe($id) - ->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->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(); }); @@ -47,15 +57,18 @@ describe('brand', function (): void { it('creates a brand with an explicit updatedAt date', function (): void { // GIVEN $id = BrandId::generate(); - $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'); $updatedAt = new \DateTimeImmutable('2026-06-15 12:00:00', new \DateTimeZone('UTC')); // WHEN - $brand = Brand::hydrate($id, $code, $name, $logo, $rcs, $contact, $updatedAt); + $brand = Brand::hydrate( + $id, + $this->code, + $this->name, + $this->logo, + $this->rcs, + $this->contact, + $updatedAt + ); // THEN expect($brand->updatedAt)->toBe($updatedAt) @@ -63,38 +76,42 @@ describe('brand', function (): void { }); it('creates a new brand with a freshly generated id', function (): void { - // GIVEN - $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'); - - // WHEN - $brand = Brand::create($code, $name, $logo, $rcs, $contact); + // GIVEN / WHEN + $brand = Brand::create( + $this->code, + $this->name, + $this->logo, + $this->rcs, + $this->contact + ); // THEN expect($brand->id())->toBeInstanceOf(BrandId::class) - ->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->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(); }); it('generates a different id for each created brand', function (): void { - // GIVEN - $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'); - - // WHEN - $first = Brand::create($code, $name, $logo, $rcs, $contact); - $second = Brand::create($code, $name, $logo, $rcs, $contact); + // GIVEN / WHEN + $first = Brand::create( + $this->code, + $this->name, + $this->logo, + $this->rcs, + $this->contact + ); + $second = Brand::create( + $this->code, + $this->name, + $this->logo, + $this->rcs, + $this->contact + ); // THEN expect($first->id()->equals($second->id()))->toBeFalse(); @@ -104,24 +121,27 @@ describe('brand', function (): void { // GIVEN CarbonImmutable::setTestNow('2026-06-15 12:00:00'); $id = BrandId::generate(); - $code = new BrandCode('AAA01'); - $name = new BrandName('Name'); $logoOld = new PicturePath('files/picture-old.jpg'); $logoNew = new PicturePath('files/picture-new.jpg'); - $rcs = new Rcs('RCS PARIS 111 222 333'); - $contact = new Email('contact@example.com'); - $brand = Brand::hydrate($id, $code, $name, $logoOld, $rcs, $contact); + $brand = Brand::hydrate( + $id, + $this->code, + $this->name, + $logoOld, + $this->rcs, + $this->contact + ); // WHEN $brand = $brand->changeLogo($logoNew); // THEN expect($brand->id())->toBe($id) - ->and($brand->code)->toBe($code) - ->and($brand->name)->toBe($name) + ->and($brand->code)->toBe($this->code) + ->and($brand->name)->toBe($this->name) ->and($brand->logo)->toBe($logoNew) - ->and($brand->rcs)->toBe($rcs) - ->and($brand->contact)->toBe($contact) + ->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'); }); @@ -130,24 +150,27 @@ describe('brand', function (): void { // GIVEN CarbonImmutable::setTestNow('2026-06-15 14:30:00'); $id = BrandId::generate(); - $code = new BrandCode('AAA01'); - $name = new BrandName('Name'); - $logo = new PicturePath('files/picture.jpg'); $rcsOld = new Rcs('RCS PARIS 111 222 333'); $rcsNew = new Rcs('RCS NANTES 444 555 666'); - $contact = new Email('contact@example.com'); - $brand = Brand::hydrate($id, $code, $name, $logo, $rcsOld, $contact); + $brand = Brand::hydrate( + $id, + $this->code, + $this->name, + $this->logo, + $rcsOld, + $this->contact + ); // WHEN $brand = $brand->changeRcs($rcsNew); // THEN expect($brand->id())->toBe($id) - ->and($brand->code)->toBe($code) - ->and($brand->name)->toBe($name) - ->and($brand->logo)->toBe($logo) + ->and($brand->code)->toBe($this->code) + ->and($brand->name)->toBe($this->name) + ->and($brand->logo)->toBe($this->logo) ->and($brand->rcs)->toBe($rcsNew) - ->and($brand->contact)->toBe($contact) + ->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'); }); @@ -156,23 +179,26 @@ describe('brand', function (): void { // GIVEN CarbonImmutable::setTestNow('2026-06-15 16:45:00'); $id = BrandId::generate(); - $code = new BrandCode('AAA01'); - $name = new BrandName('Name'); - $logo = new PicturePath('files/picture.jpg'); - $rcs = new Rcs('RCS PARIS 111 222 333'); $contactOld = new Email('contact-old@example.com'); $contactNew = new Email('contact-new@example.com'); - $brand = Brand::hydrate($id, $code, $name, $logo, $rcs, $contactOld); + $brand = Brand::hydrate( + $id, + $this->code, + $this->name, + $this->logo, + $this->rcs, + $contactOld + ); // WHEN $brand = $brand->changeContact($contactNew); // THEN expect($brand->id())->toBe($id) - ->and($brand->code)->toBe($code) - ->and($brand->name)->toBe($name) - ->and($brand->logo)->toBe($logo) - ->and($brand->rcs)->toBe($rcs) + ->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($contactNew) ->and($brand->updatedAt)->not->toBeNull() ->and($brand->updatedAt()?->format('Y-m-d H:i:s'))->toBe('2026-06-15 16:45:00'); @@ -184,11 +210,11 @@ describe('brand', function (): void { $id = new BrandId(Uuid::uuid7($expected)); $brand = Brand::hydrate( $id, - new BrandCode('AAA01'), - new BrandName('Name'), - new PicturePath('files/picture.jpg'), - new Rcs('RCS PARIS 111 222 333'), - new Email('contact@example.com'), + $this->code, + $this->name, + $this->logo, + $this->rcs, + $this->contact ); // WHEN diff --git a/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php b/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php new file mode 100644 index 0000000..e1b936f --- /dev/null +++ b/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php @@ -0,0 +1,263 @@ +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.'); + }); + + afterEach(function (): void { + CarbonImmutable::setTestNow(); + }); + + it('hydrates a campaign request with all its fields', function (): void { + // GIVEN + $id = CampaignRequestId::generate(); + + // WHEN + $campaignRequest = CampaignRequest::hydrate( + $id, + $this->code, + $this->name, + $this->applicant, + $this->campaignType, + $this->requestedCoupon, + $this->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(); + }); + + 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 { + // GIVEN / WHEN + $campaignRequest = CampaignRequest::hydrate( + CampaignRequestId::generate(), + $this->code, + $this->name, + $this->applicant, + $this->campaignType, + $this->requestedCoupon, + $this->rejectedStatus, + $this->rejectionReason, + ); + + // THEN + expect($campaignRequest->status)->toBe($this->rejectedStatus) + ->and($campaignRequest->rejectionReason)->toBe($this->rejectionReason); + }); + + it('rejects a rejected campaign request without a rejection reason', function (): void { + // GIVEN / WHEN + $action = fn (): CampaignRequest => CampaignRequest::hydrate( + CampaignRequestId::generate(), + $this->code, + $this->name, + $this->applicant, + $this->campaignType, + $this->requestedCoupon, + $this->rejectedStatus, + ); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'A rejection reason is required when the campaign request is rejected.' + ); + }); + + it('rejects a non-rejected campaign request with a rejection reason', function (): void { + // WHEN + $action = fn (): CampaignRequest => CampaignRequest::hydrate( + CampaignRequestId::generate(), + $this->code, + $this->name, + $this->applicant, + $this->campaignType, + $this->requestedCoupon, + $this->status, + $this->rejectionReason, + ); + + // THEN + expect($action)->toThrow( + \DomainException::class, + 'A rejection reason can only be set when the campaign request is rejected.' + ); + }); + + it('rejects a campaign request and sets the rejection reason and updatedAt', function (): void { + // GIVEN + CarbonImmutable::setTestNow('2026-06-15 12:00:00'); + $campaignRequest = CampaignRequest::hydrate( + CampaignRequestId::generate(), + $this->code, + $this->name, + $this->applicant, + $this->campaignType, + $this->requestedCoupon, + $this->status, + ); + + // WHEN + $rejected = $campaignRequest->reject($this->rejectionReason); + + // THEN + expect($rejected->status)->toBe(CampaignRequestStatus::REJECTED) + ->and($rejected->rejectionReason)->toBe($this->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'); + }); + + it('rejects rejecting a transformed campaign request', function (): void { + // GIVEN + $campaignRequest = CampaignRequest::hydrate( + CampaignRequestId::generate(), + $this->code, + $this->name, + $this->applicant, + $this->campaignType, + $this->requestedCoupon, + $this->transformedStatus, + ); + + // WHEN + $action = fn (): CampaignRequest => $campaignRequest->reject($this->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 { + // GIVEN + $campaignRequest = CampaignRequest::hydrate( + CampaignRequestId::generate(), + $this->code, + $this->name, + $this->applicant, + $this->campaignType, + $this->requestedCoupon, + $this->rejectedStatus, + new RejectionReason('First reason.'), + ); + + // WHEN + $action = fn (): CampaignRequest => $campaignRequest->reject(new RejectionReason('Second reason.')); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Only a created campaign request can be rejected.'); + }); + + it('returns the creation date extracted from the id', function (): 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, + ); + + // WHEN + $result = $campaignRequest->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/CampaignTest.php b/tests/Unit/Domain/Models/Entities/CampaignTest.php new file mode 100644 index 0000000..403200c --- /dev/null +++ b/tests/Unit/Domain/Models/Entities/CampaignTest.php @@ -0,0 +1,204 @@ +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(); + }); + + afterEach(function (): void { + CarbonImmutable::setTestNow(); + }); + + it('creates a campaign', function (): void { + // GIVEN + $id = CampaignId::generate(); + + // WHEN + $campaign = Campaign::hydrate( + $id, + $this->code, + $this->name, + $this->publication, + $this->printAllAtOnce, + $this->coupons, + $this->brandCode, + $this->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(); + }); + + 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 { + // GIVEN / WHEN + $campaign = Campaign::create( + $this->code, + $this->name, + $this->publication, + $this->printAllAtOnce, + $this->coupons, + $this->brandCode, + $this->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->updatedAt())->toBeNull(); + }); + + it('generates a different id for each created campaign', function (): void { + // GIVEN / WHEN + $firstCampaign = Campaign::create( + $this->code, + $this->name, + $this->publication, + $this->printAllAtOnce, + $this->coupons, + $this->brandCode, + $this->actorId + ); + $secondCampaign = Campaign::create( + $this->code, + $this->name, + $this->publication, + $this->printAllAtOnce, + $this->coupons, + $this->brandCode, + $this->actorId + ); + + // THEN + expect($firstCampaign->id()->equals($secondCampaign->id()))->toBeFalse(); + }); + + it('returns the creation date extracted from the id', function (): 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 + ); + + // WHEN + $result = $campaign->createdAt(); + + // THEN + 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 { + // GIVEN + $id = CampaignId::generate(); + $coupons = ['PC1']; + + // 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.'); + }); +}); diff --git a/tests/Unit/Domain/Models/Entities/CouponTest.php b/tests/Unit/Domain/Models/Entities/CouponTest.php new file mode 100644 index 0000000..6b54f95 --- /dev/null +++ b/tests/Unit/Domain/Models/Entities/CouponTest.php @@ -0,0 +1,439 @@ +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); + }); + + afterEach(function (): void { + CarbonImmutable::setTestNow(); + }); + + it('creates a coupon', function (): 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, + ); + + // 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(); + }); + + it('hydrates a coupon without gs1', 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, + 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 + ); + + // THEN + expect($coupon->availabilityCalendar)->toBeNull() + ->and($coupon->products)->toBe($this->products) + ->and($coupon->gs1)->toBe($this->gs1); + }); + + it('hydrates a coupon without products', 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, + availabilityCalendar: $this->availabilityCalendar, + gs1: $this->gs1 + ); + + // THEN + expect($coupon->products)->toBe([]) + ->and($coupon->availabilityCalendar)->toBe($this->availabilityCalendar) + ->and($coupon->gs1)->toBe($this->gs1); + }); + + it('hydrates a coupon with only required fields', 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, + ); + + // 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(); + }); + + 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 { + // 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.'); + }); + + 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 { + // 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, + ); + + // 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->updatedAt())->toBeNull(); + }); + + it('generates a different id for each created coupon', function (): 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, + ); + $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, + ); + + // THEN + expect($firstCoupon->id()->equals($secondCoupon->id()))->toBeFalse(); + }); + + it('returns the creation date extracted from the id', function (): 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, + ); + + // WHEN + $result = $coupon->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 new file mode 100644 index 0000000..a30b6d7 --- /dev/null +++ b/tests/Unit/Domain/Models/Entities/StockTest.php @@ -0,0 +1,150 @@ +stockAmount = new StockAmount(10); + $this->campaignCode = new CampaignCode('AAA01'); + $this->processingCode = new ProcessingCode('PC1'); + $this->stockAdjustment = [new StockAdjustment(5, 'Inventory correction', ActorId::generate())]; + }); + + it('hydrates a stock', function (): void { + // GIVEN + $id = StockId::generate(); + + // WHEN + $stock = Stock::hydrate( + $id, + $this->stockAmount, + $this->campaignCode, + $this->processingCode, + $this->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(); + }); + + it('hydrates a stock with several stock adjustments', function (): void { + // GIVEN + $id = StockId::generate(); + $stockAdjustment = [ + new StockAdjustment(5, 'Inventory correction', ActorId::generate()), + new StockAdjustment(10, 'Inventory correction', ActorId::generate()), + ]; + + // WHEN + $stock = Stock::hydrate( + $id, + $this->stockAmount, + $this->campaignCode, + $this->processingCode, + $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($stockAdjustment) + ->and($stock->updatedAt)->toBeNull() + ->and($stock->updatedAt())->toBeNull(); + }); + + it('hydrates a stock without stock adjustement', function (): void { + // GIVEN + $id = StockId::generate(); + + // WHEN + $stock = Stock::hydrate( + $id, + $this->stockAmount, + $this->campaignCode, + $this->processingCode + ); + + // 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([]) + ->and($stock->updatedAt)->toBeNull() + ->and($stock->updatedAt())->toBeNull(); + }); + + it('rejects a stock with a non-stock-adjustment element', function (): void { + // GIVEN + $id = StockId::generate(); + $stockAdjustment = [new StockAmount(5)]; + + // WHEN / THEN + expect(fn (): Stock => Stock::hydrate( + $id, + $this->stockAmount, + $this->campaignCode, + $this->processingCode, + $stockAdjustment, + ))->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 { + // 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 + ); + + // WHEN + $result = $stock->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/ValueObjects/Actor/ActorDisplayNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Actor/ActorDisplayNameTest.php new file mode 100644 index 0000000..82b09a3 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Actor/ActorDisplayNameTest.php @@ -0,0 +1,33 @@ +value = 'Johnny'; + $this->emptyValue = ''; + }); + + it('creates a valid actor displayname', function (): void { + // GIVEN / WHEN + $name = new ActorDisplayName( + $this->value + ); + + // THEN + expect($name->value)->toBe($this->value); + }); + + it('rejects invalid actor displayname', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): ActorDisplayName => new ActorDisplayName( + $this->emptyValue + ))->toThrow(\DomainException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Actor/ActorIdTest.php b/tests/Unit/Domain/Models/ValueObjects/Actor/ActorIdTest.php new file mode 100644 index 0000000..166ba24 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Actor/ActorIdTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(ActorId::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = ActorId::generate(); + $second = ActorId::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php index 2cc6013..41a3762 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php @@ -9,23 +9,26 @@ use Domain\Models\ValueObjects\Brand\BrandName; covers(BrandName::class); describe('brand name', function (): void { - it('creates a valid brand name', function (): void { - // GIVEN - $value = 'Name'; + beforeEach(function (): void { + $this->value = 'Name'; + $this->emptyValue = ''; + }); - // WHEN - $name = new BrandName($value); + it('creates a valid brand name', function (): void { + // GIVEN / WHEN + $name = new BrandName( + $this->value + ); // THEN - expect($name->value)->toBe('Name'); + expect($name->value)->toBe($this->value); }); it('rejects invalid brand name', function (): void { - // GIVEN - $value = ''; - - // GIVEN / THEN - expect(fn (): BrandName => new BrandName($value))->toThrow(\DomainException::class); + // GIVEN / WHEN / THEN + expect(fn (): BrandName => new BrandName( + $this->emptyValue + ))->toThrow(\DomainException::class); }); it('rejects a name that exceeds the maximum allowed length', function (): void { diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php new file mode 100644 index 0000000..939cefd --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCodeTest.php @@ -0,0 +1,31 @@ +code = 'Name'; + $this->emptyCode = ''; + }); + + it('creates a valid campaign code', function (): void { + // GIVEN / WHEN + $name = new CampaignCode($this->code); + + // THEN + expect($name->code)->toBe($this->code); + }); + + it('rejects invalid campaign code', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): CampaignCode => new CampaignCode( + $this->emptyCode + ))->toThrow(\DomainException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponTest.php new file mode 100644 index 0000000..e700f2a --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignCouponTest.php @@ -0,0 +1,82 @@ +processingCode = new ProcessingCode('PC1'); + $this->quantity = new CouponQuantity(10); + $this->availableIn = Country::FRANCE; + $this->printRules = new PrintRules(maxPerCustomer: 3); + }); + + it('creates a valid campaign coupon', function (): void { + // GIVEN / WHEN + $campaignCoupon = new CampaignCoupon( + $this->processingCode, + $this->quantity, + $this->availableIn, + $this->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); + }); + + it('creates a valid campaign coupon whitout warning threshold quantity', function (): void { + // GIVEN / WHEN + $campaignCoupon = new CampaignCoupon( + processingCode: $this->processingCode, + quantity: $this->quantity, + availableIn: $this->availableIn, + printRules: $this->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); + }); + + 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); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignIdTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignIdTest.php new file mode 100644 index 0000000..0e52797 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignIdTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(CampaignId::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = CampaignId::generate(); + $second = CampaignId::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignNameTest.php new file mode 100644 index 0000000..ad4af96 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Campaign/CampaignNameTest.php @@ -0,0 +1,31 @@ +value = 'Name'; + $this->emptyValue = ''; + }); + + it('creates a valid campaign name', function (): void { + // GIVEN / WHEN + $name = new CampaignName($this->value); + + // THEN + expect($name->value)->toBe($this->value); + }); + + it('rejects invalid campaign name', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): CampaignName => new CampaignName( + $this->emptyValue + ))->toThrow(\DomainException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/ApplicantTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/ApplicantTest.php new file mode 100644 index 0000000..730b5c6 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/ApplicantTest.php @@ -0,0 +1,123 @@ +firstname = 'John'; + $this->lastname = 'Doe'; + $this->email = new Email('john.doe@example.com'); + }); + + it('creates a valid applicant', function (): void { + // GIVEN / WHEN + $applicant = new Applicant( + $this->firstname, + $this->lastname, + $this->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); + }); + + it('rejects an empty or whitespace-only firstname', function (string $firstname): void { + // WHEN + $action = fn (): Applicant => new Applicant( + $firstname, + $this->lastname, + $this->email + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Applicant firstname cannot be empty.'); + })->with([ + 'empty' => '', + 'spaces' => ' ', + ]); + + it('rejects a firstname that exceeds the maximum allowed length', function (): void { + // GIVEN + $firstname = str_repeat('a', 201); + + // WHEN + $action = fn (): Applicant => new Applicant( + $firstname, + $this->lastname, + $this->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 { + // GIVEN + $firstname = str_repeat('a', 200); + + // WHEN + $applicant = new Applicant( + $firstname, + $this->lastname, + $this->email + ); + + // THEN + expect(mb_strlen($applicant->firstname))->toBe(200); + }); + + it('rejects an empty or whitespace-only lastname', function (string $lastname): void { + // WHEN + $action = fn (): Applicant => new Applicant( + $this->firstname, + $lastname, + $this->email + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Applicant lastname cannot be empty.'); + })->with([ + 'empty' => '', + 'spaces' => ' ', + ]); + + it('rejects a lastname that exceeds the maximum allowed length', function (): void { + // GIVEN + $lastname = str_repeat('a', 201); + + // WHEN + $action = fn (): Applicant => new Applicant( + $this->firstname, + $lastname, + $this->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 { + // GIVEN + $lastname = str_repeat('a', 200); + + // WHEN + $applicant = new Applicant( + $this->firstname, + $lastname, + $this->email + ); + + // THEN + expect(mb_strlen($applicant->lastname))->toBe(200); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFiltersTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFiltersTest.php new file mode 100644 index 0000000..b85bdb3 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestFiltersTest.php @@ -0,0 +1,58 @@ +brandCode = new BrandCode('AAA01'); + $this->status = CampaignRequestStatus::CREATED; + }); + + it('creates filters with both fields set', function (): void { + // GIVEN / WHEN + $filters = new CampaignRequestFilters( + brandCode: $this->brandCode, + status: $this->status + ); + + // THEN + expect($filters->brandCode)->toBe($this->brandCode) + ->and($filters->status)->toBe($this->status); + }); + + it('creates filters with only brandCode set', function (): void { + // GIVEN / WHEN + $filters = new CampaignRequestFilters( + brandCode: $this->brandCode + ); + + // THEN + expect($filters->brandCode)->toBe($this->brandCode) + ->and($filters->status)->toBeNull(); + }); + + it('creates filters with only status set', function (): void { + // GIVEN / WHEN + $filters = new CampaignRequestFilters( + status: $this->status + ); + + // THEN + expect($filters->brandCode)->toBeNull() + ->and($filters->status)->toBe($this->status); + }); + + it('rejects filters without any field set', function (): void { + // WHEN / THEN + expect(fn (): CampaignRequestFilters => new CampaignRequestFilters()) + ->toThrow(\InvalidArgumentException::class, 'CampaignRequestFilters require at least a brand code or a status.'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestIdTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestIdTest.php new file mode 100644 index 0000000..5de8df4 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestIdTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(CampaignRequestId::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = CampaignRequestId::generate(); + $second = CampaignRequestId::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortCriteriaTest.php b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortCriteriaTest.php new file mode 100644 index 0000000..af31c54 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/CampaignRequestSortCriteriaTest.php @@ -0,0 +1,58 @@ +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 = []; + }); + + + it('creates a valid campaign request sort criteria with one clause', function (): void { + // GIVEN / WHEN + $campaignRequestSortCriteria = new CampaignRequestSortCriteria( + $this->singleClauses + ); + + // THEN + expect($campaignRequestSortCriteria->clauses)->toBe($this->singleClauses); + }); + + it('creates a valid campaign request sort criteria with several clauses', function (): void { + // GIVEN / WHEN + $campaignRequestSortCriteria = new CampaignRequestSortCriteria( + $this->clauses + ); + + // THEN + expect($campaignRequestSortCriteria->clauses)->toBe($this->clauses); + }); + + it('rejects a campaign request sort criteria without clause', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($this->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 { + // GIVEN / WHEN / THEN + expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($this->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 new file mode 100644 index 0000000..f72656f --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CampaignRequest/RejectionReasonTest.php @@ -0,0 +1,35 @@ +value = 'Missing legal documents.'; + }); + + it('creates a valid rejection reason', function (): void { + // GIVEN / WHEN + $reason = new RejectionReason($this->value); + + // THEN + expect($reason)->toBeInstanceOf(RejectionReason::class) + ->and($reason->value)->toBe($this->value); + }); + + it('rejects an empty or whitespace-only reason', function (string $value): void { + // WHEN + $action = fn (): RejectionReason => new RejectionReason($value); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Rejection reason cannot be empty.'); + })->with([ + 'empty' => '', + 'spaces' => ' ', + ]); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendarTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendarTest.php new file mode 100644 index 0000000..829915c --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponAvailabilityCalendarTest.php @@ -0,0 +1,59 @@ +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 = []; + }); + + it('creates a valid coupon availability calendar with one period', function (): void { + // GIVEN / WHEN + $couponAvailabilityCalendar = new CouponAvailabilityCalendar( + $this->singlePeriods + ); + + // THEN + expect($couponAvailabilityCalendar->periods)->toBe($this->singlePeriods); + }); + + it('creates a valid coupon availability calendar with several periods', function (): void { + // GIVEN / WHEN + $couponAvailabilityCalendar = new CouponAvailabilityCalendar( + $this->periods + ); + + // THEN + expect($couponAvailabilityCalendar->periods)->toBe($this->periods); + }); + + it('rejects a coupon availability calendar without period', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): CouponAvailabilityCalendar => new CouponAvailabilityCalendar( + $this->emptyPeriods + ))->toThrow(\DomainException::class); + }); + + it('rejects a coupon availability calendar with a non-period element', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): CouponAvailabilityCalendar => new CouponAvailabilityCalendar( + $this->invalidPeriods + ))->toThrow(\DomainException::class, 'CouponAvailabilityCalendar periods must be Period instances.'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponCodeTest.php new file mode 100644 index 0000000..a139b96 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponCodeTest.php @@ -0,0 +1,75 @@ +toBeInstanceOf(CouponCode::class) + ->and($code->value)->toBe($value); + }); +}); + +describe('coupon code validation rules', function (): void { + it('rejects invalid coupon codes', function (string $invalid): void { + // WHEN / THEN + expect(fn (): CouponCode => new CouponCode($invalid))->toThrow(\DomainException::class); + + // GIVEN + })->with([ + // rejects empty strings + '', + ' ', + "\t", + "\n", + // rejects numbers only + '12345', + '67890', + // rejects letters only + 'ABCDE', + 'FGHIJ', + // rejects codes with special characters + 'AB@12', + 'CD#34', + 'EF$56', + // rejects codes that have the same digit repeated + 'ABC11', + 'DEF22', + 'GHI33', + // rejects codes with lowercase letters + 'AbC12', + 'dEf34', + 'GhI56', + ]); + + it('rejects codes with more/less than 3 letters or more/less than 2 digits', function (string $invalid): void { + // WHEN / THEN + expect(fn (): CouponCode => new CouponCode($invalid))->toThrow(\DomainException::class); + + // GIVEN + })->with([ + 'ABCD12', + 'ABC123', + 'ABCDE123', + 'AB12', + 'A1', + 'AB1', + 'A12', + 'AB123', + 'A123', + 'ABC1', + 'AB12C', + ]); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponDiffusionTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponDiffusionTest.php new file mode 100644 index 0000000..83929ee --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponDiffusionTest.php @@ -0,0 +1,25 @@ +period = new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')); + }); + + it('creates a valid coupon diffusion with one period', function (): void { + // GIVEN / WHEN + $couponDiffusion = new CouponDiffusion($this->period); + + // THEN + expect($couponDiffusion->period)->toBe($this->period); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponIdTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponIdTest.php new file mode 100644 index 0000000..b4b1aee --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponIdTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(CouponId::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = CouponId::generate(); + $second = CouponId::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationTest.php new file mode 100644 index 0000000..007b7d6 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponLegalInformationTest.php @@ -0,0 +1,38 @@ +legalText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + $this->emptyLegalText = ''; + $this->forLocale = Country::FRANCE; + }); + + it('creates a valid coupon legal information', function (): void { + // GIVEN / WHEN + $couponLegalInformation = new CouponLegalInformation( + $this->legalText, + $this->forLocale + ); + + // THEN + expect($couponLegalInformation->legalText)->toBe($this->legalText) + ->and($couponLegalInformation->forLocale)->toBe($this->forLocale); + }); + + it('rejects invalid coupon legal information', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): CouponLegalInformation => new CouponLegalInformation( + $this->emptyLegalText, + $this->forLocale + ))->toThrow(\DomainException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponQuantityTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponQuantityTest.php new file mode 100644 index 0000000..35dd4b3 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponQuantityTest.php @@ -0,0 +1,48 @@ +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/CouponValidityInformationTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationTest.php new file mode 100644 index 0000000..952a9b2 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityInformationTest.php @@ -0,0 +1,38 @@ +validityText = 'Lorem ipsum dolor sit amet consectetur adipiscing elit.'; + $this->emptyValidityText = ''; + $this->forLocale = Country::FRANCE; + }); + + it('creates a valid coupon validity information', function (): void { + // GIVEN / WHEN + $couponValidityInformation = new CouponValidityInformation( + $this->validityText, + $this->forLocale + ); + + // THEN + expect($couponValidityInformation->validityText)->toBe($this->validityText) + ->and($couponValidityInformation->forLocale)->toBe($this->forLocale); + }); + + it('rejects invalid coupon validity information', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): CouponValidityInformation => new CouponValidityInformation( + $this->emptyValidityText, + $this->forLocale + ))->toThrow(\DomainException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityTest.php b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityTest.php new file mode 100644 index 0000000..57c85c9 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Coupon/CouponValidityTest.php @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..f1b0524 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CouponNameTest.php @@ -0,0 +1,33 @@ +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/GS1Test.php b/tests/Unit/Domain/Models/ValueObjects/GS1Test.php new file mode 100644 index 0000000..1e56044 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/GS1Test.php @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..54b03ea --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/IncoCodeTest.php @@ -0,0 +1,33 @@ +value = 1010100101; + $this->zeroValue = 0; + }); + + it('creates a valid inco code', function (): void { + // GIVEN / WHEN + $incoCode = new IncoCode( + $this->value + ); + + // THEN + expect($incoCode->value)->toBe($this->value); + }); + + it('rejects invalid inco code', function (): void { + // GIVEN / WHEN / THEN + expect(fn (): IncoCode => new IncoCode( + $this->zeroValue + ))->toThrow(\DomainException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/MoneyAmountTest.php b/tests/Unit/Domain/Models/ValueObjects/MoneyAmountTest.php new file mode 100644 index 0000000..dbd4190 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/MoneyAmountTest.php @@ -0,0 +1,57 @@ +money = new Money(Currency::EUR, 300); + $this->secondMoney = new Money(Currency::EUR, 500); + }); + + it('wraps a Money value', function (): void { + // GIVEN / WHEN + $amount = new MoneyAmount( + $this->money + ); + + // THEN + expect($amount)->toBeInstanceOf(MoneyAmount::class) + ->and($amount->value)->toBe($this->money); + }); + + it('implements equals by delegating to the wrapped Money', function (): void { + // GIVEN / WHEN + $amount1 = new MoneyAmount( + $this->money + ); + $amount2 = new MoneyAmount( + $this->money + ); + $amount3 = new MoneyAmount( + $this->secondMoney + ); + + // THEN + expect($amount1->equals($amount2))->toBeTrue() + ->and($amount1->equals($amount3))->toBeFalse() + ->and($amount1->equals(null))->toBeFalse(); + }); + + it('converts to string by delegating to the wrapped Money', function (): void { + // GIVEN / WHEN + $amount = new MoneyAmount( + $this->money + ); + + // THEN + expect((string) $amount)->toBe((string) $amount->value); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php new file mode 100644 index 0000000..d45e709 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintRulesTest.php @@ -0,0 +1,87 @@ +maxPerCustomer = 3; + $this->minMaxPerCustomer = 1; + $this->zeroMaxPerCustomer = 0; + $this->cooldownDays = 30; + $this->minCooldownDays = 1; + $this->zeroCooldownDays = 0; + }); + + it('creates print rules with both fields set', function (): void { + // GIVEN / WHEN + $printRules = new PrintRules( + maxPerCustomer: $this->maxPerCustomer, + cooldownDays: $this->cooldownDays + ); + + // THEN + expect($printRules)->toBeInstanceOf(PrintRules::class) + ->and($printRules->maxPerCustomer)->toBe($this->maxPerCustomer) + ->and($printRules->cooldownDays)->toBe($this->cooldownDays); + }); + + it('creates print rules with only maxPerCustomer set', function (): void { + // GIVEN / WHEN + $printRules = new PrintRules( + maxPerCustomer: $this->maxPerCustomer + ); + + // THEN + expect($printRules->maxPerCustomer)->toBe($this->maxPerCustomer) + ->and($printRules->cooldownDays)->toBeNull(); + }); + + it('creates print rules with only cooldownDays set', function (): void { + // GIVEN / WHEN + $printRules = new PrintRules( + cooldownDays: $this->cooldownDays + ); + + // THEN + expect($printRules->maxPerCustomer)->toBeNull() + ->and($printRules->cooldownDays)->toBe($this->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.'); + }); + + it('accepts the minimum value of 1 for both fields', function (): void { + // GIVEN / WHEN + $printRules = new PrintRules( + maxPerCustomer: $this->minMaxPerCustomer, + cooldownDays: $this->minCooldownDays + ); + + // THEN + expect($printRules->maxPerCustomer)->toBe($this->minMaxPerCustomer) + ->and($printRules->cooldownDays)->toBe($this->minCooldownDays); + }); + + 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 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.'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeTest.php new file mode 100644 index 0000000..2946cd1 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/ProcessingCodeTest.php @@ -0,0 +1,33 @@ +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/RequestedCoupon/PeriodTest.php b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/PeriodTest.php new file mode 100644 index 0000000..e161db9 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/PeriodTest.php @@ -0,0 +1,49 @@ +start = new DateTime('2026-01-01'); + $this->end = new DateTime('2026-01-31'); + }); + + it('creates a valid period', function (): void { + // GIVEN / WHEN + $period = new Period( + $this->start, + $this->end + ); + + // THEN + expect($period)->toBeInstanceOf(Period::class) + ->and($period->start)->toBe($this->start) + ->and($period->end)->toBe($this->end); + }); + + it('accepts a period whose end equals its start', function (): void { + // GIVEN + $start = new DateTime('2026-01-01'); + $end = new DateTime('2026-01-01'); + + // WHEN + $period = new Period($start, $end); + + // THEN + expect($period->start)->toBe($start) + ->and($period->end)->toBe($end); + }); + + 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'))) + ->toThrow(\DomainException::class, 'Period end date cannot be before its start date.'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantityTest.php b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantityTest.php new file mode 100644 index 0000000..e5f835c --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponQuantityTest.php @@ -0,0 +1,48 @@ +value = 10; + $this->zeroValue = 0; + $this->negativeValue = -1; + }); + + it('creates a valid quantity', function (): void { + // GIVEN / WHEN + $quantity = new RequestedCouponQuantity( + $this->value + ); + + // THEN + expect($quantity)->toBeInstanceOf(RequestedCouponQuantity::class) + ->and($quantity->value)->toBe($this->value); + }); + + it('rejects a quantity of zero', function (): void { + // GIVEN / WHEN + $action = fn (): RequestedCouponQuantity => new RequestedCouponQuantity( + $this->zeroValue + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Quantity should be more than 0.'); + }); + + it('rejects a negative quantity', function (): void { + // GIVEN / WHEN + $action = fn (): RequestedCouponQuantity => new RequestedCouponQuantity( + $this->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 new file mode 100644 index 0000000..111a145 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/RequestedCoupon/RequestedCouponTest.php @@ -0,0 +1,298 @@ +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); + }); + + afterEach(function (): void { + CarbonImmutable::setTestNow(); + }); + + it('hydrates a requested coupon with all its fields', function (): 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, + ); + + // 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); + }); + + it('hydrates a requested coupon without products', 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, + printRules: $this->printRules, + gs1: $this->gs1 + ); + + // THEN + expect($coupon->products)->toBe([]) + ->and($coupon->printRules)->toBe($this->printRules) + ->and($coupon->gs1)->toBe($this->gs1); + }); + + it('hydrates a requested coupon without print rules', 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, + products: $this->products, + gs1: $this->gs1 + ); + + // THEN + expect($coupon->printRules)->toBeNull() + ->and($coupon->products)->toBe($this->products) + ->and($coupon->gs1)->toBe($this->gs1); + }); + + it('hydrates a requested coupon without gs1', 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, + products: $this->products, + printRules: $this->printRules + ); + + // THEN + expect($coupon->gs1)->toBeNull() + ->and($coupon->products)->toBe($this->products) + ->and($coupon->printRules)->toBe($this->printRules); + }); + + 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 { + // 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.'); + }); + + it('throws when validity information contains an invalid item', function (): 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.'); + }); + + it('accepts the minimum face value of 0.01€', function (): void { + // GIVEN + $moneyAmount = new MoneyAmount(new Money(Currency::EUR, 1)); + + // WHEN + $coupon = new RequestedCoupon( + name: $this->name, + quantity: $this->quantity, + diffusion: $this->diffusion, + moneyAmount: $moneyAmount, + processingCode: $this->processingCode, + couponValidity: $this->couponValidity, + couponValidityInformation: $this->couponValidityInformation, + ); + + // THEN + expect($coupon->moneyAmount)->toBe($moneyAmount); + }); + + it('accepts the maximum face value of 3.00€', function (): void { + // GIVEN + $moneyAmount = new MoneyAmount(new Money(Currency::EUR, 300)); + + // WHEN + $coupon = new RequestedCoupon( + name: $this->name, + quantity: $this->quantity, + diffusion: $this->diffusion, + moneyAmount: $moneyAmount, + processingCode: $this->processingCode, + couponValidity: $this->couponValidity, + couponValidityInformation: $this->couponValidityInformation, + ); + + // THEN + expect($coupon->moneyAmount)->toBe($moneyAmount); + }); + + it('throws when the face value is zero', function (): void { + // GIVEN / WHEN / THEN + expect(fn () => new RequestedCoupon( + name: $this->name, + quantity: $this->quantity, + diffusion: $this->diffusion, + moneyAmount: new MoneyAmount(new Money(Currency::EUR, 0)), + processingCode: $this->processingCode, + couponValidity: $this->couponValidity, + couponValidityInformation: $this->couponValidityInformation, + ))->toThrow(\DomainException::class, 'Coupon face value must be between 0.01€ and 3.00€'); + }); + + it('throws when the face value is above 3.00€', function (): void { + // GIVEN / WHEN / THEN + expect(fn () => new RequestedCoupon( + name: $this->name, + quantity: $this->quantity, + diffusion: $this->diffusion, + moneyAmount: new MoneyAmount(new Money(Currency::EUR, 301)), + processingCode: $this->processingCode, + couponValidity: $this->couponValidity, + couponValidityInformation: $this->couponValidityInformation, + ))->toThrow(\DomainException::class, 'Coupon face value must be between 0.01€ and 3.00€'); + }); + + it('throws when a gs1 coupon has no 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->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(); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Stock/StockAdjustmentTest.php b/tests/Unit/Domain/Models/ValueObjects/Stock/StockAdjustmentTest.php new file mode 100644 index 0000000..42441f1 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Stock/StockAdjustmentTest.php @@ -0,0 +1,85 @@ +quantity = 10; + $this->zeroQuantity = 0; + $this->negativeQuantity = -5; + $this->reason = 'Inventory correction'; + }); + + it('creates a valid stock adjustment', function (): void { + // GIVEN + $actorId = ActorId::generate(); + + // WHEN + $adjustment = new StockAdjustment( + $this->quantity, + $this->reason, + $actorId + ); + + // THEN + expect($adjustment)->toBeInstanceOf(StockAdjustment::class) + ->and($adjustment->quantity)->toBe($this->quantity) + ->and($adjustment->reason)->toBe($this->reason) + ->and($adjustment->actorId)->toBe($actorId) + ->and($adjustment->createdAt)->toBeNull(); + }); + + it('creates a stock adjustment with an explicit createdAt date', function (): 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, + $actorId, + $createdAt + ); + + // THEN + expect($adjustment->createdAt)->toBe($createdAt); + }); + + it('rejects a quantity of zero', function (): void { + // GIVEN + $actorId = ActorId::generate(); + + // WHEN + $action = fn (): StockAdjustment => new StockAdjustment( + $this->zeroQuantity, + $this->reason, + $actorId + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Stock Amount should be more than 0.'); + }); + + it('rejects a negative quantity', function (): void { + // GIVEN + $actorId = ActorId::generate(); + + // WHEN + $action = fn (): StockAdjustment => new StockAdjustment( + $this->negativeQuantity, + $this->reason, + $actorId + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Stock Amount should be more than 0.'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Stock/StockAmountTest.php b/tests/Unit/Domain/Models/ValueObjects/Stock/StockAmountTest.php new file mode 100644 index 0000000..59a7753 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Stock/StockAmountTest.php @@ -0,0 +1,48 @@ +value = 10; + $this->zeroValue = 0; + $this->negativeValue = -1; + }); + + it('creates a valid stock amount', function (): void { + // GIVEN / WHEN + $amount = new StockAmount( + $this->value + ); + + // THEN + expect($amount)->toBeInstanceOf(StockAmount::class) + ->and($amount->value)->toBe($this->value); + }); + + it('accepts a zero value', function (): void { + // GIVEN / WHEN + $amount = new StockAmount( + $this->zeroValue + ); + + // THEN + expect($amount->value)->toBe($this->zeroValue); + }); + + it('rejects a negative value', function (): void { + // GIVEN / WHEN + $action = fn (): StockAmount => new StockAmount( + $this->negativeValue + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Stock Amount should be positive.'); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Stock/StockIdTest.php b/tests/Unit/Domain/Models/ValueObjects/Stock/StockIdTest.php new file mode 100644 index 0000000..65e6c85 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Stock/StockIdTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(StockId::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = StockId::generate(); + $second = StockId::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Shared/Enums/CurrencyTest.php b/tests/Unit/Shared/Enums/CurrencyTest.php new file mode 100644 index 0000000..6de9498 --- /dev/null +++ b/tests/Unit/Shared/Enums/CurrencyTest.php @@ -0,0 +1,16 @@ +value)->toBe('euro'); + }); +}); diff --git a/tests/Unit/Shared/ValueObjects/MoneyTest.php b/tests/Unit/Shared/ValueObjects/MoneyTest.php new file mode 100644 index 0000000..e6bd56e --- /dev/null +++ b/tests/Unit/Shared/ValueObjects/MoneyTest.php @@ -0,0 +1,61 @@ +toBeInstanceOf(Money::class) + ->and($money->currency)->toBe(Currency::EUR) + ->and($money->value)->toBe($value); + }); + + it('accepts a zero value', function (): void { + // WHEN + $money = new Money(Currency::EUR, 0); + + // THEN + expect($money->value)->toBe(0); + }); + + it('rejects a negative value', function (): void { + // WHEN + $action = fn (): Money => new Money(Currency::EUR, -1); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Money value cannot be negative.'); + }); + + it('implements equals correctly', function (): void { + // GIVEN + $money1 = new Money(Currency::EUR, 300); + $money2 = new Money(Currency::EUR, 300); + $money3 = new Money(Currency::EUR, 500); + + // THEN + expect($money1->equals($money2))->toBeTrue() + ->and($money1->equals($money3))->toBeFalse() + ->and($money1->equals(null))->toBeFalse(); + }); + + it('converts to string', function (): void { + // GIVEN + $money = new Money(Currency::EUR, 300); + + // THEN + expect((string) $money)->toBe('300 EUR'); + }); +});