feat(domain): add Brand entity, value objects, ports, and generator & resolver domain services
This commit is contained in:
parent
8cfd5fbe0f
commit
a42a79861d
21 changed files with 996 additions and 51 deletions
28
src/Domain/Exceptions/BrandNotFoundException.php
Normal file
28
src/Domain/Exceptions/BrandNotFoundException.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
68
src/Domain/Models/Entities/Brand.php
Normal file
68
src/Domain/Models/Entities/Brand.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
14
src/Domain/Models/ValueObjects/Brand/ArcadiaBrand.php
Normal file
14
src/Domain/Models/ValueObjects/Brand/ArcadiaBrand.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
18
src/Domain/Models/ValueObjects/Brand/ArcadiaId.php
Normal file
18
src/Domain/Models/ValueObjects/Brand/ArcadiaId.php
Normal 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}$/"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/Domain/Models/ValueObjects/Brand/BrandCode.php
Normal file
26
src/Domain/Models/ValueObjects/Brand/BrandCode.php
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/Domain/Models/ValueObjects/Brand/BrandId.php
Normal file
39
src/Domain/Models/ValueObjects/Brand/BrandId.php
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/Domain/Models/ValueObjects/Brand/BrandName.php
Normal file
16
src/Domain/Models/ValueObjects/Brand/BrandName.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Domain/Ports/Directories/ArcadiaBrandDirectory.php
Normal file
18
src/Domain/Ports/Directories/ArcadiaBrandDirectory.php
Normal 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;
|
||||
}
|
||||
31
src/Domain/Ports/Repositories/BrandRepository.php
Normal file
31
src/Domain/Ports/Repositories/BrandRepository.php
Normal 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;
|
||||
}
|
||||
135
src/Domain/Services/BrandCodeGenerator.php
Normal file
135
src/Domain/Services/BrandCodeGenerator.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
108
src/Domain/Services/BrandResolver.php
Normal file
108
src/Domain/Services/BrandResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
65
tests/Unit/Domain/Models/Entities/BrandTest.php
Normal file
65
tests/Unit/Domain/Models/Entities/BrandTest.php
Normal 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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
106
tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php
Normal file
106
tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
44
tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php
Normal file
44
tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php
Normal 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'),
|
||||
]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
});
|
||||
72
tests/Unit/Domain/Services/BrandCodeGeneratorTest.php
Normal file
72
tests/Unit/Domain/Services/BrandCodeGeneratorTest.php
Normal 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'],
|
||||
]);
|
||||
});
|
||||
110
tests/Unit/Domain/Services/BrandResolverTest.php
Normal file
110
tests/Unit/Domain/Services/BrandResolverTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue