From a42a79861d2b43d9d95c2da450bdbac0b3cbc325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Thu, 20 Aug 2026 17:35:38 +0200 Subject: [PATCH] feat(domain): add Brand entity, value objects, ports, and generator & resolver domain services --- .../Exceptions/BrandNotFoundException.php | 28 ++++ src/Domain/Models/Entities/Brand.php | 68 +++++++++ .../ValueObjects/Brand/ArcadiaBrand.php | 14 ++ .../Models/ValueObjects/Brand/ArcadiaId.php | 18 +++ .../Models/ValueObjects/Brand/BrandCode.php | 26 ++++ .../Models/ValueObjects/Brand/BrandId.php | 39 +++++ .../Models/ValueObjects/Brand/BrandName.php | 16 +++ src/Domain/Models/ValueObjects/BrandCode.php | 21 --- .../Directories/ArcadiaBrandDirectory.php | 18 +++ .../Ports/Repositories/BrandRepository.php | 31 ++++ src/Domain/Services/BrandCodeGenerator.php | 135 ++++++++++++++++++ src/Domain/Services/BrandResolver.php | 108 ++++++++++++++ .../Unit/Domain/Models/Entities/BrandTest.php | 65 +++++++++ .../ValueObjects/Brand/ArcadiaBrandTest.php | 29 ++++ .../ValueObjects/Brand/ArcadiaIdTest.php | 34 +++++ .../ValueObjects/Brand/BrandCodeTest.php | 106 ++++++++++++++ .../Models/ValueObjects/Brand/BrandIdTest.php | 44 ++++++ .../ValueObjects/Brand/BrandNameTest.php | 35 +++++ .../Models/ValueObjects/BrandCodeTest.php | 30 ---- .../Services/BrandCodeGeneratorTest.php | 72 ++++++++++ .../Domain/Services/BrandResolverTest.php | 110 ++++++++++++++ 21 files changed, 996 insertions(+), 51 deletions(-) create mode 100644 src/Domain/Exceptions/BrandNotFoundException.php create mode 100644 src/Domain/Models/Entities/Brand.php create mode 100644 src/Domain/Models/ValueObjects/Brand/ArcadiaBrand.php create mode 100644 src/Domain/Models/ValueObjects/Brand/ArcadiaId.php create mode 100644 src/Domain/Models/ValueObjects/Brand/BrandCode.php create mode 100644 src/Domain/Models/ValueObjects/Brand/BrandId.php create mode 100644 src/Domain/Models/ValueObjects/Brand/BrandName.php delete mode 100644 src/Domain/Models/ValueObjects/BrandCode.php create mode 100644 src/Domain/Ports/Directories/ArcadiaBrandDirectory.php create mode 100644 src/Domain/Ports/Repositories/BrandRepository.php create mode 100644 src/Domain/Services/BrandCodeGenerator.php create mode 100644 src/Domain/Services/BrandResolver.php create mode 100644 tests/Unit/Domain/Models/Entities/BrandTest.php create mode 100644 tests/Unit/Domain/Models/ValueObjects/Brand/ArcadiaBrandTest.php create mode 100644 tests/Unit/Domain/Models/ValueObjects/Brand/ArcadiaIdTest.php create mode 100644 tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php create mode 100644 tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php create mode 100644 tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php delete mode 100644 tests/Unit/Domain/Models/ValueObjects/BrandCodeTest.php create mode 100644 tests/Unit/Domain/Services/BrandCodeGeneratorTest.php create mode 100644 tests/Unit/Domain/Services/BrandResolverTest.php diff --git a/src/Domain/Exceptions/BrandNotFoundException.php b/src/Domain/Exceptions/BrandNotFoundException.php new file mode 100644 index 0000000..a2401e3 --- /dev/null +++ b/src/Domain/Exceptions/BrandNotFoundException.php @@ -0,0 +1,28 @@ +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."); + } +} diff --git a/src/Domain/Models/Entities/Brand.php b/src/Domain/Models/Entities/Brand.php new file mode 100644 index 0000000..4946ebc --- /dev/null +++ b/src/Domain/Models/Entities/Brand.php @@ -0,0 +1,68 @@ + */ +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, + ); + } +} diff --git a/src/Domain/Models/ValueObjects/Brand/ArcadiaBrand.php b/src/Domain/Models/ValueObjects/Brand/ArcadiaBrand.php new file mode 100644 index 0000000..effcfb0 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Brand/ArcadiaBrand.php @@ -0,0 +1,14 @@ + 999) { + throw new \DomainException( + "Invalid arcadia id '{$value}' does not match pattern /^\d{1,3}$/" + ); + } + } +} diff --git a/src/Domain/Models/ValueObjects/Brand/BrandCode.php b/src/Domain/Models/ValueObjects/Brand/BrandCode.php new file mode 100644 index 0000000..3b80219 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Brand/BrandCode.php @@ -0,0 +1,26 @@ +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); + } +} diff --git a/src/Domain/Models/ValueObjects/Brand/BrandName.php b/src/Domain/Models/ValueObjects/Brand/BrandName.php new file mode 100644 index 0000000..3b05544 --- /dev/null +++ b/src/Domain/Models/ValueObjects/Brand/BrandName.php @@ -0,0 +1,16 @@ +value))) { + throw new \DomainException('Brand name cannot be empty.'); + } + } +} diff --git a/src/Domain/Models/ValueObjects/BrandCode.php b/src/Domain/Models/ValueObjects/BrandCode.php deleted file mode 100644 index 3caeb7f..0000000 --- a/src/Domain/Models/ValueObjects/BrandCode.php +++ /dev/null @@ -1,21 +0,0 @@ - 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" - ); - } - } -} diff --git a/src/Domain/Ports/Directories/ArcadiaBrandDirectory.php b/src/Domain/Ports/Directories/ArcadiaBrandDirectory.php new file mode 100644 index 0000000..1965d44 --- /dev/null +++ b/src/Domain/Ports/Directories/ArcadiaBrandDirectory.php @@ -0,0 +1,18 @@ + */ +interface ArcadiaBrandDirectory extends Directory +{ + /** @return list */ + public function findAll(): array; + + public function find(ArcadiaId $id): ?ArcadiaBrand; +} diff --git a/src/Domain/Ports/Repositories/BrandRepository.php b/src/Domain/Ports/Repositories/BrandRepository.php new file mode 100644 index 0000000..c1c3ff2 --- /dev/null +++ b/src/Domain/Ports/Repositories/BrandRepository.php @@ -0,0 +1,31 @@ + */ +interface BrandRepository extends Repository +{ + public function find(BrandId $id): ?Brand; + + public function findByCode(BrandCode $code): ?Brand; + + public function findByArcadiaId(ArcadiaId $arcadiaId): ?Brand; + + /** @return list */ + public function findAll(): array; + + public function persist(Brand $entity): void; + + /** + * @throws \Domain\Exceptions\BrandNotFoundException + */ + public function delete(Brand $entity): void; +} diff --git a/src/Domain/Services/BrandCodeGenerator.php b/src/Domain/Services/BrandCodeGenerator.php new file mode 100644 index 0000000..efed427 --- /dev/null +++ b/src/Domain/Services/BrandCodeGenerator.php @@ -0,0 +1,135 @@ +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 $words + * @return list + */ + 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 */ + private function splitIntoWords(string $value): array + { + /** @var list */ + 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}"; + } +} diff --git a/src/Domain/Services/BrandResolver.php b/src/Domain/Services/BrandResolver.php new file mode 100644 index 0000000..bfd6b18 --- /dev/null +++ b/src/Domain/Services/BrandResolver.php @@ -0,0 +1,108 @@ +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 + */ + public function resolveAll(): array + { + $arcadiaBrands = $this->arcadiaBrandDirectory->findAll(); + $brands = []; + + foreach ($arcadiaBrands as $arcadiaBrand) { + $brands[] = $this->resolve($arcadiaBrand); + } + + return $brands; + } +} diff --git a/tests/Unit/Domain/Models/Entities/BrandTest.php b/tests/Unit/Domain/Models/Entities/BrandTest.php new file mode 100644 index 0000000..28a0900 --- /dev/null +++ b/tests/Unit/Domain/Models/Entities/BrandTest.php @@ -0,0 +1,65 @@ +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()); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/ArcadiaBrandTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/ArcadiaBrandTest.php new file mode 100644 index 0000000..8c2b8a4 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/ArcadiaBrandTest.php @@ -0,0 +1,29 @@ +id)->toBe($id) + ->and($brand->name)->toBe($name); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/ArcadiaIdTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/ArcadiaIdTest.php new file mode 100644 index 0000000..9ddb07b --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/ArcadiaIdTest.php @@ -0,0 +1,34 @@ +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]); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php new file mode 100644 index 0000000..7388357 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php @@ -0,0 +1,106 @@ +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', + ]); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php new file mode 100644 index 0000000..fc8c9ce --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php @@ -0,0 +1,44 @@ +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'), + ]); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php new file mode 100644 index 0000000..7cf9e31 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php @@ -0,0 +1,35 @@ +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); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/BrandCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/BrandCodeTest.php deleted file mode 100644 index ce0e751..0000000 --- a/tests/Unit/Domain/Models/ValueObjects/BrandCodeTest.php +++ /dev/null @@ -1,30 +0,0 @@ -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]); -}); diff --git a/tests/Unit/Domain/Services/BrandCodeGeneratorTest.php b/tests/Unit/Domain/Services/BrandCodeGeneratorTest.php new file mode 100644 index 0000000..749e8ea --- /dev/null +++ b/tests/Unit/Domain/Services/BrandCodeGeneratorTest.php @@ -0,0 +1,72 @@ +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'], + ]); +}); diff --git a/tests/Unit/Domain/Services/BrandResolverTest.php b/tests/Unit/Domain/Services/BrandResolverTest.php new file mode 100644 index 0000000..0f01825 --- /dev/null +++ b/tests/Unit/Domain/Services/BrandResolverTest.php @@ -0,0 +1,110 @@ +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); + }); +});