Merge branch 'printed' into 'coupon'

feat: ✨ add use case create printable coupon + tests

See merge request ldc-web/projets/mesbr!66
This commit is contained in:
Alexis Gousseau 2026-10-15 15:48:32 +02:00
commit 56d3a35d0e
20 changed files with 719 additions and 41 deletions

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\Create;
use Domain\Models\Entities\Campaign;
use Domain\Models\Entities\Coupon;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Ports\Repositories\CampaignRepository;
use Domain\Ports\Repositories\CouponRepository;
use Domain\Ports\Repositories\PrintableCouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\DDD\UseCases\Transactional;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\PrintableCoupon\Create\Input, \Application\UseCases\PrintableCoupon\Create\Output> */
final readonly class CreatePrintableCoupon implements UseCase, Transactional
{
public function __construct(
private PrintableCouponRepository $printableCouponRepository,
private CouponRepository $couponRepository,
private CampaignRepository $campaignRepository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$campaign = $this->campaignRepository->findByCode($input->campaignCode);
if (! $campaign instanceof Campaign) {
throw new \DomainException(sprintf('Campaign from code "%s" not found.', $input->campaignCode->value));
}
if (! $campaign->publication) {
throw new \DomainException('Campaign must be published.');
}
$coupon = $this->couponRepository->findByCode($input->couponCode);
if (! $coupon instanceof Coupon) {
throw new \DomainException(sprintf('Coupon code "%s" not found.', $input->couponCode->value));
}
$campaignCoupon = $this->campaignRepository->findCampaignCoupon($campaign->code, $coupon->code);
if (! $campaignCoupon instanceof CampaignCoupon) {
throw new \DomainException('Coupon must be associated to the associated campaign coupon.');
}
$period = $coupon->getActivePeriod();
$this->checkPrintRulesFromCampaignCoupon($campaign, $campaignCoupon, $coupon, $input->crmCustomerHashcode);
$printableCoupon = PrintableCoupon::create(
campaignCode: $campaign->code,
couponCode: $coupon->code,
couponName: $coupon->name,
crmCustomerHashcode: $input->crmCustomerHashcode,
distributableUntil: $period->end,
);
$this->printableCouponRepository->save($printableCoupon);
return new Output(
campaignCode: $printableCoupon->campaignCode,
couponCode: $printableCoupon->couponCode,
couponName: $printableCoupon->couponName,
crmCustomerHashcode: $printableCoupon->crmCustomerHashcode,
distributableUntil: $printableCoupon->distributableUntil,
);
}
public function checkPrintRulesFromCampaignCoupon(
Campaign $campaign,
CampaignCoupon $campaignCoupon,
Coupon $coupon,
CrmHashcode $crmCustomerHashcode,
): void {
if ($campaignCoupon->printRules instanceof PrintRules) {
$countPrintedCoupon = $this->printableCouponRepository->countPrintedCouponForCustomer(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
);
$campaignCoupon->printRules->hasPrintLimitNotReached($countPrintedCoupon);
$lastPrintedCoupon = $this->printableCouponRepository->findLastPrintedCouponForCustomer(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
);
$diff = $lastPrintedCoupon->printedAt()->diff(new \DateTime());
$campaignCoupon->printRules->hasReprintPeriodExpired($diff->days);
}
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\Create;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Shared\DDD\UseCases\Input as UseCaseInput;
final readonly class Input implements UseCaseInput
{
public function __construct(
public CampaignCode $campaignCode,
public CouponCode $couponCode,
public CrmHashcode $crmCustomerHashcode,
) {
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\Create;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\CrmHashcode;
use Shared\DDD\UseCases\Output as UseCaseOutput;
final readonly class Output implements UseCaseOutput
{
public function __construct(
public CampaignCode $campaignCode,
public CouponCode $couponCode,
public CouponName $couponName,
public CrmHashcode $crmCustomerHashcode,
public \DateTimeInterface $distributableUntil,
) {
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Application\UseCases\PrintableCoupon\List;
use Domain\Models\Entities\Coupon;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Ports\Repositories\CouponRepository;
@ -46,8 +47,10 @@ final readonly class ListPrintableCoupons implements UseCase
continue;
}
$coupon = $this->couponRepository->findByCode($couponCode)
?? throw new \LogicException(sprintf('Coupon "%s" referenced by a printable coupon does not exist.', $couponCode->value));
$coupon = $this->couponRepository->findByCode($couponCode);
if (!$coupon instanceof Coupon) {
throw new \LogicException(sprintf('Coupon "%s" referenced by a printable coupon does not exist.', $couponCode->value));
}
$picturesByCouponCode[$couponCode->value] = $coupon->picture;
}

View file

@ -59,7 +59,6 @@ final readonly class Brand implements AggregateRoot, HasTimestampIdentifier
logo: $logo,
rcs: $rcs,
contact: $contact,
updatedAt: null,
);
}

View file

@ -65,7 +65,6 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier
coupons: $coupons,
brandCode: $brandCode,
actorId: $actorId,
updatedAt: null,
);
}

