feat: ✨ add validations to entities and value objects

This commit is contained in:
Corentin Huet 2026-09-25 09:44:57 +02:00
parent a58d604814
commit 59e4dbf2ee
16 changed files with 439 additions and 110 deletions

View file

@ -31,6 +31,11 @@ final readonly class Campaign implements AggregateRoot, HasTimestampIdentifier
public BrandCode $brand,
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

View file

@ -37,14 +37,19 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
public CouponDiffusion $diffusion,
public CouponAvailabilityCalendar $availability,
public CouponValidity $validity,
/** @var array<int, CouponLegalInformation> $legal */
public array $legal,
/** @var array<int, CouponLegalInformation> $legalInformation */
public array $legalInformation,
public ProcessingCode $code,
public BrandCode $brandCode,
public ActorId $createdBy,
public ?GS1 $gtin = null,
public ?\DateTimeInterface $updatedAt = null,
) {
foreach ($this->legalInformation as $legal) {
if (!$legal instanceof CouponLegalInformation) {
throw new \DomainException('Coupon legal information must be CouponLegalInformation instances.');
}
}
}
public function id(): CouponId
@ -63,7 +68,7 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
}
/**
* @param array<int, CouponLegalInformation> $legal
* @param array<int, CouponLegalInformation> $legalInformation
*/
public static function create(
CouponName $name,
@ -75,7 +80,7 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
CouponDiffusion $diffusion,
CouponAvailabilityCalendar $availability,
CouponValidity $validity,
array $legal,
array $legalInformation,
ProcessingCode $code,
BrandCode $brandCode,
ActorId $createdBy,
@ -93,7 +98,7 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
diffusion: $diffusion,
availability: $availability,
validity: $validity,
legal: $legal,
legalInformation: $legalInformation,
code: $code,
brandCode: $brandCode,
createdBy: $createdBy,
@ -103,7 +108,7 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
}
/**
* @param array<int, CouponLegalInformation> $legal
* @param array<int, CouponLegalInformation> $legalInformation
*/
public static function hydrate(
CouponId $id,
@ -116,7 +121,7 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
CouponDiffusion $diffusion,
CouponAvailabilityCalendar $availability,
CouponValidity $validity,
array $legal,
array $legalInformation,
ProcessingCode $code,
BrandCode $brandCode,
ActorId $createdBy,
@ -134,7 +139,7 @@ final readonly class Coupon implements AggregateRoot, HasTimestampIdentifier
diffusion: $diffusion,
availability: $availability,
validity: $validity,
legal: $legal,
legalInformation: $legalInformation,
code: $code,
brandCode: $brandCode,
createdBy: $createdBy,

View file

@ -26,6 +26,11 @@ final readonly class Stock implements AggregateRoot, HasTimestampIdentifier
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

View file

@ -13,5 +13,8 @@ final readonly class CampaignRequestFilters
public ?BrandCode $brandCode = null,
public ?CampaignStatus $status = null,
) {
if (null === $this->brandCode && null === $this->status) {
throw new \InvalidArgumentException('CampaignRequestFilters require at least a brand code or a status.');
}
}
}

View file

@ -13,5 +13,11 @@ final readonly class CampaignRequestSortCriteria
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

@ -17,6 +17,12 @@ final readonly class CouponAvailabilityCalendar implements Equatable
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

View file

@ -15,5 +15,11 @@ final readonly class CouponDiffusion
if ($this->periods === []) {
throw new \DomainException('Diffusion requires at least one period.');
}
foreach ($this->periods as $period) {
if (!$period instanceof Period) {
throw new \DomainException('CouponDiffusion periods must be Period instances.');
}
}
}
}

View file

@ -10,5 +10,16 @@ final readonly class PrintRules
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

