feat: ✨ Added an ImmutableSet class and updated all concerned classes (ex: CampaignCouponArray)

This commit is contained in:
Noah Lancien 2026-10-28 12:21:05 +01:00
parent 432a344e93
commit 6faeb77d02
16 changed files with 227 additions and 164 deletions

View file

@ -44,7 +44,7 @@ final readonly class RemoveCouponToCampaign implements UseCase, Transactional
return new CouponNotFound();
}
if (count($campaign->coupons->value) === 1) {
if (count($campaign->coupons->toArray()) === 1) {
return new NotEnoughCouponInCampaign();
}

View file

@ -5,65 +5,49 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects\Campaign;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Shared\DDD\Models\ImmutableSet;
final readonly class CampaignCouponArray
/** @extends ImmutableSet<CampaignCoupon> */
final readonly class CampaignCouponArray extends ImmutableSet
{
public function __construct(
/** @var CampaignCoupon[] */
public array $value,
) {
if ($this->value === []) {
protected function itemClass(): string
{
return CampaignCoupon::class;
}
protected function keyOf(object $item): string
{
return $item->couponCode->value;
}
protected function assertValid(): void
{
if ($this->count() === 0) {
throw new \DomainException('Coupons requires at least one CampaignCoupon.');
}
$alreadyInCampaign = [];
foreach ($this->value as $coupon) {
if (! $coupon instanceof CampaignCoupon) {
throw new \DomainException('Coupons must be CampaignCoupon instances.');
}
$key = $coupon->couponCode->value;
if (isset($alreadyInCampaign[$key])) {
throw new \DomainException(sprintf('Coupon "%s" is present more than once.', $key));
}
$alreadyInCampaign[$key] = true;
}
}
public function findCoupon(CouponCode $couponCode): ?CampaignCoupon
{
foreach ($this->value as $coupon) {
if ($coupon->hasCouponCode($couponCode)) {
return $coupon;
}
}
return null;
return $this->get($couponCode->value);
}
public function addCoupon(CampaignCoupon $newCoupon): self
public function addCoupon(CampaignCoupon $coupon): self
{
return new self([...$this->value, $newCoupon]);
return $this->with($coupon);
}
public function removeCoupon(CouponCode $couponCode): self
{
if ($this->findCoupon($couponCode) === null) {
throw new \DomainException('Coupon is not in the campaign.');
}
return new self(array_values(array_filter(
$this->value,
static fn (CampaignCoupon $campaignCoupon) => ! $campaignCoupon->hasCouponCode($couponCode),
)));
return $this->without($couponCode->value);
}
/** @return array<CouponCode> */
/** @return list<CouponCode> */
public function getCouponCodes(): array
{
return array_map(
static fn (CampaignCoupon $campaignCoupon): CouponCode => $campaignCoupon->couponCode,
$this->value,
static fn (CampaignCoupon $c): CouponCode => $c->couponCode,
$this->toArray(),
);
}
}

View file

@ -18,7 +18,7 @@ final readonly class CouponCountryCoverage
CouponValidityInformationArray $validityInformations,
CouponLegalInformationArray $legalInformations,
): void {
foreach ($processingCodes->value as $processingCode) {
foreach ($processingCodes->toArray() as $processingCode) {
$country = $processingCode->availableIn;
if (! self::hasInformationFor($country, $validityInformations)) {
@ -39,7 +39,7 @@ final readonly class CouponCountryCoverage
CouponValidityInformationArray | CouponLegalInformationArray $informations,
): bool {
return array_any(
$informations->value,
$informations->toArray(),
fn (CouponValidityInformation | CouponLegalInformation $information): bool => $information->forLocale === $country,
);
}
@ -48,7 +48,7 @@ final readonly class CouponCountryCoverage
CouponValidityInformationArray | CouponLegalInformationArray $informations,
ProcessingCodeArray $processingCodes,
): void {
if (count($informations->value) !== count($processingCodes->value)) {
if (count($informations->toArray()) !== count($processingCodes->toArray())) {
throw new \DomainException('A coupon requires exactly one information per diffusion country.');
}
}

View file

@ -4,41 +4,29 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\DDD\Models\ImmutableSet;
use Shared\Enums\Country;
final readonly class CouponLegalInformationArray
/** @extends ImmutableSet<CouponLegalInformation> */
final readonly class CouponLegalInformationArray extends ImmutableSet
{
public function __construct(
/** @var CouponLegalInformation[] */
public array $value,
) {
if ($this->value === []) {
protected function itemClass(): string
{
return CouponLegalInformation::class;
}
protected function keyOf(object $item): string
{
return $item->forLocale->name;
}
protected function assertValid(): void
{
if ($this->count() === 0) {
throw new \DomainException('CouponLegalInformations requires at least one CouponLegalInformation.');
}
$hasFrance = false;
$countries = [];
foreach ($this->value as $couponLegalInformation) {
if (! $couponLegalInformation instanceof CouponLegalInformation) {
throw new \DomainException('Value of CouponLegalInformationArray must be CouponLegalInformation instances.');
}
if (isset($countries[$couponLegalInformation->forLocale->name])) {
throw new \DomainException(sprintf(
'Only one CouponLegalInformation is allowed per country, duplicate for %s.',
$couponLegalInformation->forLocale->name,
));
}
$countries[$couponLegalInformation->forLocale->name] = true;
if ($couponLegalInformation->forLocale === Country::FRANCE) {
$hasFrance = true;
}
}
if (! $hasFrance) {
if (! $this->has(Country::FRANCE->name)) {
throw new \DomainException('CouponLegalInformations requires a CouponLegalInformation for France.');
}
}

View file

@ -4,41 +4,29 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
use Shared\DDD\Models\ImmutableSet;
use Shared\Enums\Country;
final readonly class CouponValidityInformationArray
/** @extends ImmutableSet<CouponValidityInformation> */
final readonly class CouponValidityInformationArray extends ImmutableSet
{
public function __construct(
/** @var CouponValidityInformation[] */
public array $value,
) {
if ($this->value === []) {
protected function itemClass(): string
{
return CouponValidityInformation::class;
}
protected function keyOf(object $item): string
{
return $item->forLocale->name;
}
protected function assertValid(): void
{
if ($this->count() === 0) {
throw new \DomainException('CouponValidityInformations requires at least one CouponValidityInformation.');
}
$hasFrance = false;
$countries = [];
foreach ($this->value as $couponValidityInformation) {
if (! $couponValidityInformation instanceof CouponValidityInformation) {
throw new \DomainException('Value of CouponValidityInformationArray must be CouponValidityInformation instances.');
}
if (isset($countries[$couponValidityInformation->forLocale->name])) {
throw new \DomainException(sprintf(
'Only one CouponValidityInformation is allowed per country, duplicate for %s.',
$couponValidityInformation->forLocale->name,
));
}
$countries[$couponValidityInformation->forLocale->name] = true;
if ($couponValidityInformation->forLocale === Country::FRANCE) {
$hasFrance = true;
}
}
if (! $hasFrance) {
if (! $this->has(Country::FRANCE->name)) {
throw new \DomainException('CouponValidityInformations requires a CouponValidityInformation for France.');
}
}

View file

@ -4,41 +4,29 @@ declare(strict_types=1);
namespace Domain\Models\ValueObjects;
use Shared\DDD\Models\ImmutableSet;
use Shared\Enums\Country;
final readonly class ProcessingCodeArray
/** @extends ImmutableSet<ProcessingCode> */
final readonly class ProcessingCodeArray extends ImmutableSet
{
public function __construct(
/** @var ProcessingCode[] */
public array $value,
) {
if ($this->value === []) {
protected function itemClass(): string
{
return ProcessingCode::class;
}
protected function keyOf(object $item): string
{
return $item->availableIn->name; // un seul code par pays
}
protected function assertValid(): void
{
if ($this->count() === 0) {
throw new \DomainException('ProcessingCodes requires at least one ProcessingCode.');
}
$hasFrance = false;
$countries = [];
foreach ($this->value as $processingCode) {
if (! $processingCode instanceof ProcessingCode) {
throw new \DomainException('Value of ProcessingCodeArray must be ProcessingCode instances.');
}
if (isset($countries[$processingCode->availableIn->name])) {
throw new \DomainException(sprintf(
'Only one ProcessingCode is allowed per country, duplicate for %s.',
$processingCode->availableIn->name,
));
}
$countries[$processingCode->availableIn->name] = true;
if ($processingCode->availableIn === Country::FRANCE) {
$hasFrance = true;
}
}
if (! $hasFrance) {
if (! $this->has(Country::FRANCE->name)) {
throw new \DomainException('ProcessingCodes requires a ProcessingCode for France.');
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\Models;
/**
* @template T of object
*/
abstract readonly class ImmutableSet
{
/** @var array<string, T> indexé par clé d'unicité */
private array $items;
/** @param iterable<T> $items */
final public function __construct(iterable $items = [])
{
$indexed = [];
foreach ($items as $item) {
if (! $this->accepts($item)) {
throw new \DomainException(sprintf(
'%s only accepts %s.',
new \ReflectionClass(static::class)->getShortName(),
new \ReflectionClass($this->itemClass())->getShortName(),
));
}
$key = $this->keyOf($item);
if (isset($indexed[$key])) {
throw new \DomainException(sprintf('"%s" is present more than once.', $key));
}
$indexed[$key] = $item;
}
$this->items = $indexed;
$this->assertValid();
}
/** @return class-string<T> */
abstract protected function itemClass(): string;
/** @param T $item Défini la clé d'unicité */
abstract protected function keyOf(object $item): string;
/** Règles propres à la sous-classe (ex. taille minimale du set). */
protected function assertValid(): void
{
}
public function has(string $key): bool
{
return isset($this->items[$key]);
}
/** @return T|null */
public function get(string $key): ?object
{
return $this->items[$key] ?? null;
}
/** @param T $item */
public function with(object $item): static
{
return new static([...array_values($this->items), $item]);
}
public function without(string $key): static
{
if (! $this->has($key)) {
throw new \DomainException(sprintf('"%s" is not in the set.', $key));
}
$items = $this->items;
unset($items[$key]);
return new static(array_values($items));
}
/** @return list<T> */
public function toArray(): array
{
return array_values($this->items);
}
/** @return list<string> */
public function keys(): array
{
return array_keys($this->items);
}
public function count(): int
{
return count($this->items);
}
private function accepts(mixed $item): bool
{
return $item instanceof ($this->itemClass());
}
}

View file

@ -44,7 +44,7 @@ describe('find campaign use case', function (): void {
->and($result->publication)->toBe($campaign->publication)
->and($result->printAllAtOnce)->toBe($campaign->printAllAtOnce)
->and($result->coupons)->toBe($campaign->coupons)
->and($result->coupons->value[0]->couponCode->value)->toBe('COU01')
->and($result->coupons->toArray()[0]->couponCode->value)->toBe('COU01')
->and($result->createdAt)->toEqual($campaign->createdAt())
->and($result->updatedAt)->toEqual($campaign->updatedAt());
});

View file

@ -72,7 +72,7 @@ describe('add coupons to campaign use case', function (): void {
$repository->shouldReceive('findByCode')->once()->with($campaign->code)->andReturn($campaign);
$repository->shouldReceive('findCampaignCoupon')->once()->andReturn(null);
$repository->shouldReceive('save')->once()->with(\Mockery::on(
static fn (Campaign $saved): bool => count($saved->coupons->value) === 3
static fn (Campaign $saved): bool => count($saved->coupons->toArray()) === 3
&& $saved->coupons->findCoupon($coupon3->code) instanceof CampaignCoupon,
));
@ -86,7 +86,7 @@ describe('add coupons to campaign use case', function (): void {
// THEN
expect($result)->toBeInstanceOf(CampaignUpdated::class)
->and($result->coupons->value)->toHaveCount(3);
->and($result->coupons->toArray())->toHaveCount(3);
});
it('returns a specific error when the coupon is already in the campaign', function () use ($campaignCoupon1, $coupon1): void {

View file

@ -84,9 +84,9 @@ describe('remove coupons to campaign use case', function (): void {
// THEN
expect($result)->toBeInstanceOf(CampaignUpdated::class)
->and($result->coupons->value)->toHaveCount(2)
->and($result->coupons->value[0])->toBe($campaignCoupon1)
->and($result->coupons->value[1])->toBe($campaignCoupon3);
->and($result->coupons->toArray())->toHaveCount(2)
->and($result->coupons->toArray()[0])->toBe($campaignCoupon1)
->and($result->coupons->toArray()[1])->toBe($campaignCoupon3);
});
it('returns a specific output when the coupon is not in the campaign', function () use ($existingCampaign): void {

View file

@ -204,7 +204,10 @@ describe('campaign', function (): void {
new CampaignCouponArray(['PC1']),
$brandCode,
$actorId
))->toThrow(\DomainException::class, 'Coupons must be CampaignCoupon instances.');
))->toThrow(
\DomainException::class,
'CampaignCouponArray only accepts CampaignCoupon.'
);
});
it('unpublish a campaign and sets updatedAt timestamp', function () use ($code, $name, $printAllAtOnce, $coupons, $brandCode, $actorId): void {

View file

@ -242,7 +242,10 @@ describe('coupon', function (): void {
products: $products,
movements: $movements,
availabilityCalendar: $availabilityCalendar,
))->toThrow(\DomainException::class, 'Value of CouponValidityInformationArray must be CouponValidityInformation instances.');
))->toThrow(
\DomainException::class,
'CouponValidityInformationArray only accepts CouponValidityInformation.'
);
});
it('throws when legal information contains an invalid item', function () use (
@ -278,7 +281,10 @@ describe('coupon', function (): void {
products: $products,
movements: $movements,
availabilityCalendar: $availabilityCalendar,
))->toThrow(\DomainException::class, 'Value of CouponLegalInformationArray must be CouponLegalInformation instances.');
))->toThrow(
\DomainException::class,
'CouponLegalInformationArray only accepts CouponLegalInformation.'
);
});
it('creates a new coupon with a freshly generated id', function () use (

View file

@ -30,8 +30,8 @@ describe('campaign coupon array', function (): void {
$array = new CampaignCouponArray($coupons);
// THEN
expect($array->value)->toBe($coupons)
->and($array->value)->toHaveCount(2);
expect($array->toArray())->toBe($coupons)
->and($array->toArray())->toHaveCount(2);
});
it('rejects an empty array', function (): void {
@ -47,7 +47,10 @@ describe('campaign coupon array', function (): void {
$action = fn (): CampaignCouponArray => new CampaignCouponArray(['PC1']);
// THEN
expect($action)->toThrow(\DomainException::class, 'Coupons must be CampaignCoupon instances.');
expect($action)->toThrow(
\DomainException::class,
'CampaignCouponArray only accepts CampaignCoupon.'
);
});
it('rejects a non-coupon element mixed with valid coupons', function () use ($coupon): void {
@ -58,7 +61,10 @@ describe('campaign coupon array', function (): void {
$action = fn (): CampaignCouponArray => new CampaignCouponArray($coupons);
// THEN
expect($action)->toThrow(\DomainException::class, 'Coupons must be CampaignCoupon instances.');
expect($action)->toThrow(
\DomainException::class,
'CampaignCouponArray only accepts CampaignCoupon.'
);
});
it('rejects the same coupon twice at initialisation', function (): void {
@ -72,7 +78,7 @@ describe('campaign coupon array', function (): void {
$action = fn (): CampaignCouponArray => new CampaignCouponArray($coupons);
//WHEN / THEN
expect($action)->toThrow(\DomainException::class, 'Coupon "AAA01" is present more than once.');
expect($action)->toThrow(\DomainException::class, '"AAA01" is present more than once.');
});
it('adds a coupon to a campaign coupon array', function (): void {
@ -87,7 +93,7 @@ describe('campaign coupon array', function (): void {
$array = $array->addCoupon(new CampaignCoupon(new CouponCode('AAA03')));
// THEN
expect($array->value)->toHaveCount(3);
expect($array->toArray())->toHaveCount(3);
});
it('removes a coupon to a campaign coupon array', function () use ($coupon, $coupon2): void {
@ -99,7 +105,7 @@ describe('campaign coupon array', function (): void {
$array = $array->removeCoupon($coupon2->couponCode);
// THEN
expect($array->value)->toHaveCount(1);
expect($array->toArray())->toHaveCount(1);
});
it('cant remove a coupon which is not on the campaign', function (): void {
@ -114,7 +120,7 @@ describe('campaign coupon array', function (): void {
$action = fn () => $array->removeCoupon(new CouponCode('AAA03'));
// THEN
expect($action)->toThrow(\DomainException::class, 'Coupon is not in the campaign.');
expect($action)->toThrow(\DomainException::class, '"AAA03" is not in the set.');
});
it('returns all coupon codes', function () use ($coupon, $coupon2): void {

View file

@ -22,7 +22,7 @@ describe('coupon legal information array', function (): void {
$array = new CouponLegalInformationArray($informations);
// THEN
expect($array->value)->toBe($informations);
expect($array->toArray())->toBe($informations);
});
it('accepts additional countries next to France', function () use ($franceInformation, $beneluxInformation): void {
@ -33,7 +33,7 @@ describe('coupon legal information array', function (): void {
$array = new CouponLegalInformationArray($informations);
// THEN
expect($array->value)->toBe($informations);
expect($array->toArray())->toBe($informations);
});
it('rejects an empty array', function (): void {
@ -60,7 +60,7 @@ describe('coupon legal information array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Only one CouponLegalInformation is allowed per country, duplicate for FRANCE.',
'"FRANCE" is present more than once.',
);
});
@ -71,7 +71,7 @@ describe('coupon legal information array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Value of CouponLegalInformationArray must be CouponLegalInformation instances.',
'CouponLegalInformationArray only accepts CouponLegalInformation.',
);
});
@ -85,7 +85,7 @@ describe('coupon legal information array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Value of CouponLegalInformationArray must be CouponLegalInformation instances.',
'CouponLegalInformationArray only accepts CouponLegalInformation.',
);
});

View file

@ -22,7 +22,7 @@ describe('coupon validity information array', function (): void {
$array = new CouponValidityInformationArray($informations);
// THEN
expect($array->value)->toBe($informations);
expect($array->toArray())->toBe($informations);
});
it('accepts additional countries next to France', function () use ($franceInformation, $beneluxInformation): void {
@ -33,7 +33,7 @@ describe('coupon validity information array', function (): void {
$array = new CouponValidityInformationArray($informations);
// THEN
expect($array->value)->toBe($informations);
expect($array->toArray())->toBe($informations);
});
it('rejects an empty array', function (): void {
@ -60,7 +60,7 @@ describe('coupon validity information array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Only one CouponValidityInformation is allowed per country, duplicate for FRANCE.',
'"FRANCE" is present more than once.',
);
});
@ -71,7 +71,7 @@ describe('coupon validity information array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Value of CouponValidityInformationArray must be CouponValidityInformation instances.',
'CouponValidityInformationArray only accepts CouponValidityInformation.',
);
});
@ -85,7 +85,7 @@ describe('coupon validity information array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Value of CouponValidityInformationArray must be CouponValidityInformation instances.',
'CouponValidityInformationArray only accepts CouponValidityInformation.',
);
});

View file

@ -19,10 +19,10 @@ describe('coupon processing code array', function (): void {
$processingCodes = [$franceCode];
// WHEN
$array = new ProcessingCodeArray($processingCodes);
$processingCodeSet = new ProcessingCodeArray($processingCodes);
// THEN
expect($array->value)->toBe($processingCodes);
expect($processingCodeSet->toArray())->toBe($processingCodes);
});
it('accepts additional countries next to France', function () use ($franceCode, $beneluxCode): void {
@ -30,10 +30,10 @@ describe('coupon processing code array', function (): void {
$processingCodes = [$beneluxCode, $franceCode];
// WHEN
$array = new ProcessingCodeArray($processingCodes);
$processingCodeSet = new ProcessingCodeArray($processingCodes);
// THEN
expect($array->value)->toBe($processingCodes);
expect($processingCodeSet->toArray())->toBe($processingCodes);
});
it('rejects an empty array', function (): void {
@ -61,7 +61,7 @@ describe('coupon processing code array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Only one ProcessingCode is allowed per country, duplicate for FRANCE.',
'"FRANCE" is present more than once.',
);
});
@ -72,7 +72,7 @@ describe('coupon processing code array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Value of ProcessingCodeArray must be ProcessingCode instances.',
'ProcessingCodeArray only accepts ProcessingCode.',
);
});
@ -86,7 +86,7 @@ describe('coupon processing code array', function (): void {
// THEN
expect($action)->toThrow(
\DomainException::class,
'Value of ProcessingCodeArray must be ProcessingCode instances.',
'ProcessingCodeArray only accepts ProcessingCode.',
);
});