View file

@ -71,7 +71,6 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent
campaignType: $campaignType,
requestedCoupon: $requestedCoupon,
status: CampaignRequestStatus::CREATED,
updatedAt: null,
);
}

View file

@ -18,6 +18,7 @@ use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Shared\DDD\Models\AggregateRoot;
use Shared\DDD\Models\HasTimestampIdentifier;
@ -50,6 +51,7 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
$this->validityInformations,
$this->legalInformations,
);
$this->validateCalendarPeriodsWithinDiffusionPeriod();
}
public function id(): CouponId
@ -102,7 +104,6 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
products: $products,
movements: $movements,
availabilityCalendar: $availabilityCalendar,
updatedAt: null,
);
}
@ -182,4 +183,36 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
updatedAt: DatesHelper::now(),
);
}
public function getActivePeriod(): Period
{
$currentDate = new \DateTime();
$diffusionPeriod = $this->diffusion->period;
$period = $this->availabilityCalendar?->isActiveAt($currentDate)
? $this->availabilityCalendar->getCurrentPeriod($currentDate)
?? $diffusionPeriod
: $diffusionPeriod;
if (! $period->hasStarted()) {
throw new \DomainException('The printing period has not yet begun.');
}
if (! $period->hasEnded()) {
throw new \DomainException('The printing period has expired.');
}
return $period;
}
public function validateCalendarPeriodsWithinDiffusionPeriod(): void
{
if ($this->availabilityCalendar instanceof CouponAvailabilityCalendar) {
$diffusionPeriod = $this->diffusion->period;
foreach ($this->availabilityCalendar->periods as $calendarPeriod) {
if (! $diffusionPeriod->inRange($calendarPeriod->start) || ! $diffusionPeriod->inRange($calendarPeriod->end)) {
throw new \DomainException('Calendar period must be in diffusion period.');
}
}
}
}
}

View file

@ -58,7 +58,6 @@ final readonly class PrintableCoupon implements AggregateRoot, HasTimestampIdent
couponName: $couponName,
crmCustomerHashcode: $crmCustomerHashcode,
distributableUntil: $distributableUntil,
updatedAt: null,
);
}

View file