@ -72,6 +72,34 @@ describe('campaign', function (): void {
->and($campaign->updatedAt())->toBeNull();
});
it('creates a campaign without coupon', function (): void {
// GIVEN
$id = CampaignId::generate();
$code = new CampaignCode('Camp100');
$name = new CampaignName('Name');
$publication = \boolval(0);
$printAllAtOnce = \boolval(1);
$availableIn = Country::FR;
$coupons = [];
$brand = new BrandCode('AAA01');
// WHEN
$campaign = Campaign::hydrate(
$id,
$code,
$this->tag,
$name,
$publication,
$printAllAtOnce,
$availableIn,
$coupons,
$brand,
);
// THEN
expect($campaign->coupons)->toBe([]);
});
it('creates a coupon with an explicit updatedAt date', function (): void {
// GIVEN
$id = CampaignId::generate();
@ -197,4 +225,29 @@ describe('campaign', function (): void {
// 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();
$code = new CampaignCode('Camp100');
$name = new CampaignName('Name');
$publication = \boolval(0);
$printAllAtOnce = \boolval(1);
$availableIn = Country::FR;
$coupons = ['PC1'];
$brand = new BrandCode('AAA01');
// WHEN / THEN
expect(fn (): Campaign => Campaign::hydrate(
$id,
$code,
$this->tag,
$name,
$publication,
$printAllAtOnce,
$availableIn,
$coupons,
$brand
))->toThrow(\DomainException::class, 'Campaign coupons must be CampaignCoupon instances.');
});
});

View file

