Merge branch 'printable-coupon' into 'coupon'

feat: 👷 add ListPrintableCoupon Use Case and tests

See merge request ldc-web/projets/mesbr!61
This commit is contained in:
Alexis Gousseau 2026-10-14 14:31:17 +02:00
commit f55f4bb628
24 changed files with 1153 additions and 0 deletions

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\List;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortCriteria;
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 CampaignCode $campaignCode,
public CrmHashcode $crmCustomerHashcode,
public ?PrintableCouponSortCriteria $sortCriteria = null,
) {
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\List;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Ports\Repositories\CouponRepository;
use Domain\Ports\Repositories\PrintableCouponRepository;
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\PrintableCoupon\List\Input, \Application\UseCases\PrintableCoupon\List\Output> */
final readonly class ListPrintableCoupons implements UseCase
{
public function __construct(
private PrintableCouponRepository $repository,
private CouponRepository $couponRepository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$result = $this->repository->findAllAvailable(
pagination: $input->pagination,
campaignCode: $input->campaignCode,
crmCustomerHashcode: $input->crmCustomerHashcode,
sortCriteria: $input->sortCriteria,
);
$printableCoupons = $result->getItems();
/** @var array<string, CouponPicture> $picturesByCouponCode */
$picturesByCouponCode = [];
foreach ($printableCoupons as $printableCoupon) {
$couponCode = $printableCoupon->couponCode;
if (isset($picturesByCouponCode[$couponCode->value])) {
continue;
}
$coupon = $this->couponRepository->findByCode($couponCode)
?? throw new \LogicException(sprintf('Coupon "%s" referenced by a printable coupon does not exist.', $couponCode->value));
$picturesByCouponCode[$couponCode->value] = $coupon->picture;
}
$items = array_map(
static fn (PrintableCoupon $printableCoupon): PrintableCouponListItem => new PrintableCouponListItem(
code: $printableCoupon->code,
couponCode: $printableCoupon->couponCode,
couponName: $printableCoupon->couponName,
couponPicture: $picturesByCouponCode[$printableCoupon->couponCode->value],
distributableUntil: $printableCoupon->distributableUntil,
createdAt: $printableCoupon->createdAt(),
updatedAt: $printableCoupon->updatedAt(),
),
$printableCoupons,
);
return new Output(
printableCoupons: new Paginated(
pagination: $result->getPagination(),
items: $items,
total: $result->getTotal(),
),
);
}
}

View file

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

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\List;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
final readonly class PrintableCouponListItem
{
public function __construct(
public PrintableCouponCode $code,
public CouponCode $couponCode,
public CouponName $couponName,
public CouponPicture $couponPicture,
public \DateTimeInterface $distributableUntil,
public \DateTimeInterface $createdAt,
public \DateTimeInterface $updatedAt,
) {
}
}

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
use Shared\Helpers\DatesHelper;
/**
* @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode>
*/
final readonly class PrintableCoupon implements AggregateRoot, HasTimestampIdentifier
{
private function __construct(
public PrintableCouponCode $code,
public CampaignCode $campaignCode,
public CouponCode $couponCode,
public CouponName $couponName,
public CrmHashcode $crmCustomerHashcode,
public \DateTimeInterface $distributableUntil,
public \DateTimeInterface $updatedAt,
) {
}
public function id(): PrintableCouponCode
{
return $this->code;
}
public function createdAt(): \DateTimeInterface
{
return $this->code->toDateTime();
}
public function updatedAt(): \DateTimeInterface
{
return $this->updatedAt;
}
public static function create(
CampaignCode $campaignCode,
CouponCode $couponCode,
CouponName $couponName,
CrmHashcode $crmCustomerHashcode,
\DateTimeInterface $distributableUntil,
): self {
$code = PrintableCouponCode::generate();
return new self(
code: $code,
campaignCode: $campaignCode,
couponCode: $couponCode,
couponName: $couponName,
crmCustomerHashcode: $crmCustomerHashcode,
distributableUntil: $distributableUntil,
updatedAt: $code->toDateTime(),
);
}
public static function hydrate(
PrintableCouponCode $code,
CampaignCode $campaignCode,
CouponCode $couponCode,
CouponName $couponName,
CrmHashcode $crmCustomerHashcode,
\DateTimeInterface $distributableUntil,
): self {
return new self(
code: $code,
campaignCode: $campaignCode,
couponCode: $couponCode,
couponName: $couponName,
crmCustomerHashcode: $crmCustomerHashcode,
distributableUntil: $distributableUntil,
updatedAt: DatesHelper::now(),
);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Ramsey\Uuid\UuidInterface;
use Shared\DDD\Models\Equatable;
use Shared\Helpers\UuidHelper;
final readonly class CrmHashcode implements Equatable
{
public function __construct(
public UuidInterface $value,
) {
if (! UuidHelper::isV4($value)) {
throw new \DomainException(
'Hash code must be a valid UUID v4.',
);
}
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Shared\DDD\Models\Equatable;
final readonly class MarketingDirectCode implements \Stringable, Equatable
{
private const string PATTERN = '/^\d{13}\z/';
public function __construct(
public string $value,
) {
if (preg_match(self::PATTERN, $this->value) !== 1) {
throw new \DomainException(
'Marketing Direct Code must be exactly 13 numeric characters, e.g. 5425003944130.',
);
}
}
public static function fromInt(int $sequence): self
{
if ($sequence < 0) {
throw new \DomainException('Marketing Direct sequence must be positive.');
}
return new self(str_pad((string) $sequence, 13, '0', STR_PAD_LEFT));
}
public function toInt(): int
{
return (int) $this->value;
}
#[\Override]
public function equals(mixed $other): bool
{
return $other instanceof self && $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Models\Uuidv7EntityId;
final readonly class PrintableCouponCode 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\Models\ValueObjects\PrintableCoupon;
final readonly class PrintableCouponSortCriteria
{
public function __construct(
public PrintableCouponSortField $field = PrintableCouponSortField::Name,
public \SortDirection $direction = \SortDirection::Ascending,
) {
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
enum PrintableCouponSortField: string
{
case CreatedAt = 'created_at';
case Name = 'name';
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
final readonly class PrintedCoupon
{
public function __construct(
public PrintedCouponId $id,
public MarketingDirectCode $marketingCode,
public PrintableCouponCode $printableCode,
public CouponCode $couponCode,
public CampaignCode $campaignCode,
public CrmHashcode $customer,
public \DateTimeInterface $printedAt,
) {
}
public function printedAt(): \DateTimeInterface
{
return $this->id->toDateTime();
}
}

View file

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

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
final readonly class UnprintedCoupon
{
public function __construct(
public PrintableCouponCode $printableCode,
public CouponCode $couponCode,
public CampaignCode $campaignCode,
public CrmHashcode $customer,
public UnprintedReason $unprintedReason,
) {
}
}

View file

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

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortCriteria;
use Shared\DDD\Ports\Repository;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
/**
* @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode, \Domain\Models\Entities\PrintableCoupon>
*/
interface PrintableCouponRepository extends Repository
{
/**
* @return Paginated<PrintableCoupon>
*/
public function findAllAvailable(
Pagination $pagination,
CampaignCode $campaignCode,
CrmHashcode $crmCustomerHashcode,
?PrintableCouponSortCriteria $sortCriteria = null,
): Paginated;
public function save(PrintableCoupon $printableCoupon): void;
}

View file

@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\PrintableCoupon\List;
use Application\UseCases\PrintableCoupon\List\Input;
use Application\UseCases\PrintableCoupon\List\ListPrintableCoupons;
use Application\UseCases\PrintableCoupon\List\Output;
use Application\UseCases\PrintableCoupon\List\PrintableCouponListItem;
use DateTime;
use Domain\Models\Entities\Coupon;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\IncoCode;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortCriteria;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortField;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Ports\Repositories\CouponRepository;
use Domain\Ports\Repositories\PrintableCouponRepository;
use Ramsey\Uuid\Uuid;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Money;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
covers(ListPrintableCoupons::class, Input::class, Output::class, PrintableCouponListItem::class);
describe('list printable coupons use case', function (): void {
$createPicture = fn (string $name): CouponPicture => new CouponPicture(
new PicturePath("files/{$name}.jpg"),
new PicturePath("files/{$name}-thumbnail.jpg"),
new PicturePath("files/{$name}-mobile.jpg"),
);
$createInput = fn (PrintableCouponSortCriteria | null $sortCriteria = null): Input => new Input(
pagination: Pagination::firstPage(),
campaignCode: new CampaignCode('AAA01'),
crmCustomerHashcode: new CrmHashcode(Uuid::uuid4()),
sortCriteria: $sortCriteria ?? new PrintableCouponSortCriteria(
field: PrintableCouponSortField::CreatedAt,
direction: \SortDirection::Descending,
),
);
$createPrintableCoupon = fn (CouponCode $couponCode): PrintableCoupon => PrintableCoupon::hydrate(
code: PrintableCouponCode::generate(),
campaignCode: new CampaignCode('AAA01'),
couponCode: $couponCode,
couponName: new CouponName('Coupon name'),
crmCustomerHashcode: new CrmHashcode(Uuid::uuid4()),
distributableUntil: new DateTime('2027-01-01'),
);
$createCoupon = fn (CouponCode $code, CouponPicture $picture): Coupon => Coupon::create(
code: $code,
name: new CouponName('Coupon name'),
amount: new MoneyAmount(new Money(Currency::EUR, 100)),
picture: $picture,
diffusion: new CouponDiffusion(
new Period(
new DateTime('2026-01-01'),
new DateTime('2026-02-01'),
),
),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE
),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE
),
]),
processingCode: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
]),
brandCode: new BrandCode('AAA01'),
createdBy: ActorId::generate(),
products: new CouponProductArray([
new IncoCode(35784),
new IncoCode(15522),
]),
movements: new CouponMovementArray([
new CouponMovement(1000)
]),
quantity: new CouponQuantity(1000),
);
it('returns the printable coupons with the picture of their coupon', function () use (
$createInput,
$createPicture,
$createCoupon,
$createPrintableCoupon,
): void {
// GIVEN
$input = $createInput();
$picture = $createPicture('picture');
$couponCode = new CouponCode('AAA01');
$printable = $createPrintableCoupon($couponCode);
$coupon = $createCoupon($couponCode, $picture);
$paginated = new Paginated(pagination: $input->pagination, items: [$printable], total: 1);
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')
->once()
->with($input->pagination, $input->campaignCode, $input->crmCustomerHashcode, $input->sortCriteria)
->andReturn($paginated);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')
->once()
->with($printable->couponCode)
->andReturn($coupon);
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$output = $useCase->execute($input);
// THEN
$items = $output->printableCoupons->getItems();
expect($items)->toHaveCount(1)
->and($output->printableCoupons->getTotal())->toBe(1)
->and($items[0]->code)->toBe($printable->code)
->and($items[0]->couponCode)->toBe($printable->couponCode)
->and($items[0]->couponName)->toBe($printable->couponName)
->and($items[0]->couponPicture)->toBe($picture)
->and($items[0]->distributableUntil)->toBe($printable->distributableUntil);
});
it('forwards the sort criteria and keeps the order returned by the repository', function () use (
$createInput,
$createPicture,
$createCoupon,
$createPrintableCoupon,
): void {
// GIVEN
$sortCriteria = new PrintableCouponSortCriteria(
field: PrintableCouponSortField::Name,
direction: \SortDirection::Descending,
);
$input = $createInput($sortCriteria);
$code1 = new CouponCode('AAA01');
$code2 = new CouponCode('BBB02');
$picture1 = $createPicture('picture-1');
$picture2 = $createPicture('picture-2');
$printableCoupon1 = $createPrintableCoupon($code1);
$printableCoupon2 = $createPrintableCoupon($code2);
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')
->once()
->with($input->pagination, $input->campaignCode, $input->crmCustomerHashcode, $sortCriteria)
->andReturn(new Paginated(pagination: $input->pagination, items: [$printableCoupon2, $printableCoupon1], total: 2));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->with($code2)->once()
->andReturn($createCoupon($code2, $picture2));
$couponRepository->shouldReceive('findByCode')->with($code1)->once()
->andReturn($createCoupon($code1, $picture1));
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$items = $useCase->execute($input)->printableCoupons->getItems();
// THEN
expect($items)->toHaveCount(2)
->and($items[0]->code)->toBe($printableCoupon2->code)
->and($items[0]->couponName)->toBe($printableCoupon2->couponName)
->and($items[0]->couponPicture)->toBe($picture2)
->and($items[1]->code)->toBe($printableCoupon1->code)
->and($items[1]->couponName)->toBe($printableCoupon1->couponName)
->and($items[1]->couponPicture)->toBe($picture1);
});
it('returns an empty list without error when there is no printable coupon', function () use ($createInput): void {
// GIVEN
$input = $createInput();
$paginated = new Paginated(pagination: $input->pagination, items: [], total: 0);
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')->once()->andReturn($paginated);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldNotReceive('findByCode');
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$output = $useCase->execute($input);
// THEN
expect($output->printableCoupons->getItems())->toBe([])
->and($output->printableCoupons->getTotal())->toBe(0);
});
it('loads a shared coupon only once', function () use (
$createInput,
$createPicture,
$createPrintableCoupon,
$createCoupon,
): void {
// GIVEN
$input = $createInput();
$picture = $createPicture('picture');
$couponCode = new CouponCode('AAA01');
$items = [$createPrintableCoupon($couponCode), $createPrintableCoupon($couponCode)];
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')
->once()
->andReturn(new Paginated(pagination: $input->pagination, items: $items, total: 2));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')
->once()
->andReturn($createCoupon($couponCode, $picture));
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$output = $useCase->execute($input);
// THEN
expect($output->printableCoupons->getItems())->toHaveCount(2);
});
it('throws when the coupon of a printable coupon does not exist', function () use ($createInput, $createPrintableCoupon): void {
// GIVEN
$input = $createInput();
$printable = $createPrintableCoupon(new CouponCode('BBB02'));
$repository = \Mockery::mock(PrintableCouponRepository::class);
$repository->shouldReceive('findAllAvailable')
->once()
->andReturn(new Paginated(pagination: $input->pagination, items: [$printable], total: 1));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->andReturn(null);
$useCase = new ListPrintableCoupons($repository, $couponRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\LogicException::class);
});
});

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Ramsey\Uuid\Uuid;
covers(PrintableCoupon::class);
describe('printable coupon', function (): void {
$campaignCode = new CampaignCode('AAA01');
$couponCode = new CouponCode('AAA01');
$couponName = new CouponName('Coupon name');
$crmCustomerHashcode = new CrmHashcode(Uuid::uuid4());
$distributableUntil = new \DateTimeImmutable('2027-01-01');
it('creates a printable coupon with the given values', function () use (
$campaignCode,
$couponCode,
$couponName,
$crmCustomerHashcode,
$distributableUntil,
): void {
// GIVEN / WHEN
$printable = PrintableCoupon::create(
$campaignCode,
$couponCode,
$couponName,
$crmCustomerHashcode,
$distributableUntil,
);
// THEN
expect($printable->campaignCode)->toBe($campaignCode)
->and($printable->couponCode)->toBe($couponCode)
->and($printable->couponName)->toBe($couponName)
->and($printable->crmCustomerHashcode)->toBe($crmCustomerHashcode)
->and($printable->distributableUntil)->toBe($distributableUntil);
});
it('generates a new code on each creation', function () use (
$campaignCode,
$couponCode,
$couponName,
$crmCustomerHashcode,
$distributableUntil,
): void {
// GIVEN / WHEN
$first = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil);
$second = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil);
// THEN
expect($first->code)->toBeInstanceOf(PrintableCouponCode::class)
->and($first->code->equals($second->code))->toBeFalse();
});
it('exposes its code as identifier', function () use (
$campaignCode,
$couponCode,
$couponName,
$crmCustomerHashcode,
$distributableUntil,
): void {
// GIVEN
$printable = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil);
// WHEN / THEN
expect($printable->id())->toBe($printable->code);
});
it('has an update date equal to its creation date', function () use (
$campaignCode,
$couponCode,
$couponName,
$crmCustomerHashcode,
$distributableUntil,
): void {
// GIVEN / WHEN
$printable = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil);
// THEN
expect($printable->updatedAt)->toEqual($printable->createdAt())
->and($printable->updatedAt())->toBe($printable->updatedAt);
});
it('hydrates a printable coupon with the given values', function () use (
$campaignCode,
$couponCode,
$couponName,
$crmCustomerHashcode,
$distributableUntil,
): void {
// GIVEN
$code = PrintableCouponCode::generate();
// WHEN
$printable = PrintableCoupon::hydrate(
$code,
$campaignCode,
$couponCode,
$couponName,
$crmCustomerHashcode,
$distributableUntil,
);
// THEN
expect($printable->code)->toBe($code)
->and($printable->campaignCode)->toBe($campaignCode)
->and($printable->couponCode)->toBe($couponCode)
->and($printable->couponName)->toBe($couponName)
->and($printable->crmCustomerHashcode)->toBe($crmCustomerHashcode)
->and($printable->distributableUntil)->toBe($distributableUntil)
->and($printable->createdAt())->toEqual($code->toDateTime());
});
});

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects;
use Domain\Models\ValueObjects\CrmHashcode;
use Ramsey\Uuid\Uuid;
covers(CrmHashcode::class);
describe('crm hashcode', function (): void {
$value = Uuid::uuid4();
it('creates a valid hashcode', function () use ($value): void {
// GIVEN / WHEN
$hashcode = new CrmHashcode($value);
// THEN
expect($hashcode->value)->toBe($value);
});
it('is equal to a hashcode with the same value', function () use ($value): void {
// GIVEN
$hashcode = new CrmHashcode($value);
$other = new CrmHashcode($value);
// WHEN / THEN
expect($hashcode->equals($other))->toBeTrue();
});
it('is not equal to a hashcode with a different value', function () use ($value): void {
// GIVEN
$hashcode = new CrmHashcode($value);
$other = new CrmHashcode(Uuid::uuid4());
// WHEN / THEN
expect($hashcode->equals($other))->toBeFalse();
});
it('is not equal to a value that is not a hashcode', function () use ($value): void {
// GIVEN
$hashcode = new CrmHashcode($value);
// WHEN / THEN
expect($hashcode->equals($value))->toBeFalse()
->and($hashcode->equals(null))->toBeFalse();
});
});

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\PrintableCoupon\MarketingDirectCode;
covers(MarketingDirectCode::class);
describe('marketing direct code', function (): void {
it('builds a code from an incrementing integer', function (int $sequence, string $expected): void {
expect(MarketingDirectCode::fromInt($sequence)->value)->toBe($expected);
})->with([
'one' => [1, '0000000000001'],
'zero' => [0, '0000000000000'],
'full size' => [5425003944130, '5425003944130'],
]);
it('rejects a negative or too large sequence', function (int $sequence): void {
$action = fn (): MarketingDirectCode => MarketingDirectCode::fromInt($sequence);
expect($action)->toThrow(\DomainException::class);
})->with([
'negative' => [-1],
'too large' => [10_000_000_000_000],
]);
it('converts back to its integer', function (): void {
expect(MarketingDirectCode::fromInt(42)->toInt())->toBe(42);
});
});

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
covers(PrintableCouponCode::class);
describe('printable coupon code', function (): void {
it('generates a unique campaign id', function (): void {
// WHEN
$id = PrintableCouponCode::generate();
// THEN
expect($id)->toBeInstanceOf(PrintableCouponCode::class);
});
it('generates different ids on each call', function (): void {
// WHEN
$first = PrintableCouponCode::generate();
$second = PrintableCouponCode::generate();
// THEN
expect((string) $first)->not->toBe((string) $second);
});
});

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCouponId;
covers(PrintedCouponId::class);
describe('printed coupon id', function (): void {
it('generates a unique campaign id', function (): void {
// WHEN
$id = PrintedCouponId::generate();
// THEN
expect($id)->toBeInstanceOf(PrintedCouponId::class);
});
it('generates different ids on each call', function (): void {
// WHEN
$first = PrintedCouponId::generate();
$second = PrintedCouponId::generate();
// THEN
expect((string) $first)->not->toBe((string) $second);
});
});

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintableCoupon\MarketingDirectCode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCoupon;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCouponId;
use Ramsey\Uuid\Uuid;
covers(PrintedCoupon::class);
describe('printed coupon', function (): void {
$printableCode = PrintableCouponCode::generate();
$marketingCode = new MarketingDirectCode('0000000000001');
$couponCode = new CouponCode('AAA01');
$campaignCode = new CampaignCode('AAA01');
$customer = new CrmHashcode(Uuid::uuid4());
it('keeps all the given values', function () use ($marketingCode, $printableCode, $couponCode, $campaignCode, $customer): void {
// GIVEN
$id = PrintedCouponId::generate();
$printedAt = new \DateTimeImmutable('2026-01-01 12:00:00');
// WHEN
$printed = new PrintedCoupon($id, $marketingCode, $printableCode, $couponCode, $campaignCode, $customer, $printedAt);
// THEN
expect($printed->id)->toBe($id)
->and($printed->printableCode)->toBe($printableCode)
->and($printed->couponCode)->toBe($couponCode)
->and($printed->campaignCode)->toBe($campaignCode)
->and($printed->customer)->toBe($customer)
->and($printed->printedAt)->toBe($printedAt);
});
it('derives the printing date from its id', function () use ($marketingCode, $printableCode, $couponCode, $campaignCode, $customer): void {
// GIVEN
$id = PrintedCouponId::generate();
$printed = new PrintedCoupon(
$id,
$marketingCode,
$printableCode,
$couponCode,
$campaignCode,
$customer,
new \DateTimeImmutable('2020-01-01'),
);
// WHEN
$date = $printed->printedAt();
// THEN
expect($date)->toEqual($id->toDateTime())
->and($date)->not->toEqual($printed->printedAt);
});
});

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Domain\Models\ValueObjects\PrintableCoupon\UnprintedCoupon;
use Domain\Models\ValueObjects\PrintableCoupon\UnprintedReason;
use Ramsey\Uuid\Uuid;
covers(UnprintedCoupon::class);
describe('unprinted coupon', function (): void {
$printableCode = PrintableCouponCode::generate();
$couponCode = new CouponCode('AAA01');
$campaignCode = new CampaignCode('AAA01');
$customer = new CrmHashcode(Uuid::uuid4());
it('keeps all the given values', function () use ($printableCode, $couponCode, $campaignCode, $customer): void {
// GIVEN
$reason = new UnprintedReason('Coupon expired before printing.');
// WHEN
$unprinted = new UnprintedCoupon($printableCode, $couponCode, $campaignCode, $customer, $reason);
// THEN
expect($unprinted->printableCode)->toBe($printableCode)
->and($unprinted->couponCode)->toBe($couponCode)
->and($unprinted->campaignCode)->toBe($campaignCode)
->and($unprinted->customer)->toBe($customer)
->and($unprinted->unprintedReason)->toBe($reason);
});
it('keeps the reason as given without trimming it', function () use ($printableCode, $couponCode, $campaignCode, $customer): void {
// GIVEN
$reason = new UnprintedReason(' Printer failure ');
// WHEN
$unprinted = new UnprintedCoupon($printableCode, $couponCode, $campaignCode, $customer, $reason);
// THEN
expect($unprinted->unprintedReason)->toBe($reason);
});
it('rejects an empty reason', function (string $invalidReason) use ($printableCode, $couponCode, $campaignCode, $customer): void {
// GIVEN / WHEN
$action = fn (): UnprintedCoupon => new UnprintedCoupon(
$printableCode,
$couponCode,
$campaignCode,
$customer,
new UnprintedReason($invalidReason),
);
// THEN
expect($action)->toThrow(\DomainException::class, 'Unprinted reason cannot be empty.');
})->with([
'empty' => '',
'spaces' => ' ',
'tab' => "\t",
'new lines' => "\n\r\n",
'mixed spaces' => " \t\n ",
'unicode spaces' => "\u{00A0}\u{2003}",
]);
});

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\PrintableCoupon;
use Domain\Models\ValueObjects\PrintableCoupon\UnprintedReason;
covers(UnprintedReason::class);
describe('unprinted reason', function (): void {
$value = 'Expired date.';
it('creates a valid rejection reason', function () use ($value): void {
// GIVEN / WHEN
$reason = new UnprintedReason($value);
// THEN
expect($reason)->toBeInstanceOf(UnprintedReason::class)
->and($reason->value)->toBe($value);
});
it('rejects an empty or whitespace-only reason', function (string $invalidValue): void {
// WHEN
$action = fn (): UnprintedReason => new UnprintedReason($invalidValue);
// THEN
expect($action)->toThrow(\DomainException::class, 'Unprinted reason cannot be empty.');
})->with([
'empty' => '',
'spaces' => ' ',
]);
});