@ -7,9 +7,9 @@ namespace Domain\Models\ValueObjects\Campaign;
final readonly class CampaignCode
{
public function __construct(
public string $code,
public string $value,
) {
if (mb_trim($this->code) === '') {
if (mb_trim($this->value) === '') {
throw new \DomainException('Campaign code cannot be empty.');
}
}

View file

@ -10,8 +10,10 @@ use DateTime;
final readonly class CouponAvailabilityCalendar implements Equatable
{
/**
* @param array<Period> $periods
*/
public function __construct(
/** @var Period[] */
public array $periods,
) {
if ($this->periods === []) {
@ -27,7 +29,7 @@ final readonly class CouponAvailabilityCalendar implements Equatable
public function isActiveAt(DateTime $in): bool
{
return array_any($this->periods, fn ($period): bool => $in >= $period->start && $in <= $period->end);
return array_any($this->periods, fn ($period): bool => $period->inRange($in));
}
public function beginsAt(): DateTime
@ -40,6 +42,11 @@ final readonly class CouponAvailabilityCalendar implements Equatable
return max(array_map(fn (Period $period): DateTime => $period->end, $this->periods));
}
public function getCurrentPeriod(DateTime $in): ?Period
{
return array_first(array_filter($this->periods, fn ($period): bool => $period->inRange($in)));
}
#[\Override]
public function equals(mixed $other): bool
{

View file

@ -4,16 +4,31 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use DateTime;
use Shared\Helpers\DatesHelper;
final readonly class Period
{
public function __construct(
public DateTime $start,
public DateTime $end,
public \DateTime $start,
public \DateTime $end,
) {
if ($this->end < $this->start) {
throw new \DomainException('Period end date cannot be before its start date.');
}
}
public function hasStarted(): bool
{
return $this->start <= DatesHelper::now();
}
public function hasEnded(): bool
{
return $this->end >= DatesHelper::now();
}
public function inRange(\DateTime $date): bool
{
return $date >= $this->start && $date <= $this->end;
}
}

View file

@ -11,15 +11,33 @@ final readonly class PrintRules
public ?int $cooldownDays = null,
) {
if (null === $this->maxPerCustomer && null === $this->cooldownDays) {
throw new \InvalidArgumentException('PrintRules require at least a max per customer or a cooldown days.');
throw new \DomainException('PrintRules require at least a max per customer or a cooldown days.');
}
if (null !== $this->maxPerCustomer && $this->maxPerCustomer <= 0) {
throw new \InvalidArgumentException('Max per customer must be greater than 0.');
throw new \DomainException('Max per customer must be greater than 0.');
}
if (null !== $this->cooldownDays && $this->cooldownDays <= 0) {
throw new \InvalidArgumentException('Cooldown days must be greater than 0.');
if (null !== $this->cooldownDays && $this->cooldownDays < 30) {
throw new \DomainException('Cooldown days must be greater than 30.');
}
if ($this->maxPerCustomer && $this->cooldownDays) {
throw new \DomainException('No more than one print rule.');
}
}
public function hasPrintLimitNotReached(int $countPrintedCoupon): void
{
if ($this->maxPerCustomer && $this->maxPerCustomer < $countPrintedCoupon) {
throw new \DomainException('Maximum number of prints reached.');
}
}
public function hasReprintPeriodExpired(int $diffDays): void
{
if ($this->cooldownDays && $diffDays < $this->cooldownDays) {
throw new \DomainException('Reprint period has not expired.');
}
}
}

View file

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Domain\Models\Entities\Campaign;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Shared\DDD\Ports\Repository;
@ -19,4 +21,8 @@ interface CampaignRepository extends Repository
* @return array<Campaign>
*/
public function findByCouponCode(CouponCode $couponCode): array;
public function findByCode(CampaignCode $code): ?Campaign;
public function findCampaignCoupon(CampaignCode $campaignCode, CouponCode $couponCode): ?CampaignCoupon;
}

View file

@ -6,8 +6,10 @@ namespace Domain\Ports\Repositories;
use Domain\Models\Entities\PrintableCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\CrmHashcode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponSortCriteria;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCoupon;
use Shared\DDD\Ports\Repository;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
@ -27,5 +29,17 @@ interface PrintableCouponRepository extends Repository
?PrintableCouponSortCriteria $sortCriteria = null,
): Paginated;
public function countPrintedCouponForCustomer(
CampaignCode $campaignCode,
CouponCode $couponCode,
CrmHashcode $crmCustomerHashcode,
): int;
public function findLastPrintedCouponForCustomer(
CampaignCode $campaignCode,
CouponCode $couponCode,
CrmHashcode $crmCustomerHashcode,
): PrintedCoupon;
public function save(PrintableCoupon $printableCoupon): void;
}

View file

@ -8,6 +8,7 @@ use Application\UseCases\Coupon\Find\FindCoupon;
use Application\UseCases\Coupon\Find\Input;
use Application\UseCases\Coupon\Find\Output\CouponFound;
use Application\UseCases\Coupon\Find\Output\CouponNotFound;
use Application\UseCases\Coupon\Find\Output\Output;
use DateTime;
use Domain\Models\Entities\Brand;
use Domain\Models\Entities\Campaign;
@ -180,7 +181,7 @@ describe('find coupon use case', function (): void {
$useCase = new FindCoupon($couponRepository, $brandRepository, $campaignRepository);
// WHEN / THEN
expect(fn () => $useCase->execute(new Input(code: $coupon->code)))
expect(fn (): Output => $useCase->execute(new Input(code: $coupon->code)))
->toThrow(\DomainException::class);
});

View file

@ -0,0 +1,429 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\PrintableCoupon\Create;
use Application\UseCases\PrintableCoupon\Create\Input;
use Application\UseCases\PrintableCoupon\Create\Output;
use Application\UseCases\PrintableCoupon\Create\CreatePrintableCoupon;
use DateTime;
use Domain\Models\Entities\Campaign;
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\Campaign\CampaignCoupon;
use Domain\Models\ValueObjects\Campaign\CampaignCouponArray;
use Domain\Models\ValueObjects\Campaign\CampaignName;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
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\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\MarketingDirectCode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintableCouponCode;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCoupon;
use Domain\Models\ValueObjects\PrintableCoupon\PrintedCouponId;
use Domain\Models\ValueObjects\PrintRules;
use Domain\Models\ValueObjects\ProcessingCode;
use Domain\Models\ValueObjects\ProcessingCodeArray;
use Domain\Ports\Repositories\CampaignRepository;
use Domain\Ports\Repositories\CouponRepository;
use Domain\Ports\Repositories\PrintableCouponRepository;
use Ramsey\Uuid\Uuid;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\Helpers\DatesHelper;
use Shared\ValueObjects\Money;
covers(CreatePrintableCoupon::class, Input::class, Output::class);
describe('create coupon use case', function (): void {
$crmCustomerHashcode = new CrmHashcode(Uuid::uuid4());
$brandCode = new BrandCode('AAA01');
$campaignCode = new CampaignCode('CMP01');
$couponCode = new CouponCode('COU01');
$couponName = new CouponName('Coupon name');
$couponDiffusionStart = new DateTime()->modify('-1 month');
$couponDiffusionEnd = new DateTime()->modify('+1 month');
$makeCoupon = fn (array $overrides = []): Coupon => Coupon::create(
code: $couponCode,
name: $couponName,
amount: new MoneyAmount(new Money(Currency::EUR, 100)),
picture: new CouponPicture(
new PicturePath('files/product.jpg'),
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobile_thumbnail.jpg'),
),
diffusion: new CouponDiffusion(
new Period(
$overrides['diffusionStart'] ?? $couponDiffusionStart,
$overrides['diffusionEnd'] ?? $couponDiffusionEnd,
),
),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE,
),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',
Country::FRANCE,
),
]),
processingCode: new ProcessingCodeArray([
new ProcessingCode('5425003944130', Country::FRANCE),
]),
brandCode: $brandCode,
createdBy: ActorId::generate(),
products: new CouponProductArray([
new IncoCode(35784) ,
new IncoCode(15522),
]),
movements: new CouponMovementArray([
new CouponMovement(1000)
]),
availabilityCalendar: $overrides['availabilityCalendar'] ?? null,
);
$printRules = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null): ?PrintRules => ($maxPerCustomer || $cooldownDays) ? new PrintRules(
maxPerCustomer: $maxPerCustomer,
cooldownDays: $cooldownDays,
) : null;
$campaignCoupon = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null): CampaignCoupon => new CampaignCoupon(
$couponCode,
$printRules($maxPerCustomer, $cooldownDays),
);
$makeCampaign = fn (?int $maxPerCustomer = null, ?int $cooldownDays = null, bool $published = true): Campaign => Campaign::create(
code: $campaignCode,
name: new CampaignName('Campaign name'),
publication: $published,
printAllAtOnce: true,
coupons: new CampaignCouponArray([
$campaignCoupon($maxPerCustomer, $cooldownDays),
]),
brandCode: $brandCode,
actorId: ActorId::generate(),
);
$printedCoupon = new PrintedCoupon(
id: PrintedCouponId::generate(),
marketingCode: new MarketingDirectCode('0000000000001'),
printableCode: PrintableCouponCode::generate(),
couponCode: $couponCode,
campaignCode: $campaignCode,
customer: $crmCustomerHashcode,
printedAt: DatesHelper::now(),
);
$makeInput = fn (): Input => new Input(
campaignCode: $campaignCode,
couponCode: $couponCode,
crmCustomerHashcode: $crmCustomerHashcode,
);
it('creates a printable coupon', function () use ($makeInput, $crmCustomerHashcode, $makeCampaign, $makeCoupon, $campaignCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon());
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$printableCouponRepository->shouldReceive('save')->once()->with(\Mockery::type(PrintableCoupon::class));
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->campaignCode)->toEqual($campaign->code)
->and($result->couponCode)->toEqual($coupon->code)
->and($result->crmCustomerHashcode)->toEqual($crmCustomerHashcode)
->and($result->distributableUntil)->toEqual($coupon->diffusion->period->end);
});
it('rejects a printable coupon when campaign not found', function () use ($makeInput, $makeCampaign): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn(null);
$couponRepository = \Mockery::mock(CouponRepository::class);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, sprintf('Campaign from code "%s" not found.', $input->campaignCode->value));
});
it('rejects a printable coupon when campaign is not published', function () use ($makeInput, $makeCampaign): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign(published: false);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$couponRepository = \Mockery::mock(CouponRepository::class);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Campaign must be published.');
});
it('rejects a printable coupon when coupon is not found', function () use ($makeInput, $makeCampaign, $makeCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn(null);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, sprintf('Coupon code "%s" not found.', $input->couponCode->value));
});
it('rejects a printable coupon when campaign coupon is not found', function () use ($makeInput, $makeCampaign, $makeCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn(null);
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Coupon must be associated to the associated campaign coupon.');
});
it('rejects a printable coupon when outside of diffusion start', function () use ($makeInput, $makeCampaign, $makeCoupon, $campaignCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon(['diffusionStart' => new DateTime()->modify('+1 day')]);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon());
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'The printing period has not yet begun.');
});
it('rejects a printable coupon when outside of diffusion end', function () use ($makeInput, $makeCampaign, $makeCoupon, $campaignCoupon): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon(['diffusionEnd' => new DateTime()->modify('-1 day')]);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon());
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'The printing period has expired.');
});
it('creates a printable coupon when calendar period is inside diffusion period', function () use (
$makeInput,
$crmCustomerHashcode,
$makeCampaign,
$makeCoupon,
$campaignCoupon,
): void {
// GIVEN
$calendarStart = new DateTime()->modify('-1 day');
$calendarEnd = new DateTime()->modify('+1 day');
$input = $makeInput();
$campaign = $makeCampaign();
$coupon = $makeCoupon([
'availabilityCalendar' => new CouponAvailabilityCalendar([
new Period(
start: $calendarStart,
end: $calendarEnd,
),
]),
]);
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon());
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$printableCouponRepository->shouldReceive('save')->once()->with(\Mockery::type(PrintableCoupon::class));
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->campaignCode)->toEqual($campaign->code)
->and($result->crmCustomerHashcode)->toEqual($crmCustomerHashcode)
->and($result->distributableUntil)->toEqual($calendarEnd);
});
it('rejects a printable coupon when maximum number of prints reached', function () use (
$makeInput,
$crmCustomerHashcode,
$makeCampaign,
$makeCoupon,
$campaignCoupon,
): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign(maxPerCustomer: 1);
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon(maxPerCustomer: 1));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$printableCouponRepository->shouldReceive('countPrintedCouponForCustomer')->once()->with(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
)->andReturn(5);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Maximum number of prints reached.');
});
it('rejects a printable coupon when reprint period has expired', function () use (
$makeInput,
$crmCustomerHashcode,
$makeCampaign,
$makeCoupon,
$campaignCoupon,
$printedCoupon,
): void {
// GIVEN
$input = $makeInput();
$campaign = $makeCampaign(cooldownDays: 30);
$coupon = $makeCoupon();
$campaignRepository = \Mockery::mock(CampaignRepository::class);
$campaignRepository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$campaignRepository->shouldReceive('findCampaignCoupon')->once()->with($campaign->code, $coupon->code)
->andReturn($campaignCoupon(cooldownDays: 30));
$couponRepository = \Mockery::mock(CouponRepository::class);
$couponRepository->shouldReceive('findByCode')->once()->with($coupon->code)->andReturn($coupon);
$printableCouponRepository = \Mockery::mock(PrintableCouponRepository::class);
$printableCouponRepository->shouldReceive('countPrintedCouponForCustomer')->once()->with(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
)->andReturn(5);
$printableCouponRepository->shouldReceive('findLastPrintedCouponForCustomer')->once()->with(
$campaign->code,
$coupon->code,
$crmCustomerHashcode,
)->andReturn($printedCoupon);
$useCase = new CreatePrintableCoupon($printableCouponRepository, $couponRepository, $campaignRepository);
// WHEN
$action = fn (): Output => $useCase->execute($input);
// THEN
expect($action)->toThrow(\DomainException::class, 'Reprint period has not expired.');
});
});