@ -21,6 +21,7 @@ use Domain\Models\ValueObjects\CouponPicture;
use Domain\Models\ValueObjects\CouponProductName;
use Domain\Models\ValueObjects\GS1;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Models\ValueObjects\ProcessingCode;
use Ramsey\Uuid\Uuid;
@ -37,21 +38,21 @@ describe('coupon', function (): void {
it('creates a coupon', function (): void {
// GIVEN
$id = CouponId::generate();
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion(['2026-08-25', '2026-10-31']);
$availability = new CouponAvailabilityCalendar(['2026-08-25', '2026-10-31']);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legal = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$createdBy = ActorId::generate();
$gtin = new GS1('110101010', BarcodeFormat::EAN13);
$id = CouponId::generate();
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion([new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01'))]);
$availability = new CouponAvailabilityCalendar([new Period(new DateTime('2026-08-25'), new DateTime('2026-10-31'))]);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legalInformation = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$createdBy = ActorId::generate();
$gtin = new GS1('110101010', BarcodeFormat::EAN13);
// WHEN
$coupon = Coupon::hydrate(
@ -65,7 +66,7 @@ describe('coupon', function (): void {
$diffusion,
$availability,
$validity,
$legal,
$legalInformation,
$code,
$brandCode,
$createdBy,
@ -83,7 +84,7 @@ describe('coupon', function (): void {
->and($coupon->diffusion)->toBe($diffusion)
->and($coupon->availability)->toBe($availability)
->and($coupon->validity)->toBe($validity)
->and($coupon->legal)->toBe($legal)
->and($coupon->legalInformation)->toBe($legalInformation)
->and($coupon->code)->toBe($code)
->and($coupon->brandCode)->toBe($brandCode)
->and($coupon->createdBy)->toBe($createdBy)
@ -94,20 +95,20 @@ describe('coupon', function (): void {
it('creates a coupon without gs1', function (): void {
// GIVEN
$id = CouponId::generate();
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion(['2026-08-25', '2026-10-31']);
$availability = new CouponAvailabilityCalendar(['2026-08-25', '2026-10-31']);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legal = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$createdBy = ActorId::generate();
$id = CouponId::generate();
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion([new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01'))]);
$availability = new CouponAvailabilityCalendar([new Period(new DateTime('2026-08-25'), new DateTime('2026-10-31'))]);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legalInformation = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$createdBy = ActorId::generate();
// WHEN
$coupon = Coupon::hydrate(
@ -121,7 +122,7 @@ describe('coupon', function (): void {
$diffusion,
$availability,
$validity,
$legal,
$legalInformation,
$code,
$brandCode,
$createdBy,
@ -138,7 +139,7 @@ describe('coupon', function (): void {
->and($coupon->diffusion)->toBe($diffusion)
->and($coupon->availability)->toBe($availability)
->and($coupon->validity)->toBe($validity)
->and($coupon->legal)->toBe($legal)
->and($coupon->legalInformation)->toBe($legalInformation)
->and($coupon->code)->toBe($code)
->and($coupon->brandCode)->toBe($brandCode)
->and($coupon->createdBy)->toBe($createdBy)
@ -148,22 +149,22 @@ describe('coupon', function (): void {
it('creates a coupon with an explicit updatedAt date', function (): void {
// GIVEN
$id = CouponId::generate();
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion(['2026-08-25', '2026-10-31']);
$availability = new CouponAvailabilityCalendar(['2026-08-25', '2026-10-31']);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legal = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$gtin = new GS1('110101010', BarcodeFormat::EAN13);
$createdBy = ActorId::generate();
$updatedAt = new \DateTimeImmutable('2026-09-22 10:00:00', new \DateTimeZone('UTC'));
$id = CouponId::generate();
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion([new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01'))]);
$availability = new CouponAvailabilityCalendar([new Period(new DateTime('2026-08-25'), new DateTime('2026-10-31'))]);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legalInformation = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$gtin = new GS1('110101010', BarcodeFormat::EAN13);
$createdBy = ActorId::generate();
$updatedAt = new \DateTimeImmutable('2026-09-22 10:00:00', new \DateTimeZone('UTC'));
// WHEN
$coupon = Coupon::hydrate(
@ -177,7 +178,7 @@ describe('coupon', function (): void {
$diffusion,
$availability,
$validity,
$legal,
$legalInformation,
$code,
$brandCode,
$createdBy,
@ -192,20 +193,20 @@ describe('coupon', function (): void {
it('creates a new coupon with a freshly generated id', function (): void {
// GIVEN
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion(['2026-08-25', '2026-10-31']);
$availability = new CouponAvailabilityCalendar(['2026-08-25', '2026-10-31']);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legal = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$createdBy = ActorId::generate();
$gtin = new GS1('110101010', BarcodeFormat::EAN13);
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion([new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01'))]);
$availability = new CouponAvailabilityCalendar([new Period(new DateTime('2026-08-25'), new DateTime('2026-10-31'))]);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legalInformation = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$createdBy = ActorId::generate();
$gtin = new GS1('110101010', BarcodeFormat::EAN13);
// WHEN
$coupon = Coupon::create(
@ -218,7 +219,7 @@ describe('coupon', function (): void {
$diffusion,
$availability,
$validity,
$legal,
$legalInformation,
$code,
$brandCode,
$createdBy,
@ -236,7 +237,7 @@ describe('coupon', function (): void {
->and($coupon->diffusion)->toBe($diffusion)
->and($coupon->availability)->toBe($availability)
->and($coupon->validity)->toBe($validity)
->and($coupon->legal)->toBe($legal)
->and($coupon->legalInformation)->toBe($legalInformation)
->and($coupon->code)->toBe($code)
->and($coupon->brandCode)->toBe($brandCode)
->and($coupon->createdBy)->toBe($createdBy)
@ -247,20 +248,20 @@ describe('coupon', function (): void {
it('generates a different id for each created coupon', function (): void {
// GIVEN
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion(['2026-08-25', '2026-10-31']);
$availability = new CouponAvailabilityCalendar(['2026-08-25', '2026-10-31']);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legal = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$createdBy = ActorId::generate();
$gtin = new GS1('110101010', BarcodeFormat::EAN13);
$name = new CouponName('Name');
$status = \boolval(0);
$productName = new CouponProductName('Lorem ipsum');
$amount = new MoneyAmount(new Money(Currency::EUR, 1));
$picture = new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg'));
$product = new CouponProduct('Lorem ipsum', [], []);
$diffusion = new CouponDiffusion([new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01'))]);
$availability = new CouponAvailabilityCalendar([new Period(new DateTime('2026-08-25'), new DateTime('2026-10-31'))]);
$validity = new CouponValidity(30, new DateTime('2026-11-30'));
$legalInformation = [new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)];
$code = new ProcessingCode('5425003944130');
$brandCode = new BrandCode('AAA01');
$createdBy = ActorId::generate();
$gtin = new GS1('110101010', BarcodeFormat::EAN13);
// WHEN
$firstCoupon = Coupon::create(
@ -273,7 +274,7 @@ describe('coupon', function (): void {
$diffusion,
$availability,
$validity,
$legal,
$legalInformation,
$code,
$brandCode,
$createdBy,
@ -289,7 +290,7 @@ describe('coupon', function (): void {
$diffusion,
$availability,
$validity,
$legal,
$legalInformation,
$code,
$brandCode,
$createdBy,
@ -312,8 +313,8 @@ describe('coupon', function (): void {
new MoneyAmount(new Money(Currency::EUR, 1)),
new CouponPicture(new PicturePath('files/product.jpg'), new PicturePath('files/thumbnail.jpg')),
new CouponProduct('Lorem ipsum', [], []),
new CouponDiffusion(['2026-08-25', '2026-10-31']),
new CouponAvailabilityCalendar(['2026-08-25', '2026-10-31']),
new CouponDiffusion([new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01'))]),
new CouponAvailabilityCalendar([new Period(new DateTime('2026-08-25'), new DateTime('2026-10-31'))]),
new CouponValidity(30, new DateTime('2026-11-30')),
[new CouponLegalInformation('Lorem ipsum dolor sit amet consectetur adipiscing elit.', Country::FR)],
new ProcessingCode('5425003944130'),

View file

@ -20,9 +20,9 @@ describe('stock', function (): void {
// GIVEN
$id = StockId::generate();
$stockAmount = new StockAmount(10);
$stockAdjustment = [new StockAdjustment(5, 'Inventory correction', ActorId::generate())];
$campaignCode = new CampaignCode('AAA01');
$processingCode = new ProcessingCode('PC1');
$stockAdjustment = [new StockAdjustment(5, 'Inventory correction', ActorId::generate())];
// WHEN
$stock = Stock::hydrate($id, $stockAmount, $campaignCode, $processingCode, $stockAdjustment);
@ -30,13 +30,81 @@ describe('stock', function (): void {
// THEN
expect($stock->id())->toBe($id)
->and($stock->stockAmount)->toBe($stockAmount)
->and($stock->stockAdjustment)->toBe($stockAdjustment)
->and($stock->campaignCode)->toBe($campaignCode)
->and($stock->processingCode)->toBe($processingCode)
->and($stock->stockAdjustment)->toBe($stockAdjustment)
->and($stock->updatedAt)->toBeNull()
->and($stock->updatedAt())->toBeNull();
});
it('hydrates a stock with several stock adjustments', function (): void {
// GIVEN
$id = StockId::generate();
$stockAmount = new StockAmount(10);
$campaignCode = new CampaignCode('AAA01');
$processingCode = new ProcessingCode('PC1');
$stockAdjustment = [
new StockAdjustment(5, 'Inventory correction', ActorId::generate()),
new StockAdjustment(10, 'Inventory correction', ActorId::generate()),
];
// WHEN
$stock = Stock::hydrate(
$id,
$stockAmount,
$campaignCode,
$processingCode,
$stockAdjustment,
);
// THEN
expect($stock->id())->toBe($id)
->and($stock->stockAmount)->toBe($stockAmount)
->and($stock->campaignCode)->toBe($campaignCode)
->and($stock->processingCode)->toBe($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();
$stockAmount = new StockAmount(10);
$campaignCode = new CampaignCode('AAA01');
$processingCode = new ProcessingCode('PC1');
// WHEN
$stock = Stock::hydrate($id, $stockAmount, $campaignCode, $processingCode);
// THEN
expect($stock->id())->toBe($id)
->and($stock->stockAmount)->toBe($stockAmount)
->and($stock->campaignCode)->toBe($campaignCode)
->and($stock->processingCode)->toBe($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();
$stockAmount = new StockAmount(10);
$campaignCode = new CampaignCode('AAA01');
$processingCode = new ProcessingCode('PC1');
$stockAdjustment = [new StockAmount(5)];
// WHEN / THEN
expect(fn (): Stock => Stock::hydrate(
$id,
$stockAmount,
$campaignCode,
$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'));

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects;
use Domain\Enums\Campaign\CampaignStatus;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestFilters;
covers(CampaignRequestFilters::class);
describe('campaign request filters', function (): void {
it('creates filters with both fields set', function (): void {
// GIVEN
$brandCode = new BrandCode('AAA01');
$status = CampaignStatus::CREATED;
// WHEN
$filters = new CampaignRequestFilters(brandCode: $brandCode, status: $status);
// THEN
expect($filters->brandCode)->toBe($brandCode)
->and($filters->status)->toBe($status);
});
it('creates filters with only brandCode set', function (): void {
// GIVEN
$brandCode = new BrandCode('AAA01');
// WHEN
$filters = new CampaignRequestFilters(brandCode: $brandCode);
// THEN
expect($filters->brandCode)->toBe($brandCode)
->and($filters->status)->toBeNull();
});
it('creates filters with only status set', function (): void {
// GIVEN
$status = CampaignStatus::CREATED;
// WHEN
$filters = new CampaignRequestFilters(status: $status);
// THEN
expect($filters->brandCode)->toBeNull()
->and($filters->status)->toBe($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

@ -11,9 +11,11 @@ use Domain\Models\ValueObjects\CampaignRequest\CampaignRequestSortField;
covers(CampaignRequestSortCriteria::class);
describe('campaign request sort criteria', function (): void {
it('creates a valid campaign request sort criteria', function (): void {
it('creates a valid campaign request sort criteria with one clause', function (): void {
// GIVEN
$clauses = [new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending)];
$clauses = [
new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending),
];
// WHEN
$campaignRequestSortCriteria = new CampaignRequestSortCriteria($clauses);
@ -22,11 +24,35 @@ describe('campaign request sort criteria', function (): void {
expect($campaignRequestSortCriteria->clauses)->toBe($clauses);
});
it('rejects invalid campaign request sort criteria', function (): void {
it('creates a valid campaign request sort criteria with several clauses', function (): void {
// GIVEN
$clauses = [];
$clauses = [
new CampaignRequestSortClause(field: CampaignRequestSortField::BrandCode, direction: \SortDirection::Ascending),
new CampaignRequestSortClause(field: CampaignRequestSortField::Status, direction: \SortDirection::Descending),
];
// GIVEN / THEN
expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($clauses))->toThrow(\DomainException::class);
// WHEN
$campaignRequestSortCriteria = new CampaignRequestSortCriteria($clauses);
// THEN
expect($campaignRequestSortCriteria->clauses)->toBe($clauses);
});
it('rejects a campaign request sort criteria without clause', function (): void {
// GIVEN
$clauses = [];
// WHEN / THEN
expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($clauses))
->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
$clauses = [CampaignRequestSortField::BrandCode];
// WHEN / THEN
expect(fn (): CampaignRequestSortCriteria => new CampaignRequestSortCriteria($clauses))
->toThrow(\DomainException::class, 'CampaignRequestSortCriteria clauses must be CampaignRequestSortClause instances.');
});
});

View file

@ -5,13 +5,17 @@ declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Period;
use Shared\ValueObjects\DateTime;
covers(CouponAvailabilityCalendar::class);
describe('coupon availability calendar', function (): void {
it('creates a valid coupon availability calendar', function (): void {
it('creates a valid coupon availability calendar with one period', function (): void {
// GIVEN
$periods = ['2026-08-25', '2026-10-31'];
$periods = [
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
];
// WHEN
$couponAvailabilityCalendar = new CouponAvailabilityCalendar($periods);
@ -20,11 +24,34 @@ describe('coupon availability calendar', function (): void {
expect($couponAvailabilityCalendar->periods)->toBe($periods);
});
it('rejects invalid coupon availability calendar', function (): void {
it('creates a valid coupon availability calendar with several periods', function (): void {
// GIVEN
$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')),
];
// WHEN
$couponAvailabilityCalendar = new CouponAvailabilityCalendar($periods);
// THEN
expect($couponAvailabilityCalendar->periods)->toBe($periods);
});
it('rejects a coupon availability calendar without period', function (): void {
// GIVEN
$periods = [];
// GIVEN / THEN
expect(fn (): CouponAvailabilityCalendar => new CouponAvailabilityCalendar($periods))->toThrow(\DomainException::class);
});
it('rejects a coupon availability calendar with a non-period element', function (): void {
// GIVEN
$periods = ['2026-08-25', '2026-10-31'];
// WHEN / THEN
expect(fn (): CouponAvailabilityCalendar => new CouponAvailabilityCalendar($periods))
->toThrow(\DomainException::class, 'CouponAvailabilityCalendar periods must be Period instances.');
});
});

View file

@ -5,13 +5,17 @@ declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Coupon;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Period;
use Shared\ValueObjects\DateTime;
covers(CouponDiffusion::class);
describe('coupon diffusion', function (): void {
it('creates a valid coupon diffusion', function (): void {
it('creates a valid coupon diffusion with one period', function (): void {
// GIVEN
$periods = ['2026-08-25', '2026-10-31'];
$periods = [
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
];
// WHEN
$couponDiffusion = new CouponDiffusion($periods);
@ -20,11 +24,34 @@ describe('coupon diffusion', function (): void {
expect($couponDiffusion->periods)->toBe($periods);
});
it('rejects invalid coupon diffusion', function (): void {
it('creates a valid coupon diffusion with several periods', function (): void {
// GIVEN
$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')),
];
// WHEN
$couponDiffusion = new CouponDiffusion($periods);
// THEN
expect($couponDiffusion->periods)->toBe($periods);
});
it('rejects a coupon diffusion without period', function (): void {
// GIVEN
$periods = [];
// GIVEN / THEN
expect(fn (): CouponDiffusion => new CouponDiffusion($periods))->toThrow(\DomainException::class);
});
it('rejects a coupon diffusion with a non-period element', function (): void {
// GIVEN
$periods = ['2026-08-25', '2026-10-31'];
// WHEN / THEN
expect(fn (): CouponDiffusion => new CouponDiffusion($periods))
->toThrow(\DomainException::class, 'CouponDiffusion periods must be Period instances.');
});
});

View file

@ -37,12 +37,36 @@ describe('print rules', function (): void {
->and($printRules->cooldownDays)->toBe(45);
});
it('creates print rules with both fields null by default', 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 (): void {
// WHEN
$printRules = new PrintRules();
$printRules = new PrintRules(maxPerCustomer: 1, cooldownDays: 1);
// THEN
expect($printRules->maxPerCustomer)->toBeNull()
->and($printRules->cooldownDays)->toBeNull();
expect($printRules->maxPerCustomer)->toBe(1)
->and($printRules->cooldownDays)->toBe(1);
});
it('rejects a maxPerCustomer equal to zero', function (): void {
// GIVEN
$maxPerCustomer = 0;
// WHEN / THEN
expect(fn (): PrintRules => new PrintRules(maxPerCustomer: $maxPerCustomer))
->toThrow(\InvalidArgumentException::class, 'Max per customer must be greater than 0.');
});
it('rejects a cooldownDays equal to zero', function (): void {
// GIVEN
$cooldownDays = 0;
// WHEN / THEN
expect(fn (): PrintRules => new PrintRules(cooldownDays: $cooldownDays))
->toThrow(\InvalidArgumentException::class, 'Cooldown days must be greater than 0.');
});
});