Merge branch 'common-sprint-2' into 'refonte'

Common sprint 2

See merge request ldc-web/projets/mesbr!39
This commit is contained in:
Corentin HUET 2026-10-02 10:51:09 +00:00
commit a2d211814b
121 changed files with 5786 additions and 91 deletions

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Create;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\CampaignRequest;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CampaignRequestRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\Transactional;
use Shared\DDD\UseCases\UseCase;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\CampaignRequest\Create\Input, \Application\UseCases\CampaignRequest\Create\Output> */
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(),
);
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Create;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
final readonly class Input implements UseCaseInput
{
public function __construct(
public BrandCode $brandCode,
public CampaignName $name,
public Applicant $applicant,
public CampaignRequestType $campaignType,
public RequestedCoupon $requestedCoupon,
) {
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Create;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Shared\DDD\UseCases\Output as UseCaseOutput;
final readonly class Output implements UseCaseOutput
{
public function __construct(
public CampaignRequestId $id,
public BrandCode $brandCode,
public CampaignName $name,
public Applicant $applicant,
public CampaignRequestType $campaignType,
public RequestedCoupon $requestedCoupon,
public CampaignRequestStatus $status,
public \DateTimeInterface $createdAt,
) {
}
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Find;
use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestFound;
use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestNotFound;
use Application\UseCases\CampaignRequest\Find\Output\Output;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\Entities\CampaignRequest;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CampaignRequestRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\CampaignRequest\Find\Input, \Application\UseCases\CampaignRequest\Find\Output\Output> */
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(),
);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Find;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Shared\DDD\UseCases\Input as UseCaseInput;
final readonly class Input implements UseCaseInput
{
public function __construct(
public CampaignRequestId $id,
) {
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Find\Output;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
final readonly class CampaignRequestFound implements Output
{
public function __construct(
public Brand $brand,
public CampaignName $name,
public Applicant $applicant,
public CampaignRequestType $campaignType,
public RequestedCoupon $requestedCoupon,
public CampaignRequestStatus $status,
public ?RejectionReason $rejectionReason,
public \DateTimeInterface $createdAt,
public ?\DateTimeInterface $updatedAt,
) {
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Find\Output;
final readonly class CampaignRequestNotFound implements Output
{
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Find\Output;
use Shared\DDD\UseCases\Output as UseCaseOutput;
interface Output extends UseCaseOutput
{
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\List;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
final readonly class CampaignRequestListItem
{
public function __construct(
public BrandCode $brandCode,
public CampaignName $name,
public Applicant $applicant,
public CampaignRequestType $campaignType,
public CampaignRequestStatus $status
) {
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\List;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestFilters;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortCriteria;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Shared\ValueObjects\Pagination\Pagination;
final readonly class Input implements UseCaseInput
{
public function __construct(
public Pagination $pagination,
public ?CampaignRequestSortCriteria $sort = null,
public ?CampaignRequestFilters $filters = null,
) {
}
}

View file

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\List;
use Domain\Models\Entities\CampaignRequest;
use Domain\Ports\Repositories\CampaignRequestRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\ValueObjects\Pagination\Paginated;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\CampaignRequest\List\Input, \Application\UseCases\CampaignRequest\List\Output> */
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(),
),
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\List;
use Shared\DDD\UseCases\Output as UseCaseOutput;
use Shared\ValueObjects\Pagination\Paginated;
final readonly class Output implements UseCaseOutput
{
/** @param Paginated<CampaignRequestListItem> $campaignsRequest */
public function __construct(
public Paginated $campaignsRequest,
) {
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Reject;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
use Shared\DDD\UseCases\Input as UseCaseInput;
final readonly class Input implements UseCaseInput
{
public function __construct(
public CampaignRequestId $id,
public RejectionReason $reason,
) {
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Reject\Output;
final readonly class CampaignRequestNotFound implements Output
{
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Reject\Output;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
final readonly class CampaignRequestRejected implements Output
{
public function __construct(
public CampaignRequestId $id,
public CampaignRequestStatus $status,
public RejectionReason $rejectionReason,
public ?\DateTimeInterface $updatedAt = null
) {
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Reject\Output;
use Shared\DDD\UseCases\Output as UseCaseOutput;
interface Output extends UseCaseOutput
{
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\Reject;
use Application\UseCases\CampaignRequest\Reject\Output\CampaignRequestNotFound;
use Application\UseCases\CampaignRequest\Reject\Output\CampaignRequestRejected;
use Application\UseCases\CampaignRequest\Reject\Output\Output;
use Domain\Models\Entities\CampaignRequest;
use Domain\Ports\Repositories\CampaignRequestRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\Transactional;
use Shared\DDD\UseCases\UseCase;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\CampaignRequest\Reject\Input, \Application\UseCases\CampaignRequest\Reject\Output\Output> */
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(),
);
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Domain\Enums;
enum BarcodeFormat: string
{
case EAN13 = 'ean';
case CODE128 = 'code128';
case GS1_128 = 'gs1';
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Domain\Exceptions;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Shared\DDD\Exceptions\NotFoundException;
/**
* The brand referenced by a code could not be found.
*/
final class BrandNotFoundException extends NotFoundException
{
public function __construct(BrandCode $brandCode)
{
parent::__construct(sprintf('Brand with code "%s" was not found.', $brandCode->value));
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Actor\ActorDisplayName;
use Domain\Models\ValueObjects\Actor\ActorId;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Actor\ActorId>
*/
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,
);
}
}

View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignId;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Campaign\CampaignId>
*/
final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier
{
/**
* @param array<CampaignCoupon> $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<CampaignCoupon> $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<CampaignCoupon> $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,
);
}
}

View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
use Shared\Helpers\DatesHelper;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId>
*/
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;
}
}

View file

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponId;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\CouponPicture;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\ProcessingCode;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Coupon\CouponId>
*/
final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
{
/**
* @param array<CouponValidityInformation> $couponValidityInformation
* @param array<CouponLegalInformation> $couponLegalInformation
* @param array<IncoCode> $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> $couponValidityInformation
* @param array<CouponLegalInformation> $couponLegalInformation
* @param array<IncoCode> $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> $couponValidityInformation
* @param array<CouponLegalInformation> $couponLegalInformation
* @param array<IncoCode> $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,
);
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\Stock\StockAdjustment;
use Domain\Models\ValueObjects\Stock\StockAmount;
use Domain\Models\ValueObjects\Stock\StockId;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Stock\StockId>
*/
final readonly class Stock implements AggregateRoot, HasTimestampIdentifier
{
private function __construct(
public StockId $id,
public StockAmount $stockAmount,
public CampaignCode $campaignCode,
public ProcessingCode $processingCode,
/** @var array<int, StockAdjustment> $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<int, StockAdjustment> $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,
);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Actor;
final readonly class ActorDisplayName
{
public function __construct(
public string $value,
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('Display name cannot be empty.');
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Actor;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Models\Uuidv7EntityId;
final readonly class ActorId extends Uuidv7EntityId
{
public static function generate(): self
{
return new self(Uuid::uuid7());
}
}

View file

@ -9,7 +9,7 @@ final readonly class BrandName
public function __construct(
public string $value,
) {
$trimmed = trim($value);
$trimmed = mb_trim($value);
if ($trimmed === '') {
throw new \DomainException('Brand name cannot be empty.');

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Campaign;
final readonly class CampaignCode
{
public function __construct(
public string $code,
) {
if (mb_trim($this->code) === '') {
throw new \DomainException('Campaign code cannot be empty.');
}
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Campaign;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Shared\Enums\Country;
final readonly class CampaignCoupon
{
public function __construct(
public ProcessingCode $processingCode,
public CouponQuantity $quantity,
public Country $availableIn,
public ?PrintRules $printRules = null,
) {
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Campaign;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Models\Uuidv7EntityId;
final readonly class CampaignId extends Uuidv7EntityId
{
public static function generate(): self
{
return new self(Uuid::uuid7());
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Campaign;
final readonly class CampaignName
{
public function __construct(
public string $value,
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('Campaign name cannot be empty.');
}
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
use Shared\ValueObjects\Email;
final readonly class Applicant
{
public function __construct(
public string $firstname,
public string $lastname,
public Email $email,
) {
$trimmedFirstname = mb_trim($firstname);
if ($trimmedFirstname === '') {
throw new \DomainException('Applicant firstname cannot be empty.');
}
if (mb_strlen($trimmedFirstname) > 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.');
}
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
final readonly class CampaignRequestFilters
{
public function __construct(
public ?BrandCode $brandCode = null,
public ?CampaignRequestStatus $status = null,
) {
if (null === $this->brandCode && null === $this->status) {
throw new \InvalidArgumentException('CampaignRequestFilters require at least a brand code or a status.');
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Models\Uuidv7EntityId;
final readonly class CampaignRequestId extends Uuidv7EntityId
{
public static function generate(): static
{
return new self(Uuid::uuid7());
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
final readonly class CampaignRequestSortClause
{
public function __construct(
public CampaignRequestSortField $field,
public \SortDirection $direction,
) {
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
final readonly class CampaignRequestSortCriteria
{
public function __construct(
/** @var CampaignRequestSortClause[] */
public array $clauses
) {
if ($this->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.');
}
}
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
enum CampaignRequestSortField: string
{
case BrandCode = 'brand_code';
case Status = 'status';
case CreatedAt = 'created_at';
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
enum CampaignRequestStatus: string
{
case CREATED = 'created';
case TRANSFORMED = 'transformed';
case REJECTED = 'rejected';
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
enum CampaignRequestType: string
{
case CLUB = 'club';
case GAME = 'game';
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\CampaignRequest;
final readonly class RejectionReason
{
public function __construct(
public string $value,
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('Rejection reason cannot be empty.');
}
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\Period;
use Shared\DDD\Models\Equatable;
use DateTime;
final readonly class CouponAvailabilityCalendar implements Equatable
{
public function __construct(
/** @var Period[] */
public array $periods,
) {
if ($this->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;
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponCode
{
private const string REGEX = '/^[A-Z]{3}\d{2}$/';
public function __construct(
public string $value,
) {
if (! preg_match(self::REGEX, $value)) {
throw new \DomainException(
"Invalid code '{$value}' does not match pattern " . self::REGEX,
);
}
if ($value[3] === $value[4]) {
throw new \DomainException(
"Invalid code '{$value}' has the same digit repeated",
);
}
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\Period;
final readonly class CouponDiffusion
{
public function __construct(
public Period $period,
) {
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Models\Uuidv7EntityId;
final readonly class CouponId extends Uuidv7EntityId
{
public static function generate(): self
{
return new self(Uuid::uuid7());
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\Enums\Country;
final readonly class CouponLegalInformation
{
public function __construct(
public string $legalText,
public Country $forLocale
) {
if (mb_trim($this->legalText) === '') {
throw new \DomainException('Legal text cannot be empty.');
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponQuantity
{
public function __construct(
public int $quantity,
) {
if ($this->quantity <= 0) {
throw new \DomainException('CouponQuantity quantity must be positive.');
}
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\DDD\Models\Equatable;
use DateTime;
final readonly class CouponValidity implements Equatable
{
public function __construct(
public int $days,
public DateTime $until
) {
if ($this->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;
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\Enums\Country;
final readonly class CouponValidityInformation
{
public function __construct(
public string $validityText,
public Country $forLocale
) {
if (mb_trim($this->validityText) === '') {
throw new \DomainException('Validity text cannot be empty.');
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
final readonly class CouponName
{
public function __construct(
public string $value,
) {
if (mb_trim($this->value) === '') {
throw new \DomainException('Coupon name cannot be empty.');
}
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
final readonly class CouponPicture
{
public function __construct(
public PicturePath $decoration,
public PicturePath $thumbnail,
public PicturePath $inactiveThumbnail
) {
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Domain\Enums\BarcodeFormat;
use Shared\DDD\Models\Equatable;
final readonly class GS1 implements \Stringable, Equatable
{
public function __construct(
public string $value,
public BarcodeFormat $format
) {
if (mb_trim($this->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;
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Shared\DDD\Models\Equatable;
final readonly class IncoCode implements Equatable
{
/**
* @throws \DomainException
*/
public function __construct(
public int $value,
) {
if ($this->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;
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Shared\DDD\Models\Equatable;
use Shared\ValueObjects\Money;
/**
* Wraps a Money value as a requested/expected monetary amount.
*/
final readonly class MoneyAmount implements \Stringable, Equatable
{
public function __construct(
public Money $value,
) {
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value->equals($other->value);
}
public function __toString(): string
{
return (string) $this->value;
}
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use DateTime;
final readonly class Period
{
public function __construct(
public DateTime $start,
public DateTime $end,
) {
if ($this->end < $this->start) {
throw new \DomainException('Period end date cannot be before its start date.');
}
}
}

View file

@ -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.');

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
final readonly class PrintRules
{
public function __construct(
public ?int $maxPerCustomer = null,
public ?int $cooldownDays = null,
) {
if (null === $this->maxPerCustomer && null === $this->cooldownDays) {
throw new \InvalidArgumentException('PrintRules require at least a max per customer or a cooldown days.');
}
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.');
}
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Shared\DDD\Models\Equatable;
final readonly class ProcessingCode implements \Stringable, Equatable
{
public function __construct(
public string $value,
) {
if (mb_trim($this->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;
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\RequestedCoupon;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Shared\Enums\Country;
final readonly class RequestedCoupon
{
private const int MIN_FACE_VALUE_IN_CENTS = 1;
private const int MAX_FACE_VALUE_IN_CENTS = 300;
/**
* @param array<CouponValidityInformation> $couponValidityInformation
* @param array<IncoCode> $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;
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\RequestedCoupon;
final readonly class RequestedCouponQuantity
{
public function __construct(
public int $value,
) {
if ($this->value <= 0) {
throw new \DomainException('Quantity should be more than 0.');
}
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Stock;
use Domain\Models\ValueObjects\Actor\ActorId;
final readonly class StockAdjustment
{
public function __construct(
public int $quantity,
public string $reason,
public ActorId $actorId,
public ?\DateTimeInterface $createdAt = null,
) {
if ($this->quantity <= 0) {
throw new \DomainException('Stock Amount should be more than 0.');
}
if (mb_trim($this->reason) === '') {
throw new \DomainException('Reason cannot be empty.');
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Stock;
final readonly class StockAmount
{
public function __construct(
public int $value,
) {
if ($this->value < 0) {
throw new \DomainException('Stock Amount should be positive.');
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Stock;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Models\Uuidv7EntityId;
final readonly class StockId extends Uuidv7EntityId
{
public static function generate(): self
{
return new self(Uuid::uuid7());
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Shared\DDD\Ports\Repository;
/**
* @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\Campaign\CampaignId, \Domain\Models\Entities\Campaign>
*/
interface CampaignRepository extends Repository
{
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestFilters;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortCriteria;
use Shared\DDD\Ports\Repository;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
/**
* @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId, \Domain\Models\Entities\CampaignRequest>
*/
interface CampaignRequestRepository extends Repository
{
public function findById(CampaignRequestId $id): ?CampaignRequest;
public function save(CampaignRequest $campaignRequest): void;
/**
* @return Paginated<CampaignRequest>
*/
public function findAll(
Pagination $pagination,
?CampaignRequestSortCriteria $sortCriteria = null,
?CampaignRequestFilters $filters = null,
): Paginated;
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Shared\DDD\Ports\Repository;
/**
* @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\Coupon\CouponId, \Domain\Models\Entities\Coupon>
*/
interface CouponRepository extends Repository
{
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\Exceptions;
use Shared\Enums\Country;
final class UnknownCountryIsoCodeException extends \DomainException
{
public static function fromIsoCode(string $isoCode): self
{
return new self("No country found for ISO code '{$isoCode}'");
}
public static function fromCountry(Country $country): self
{
return new self("No ISO code found for country '{$country->name}'");
}
}

View file

@ -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');
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Shared\Enums;
enum Country: string
{
case FRANCE = 'france';
case BENELUX = 'benelux';
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shared\Enums;
enum Currency: string
{
case EUR = 'euro';
}

View file

@ -63,7 +63,7 @@ final class DatesHelper
public static function parseDateTime(string $datetime): \DateTimeInterface
{
$datetime = trim($datetime);
$datetime = mb_trim($datetime);
if ($datetime === '') {
throw InvalidDateFormatException::forDateString($datetime);

View file

@ -20,7 +20,7 @@ final readonly class Email implements \Stringable, Equatable
public function __construct(
string $value,
) {
$trimmed = trim($value);
$trimmed = mb_trim($value);
if ($trimmed === '') {
throw new \DomainException('Email cannot be empty.');

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Shared\ValueObjects;
use Shared\DDD\Models\Equatable;
use Shared\Enums\Currency;
/**
* Represents a monetary amount, expressed as an integer in the currency's
* smallest unit (e.g. cents).
*/
final readonly class Money implements \Stringable, Equatable
{
/**
* @param Currency $currency Currency of the amount.
* @param int $value Amount expressed in the currency's smallest unit (e.g. cents). Must be zero or positive.
* @throws \DomainException
*/
public function __construct(
public Currency $currency,
public int $value,
) {
if ($this->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);
}
}

View file

@ -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.');

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\CampaignRequest\Create;
use Application\UseCases\CampaignRequest\Create\CreateCampaignRequest;
use Application\UseCases\CampaignRequest\Create\Input;
use Application\UseCases\CampaignRequest\Create\Output;
use DateTime;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CampaignRequestRepository;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Email;
use Shared\ValueObjects\Money;
covers(CreateCampaignRequest::class, Input::class, Output::class);
beforeEach(function (): void {
$this->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);
});
});

View file

@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\CampaignRequest\Find;
use Application\UseCases\CampaignRequest\Find\FindCampaignRequest;
use Application\UseCases\CampaignRequest\Find\Input;
use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestFound;
use Application\UseCases\CampaignRequest\Find\Output\CampaignRequestNotFound;
use Application\UseCases\CampaignRequest\Find\Output\Output;
use DateTime;
use Domain\Enums\BarcodeFormat;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Repositories\CampaignRequestRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Email;
use Shared\ValueObjects\Money;
use Shared\ValueObjects\Rcs;
covers(FindCampaignRequest::class, Input::class, CampaignRequestFound::class, CampaignRequestNotFound::class);
describe('find campaign request use case', function (): void {
beforeEach(function (): void {
$this->requestedCoupon = new RequestedCoupon(
new CouponName('Coupon A'),
new RequestedCouponQuantity(100),
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new MoneyAmount(new Money(Currency::EUR, 300)),
new ProcessingCode('PC1'),
new CouponValidity(30, new DateTime('2026-02-01')),
[
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
],
[new IncoCode(35784), new IncoCode(15522)],
new PrintRules(maxPerCustomer: 3),
new GS1('01234567890128', BarcodeFormat::EAN13)
);
});
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);
});
});

View file

@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\CampaignRequest\List;
use DateTime;
use Domain\Enums\BarcodeFormat;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestFilters;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortClause;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortCriteria;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortField;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Domain\Ports\Repositories\CampaignRequestRepository;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Email;
use Shared\ValueObjects\Money;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
covers(ListCampaignsRequest::class, Input::class, Output::class, CampaignRequestListItem::class);
describe('list campaigns request use case', function (): void {
beforeEach(function (): void {
$this->couponA = new RequestedCoupon(
new CouponName('Coupon A'),
new RequestedCouponQuantity(100),
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new MoneyAmount(new Money(Currency::EUR, 300)),
new ProcessingCode('PC1'),
new CouponValidity(30, new DateTime('2026-02-01')),
[
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
],
[new IncoCode(35784), new IncoCode(15522)],
new PrintRules(maxPerCustomer: 3),
new GS1('01234567890128', BarcodeFormat::EAN13)
);
$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);
});
});

View file

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\CampaignRequest\Reject;
use Application\UseCases\CampaignRequest\Reject\Input;
use Application\UseCases\CampaignRequest\Reject\Output\CampaignRequestNotFound;
use Application\UseCases\CampaignRequest\Reject\Output\CampaignRequestRejected;
use Application\UseCases\CampaignRequest\Reject\Output\Output;
use Application\UseCases\CampaignRequest\Reject\RejectCampaignRequest;
use Carbon\CarbonImmutable;
use DateTime;
use Domain\Enums\BarcodeFormat;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Domain\Ports\Repositories\CampaignRequestRepository;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Email;
use Shared\ValueObjects\Money;
covers(RejectCampaignRequest::class, Input::class, CampaignRequestRejected::class, CampaignRequestNotFound::class);
describe('reject campaign request use case', function (): void {
beforeEach(function (): void {
$this->requestedCoupon = new RequestedCoupon(
new CouponName('Coupon A'),
new RequestedCouponQuantity(100),
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new MoneyAmount(new Money(Currency::EUR, 300)),
new ProcessingCode('PC1'),
new CouponValidity(30, new DateTime('2026-02-01')),
[
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
],
[new IncoCode(35784), new IncoCode(15522)],
new PrintRules(maxPerCustomer: 3),
new GS1('01234567890128', BarcodeFormat::EAN13)
);
});
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);
});
});

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Enums;
use Domain\Enums\BarcodeFormat;
covers(BarcodeFormat::class);
describe('barcode format', function (): void {
it('has the expected cases with their backing values', function (): void {
// THEN
expect(BarcodeFormat::EAN13->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');
});
});

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Enums\Campaign;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
covers(CampaignRequestStatus::class);
describe('campaign status', function (): void {
it('has the expected cases with their backing values', function (): void {
// THEN
expect(CampaignRequestStatus::CREATED->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');
});
});

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Enums\Campaign;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
covers(CampaignRequestType::class);
describe('campaign type', function (): void {
it('has the expected cases with their backing values', function (): void {
// THEN
expect(CampaignRequestType::CLUB->value)->toBe('club')
->and(CampaignRequestType::GAME->value)->toBe('game');
});
it('exposes its case name', function (): void {
// THEN
expect(CampaignRequestType::CLUB->name)->toBe('CLUB');
});
});

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Enums;
use Shared\Enums\Country;
covers(Country::class);
describe('country', function (): void {
it('has the expected cases with their backing values', function (): void {
// THEN
expect(Country::FRANCE->value)->toBe('france')
->and(Country::BENELUX->value)->toBe('benelux');
});
it('exposes its case name', function (): void {
// THEN
expect(Country::FRANCE->name)->toBe('FRANCE');
});
});

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities;
use Domain\Models\Entities\Actor;
use Domain\Models\ValueObjects\Actor\ActorDisplayName;
use Domain\Models\ValueObjects\Actor\ActorId;
use Ramsey\Uuid\Uuid;
covers(Actor::class);
describe('actor', function (): void {
beforeEach(function (): void {
$this->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'));
});
});

View file

@ -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

View file

@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities;
use Carbon\CarbonImmutable;
use DateTime;
use Domain\Enums\BarcodeFormat;
use Domain\Models\Entities\CampaignRequest;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestType;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCoupon;
use Domain\Models\ValueObjects\RequestedCoupon\RequestedCouponQuantity;
use Ramsey\Uuid\Uuid;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Email;
use Shared\ValueObjects\Money;
covers(CampaignRequest::class);
describe('campaign request', function (): void {
beforeEach(function (): void {
$this->requestedCoupon = new RequestedCoupon(
new CouponName('Coupon A'),
new RequestedCouponQuantity(100),
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new MoneyAmount(new Money(Currency::EUR, 300)),
new ProcessingCode('PC1'),
new CouponValidity(30, new DateTime('2026-02-01')),
[
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FRANCE),
new CouponValidityInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::BENELUX)
],
[new IncoCode(35784), new IncoCode(15522)],
new PrintRules(maxPerCustomer: 3),
new GS1('01234567890128', BarcodeFormat::EAN13)
);
$this->code = new BrandCode('AAA01');
$this->name = new CampaignName('Campaign A');
$this->applicant = new Applicant('John', 'Doe', new Email('john.doe@example.com'));
$this->campaignType = CampaignRequestType::CLUB;
$this->requestedCoupon = $this->requestedCoupon;
$this->status = CampaignRequestStatus::CREATED;
$this->transformedStatus = CampaignRequestStatus::TRANSFORMED;
$this->rejectedStatus = CampaignRequestStatus::REJECTED;
$this->rejectionReason = new RejectionReason('Missing legal documents.');
});
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'));
});
});

View file

@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities;
use Carbon\CarbonImmutable;
use Shared\Enums\Country;
use Domain\Models\Entities\Campaign;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignId;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Ramsey\Uuid\Uuid;
covers(Campaign::class);
describe('campaign', function (): void {
beforeEach(function (): void {
$this->code = new CampaignCode('Camp100');
$this->name = new CampaignName('Name');
$this->publication = \boolval(0);
$this->printAllAtOnce = \boolval(1);
$this->coupons = [
new CampaignCoupon(
new ProcessingCode('PC1'),
new CouponQuantity(100),
Country::FRANCE,
new PrintRules(maxPerCustomer: 3)
)
];
$this->brandCode = new BrandCode('AAA01');
$this->actorId = ActorId::generate();
});
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.');
});
});

View file

@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities;
use Carbon\CarbonImmutable;
use DateTime;
use Domain\Enums\BarcodeFormat;
use Shared\Enums\Country;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponId;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\CouponName;
use Domain\Models\ValueObjects\CouponPicture;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\ProcessingCode;
use Ramsey\Uuid\Uuid;
use Shared\Enums\Currency;
use Shared\ValueObjects\Money;
covers(Coupon::class);
describe('coupon', function (): void {
beforeEach(function (): void {
$this->code = new CouponCode('COU01');
$this->name = new CouponName('Name');
$this->amount = new MoneyAmount(new Money(Currency::EUR, 1));
$this->picture = new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/inactive_thumbnail.jpg')
);
$this->diffusion = new CouponDiffusion(new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')));
$this->validity = new CouponValidity(30, new DateTime('2026-02-01'));
$this->couponValidityInformation = [
new CouponValidityInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE
)
];
$this->couponLegalInformation = [
new CouponLegalInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE
)
];
$this->processingCode = new ProcessingCode('5425003944130');
$this->brandCode = new BrandCode('AAA01');
$this->createdBy = ActorId::generate();
$this->products = [new IncoCode(35784), new IncoCode(15522)];
$this->availabilityCalendar = new CouponAvailabilityCalendar([new Period(new DateTime('2026-08-25'), new DateTime('2026-10-31'))]);
$this->gs1 = new GS1('110101010', BarcodeFormat::EAN13);
});
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'));
});
});

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities;
use Domain\Models\Entities\Stock;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\Stock\StockAdjustment;
use Domain\Models\ValueObjects\Stock\StockAmount;
use Domain\Models\ValueObjects\Stock\StockId;
use Ramsey\Uuid\Uuid;
covers(Stock::class);
describe('stock', function (): void {
beforeEach(function (): void {
$this->stockAmount = new StockAmount(10);
$this->campaignCode = new CampaignCode('AAA01');
$this->processingCode = new ProcessingCode('PC1');
$this->stockAdjustment = [new StockAdjustment(5, 'Inventory correction', ActorId::generate())];
});
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'));
});
});

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Actor;
use Domain\Models\ValueObjects\Actor\ActorDisplayName;
covers(ActorDisplayName::class);
describe('actor displayname', function (): void {
beforeEach(function (): void {
$this->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);
});
});

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Actor;
use Domain\Models\ValueObjects\Actor\ActorId;
covers(ActorId::class);
describe('actor id', function (): void {
it('generates a unique actor id', function (): void {
// WHEN
$id = ActorId::generate();
// THEN
expect($id)->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);
});
});

View file

@ -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 {

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Campaign;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
covers(CampaignCode::class);
describe('campaign code', function (): void {
beforeEach(function (): void {
$this->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);
});
});

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Campaign;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Shared\Enums\Country;
covers(CampaignCoupon::class);
describe('campaign coupon', function (): void {
beforeEach(function (): void {
$this->processingCode = new ProcessingCode('PC1');
$this->quantity = new CouponQuantity(10);
$this->availableIn = Country::FRANCE;
$this->printRules = new PrintRules(maxPerCustomer: 3);
});
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);
});
});

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Campaign;
use Domain\Models\ValueObjects\Campaign\CampaignId;
covers(CampaignId::class);
describe('campaign', function (): void {
it('generates a unique campaign id', function (): void {
// WHEN
$id = CampaignId::generate();
// THEN
expect($id)->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);
});
});

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Campaign;
use Domain\Models\ValueObjects\Campaign\CampaignName;
covers(CampaignName::class);
describe('campaign name', function (): void {
beforeEach(function (): void {
$this->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);
});
});

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\CampaignRequest;
use Domain\Models\ValueObjects\CampaignRequest\Applicant;
use Shared\ValueObjects\Email;
covers(Applicant::class);
describe('applicant', function (): void {
beforeEach(function (): void {
$this->firstname = 'John';
$this->lastname = 'Doe';
$this->email = new Email('john.doe@example.com');
});
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);
});
});

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestFilters;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestStatus;
covers(CampaignRequestFilters::class);
describe('campaign request filters', function (): void {
beforeEach(function (): void {
$this->brandCode = new BrandCode('AAA01');
$this->status = CampaignRequestStatus::CREATED;
});
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.');
});
});

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\CampaignRequest;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId;
covers(CampaignRequestId::class);
describe('campaign request id', function (): void {
it('generates a unique campaign request id', function (): void {
// WHEN
$id = CampaignRequestId::generate();
// THEN
expect($id)->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);
});
});

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortClause;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortCriteria;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortField;
covers(CampaignRequestSortCriteria::class);
describe('campaign request sort criteria', function (): void {
beforeEach(function (): void {
$this->singleClauses = [
new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending),
];
$this->clauses = [
new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending),
new CampaignRequestSortClause(field: CampaignRequestSortField::Status, direction: \SortDirection::Descending),
];
$this->invalidClauses = [CampaignRequestSortField::BrandCode];
$this->emptyClauses = [];
});
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.');
});
});

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\CampaignRequest;
use Domain\Models\ValueObjects\CampaignRequest\RejectionReason;
covers(RejectionReason::class);
describe('rejection reason', function (): void {
beforeEach(function (): void {
$this->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' => ' ',
]);
});

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Period;
use DateTime;
covers(CouponAvailabilityCalendar::class);
describe('coupon availability calendar', function (): void {
beforeEach(function (): void {
$this->singlePeriods = [
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
];
$this->periods = [
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
new Period(new DateTime('2026-03-01'), new DateTime('2026-04-01')),
];
$this->invalidPeriods = ['2026-08-25', '2026-10-31'];
$this->emptyPeriods = [];
});
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.');
});
});

Some files were not shown because too many files have changed in this diff Show more