View file

@ -45,7 +45,7 @@ describe('coupon', function (): void {
new PicturePath('files/thumbnail.jpg'),
new PicturePath('files/mobileThumbnail.jpg'),
);
$diffusion = new CouponDiffusion(new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')));
$diffusion = new CouponDiffusion(new Period(new DateTime('2026-01-01'), new DateTime('2026-12-31')));
$validityInformations = new CouponValidityInformationArray([
new CouponValidityInformation(
'Lorem ipsum dolor sit amet consectetur adipiscing elit.',

View file

@ -17,7 +17,7 @@ describe('campaign code', function (): void {
$name = new CampaignCode($code);
// THEN
expect($name->code)->toBe($code);
expect($name->value)->toBe($code);
});
it('rejects invalid campaign code', function () use ($emptyCode): void {

View file

@ -16,17 +16,16 @@ describe('print rules', function (): void {
$minCooldownDays = 1;
$zeroCooldownDays = 0;
it('creates print rules with both fields set', function () use ($maxPerCustomer, $cooldownDays): void {
it('rejects print rules with both fields set', function () use ($maxPerCustomer, $cooldownDays): void {
// GIVEN / WHEN
$printRules = new PrintRules(
$printRules = fn (): PrintRules => new PrintRules(
maxPerCustomer: $maxPerCustomer,
cooldownDays: $cooldownDays
);
// THEN
expect($printRules)->toBeInstanceOf(PrintRules::class)
->and($printRules->maxPerCustomer)->toBe($maxPerCustomer)
->and($printRules->cooldownDays)->toBe($cooldownDays);
expect($printRules)
->toThrow(\DomainException::class, 'No more than one print rule.');
});
it('creates print rules with only maxPerCustomer set', function () use ($maxPerCustomer): void {
@ -54,32 +53,46 @@ describe('print rules', function (): void {
it('rejects print rules without any field set', function (): void {
// WHEN / THEN
expect(fn (): PrintRules => new PrintRules())
->toThrow(\InvalidArgumentException::class, 'PrintRules require at least a max per customer or a cooldown days.');
});
it('accepts the minimum value of 1 for both fields', function () use ($minMaxPerCustomer, $minCooldownDays): void {
// GIVEN / WHEN
$printRules = new PrintRules(
maxPerCustomer: $minMaxPerCustomer,
cooldownDays: $minCooldownDays
);
// THEN
expect($printRules->maxPerCustomer)->toBe($minMaxPerCustomer)
->and($printRules->cooldownDays)->toBe($minCooldownDays);
->toThrow(\DomainException::class, 'PrintRules require at least a max per customer or a cooldown days.');
});
it('rejects a maxPerCustomer equal to zero', function () use ($zeroMaxPerCustomer): void {
// GIVEN / WHEN / THEN
expect(fn (): PrintRules => new PrintRules(
maxPerCustomer: $zeroMaxPerCustomer
))->toThrow(\InvalidArgumentException::class, 'Max per customer must be greater than 0.');
))->toThrow(\DomainException::class, 'Max per customer must be greater than 0.');
});
it('rejects a cooldownDays equal to zero', function () use ($zeroCooldownDays): void {
// GIVEN / WHEN / THEN
expect(fn (): PrintRules => new PrintRules(
cooldownDays: $zeroCooldownDays
))->toThrow(\InvalidArgumentException::class, 'Cooldown days must be greater than 0.');
))->toThrow(\DomainException::class, 'Cooldown days must be greater than 30.');
});
it('rejects print rule when max per customer limit has reached', function () use ($maxPerCustomer, $minMaxPerCustomer): void {
// GIVEN
$printRules = new PrintRules(
maxPerCustomer: $minMaxPerCustomer
);
// WHEN
$action = fn () => $printRules->hasPrintLimitNotReached($maxPerCustomer);
// THEN
expect($action)->toThrow(\DomainException::class, 'Maximum number of prints reached.');
});
it('rejects print rule when cooldown days period has expired', function () use ($cooldownDays, $minCooldownDays): void {
// GIVEN
$printRules = new PrintRules(
cooldownDays: $cooldownDays
);
// WHEN
$action = fn () => $printRules->hasReprintPeriodExpired($minCooldownDays);
// THEN
expect($action)->toThrow(\DomainException::class, 'Reprint period has not expired.');
});
});