feat(domain): add Brand entity, value objects, ports, and generator & resolver domain services

This commit is contained in:
Jérémy Munsch 2026-08-20 17:35:38 +02:00
parent 8cfd5fbe0f
commit a42a79861d
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
21 changed files with 996 additions and 51 deletions

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Domain\Exceptions;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Shared\DDD\Exception\NotFoundException;
final class BrandNotFoundException extends NotFoundException
{
public static function forBrand(BrandId $brandId): self
{
return new self("Brand '{$brandId->value}' not found.");
}
public static function forBrandCode(BrandCode $code): self
{
return new self("Brand with code '{$code->value}' not found.");
}
public static function forArcadiaId(ArcadiaId $arcadiaId): self
{
return new self("Arcadia brand with ID {$arcadiaId->value} not found.");
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Model\Entity;
use Shared\DDD\Model\HasTimestampIdentifier;
/** @implements Entity<BrandId> */
final readonly class Brand implements Entity, HasTimestampIdentifier
{
public BrandId $id;
private function __construct(
BrandId $id,
public BrandName $name,
public BrandCode $code,
public ArcadiaId $arcadiaId,
) {
$this->id = $id;
}
#[\Override]
public function id(): BrandId
{
return $this->id;
}
#[\Override]
public function createdAt(): \DateTimeInterface
{
return $this->id->toDateTime();
}
public static function create(
BrandName $name,
BrandCode $code,
ArcadiaId $arcadiaId,
): self {
return new self(
id: new BrandId(Uuid::uuid7()),
name: $name,
code: $code,
arcadiaId: $arcadiaId,
);
}
public static function hydrate(
BrandId $id,
BrandName $name,
BrandCode $code,
ArcadiaId $arcadiaId,
): self {
return new self(
id: $id,
name: $name,
code: $code,
arcadiaId: $arcadiaId,
);
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Brand;
final readonly class ArcadiaBrand
{
public function __construct(
public ArcadiaId $id,
public BrandName $name,
) {
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Brand;
final readonly class ArcadiaId
{
public function __construct(
public readonly int $value
) {
if ($value < 1 || $value > 999) {
throw new \DomainException(
"Invalid arcadia id '{$value}' does not match pattern /^\d{1,3}$/"
);
}
}
}

View file

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

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Brand;
use Ramsey\Uuid\UuidInterface;
use Shared\DDD\Model\EntityId;
use Shared\Helpers\UuidHelper;
final readonly class BrandId extends EntityId
{
public function __construct(
public readonly UuidInterface $identifier
) {
parent::__construct($identifier->toString());
try {
UuidHelper::validateUuid($identifier);
} catch (\InvalidArgumentException $e) {
throw new \DomainException(
"Invalid value '{$identifier->toString()}' is not a valid UUID",
0,
$e
);
}
if (! UuidHelper::isV7($identifier)) {
throw new \DomainException(
"Invalid value '{$identifier}' is not a valid V7 UUID"
);
}
}
public function toDateTime(): \DateTimeInterface
{
return UuidHelper::toDateTime($this->identifier);
}
}

View file

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

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects;
/**
* eg: 13 -> Sans marque
*/
final readonly class BrandCode
{
public function __construct(
public readonly int $value
) {
if ($value <= 0) {
throw new \DomainException(
"Invalid code '{$value}' shall be greater than 0"
);
}
}
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Domain\Ports\Directories;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Shared\DDD\Port\Directory;
/** @extends Directory<ArcadiaId, ArcadiaBrand> */
interface ArcadiaBrandDirectory extends Directory
{
/** @return list<ArcadiaBrand> */
public function findAll(): array;
public function find(ArcadiaId $id): ?ArcadiaBrand;
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Shared\DDD\Port\Repository;
/** @extends Repository<BrandId, Brand> */
interface BrandRepository extends Repository
{
public function find(BrandId $id): ?Brand;
public function findByCode(BrandCode $code): ?Brand;
public function findByArcadiaId(ArcadiaId $arcadiaId): ?Brand;
/** @return list<Brand> */
public function findAll(): array;
public function persist(Brand $entity): void;
/**
* @throws \Domain\Exceptions\BrandNotFoundException
*/
public function delete(Brand $entity): void;
}

View file

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Domain\Services;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
final readonly class BrandCodeGenerator
{
private const array STOP_WORDS = [
'LE',
'LA',
'LES',
'UN',
'UNE',
'DES',
'DU',
'DE',
'D',
'ET',
'AU',
'AUX',
'EN',
'AND',
'THE',
'OF',
];
public function generate(BrandName $brandName): BrandCode
{
return new BrandCode(
$this->trigramFrom($brandName) . $this->randomTwoDigits()
);
}
private function trigramFrom(BrandName $brandName): string
{
$words = $brandName->value
// Normalize to ASCII uppercase
|> $this->normalizeString(...)
// Split into word tokens
|> $this->splitIntoWords(...);
if (empty($words)) {
return 'XXX';
}
// Filter out stop words
$significantWords = $this->filterStopWords($words);
// Case 1: 3+ significant words (e.g. "Fermiers Bio Français" -> FBF, "Les Grands Domaines Agricoles" -> GDA)
if (count($significantWords) >= 3) {
return $significantWords[0][0] . $significantWords[1][0] . $significantWords[2][0];
}
// Case 2: 3+ total words with connectors (e.g. "Tradition d'Asie" -> TDA, "Poule et Toque" -> PET)
if (count($words) >= 3) {
return $words[0][0] . $words[1][0] . $words[2][0];
}
// Case 3: Exactly 2 words (e.g. "Cailles Robin" -> CRB, "Nature Respect" -> NRP, "Le Gaulois" -> LGL)
if (count($words) === 2) {
[$w1, $w2] = $words;
return $w1[0] . $w2[0] . $this->secondSyllableOnset($w2);
}
// Case 4: Single word with 3+ letters (e.g. "Marie" -> MAR, "Loué" -> LOU)
$single = $words[0];
if (strlen($single) >= 3) {
return substr($single, 0, 3);
}
// Case 5: Short word (< 3 letters, e.g. "LU" -> LUX, "K" -> KXX)
return str_pad($single, 3, 'X');
}
/**
* Extracts the second syllable onset or strong transition consonant.
* e.g. ROBIN -> B (CRB), RESPECT -> P (NRP), MARTINET -> T (PMT), GAULOIS -> L (LGL), COQ -> Q (MCQ)
*/
private function secondSyllableOnset(string $word): string
{
// 1. Match consonant onset after first vowels in consonant clusters (e.g. Respect -> P, Martinet -> T)
if (preg_match('/^[A-Z]*?[AEIOUY]+(?:[RSLMN])?([^AEIOUY])/i', $word, $matches)) {
return strtoupper($matches[1]);
}
// 2. Match single consonant after first vowels (e.g. Robin -> B, Gaulois -> L, Coq -> Q)
if (preg_match('/^[A-Z]*?[AEIOUY]+([^AEIOUY])/i', $word, $matches)) {
return strtoupper($matches[1]);
}
// 3. Fallback: last letter
return $word[strlen($word) - 1];
}
/**
* @param list<string> $words
* @return list<string>
*/
private function filterStopWords(array $words): array
{
return array_values(array_filter(
$words,
static fn (string $w): bool => ! in_array($w, self::STOP_WORDS, true)
));
}
/** @return list<string> */
private function splitIntoWords(string $value): array
{
/** @var list<string> */
return preg_split('/[^A-Z]+/', $value, -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
private function normalizeString(string $value): string
{
$ascii = transliterator_transliterate('Any-Latin; Latin-ASCII; Upper();', $value);
return is_string($ascii) ? $ascii : mb_strtoupper($value);
}
private function randomTwoDigits(): string
{
$firstDigit = random_int(0, 9);
do {
$secondDigit = random_int(0, 9);
} while ($secondDigit === $firstDigit);
return "{$firstDigit}{$secondDigit}";
}
}

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Domain\Services;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Ports\Directories\ArcadiaBrandDirectory;
use Domain\Ports\Repositories\BrandRepository;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
final readonly class BrandResolver
{
private LoggerInterface $logger;
public function __construct(
private ArcadiaBrandDirectory $arcadiaBrandDirectory,
private BrandRepository $brandRepository,
private BrandCodeGenerator $brandCodeGenerator,
?LoggerInterface $logger = null,
) {
$this->logger = $logger ?? new NullLogger();
}
/**
* Resolve or generate/persist a Brand entity from an ArcadiaBrand.
*/
public function resolve(ArcadiaBrand $arcadiaBrand): Brand
{
$existingBrand = $this->brandRepository->findByArcadiaId($arcadiaBrand->id);
if ($existingBrand !== null) {
return $existingBrand;
}
do {
$code = $this->brandCodeGenerator->generate($arcadiaBrand->name);
} while ($this->brandRepository->findByCode($code) !== null);
$brand = Brand::create(
name: $arcadiaBrand->name,
code: $code,
arcadiaId: $arcadiaBrand->id,
);
$this->brandRepository->persist($brand);
$this->logger->info(
sprintf(
'Created new brand code "%s" for brand "%s" (Arcadia ID %d)',
$code->value,
$arcadiaBrand->name->value,
$arcadiaBrand->id->value
),
[
'brand_id' => $brand->id()->value,
'code' => $code->value,
'name' => $arcadiaBrand->name->value,
'arcadia_id' => $arcadiaBrand->id->value,
]
);
return $brand;
}
/**
* Resolve or generate/persist a Brand entity given an ArcadiaId.
*
* @throws BrandNotFoundException
*/
public function resolveByArcadiaId(ArcadiaId $arcadiaId): Brand
{
$existingBrand = $this->brandRepository->findByArcadiaId($arcadiaId);
if ($existingBrand !== null) {
return $existingBrand;
}
$arcadiaBrand = $this->arcadiaBrandDirectory->find($arcadiaId);
if ($arcadiaBrand === null) {
throw BrandNotFoundException::forArcadiaId($arcadiaId);
}
return $this->resolve($arcadiaBrand);
}
/**
* Resolve all brands known in Arcadia directory.
*
* @return list<Brand>
*/
public function resolveAll(): array
{
$arcadiaBrands = $this->arcadiaBrandDirectory->findAll();
$brands = [];
foreach ($arcadiaBrands as $arcadiaBrand) {
$brands[] = $this->resolve($arcadiaBrand);
}
return $brands;
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\Entities;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Model\HasTimestampIdentifier;
covers(Brand::class);
describe('Brand Entity', function (): void {
it('creates a brand and implements HasTimestampIdentifier', function (): void {
// GIVEN
$brandCode = new BrandCode('LGL89');
$arcadiaId = new ArcadiaId(123);
$brandName = new BrandName('Arcadia Brand Name');
// WHEN
$brand = Brand::create(
name: $brandName,
code: $brandCode,
arcadiaId: $arcadiaId,
);
// THEN
expect($brand)->toBeInstanceOf(HasTimestampIdentifier::class)
->and($brand->name)->toBe($brandName)
->and($brand->code)->toBe($brandCode)
->and($brand->arcadiaId)->toBe($arcadiaId)
->and($brand->createdAt())->toBeInstanceOf(\DateTimeInterface::class)
->and($brand->createdAt()->getTimestamp())->toBeLessThanOrEqual(time());
});
it('hydrates a brand correctly', function (): void {
// GIVEN
$uuid = Uuid::uuid7();
$brandId = new BrandId($uuid);
$brandCode = new BrandCode('LGL89');
$arcadiaId = new ArcadiaId(123);
$brandName = new BrandName('Arcadia Brand Name');
// WHEN
$brand = Brand::hydrate(
id: $brandId,
name: $brandName,
code: $brandCode,
arcadiaId: $arcadiaId,
);
// THEN
expect($brand)->toBeInstanceOf(HasTimestampIdentifier::class)
->and($brand->id())->toBe($brandId)
->and($brand->name)->toBe($brandName)
->and($brand->code)->toBe($brandCode)
->and($brand->arcadiaId)->toBe($arcadiaId)
->and($brand->createdAt())->toBeInstanceOf(\DateTimeInterface::class)
->and($brand->createdAt()->getTimestamp())->toBeLessThanOrEqual(time());
});
});

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandName;
covers(ArcadiaBrand::class);
describe('ArcadiaBrand Value Object Unit Tests', function (): void {
it('instantiates an ArcadiaBrand VO correctly', function (): void {
// GIVEN
$id = new ArcadiaId(1);
$name = new BrandName('Le Gaulois');
// WHEN
$brand = new ArcadiaBrand(
id: $id,
name: $name,
);
// THEN
expect($brand->id)->toBe($id)
->and($brand->name)->toBe($name);
});
});

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
covers(ArcadiaId::class);
describe('ArcadiaId Value Object Instantiation', function (): void {
it('accepts valid positive arcadia ids', function (int $data): void {
// GIVEN
$data = (int) $data;
// WHEN
$arcadiaId = new ArcadiaId($data);
// THEN
expect($arcadiaId->value)->toBe($data);
})->with([1, 999, 123]);
});
describe('ArcadiaId Value Object Domain Rules', function (): void {
it('rejects less than 1 or more than 999', function (int $invalid): void {
// WHEN
expect(fn () => new ArcadiaId($invalid))
// THEN
->toThrow(\DomainException::class);
// GIVEN
})->with([0, 1000, -1, -999]);
});

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Brand;
use Domain\Models\ValueObjects\Brand\BrandCode;
covers(BrandCode::class);
describe('BrandCode Value Object Instantiation', function (): void {
it('accepts valid positive brand codes', function (): void {
// GIVEN
$value = 'LGL89';
// WHEN
$brandCode = new BrandCode($value);
// THEN
expect($brandCode->value)->toBe($value);
});
});
describe('BrandCode Value Object Domain Rules', function (): void {
it('rejects empty strings', function (string $invalid): void {
// WHEN
expect(fn () => new BrandCode($invalid))
// THEN
->toThrow(\DomainException::class);
// GIVEN
})->with(['', ' ', "\t", "\n"]);
it('rejects numbers only', function (string $invalid): void {
// WHEN
expect(fn () => new BrandCode($invalid))
// THEN
->toThrow(\DomainException::class);
// GIVEN
})->with(['12345', '67890']);
it('rejects letters only', function (string $invalid): void {
// WHEN
expect(fn () => new BrandCode($invalid))
// THEN
->toThrow(\DomainException::class);
// GIVEN
})->with(['ABCDE', 'FGHIJ']);
it('rejects codes with special characters', function (string $invalid): void {
// WHEN
expect(fn () => new BrandCode($invalid))
// THEN
->toThrow(\DomainException::class);
// GIVEN
})->with(['AB@12', 'CD#34', 'EF$56']);
it('rejects codes that have the same digit repeated', function (string $invalid): void {
// WHEN
expect(fn () => new BrandCode($invalid))
// THEN
->toThrow(\DomainException::class);
// GIVEN
})->with(['ABC11', 'DEF22', 'GHI33']);
it('rejects codes with lowercase letters', function (string $invalid): void {
// WHEN
expect(fn () => new BrandCode($invalid))
// THEN
->toThrow(\DomainException::class);
// GIVEN
})->with(['AbC12', 'dEf34', 'GhI56']);
it('rejects codes with more/less than 3 letters or more/less than 2 digits', function (string $invalid): void {
// WHEN
expect(fn () => new BrandCode($invalid))
// THEN
->toThrow(\DomainException::class);
// GIVEN
})->with([
'ABCD12',
'ABC123',
'ABCDE123',
'AB12',
'A1',
'AB1',
'A12',
'AB123',
'A123',
'ABC1',
'AB12C',
]);
});

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Brand;
use Domain\Models\ValueObjects\Brand\BrandId;
use Ramsey\Uuid\Lazy\LazyUuidFromString;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
covers(BrandId::class);
describe('BrandId Value Object Instantiation', function (): void {
it('accepts valid positive brand ids', function (): void {
// GIVEN
$data = Uuid::uuid7();
// WHEN
$brandId = new BrandId($data);
// THEN
expect($brandId->identifier)->toBe($data)
->and($brandId->value)->toBe($data->toString())
->and($brandId->toDateTime())->toBeInstanceOf(\DateTimeInterface::class)
->and($brandId->toDateTime()->getTimestamp())->toBeLessThanOrEqual(time());
});
});
describe('BrandId Value Object Domain Rules', function (): void {
it('rejects invalid uuids or not v7', function (UuidInterface $invalid): void {
// GIVEN
$input = $invalid;
// WHEN & THEN
expect(fn () => new BrandId($input))
->toThrow(\DomainException::class);
})->with([
'UUID v1' => fn () => Uuid::uuid1(),
'UUID v4' => fn () => Uuid::uuid4(),
'UUID v6' => fn () => Uuid::uuid6(),
'Malformed UUID' => fn () => new LazyUuidFromString('invalid-uuid'),
]);
});

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\Brand;
use Domain\Models\ValueObjects\Brand\BrandName;
covers(BrandName::class);
describe('BrandName Value Object Instantiation', function (): void {
it('accepts valid brand names', function (): void {
// GIVEN
$value = 'Le Gaulois';
// WHEN
$brandName = new BrandName($value);
// THEN
expect($brandName->value)->toBe($value);
});
});
describe('BrandName Value Object Validation', function (): void {
it('rejects invalid brand names', function (): void {
// GIVEN
$value = '';
// GIVEN
expect(fn () => new BrandName($value))
// THEN
->toThrow(\DomainException::class);
});
});

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects;
use Domain\Models\ValueObjects\BrandCode;
covers(BrandCode::class);
describe('BrandCode Value Object Instantiation', function (): void {
it('accepts valid positive brand codes', function (int $input): void {
// GIVEN
$value = $input;
// WHEN
$brandCode = new BrandCode($value);
// THEN
expect($brandCode->value)->toBe($value);
})->with([13, 1, 9999]);
});
describe('BrandCode Value Object Domain Rules', function (): void {
it('rejects zero or negative brand codes', function (int $invalid): void {
// GIVEN / WHEN / THEN
expect(fn () => new BrandCode($invalid))
->toThrow(\DomainException::class);
})->with([0, -1, -9999]);
});

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Services;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Services\BrandCodeGenerator;
covers(BrandCodeGenerator::class);
beforeEach(function (): void {
fake()->seed();
});
describe('BrandCodeGenerator Domain Service', function (): void {
it('generates a valid brand code conforming to BrandCode invariants', function (): void {
// GIVEN
$generator = new BrandCodeGenerator();
$brandName = new BrandName(fake()->company());
// WHEN
$code = $generator->generate($brandName);
// THEN
expect($code)->toBeInstanceOf(BrandCode::class)
->and($code->value)->toMatch('/^[A-Z]{3}\d{2}$/')
->and($code->value[3])->not->toBe($code->value[4]);
})->repeat(100);
it('handles short names and names with accents or special characters', function (string $input, string $expectedTrigram): void {
// GIVEN
$generator = new BrandCodeGenerator();
$brandName = new BrandName($input);
// WHEN
$code = $generator->generate($brandName);
// THEN
expect($code)->toBeInstanceOf(BrandCode::class)
->and(str_starts_with($code->value, $expectedTrigram))->toBeTrue();
})->with([
['Le Gaulois', 'LGL'],
['Maître CoQ', 'MCQ'],
['Tradition d\'Asie', 'TDA'],
['Loué', 'LOU'],
['Marie', 'MAR'],
['Réghalal', 'REG'],
['Pierre Martinet', 'PMT'],
['Galina', 'GAL'],
['Matines', 'MAT'],
['Nature & Respect', 'NRP'],
['Poule et Toque', 'PET'],
['Doux', 'DOU'],
['Domaine de Lanvaux', 'DDL'],
['Cailles Robin', 'CRB'],
['Régalette', 'REG'],
['Trégalette', 'TRE'],
['La Toque Angevine', 'LTA'],
['Calibra', 'CAL'],
['Agis', 'AGI'],
['Drosed', 'DRO'],
['Indykpol', 'IND'],
['Goldenfood', 'GOL'],
['Les Grands Domaines Agricoles', 'GDA'],
['Fermiers Bio Français', 'FBF'],
['LU', 'LUX'],
['K', 'KXX'],
]);
});

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Services;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Services\BrandCodeGenerator;
use Domain\Services\BrandResolver;
use Infra\Adapters\Directories\InMemoryArcadiaBrandDirectory;
use Infra\Adapters\Repositories\InMemoryBrandRepository;
use Psr\Log\NullLogger;
use Ramsey\Uuid\Uuid;
covers(BrandResolver::class);
describe('BrandResolver Unit Tests', function (): void {
$arcadiaDirectory = new InMemoryArcadiaBrandDirectory();
$brandRepository = new InMemoryBrandRepository();
$codeGenerator = new BrandCodeGenerator();
$logger = new NullLogger();
beforeEach(function () use ($arcadiaDirectory, $brandRepository): void {
$arcadiaDirectory->clear();
$brandRepository->clear();
});
it('returns existing brand if already present in repository', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$existing = Brand::hydrate(
id: new BrandId(Uuid::uuid7()),
name: new BrandName('Le Gaulois'),
code: new BrandCode('LGL01'),
arcadiaId: new ArcadiaId(1),
);
$brandRepository->add($existing);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$arcadiaBrand = new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois'));
// WHEN
$resolved = $resolver->resolve($arcadiaBrand);
// THEN
expect($resolved)->toBe($existing);
});
it('generates, persists and logs a new brand if not in repository', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$arcadiaBrand = new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ'));
// WHEN
$resolved = $resolver->resolve($arcadiaBrand);
// THEN
expect($resolved->arcadiaId->value)->toBe(2)
->and($resolved->name->value)->toBe('Maître CoQ')
->and(str_starts_with($resolved->code->value, 'MCQ'))->toBeTrue();
expect($brandRepository->findByArcadiaId(new ArcadiaId(2)))->not->toBeNull();
});
it('resolves by ArcadiaId directly', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$arcadiaBrand = new ArcadiaBrand(new ArcadiaId(3), new BrandName('Loué'));
$arcadiaDirectory->add($arcadiaBrand);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
// WHEN
$resolved = $resolver->resolveByArcadiaId(new ArcadiaId(3));
// THEN
expect($resolved->arcadiaId->value)->toBe(3)
->and($resolved->name->value)->toBe('Loué');
});
it('throws BrandNotFoundException when resolving by unknown ArcadiaId', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
// WHEN & THEN
expect(fn () => $resolver->resolveByArcadiaId(new ArcadiaId(999)))
->toThrow(BrandNotFoundException::class, 'Arcadia brand with ID 999 not found.');
});
it('resolves all brands from Arcadia directory', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$arcadiaDirectory->add(
new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois')),
new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ')),
);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
// WHEN
$resolvedList = $resolver->resolveAll();
// THEN
expect($resolvedList)->toHaveCount(2)
->and($brandRepository->findAll())->toHaveCount(2);
});
});