diff --git a/composer.json b/composer.json index 372673f..6a2ffe7 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,8 @@ "phpunit/phpunit": "^12.5", "ramsey/uuid": "^4.9", "rector/rector": "^2.5", - "slevomat/coding-standard": "^8.22" + "slevomat/coding-standard": "^8.22", + "symfony/polyfill-php86": "^1.41" }, "config": { "optimize-autoloader": true, diff --git a/composer.lock b/composer.lock index b3b9ea1..7f0d66c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "886ef23f3ab9016783ebbad69a6cf3cc", + "content-hash": "1eb92d36919b5658369aa52580c0dc4a", "packages": [], "packages-dev": [ { @@ -5106,6 +5106,86 @@ ], "time": "2026-07-01T12:47:55+00:00" }, + { + "name": "symfony/polyfill-php86", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-02T13:42:24+00:00" + }, { "name": "symfony/process", "version": "v8.1.5", diff --git a/src/Application/.gitkeep b/src/Application/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Application/UseCases/Brand/Create/CreateBrand.php b/src/Application/UseCases/Brand/Create/CreateBrand.php new file mode 100644 index 0000000..9579557 --- /dev/null +++ b/src/Application/UseCases/Brand/Create/CreateBrand.php @@ -0,0 +1,54 @@ + */ +final readonly class CreateBrand implements UseCase +{ + public function __construct( + private BrandRepository $repository, + private PictureStorage $pictureStorage, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + if ($this->repository->existsByCode($input->code)) { + throw new BrandCodeAlreadyExistsException($input->code); + } + + $logoPath = $this->pictureStorage->store($input->logo); + + $brand = Brand::create( + code: $input->code, + name: $input->name, + logo: $logoPath, + rcs: $input->rcs, + contact: $input->contact, + ); + + $this->repository->save($brand); + + return new Output( + name: $brand->name, + code: $brand->code, + logo: $brand->logo, + rcs: $brand->rcs, + contact: $brand->contact, + createdAt: $brand->createdAt(), + ); + } +} diff --git a/src/Application/UseCases/Brand/Create/Input.php b/src/Application/UseCases/Brand/Create/Input.php new file mode 100644 index 0000000..adc51b1 --- /dev/null +++ b/src/Application/UseCases/Brand/Create/Input.php @@ -0,0 +1,24 @@ + */ +final readonly class FindBrand implements UseCase +{ + public function __construct( + private BrandRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $brand = $this->repository->findByCode($input->code); + + if (! $brand instanceof Brand) { + return new BrandNotFound(); + } + + return new BrandFound( + name: $brand->name, + code: $brand->code, + logo: $brand->logo, + rcs: $brand->rcs, + contact: $brand->contact, + createdAt: $brand->createdAt(), + ); + } +} diff --git a/src/Application/UseCases/Brand/Find/Input.php b/src/Application/UseCases/Brand/Find/Input.php new file mode 100644 index 0000000..cc98e3d --- /dev/null +++ b/src/Application/UseCases/Brand/Find/Input.php @@ -0,0 +1,16 @@ + */ +final readonly class ListBrands implements UseCase +{ + public function __construct( + private BrandRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $result = $this->repository->findAll( + pagination: $input->pagination, + sortField: $input->sort?->field, + sortDirection: $input->sort?->direction, + ); + + $items = array_map( + static fn (Brand $brand): BrandListItem => new BrandListItem( + code: $brand->code, + name: $brand->name, + logo: $brand->logo, + ), + $result->getItems(), + ); + + return new Output( + brands: new Paginated( + pagination: $result->getPagination(), + items: $items, + total: $result->getTotal(), + ), + ); + } +} diff --git a/src/Application/UseCases/Brand/List/Output.php b/src/Application/UseCases/Brand/List/Output.php new file mode 100644 index 0000000..4b3873e --- /dev/null +++ b/src/Application/UseCases/Brand/List/Output.php @@ -0,0 +1,17 @@ + $brands */ + public function __construct( + public Paginated $brands, + ) { + } +} diff --git a/src/Application/UseCases/Brand/List/SortCriteria.php b/src/Application/UseCases/Brand/List/SortCriteria.php new file mode 100644 index 0000000..f6724b8 --- /dev/null +++ b/src/Application/UseCases/Brand/List/SortCriteria.php @@ -0,0 +1,16 @@ +logo instanceof PicturePath + || $this->rcs instanceof Rcs + || $this->contact instanceof Email, + 'At least one field must be provided to update the brand.', + ); + } +} diff --git a/src/Application/UseCases/Brand/Update/Output.php b/src/Application/UseCases/Brand/Update/Output.php new file mode 100644 index 0000000..31d4459 --- /dev/null +++ b/src/Application/UseCases/Brand/Update/Output.php @@ -0,0 +1,25 @@ + */ +final readonly class UpdateBrand implements UseCase +{ + public function __construct( + private BrandRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $brand = $this->repository->findByCode($input->code); + + if (! $brand instanceof Brand) { + throw new NotFoundException(); + } + + if ($input->logo instanceof PicturePath) { + $brand = $brand->changeLogo($input->logo); + } + + if ($input->rcs instanceof Rcs) { + $brand = $brand->changeRcs($input->rcs); + } + + if ($input->contact instanceof Email) { + $brand = $brand->changeContact($input->contact); + } + + $this->repository->save($brand); + + return new Output( + name: $brand->name, + code: $brand->code, + logo: $brand->logo, + rcs: $brand->rcs, + contact: $brand->contact, + createdAt: $brand->createdAt(), + ); + } +} diff --git a/src/Domain/Enums/Brand/SortDirection.php b/src/Domain/Enums/Brand/SortDirection.php deleted file mode 100644 index 30c1fa1..0000000 --- a/src/Domain/Enums/Brand/SortDirection.php +++ /dev/null @@ -1,11 +0,0 @@ -value())); + parent::__construct(sprintf('Brand code "%s" already exists.', $code->value)); } } diff --git a/src/Domain/Exceptions/BrandNotFoundException.php b/src/Domain/Exceptions/BrandNotFoundException.php deleted file mode 100644 index be1f8bb..0000000 --- a/src/Domain/Exceptions/BrandNotFoundException.php +++ /dev/null @@ -1,32 +0,0 @@ -value())); - } -} diff --git a/src/Domain/Models/Entities/Brand.php b/src/Domain/Models/Entities/Brand.php index 0d884e6..a942a80 100644 --- a/src/Domain/Models/Entities/Brand.php +++ b/src/Domain/Models/Entities/Brand.php @@ -4,41 +4,40 @@ declare(strict_types=1); namespace Domain\Models\Entities; -use DateTimeInterface; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Brand\BrandId; use Domain\Models\ValueObjects\Brand\BrandName; +use Shared\DDD\Models\AggregateRoot; +use Shared\DDD\Models\HasTimestampIdentifier; use Shared\Models\ValueObjects\Email; use Shared\Models\ValueObjects\PicturePath; -use Shared\Models\ValueObjects\RCS; -use Shared\DDD\Entity; -use Shared\DDD\Equatable; +use Shared\Models\ValueObjects\Rcs; /** - * @implements \Shared\DDD\Entity<\Domain\Models\ValueObjects\Brand\BrandId> + * @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\Brand\BrandId> */ -final readonly class Brand implements Entity, Equatable +final readonly class Brand implements AggregateRoot, HasTimestampIdentifier { /** * @param BrandId $id Universally unique identifier. * @param BrandCode $code Unique identification code. * @param BrandName $name Name of the brand. * @param PicturePath $logo Logo of the brand. - * @param RCS $rcs RCS text of the brand. + * @param Rcs $rcs Rcs text of the brand. * @param Email $contact Contact email of the brand. */ - public function __construct( - private BrandId $id, - private BrandCode $code, - private BrandName $name, - private PicturePath $logo, - private RCS $rcs, - private Email $contact, + private function __construct( + public BrandId $id, + public BrandCode $code, + public BrandName $name, + public PicturePath $logo, + public Rcs $rcs, + public Email $contact, ) { } /** - * Get the value object id of the brand. + * Get the value id of the brand. */ public function id(): BrandId { @@ -46,51 +45,65 @@ final readonly class Brand implements Entity, Equatable } /** - * Get the value object created_at of the brand. + * Get the value object createdAt of the brand. */ - public function createdAt(): DateTimeInterface + public function createdAt(): \DateTimeInterface { return $this->id->toDateTime(); } /** - * Get the value object code of the brand. + * Create a new brand. + * + * @param BrandCode $code Unique identification code. + * @param BrandName $name Name of the brand. + * @param PicturePath $logo Logo of the brand. + * @param Rcs $rcs Rcs text of the brand. + * @param Email $contact Contact email of the brand. */ - public function code(): BrandCode - { - return $this->code; + public static function create( + BrandCode $code, + BrandName $name, + PicturePath $logo, + Rcs $rcs, + Email $contact, + ): Brand { + return new self( + id: BrandId::generate(), + code: $code, + name: $name, + logo: $logo, + rcs: $rcs, + contact: $contact, + ); } /** - * Get the name of the brand. + * Create a new brand. + * + * @param BrandId $id Universally unique identifier. + * @param BrandCode $code Unique identification code. + * @param BrandName $name Name of the brand. + * @param PicturePath $logo Logo of the brand. + * @param Rcs $rcs Rcs text of the brand. + * @param Email $contact Contact email of the brand. */ - public function name(): BrandName - { - return $this->name; - } - - /** - * Get the logo of the brand. - */ - public function logo(): PicturePath - { - return $this->logo; - } - - /** - * Get the RCS of the brand. - */ - public function rcs(): RCS - { - return $this->rcs; - } - - /** - * Get the contact of the brand. - */ - public function contact(): Email - { - return $this->contact; + public static function hydrate( + BrandId $id, + BrandCode $code, + BrandName $name, + PicturePath $logo, + Rcs $rcs, + Email $contact, + ): self { + return new self( + id: $id, + code: $code, + name: $name, + logo: $logo, + rcs: $rcs, + contact: $contact, + ); } /** @@ -100,17 +113,31 @@ final readonly class Brand implements Entity, Equatable */ public function changeLogo(PicturePath $logo): self { - return new self($this->id, $this->code, $this->name, $logo, $this->rcs, $this->contact); + return new self( + id: $this->id, + code: $this->code, + name: $this->name, + logo: $logo, + rcs: $this->rcs, + contact: $this->contact, + ); } /** - * Set the RCS of the brand. + * Set the Rcs of the brand. * - * @param RCS $rcs RCS text in the brand to set. + * @param Rcs $rcs Rcs text in the brand to set. */ - public function changeRcs(RCS $rcs): self + public function changeRcs(Rcs $rcs): self { - return new self($this->id, $this->code, $this->name, $this->logo, $rcs, $this->contact); + return new self( + id: $this->id, + code: $this->code, + name: $this->name, + logo: $this->logo, + rcs: $rcs, + contact: $this->contact, + ); } /** @@ -120,20 +147,13 @@ final readonly class Brand implements Entity, Equatable */ public function changeContact(Email $contact): self { - return new self($this->id, $this->code, $this->name, $this->logo, $this->rcs, $contact); - } - - /** - * Compare the universally unique identifier of the brand. - * - * @param mixed $other Brand code to compare with. - */ - public function equals(mixed $other): bool - { - if (! $other instanceof self) { - return false; - } - - return $this->id->equals($other->id); + return new self( + id: $this->id, + code: $this->code, + name: $this->name, + logo: $this->logo, + rcs: $this->rcs, + contact: $contact, + ); } } diff --git a/src/Domain/Models/ValueObjects/Brand/BrandCode.php b/src/Domain/Models/ValueObjects/Brand/BrandCode.php index 7a148be..125e345 100644 --- a/src/Domain/Models/ValueObjects/Brand/BrandCode.php +++ b/src/Domain/Models/ValueObjects/Brand/BrandCode.php @@ -4,48 +4,26 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects\Brand; -use DomainException; -use Shared\DDD\Equatable; - -final readonly class BrandCode implements Equatable +final readonly class BrandCode { + private const string REGEX = '/^[A-Z]{3}\d{2}$/'; + /** * @param string $value Unique identification code. */ public function __construct( - private string $value, + public 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 (! preg_match(self::REGEX, $value)) { + throw new \DomainException( + "Invalid code '{$value}' does not match pattern " . self::REGEX, ); } + if ($value[3] === $value[4]) { - throw new DomainException( - "Invalid code '{$value}' has the same digit repeated" + throw new \DomainException( + "Invalid code '{$value}' has the same digit repeated", ); } } - - /** - * Get the value of the unique identification code. - */ - public function value(): string - { - return $this->value; - } - - /** - * Compare the unique identification code of the brand. - * - * @param mixed $other Unique identification code to compare with. - */ - public function equals(mixed $other): bool - { - if (! $other instanceof self) { - return false; - } - - return $this->value === $other->value; - } } diff --git a/src/Domain/Models/ValueObjects/Brand/BrandId.php b/src/Domain/Models/ValueObjects/Brand/BrandId.php index c0fb89e..fc53679 100644 --- a/src/Domain/Models/ValueObjects/Brand/BrandId.php +++ b/src/Domain/Models/ValueObjects/Brand/BrandId.php @@ -4,12 +4,9 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects\Brand; -use DateTimeInterface; -use DomainException; -use InvalidArgumentException; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; -use Shared\DDD\EntityId; +use Shared\DDD\Models\EntityId; use Shared\Helpers\UuidHelper; final readonly class BrandId extends EntityId @@ -18,14 +15,14 @@ final readonly class BrandId extends EntityId * @param UuidInterface $identifier Universally unique identifier. */ public function __construct( - private UuidInterface $identifier + public UuidInterface $identifier ) { parent::__construct($identifier->toString()); try { UuidHelper::validateUuid($identifier); - } catch (InvalidArgumentException $e) { - throw new DomainException( + } catch (\InvalidArgumentException $e) { + throw new \DomainException( "Invalid value '{$identifier->toString()}' is not a valid UUID", 0, $e @@ -33,8 +30,8 @@ final readonly class BrandId extends EntityId } if (! UuidHelper::isV7($identifier)) { - throw new DomainException( - "Invalid value '{$identifier}' is not a valid V7 UUID" + throw new \DomainException( + "Invalid value '{$identifier}' is not a valid V7 UUID", ); } } @@ -42,7 +39,7 @@ final readonly class BrandId extends EntityId /** * Get the date time from the universally unique identifier. */ - public function toDateTime(): DateTimeInterface + 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 index 9210e29..b15371a 100644 --- a/src/Domain/Models/ValueObjects/Brand/BrandName.php +++ b/src/Domain/Models/ValueObjects/Brand/BrandName.php @@ -4,46 +4,22 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects\Brand; -use DomainException; -use Shared\DDD\Equatable; - -final readonly class BrandName implements Equatable +final readonly class BrandName { /** * @param string $value Name. */ public function __construct( - private string $value, + public string $value, ) { $trimmed = trim($value); if ($trimmed === '') { - throw new DomainException('Brand name cannot be empty.'); - } - if (mb_strlen($trimmed) > 2048) { - throw new DomainException('Brand name exceeds the maximum allowed length.'); - } - } - - /** - * Get the value of the name. - */ - public function value(): string - { - return $this->value; - } - - /** - * Compare the name of the brand. - * - * @param mixed $other Unique identification name to compare with. - */ - public function equals(mixed $other): bool - { - if (! $other instanceof self) { - return false; + throw new \DomainException('Brand name cannot be empty.'); } - return $this->value === $other->value; + if (mb_strlen($trimmed) > 200) { + throw new \DomainException('Brand name exceeds the maximum allowed length.'); + } } } diff --git a/src/Domain/Ports/Repositories/BrandRepository.php b/src/Domain/Ports/Repositories/BrandRepository.php index 6945385..c58882c 100644 --- a/src/Domain/Ports/Repositories/BrandRepository.php +++ b/src/Domain/Ports/Repositories/BrandRepository.php @@ -4,15 +4,16 @@ declare(strict_types=1); namespace Domain\Ports\Repositories; -use Domain\Enums\Brand\SortDirection; use Domain\Enums\Brand\SortField; use Domain\Models\Entities\Brand; use Domain\Models\ValueObjects\Brand\BrandCode; -use Domain\Models\ValueObjects\Brand\BrandId; -use Shared\DDD\Repository; +use Shared\DDD\Ports\Repository; use Shared\Helpers\Paginated; use Shared\Helpers\Pagination; +/** + * @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\Brand\BrandId, \Domain\Models\Entities\Brand> + */ interface BrandRepository extends Repository { /** @@ -22,13 +23,6 @@ interface BrandRepository extends Repository */ public function existsByCode(BrandCode $code): bool; - /** - * Find a brand from his universally unique identifier. - * - * @param BrandId $id Universally unique identifier. - */ - public function findById(BrandId $id): ?Brand; - /** * Find a brand from his universally unique identifier. * @@ -39,15 +33,15 @@ interface BrandRepository extends Repository /** * Get all brands sorted and paginated. * - * @param Pagination $pagination Pagination range to retrieve. - * @param SortField $sortField Target field to sort the list. - * @param SortDirection $sortDirection Target direction to sort the list. + * @param Pagination $pagination Pagination range to retrieve. + * @param SortField|null $sortField Target field to sort the list. + * @param \SortDirection|null $sortDirection Target direction to sort the list. * @return Paginated */ public function findAll( Pagination $pagination, - SortField $sortField = SortField::NAME, - SortDirection $sortDirection = SortDirection::ASC, + ?SortField $sortField = null, + ?\SortDirection $sortDirection = null, ): Paginated; /** diff --git a/src/Domain/Ports/Services/PictureStorage.php b/src/Domain/Ports/Services/PictureStorage.php new file mode 100644 index 0000000..8df10b3 --- /dev/null +++ b/src/Domain/Ports/Services/PictureStorage.php @@ -0,0 +1,16 @@ +repository->existsByCode($code)) { - throw new BrandCodeAlreadyExistsException($code); - } - - $brand = new Brand(BrandId::generate(), $code, $name, $logo, $rcs, $contact); - - $this->repository->save($brand); - - return $brand; - } - - /** - * Get a brand by its identifier. - * - * @param BrandId $id Identifier of the brand to retrieve. - */ - public function getById(BrandId $id): Brand - { - $brand = $this->repository->findById($id); - - if (! $brand instanceof Brand) { - throw BrandNotFoundException::fromId($id); - } - - return $brand; - } - - /** - * Get a brand by its unique code. - * - * @param BrandCode $code Unique code of the brand to retrieve. - */ - public function getByCode(BrandCode $code): Brand - { - $brand = $this->repository->findByCode($code); - - if (! $brand instanceof Brand) { - throw BrandNotFoundException::fromCode($code); - } - - return $brand; - } - - /** - * @return Paginated - */ - public function getAll( - Pagination $pagination, - SortField $sortField = SortField::NAME, - SortDirection $sortDirection = SortDirection::ASC, - ): Paginated { - return $this->repository->findAll( - $pagination, - $sortField, - $sortDirection, - ); - } - - /** - * Update the logo of a brand. - * - * @param BrandId $id Identifier of the brand to update. - * @param PicturePath $newLogo New logo to set. - */ - public function updateLogo(BrandId $id, PicturePath $newLogo): Brand - { - $updated = $this->getById($id)->changeLogo($newLogo); - - $this->repository->save($updated); - - return $updated; - } - - /** - * Update the RCS text of a brand. - * - * @param BrandId $id Identifier of the brand to update. - * @param RCS $newRcs New RCS text to set. - */ - public function updateRcs(BrandId $id, RCS $newRcs): Brand - { - $updated = $this->getById($id)->changeRcs($newRcs); - - $this->repository->save($updated); - - return $updated; - } - - /** - * Update the contact email of a brand. - * - * @param BrandId $id Identifier of the brand to update. - * @param Email $newContact New contact email to set. - */ - public function updateContact(BrandId $id, Email $newContact): Brand - { - $updated = $this->getById($id)->changeContact($newContact); - - $this->repository->save($updated); - - return $updated; - } -} diff --git a/src/Shared/DDD/Exceptions/InvalidUuidException.php b/src/Shared/DDD/Exceptions/InvalidUuidException.php new file mode 100644 index 0000000..e39d348 --- /dev/null +++ b/src/Shared/DDD/Exceptions/InvalidUuidException.php @@ -0,0 +1,13 @@ +from, + $this->to, + ); + } + /** * Get the from value of the pagination. */ @@ -109,16 +119,4 @@ final readonly class Pagination implements Stringable { return self::ofPage(1, $pageSize); } - - /** - * Get the string representation of the pagination range. - */ - public function __toString(): string - { - return sprintf( - 'Pagination{from=%d, to=%d}', - $this->from, - $this->to, - ); - } } diff --git a/src/Shared/Helpers/UuidHelper.php b/src/Shared/Helpers/UuidHelper.php index 6d44061..a527f28 100644 --- a/src/Shared/Helpers/UuidHelper.php +++ b/src/Shared/Helpers/UuidHelper.php @@ -4,12 +4,10 @@ declare(strict_types=1); namespace Shared\Helpers; -use DateTimeImmutable; -use DateTimeZone; use Ramsey\Uuid\Rfc4122\FieldsInterface; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; -use Shared\Exceptions\InvalidUuidException; +use Shared\DDD\Exceptions\InvalidUuidException; final readonly class UuidHelper { @@ -51,14 +49,14 @@ final readonly class UuidHelper public static function validateUuid(UuidInterface $uuid): void { if (! Uuid::isValid($uuid->toString())) { - throw InvalidUuidException::forValue($uuid->toString()); + throw new InvalidUuidException($uuid->toString()); } } /** * Get date time from uuid. */ - public static function toDateTime(UuidInterface $uuid): DateTimeImmutable + public static function toDateTime(UuidInterface $uuid): \DateTimeImmutable { if (! self::isV7($uuid)) { throw new InvalidUuidException("Cannot extract datetime from non-V7 UUID: {$uuid->toString()}"); @@ -77,12 +75,12 @@ final readonly class UuidHelper $sec = intdiv($timeMs, 1000); $ms = $timeMs % 1000; - $dateTime = DateTimeImmutable::createFromFormat('U.u', sprintf('%d.%06d', $sec, $ms * 1000)); + $dateTime = \DateTimeImmutable::createFromFormat('U.u', sprintf('%d.%06d', $sec, $ms * 1000)); if ($dateTime === false) { throw new InvalidUuidException("Failed to extract datetime from UUID: {$uuid->toString()}"); } - return $dateTime->setTimezone(new DateTimeZone('UTC')); + return $dateTime->setTimezone(new \DateTimeZone('UTC')); } } diff --git a/src/Shared/Models/ValueObjects/Email.php b/src/Shared/Models/ValueObjects/Email.php index 358ee35..c3bdde0 100644 --- a/src/Shared/Models/ValueObjects/Email.php +++ b/src/Shared/Models/ValueObjects/Email.php @@ -4,19 +4,16 @@ declare(strict_types=1); namespace Shared\Models\ValueObjects; -use DomainException; -use Shared\DDD\Equatable; - /** * Represents a valid email. */ -final readonly class Email implements Equatable +final readonly class Email { - private string $value; + public string $value; /** * @param string $value A valid email address. - * @throws DomainException + * @throws \DomainException */ public function __construct( string $value, @@ -24,37 +21,17 @@ final readonly class Email implements Equatable $trimmed = trim($value); if ($trimmed === '') { - throw new DomainException('Email cannot be empty.'); + throw new \DomainException('Email cannot be empty.'); } - if (mb_strlen($trimmed) > 2048) { - throw new DomainException('Email exceeds the maximum allowed length.'); + + if (mb_strlen($trimmed) > 200) { + throw new \DomainException('Email exceeds the maximum allowed length.'); } + if (filter_var($trimmed, FILTER_VALIDATE_EMAIL) === false) { - throw new DomainException(sprintf('"%s" is not a valid email.', $value)); + throw new \DomainException(sprintf('"%s" is not a valid email.', $value)); } $this->value = mb_strtolower($trimmed, 'UTF-8'); } - - /** - * Get the email address value. - */ - public function value(): string - { - return $this->value; - } - - /** - * Compare two email addresses for equality. - * - * @param mixed $other Email to compare with. - */ - public function equals(mixed $other): bool - { - if (! $other instanceof self) { - return false; - } - - return $this->value === $other->value; - } } diff --git a/src/Shared/Models/ValueObjects/PicturePath.php b/src/Shared/Models/ValueObjects/PicturePath.php index 173eca8..1a31cf7 100644 --- a/src/Shared/Models/ValueObjects/PicturePath.php +++ b/src/Shared/Models/ValueObjects/PicturePath.php @@ -4,56 +4,53 @@ declare(strict_types=1); namespace Shared\Models\ValueObjects; -use DomainException; -use Shared\DDD\Equatable; - /** - * Represents the path to an image file. + * Represents the path to an already stored image file. */ -final readonly class PicturePath implements Equatable +final readonly class PicturePath { public const array ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp']; + private const string REGEX = '#^(?:[a-zA-Z]:[\\\\/]|[a-zA-Z][a-zA-Z0-9+.\-]*://)#'; + /** * @param string $value path of picture - * @throws DomainException + * @throws \DomainException */ public function __construct( - private string $value, + public string $value, ) { $trimmed = trim($this->value); if ($trimmed === '') { - throw new DomainException('Picture Path cannot be empty.'); + throw new \DomainException('Picture Path cannot be empty.'); } - if (mb_strlen($trimmed) > 2048) { - throw new DomainException('Picture Path exceeds the maximum allowed length.'); + + if (mb_strlen($trimmed) > 200) { + throw new \DomainException('Picture Path exceeds the maximum allowed length.'); } + + if (str_contains($trimmed, "\0")) { + throw new \DomainException('Picture Path contains invalid null byte.'); + } + + if (str_starts_with($trimmed, '//')) { + throw new \DomainException('Picture Path must not start with "//".'); + } + + if (preg_match(self::REGEX, $trimmed)) { + throw new \DomainException('Picture Path must not be an absolute drive path or a URI.'); + } + + $normalized = str_replace('\\', '/', $trimmed); + $segments = explode('/', $normalized); + if (in_array('..', $segments, true)) { + throw new \DomainException('Picture Path must not contain relative traversal segments ("..").'); + } + $extension = strtolower(pathinfo($trimmed, PATHINFO_EXTENSION)); if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) { - throw new DomainException('Picture Path must end with one of: ' . implode(', ', self::ALLOWED_EXTENSIONS)); + throw new \DomainException('Picture Path must end with one of: ' . implode(', ', self::ALLOWED_EXTENSIONS)); } } - - /** - * Get the path name value. - */ - public function value(): string - { - return $this->value; - } - - /** - * Compare two path addresses for equality. - * - * @param mixed $other Picture path to compare with. - */ - public function equals(mixed $other): bool - { - if (! $other instanceof self) { - return false; - } - - return $this->value === $other->value; - } } diff --git a/src/Shared/Models/ValueObjects/RCS.php b/src/Shared/Models/ValueObjects/RCS.php deleted file mode 100644 index 28df707..0000000 --- a/src/Shared/Models/ValueObjects/RCS.php +++ /dev/null @@ -1,56 +0,0 @@ -value); - - if ($trimmed === '') { - throw new DomainException('RCS text cannot be empty.'); - } - if (mb_strlen($trimmed) > 2048) { - throw new DomainException('RCS text exceeds the maximum allowed length.'); - } - if (! preg_match('/^RCS\s+[A-ZÀ-ÖØ-Ý][A-ZÀ-ÖØ-Ý\s-]*\s+\d{3}\s\d{3}\s\d{3}$/u', $trimmed)) { - throw new DomainException('RCS is not a valid RCS text.'); - } - } - - /** - * Get the RCS value. - */ - public function value(): string - { - return $this->value; - } - - /** - * Compare two RCS for equality. - * - * @param mixed $other Email to compare with. - */ - public function equals(mixed $other): bool - { - if (! $other instanceof self) { - return false; - } - - return $this->value === $other->value; - } -} diff --git a/src/Shared/Models/ValueObjects/RawPicture.php b/src/Shared/Models/ValueObjects/RawPicture.php new file mode 100644 index 0000000..923464b --- /dev/null +++ b/src/Shared/Models/ValueObjects/RawPicture.php @@ -0,0 +1,44 @@ +content === '') { + throw new \DomainException('Raw picture content cannot be empty.'); + } + + if (strlen($this->content) > self::MAX_SIZE_IN_BYTES) { + throw new \DomainException('Raw picture exceeds the maximum allowed size.'); + } + + $trimmedName = trim($this->originalName); + + if ($trimmedName === '') { + throw new \DomainException('Raw picture original name cannot be empty.'); + } + + $extension = strtolower(pathinfo($trimmedName, PATHINFO_EXTENSION)); + if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) { + throw new \DomainException('Raw picture must have one of these extensions: ' . implode(', ', self::ALLOWED_EXTENSIONS)); + } + } +} diff --git a/src/Shared/Models/ValueObjects/Rcs.php b/src/Shared/Models/ValueObjects/Rcs.php new file mode 100644 index 0000000..0f5604f --- /dev/null +++ b/src/Shared/Models/ValueObjects/Rcs.php @@ -0,0 +1,32 @@ +value); + + if ($trimmed === '') { + throw new \DomainException('Rcs text cannot be empty.'); + } + + if (! preg_match(self::REGEX, $trimmed)) { + throw new \DomainException('Rcs is not a valid Rcs text.'); + } + } +} diff --git a/tests/Architecture/DomainTest.php b/tests/Architecture/DomainTest.php index 2a2c44a..3172a19 100644 --- a/tests/Architecture/DomainTest.php +++ b/tests/Architecture/DomainTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Shared\DDD\Entity; -use Shared\DDD\Repository; +use Shared\DDD\Models\Entity; +use Shared\DDD\Ports\Repository; describe('domain coding rules', function (): void { arch('entities implements Entity') diff --git a/tests/Architecture/SharedTest.php b/tests/Architecture/SharedTest.php index 978650a..9108b5c 100644 --- a/tests/Architecture/SharedTest.php +++ b/tests/Architecture/SharedTest.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Shared\DDD\Entity; -use Shared\DDD\Repository; +use Shared\DDD\Models\Entity; +use Shared\DDD\Ports\Repository; describe('shared coding rules', function (): void { arch('entities implements Entity') @@ -16,7 +16,7 @@ describe('shared coding rules', function (): void { ->toBeFinal(); arch('valueObjects are immutable (disabled due to mock-final-classes conflict)') - ->expect('Shared\\ValueObjects') + ->expect('Shared\\Models\\ValueObjects') ->toBeReadonly() ->toBeFinal(); diff --git a/tests/Unit/Application/UseCases/Brand/Create/CreateBrandTest.php b/tests/Unit/Application/UseCases/Brand/Create/CreateBrandTest.php new file mode 100644 index 0000000..6d4fdcd --- /dev/null +++ b/tests/Unit/Application/UseCases/Brand/Create/CreateBrandTest.php @@ -0,0 +1,96 @@ +shouldReceive('store')->once()->with($rawPicture)->andReturn($logoPath); + + $repository = \Mockery::mock(BrandRepository::class); + $repository->shouldReceive('existsByCode')->once()->with($input->code)->andReturn(false); + $repository->shouldReceive('save')->once()->with(\Mockery::type(Brand::class)); + + $useCase = new CreateBrand($repository, $pictureStorage); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->code)->toBeInstanceOf(BrandCode::class); + }); + + it('throws when the brand code already exists', function (): void { + // GIVEN + $input = new Input( + code: new BrandCode('AAA01'), + name: new BrandName('Name'), + logo: new RawPicture(content: 'binary-content', originalName: 'picture.jpg'), + rcs: new Rcs('RCS PARIS 111 222 333'), + contact: new Email('contact@example.com'), + ); + + $pictureStorage = \Mockery::mock(PictureStorage::class); + $pictureStorage->shouldReceive('store'); + + $repository = \Mockery::mock(BrandRepository::class); + $repository->shouldReceive('existsByCode')->once()->with($input->code)->andReturn(true); + $repository->shouldNotReceive('save'); + + $useCase = new CreateBrand($repository, $pictureStorage); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(BrandCodeAlreadyExistsException::class); + }); + + it('rejects an input of the wrong type', function (): void { + // GIVEN + $repository = \Mockery::mock(BrandRepository::class); + $pictureStorage = \Mockery::mock(PictureStorage::class); + $useCase = new CreateBrand($repository, $pictureStorage); + $invalidInput = new class () implements BaseInput + { + }; + + // WHEN + $action = fn (): Output => $useCase->execute($invalidInput); + + // THEN + expect($action)->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/tests/Unit/Application/UseCases/Brand/Find/FindBrandTest.php b/tests/Unit/Application/UseCases/Brand/Find/FindBrandTest.php new file mode 100644 index 0000000..6d55ed8 --- /dev/null +++ b/tests/Unit/Application/UseCases/Brand/Find/FindBrandTest.php @@ -0,0 +1,82 @@ +shouldReceive('findByCode')->once()->with($code)->andReturn($brand); + + $useCase = new FindBrand($repository); + + // WHEN + $result = $useCase->execute(new Input(code: $code)); + + // THEN + expect($result)->toBeInstanceOf(BrandFound::class) + ->and($result->code)->toBe($brand->code) + ->and($result->rcs)->toBe($brand->rcs) + ->and($result->contact)->toBe($brand->contact); + }); + + it('throws when the code is not found', function (): void { + // GIVEN + $code = new BrandCode('AAA01'); + + $repository = \Mockery::mock(BrandRepository::class); + $repository->shouldReceive('findByCode')->once()->with($code)->andReturn(null); + + $useCase = new FindBrand($repository); + + // WHEN + $result = $useCase->execute(new Input(code: $code)); + + // WHEN / THEN + expect($result)->toBeInstanceOf(BrandNotFound::class); + }); + + it('rejects an input of the wrong type', function (): void { + // GIVEN + $repository = \Mockery::mock(BrandRepository::class); + $useCase = new FindBrand($repository); + $invalidInput = new class () implements BaseInput + { + }; + + // WHEN + $action = fn (): Output => $useCase->execute($invalidInput); + + // THEN + expect($action)->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/tests/Unit/Application/UseCases/Brand/List/ListBrandsTest.php b/tests/Unit/Application/UseCases/Brand/List/ListBrandsTest.php new file mode 100644 index 0000000..dd31702 --- /dev/null +++ b/tests/Unit/Application/UseCases/Brand/List/ListBrandsTest.php @@ -0,0 +1,112 @@ +shouldReceive('findAll') + ->once() + ->with($pagination, SortField::CreatedAt, \SortDirection::Descending) + ->andReturn($paginated); + + $useCase = new ListBrands($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class); + + $items = $result->brands->getItems(); + expect($items)->toHaveCount(2) + ->and($items[0])->toBeInstanceOf(BrandListItem::class) + ->and($items[0]->name)->toBe($brand1->name) + ->and($items[0]->code)->toBe($brand1->code) + ->and($items[0]->logo)->toBe($brand1->logo) + ->and($result->brands->getTotal())->toBe(2) + ->and($result->brands->getPagination())->toBe($pagination); + }); + + it('returns an empty list without error when there are no brands', function (): void { + // GIVEN + $pagination = Pagination::firstPage(25); + $paginated = new Paginated($pagination, [], 0); + $input = new Input(pagination: $pagination); + + $repository = \Mockery::mock(BrandRepository::class); + $repository->shouldReceive('findAll') + ->once() + ->with($pagination, null, null) + ->andReturn($paginated); + + $useCase = new ListBrands($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result->brands->getItems())->toBe([]) + ->and($result->brands->getTotal())->toBe(0); + }); + + it('rejects an input of the wrong type', function (): void { + // GIVEN + $repository = \Mockery::mock(BrandRepository::class); + $useCase = new ListBrands($repository); + $invalidInput = new class () implements BaseInput + { + }; + + // WHEN + $action = fn (): Output => $useCase->execute($invalidInput); + + // THEN + expect($action)->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php b/tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php new file mode 100644 index 0000000..7508ae6 --- /dev/null +++ b/tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php @@ -0,0 +1,118 @@ + Brand::hydrate( + BrandId::generate(), + $code, + new BrandName('Name'), + new PicturePath('files/picture-old.jpg'), + new Rcs('RCS PARIS 111 222 333'), + new Email('contact-old@example.com'), +); + +describe('update brand use case', function () use ($existingBrand): void { + it('updates every provided field', function () use ($existingBrand): void { + // GIVEN + $code = new BrandCode('AAA01'); + $brand = $existingBrand($code); + $newLogo = new PicturePath('files/picture-new.jpg'); + $newRcs = new Rcs('RCS NANTES 444 555 666'); + $newContact = new Email('contact-new@example.com'); + $input = new Input(code: $brand->code, logo: $newLogo, rcs: $newRcs, contact: $newContact); + + $repository = \Mockery::mock(BrandRepository::class); + $repository->shouldReceive('findByCode')->once()->with($code)->andReturn($brand); + $repository->shouldReceive('save')->once()->with(\Mockery::on( + fn (Brand $saved): bool => $saved->logo === $newLogo + && $saved->rcs === $newRcs + && $saved->contact === $newContact + && $saved->code === $brand->code + && $saved->name === $brand->name, + )); + + $useCase = new UpdateBrand($repository); + + // WHEN + $result = $useCase->execute($input); + + // THEN + expect($result)->toBeInstanceOf(Output::class) + ->and($result->code)->toBe($brand->code); + }); + + it('updates only the contact and leaves logo/rcs untouched', function () use ($existingBrand): void { + // GIVEN + $code = new BrandCode('AAA01'); + $brand = $existingBrand($code); + $newContact = new Email('contact-new@example.com'); + $input = new Input(code: $brand->code, contact: $newContact); + + $repository = \Mockery::mock(BrandRepository::class); + $repository->shouldReceive('findByCode')->once()->with($code)->andReturn($brand); + $repository->shouldReceive('save')->once()->with(\Mockery::on( + fn (Brand $saved): bool => $saved->contact === $newContact + && $saved->logo === $brand->logo + && $saved->rcs === $brand->rcs + && $saved->code === $brand->code + && $saved->name === $brand->name, + )); + + $useCase = new UpdateBrand($repository); + + // WHEN + $useCase->execute($input); + + // THEN handled via the save() expectation above + expect(true)->toBeTrue(); + }); + + it('throws when the brand does not exist', function (): void { + // GIVEN + $code = new BrandCode('AAA01'); + $input = new Input(code: $code, contact: new Email('contact-new@example.com')); + + $repository = \Mockery::mock(BrandRepository::class); + $repository->shouldReceive('findByCode')->once()->with($code)->andReturn(null); + $repository->shouldNotReceive('save'); + + $useCase = new UpdateBrand($repository); + + // WHEN / THEN + expect(fn (): Output => $useCase->execute($input)) + ->toThrow(NotFoundException::class); + }); + + it('rejects an input of the wrong type', function (): void { + // GIVEN + $repository = \Mockery::mock(BrandRepository::class); + $useCase = new UpdateBrand($repository); + $invalidInput = new class () implements BaseInput + { + }; + + // WHEN + $action = fn (): Output => $useCase->execute($invalidInput); + + // THEN + expect($action)->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/tests/Unit/Domain/Exceptions/BrandCodeAlreadyExistsExceptionTest.php b/tests/Unit/Domain/Exceptions/BrandCodeAlreadyExistsExceptionTest.php index f6c6874..9ead53c 100644 --- a/tests/Unit/Domain/Exceptions/BrandCodeAlreadyExistsExceptionTest.php +++ b/tests/Unit/Domain/Exceptions/BrandCodeAlreadyExistsExceptionTest.php @@ -6,7 +6,6 @@ namespace Tests\Unit\Domain\Exceptions; use Domain\Exceptions\BrandCodeAlreadyExistsException; use Domain\Models\ValueObjects\Brand\BrandCode; -use DomainException; covers(BrandCodeAlreadyExistsException::class); @@ -20,6 +19,6 @@ describe('brand code already exists exception', function (): void { // THEN expect($exception->getMessage())->toBe('Brand code "AAA01" already exists.') - ->and($exception)->toBeInstanceOf(DomainException::class); + ->and($exception)->toBeInstanceOf(\DomainException::class); }); }); diff --git a/tests/Unit/Domain/Exceptions/BrandNotFoundExceptionTest.php b/tests/Unit/Domain/Exceptions/BrandNotFoundExceptionTest.php deleted file mode 100644 index 684ca91..0000000 --- a/tests/Unit/Domain/Exceptions/BrandNotFoundExceptionTest.php +++ /dev/null @@ -1,38 +0,0 @@ -getMessage())->toBe('Brand with id "' . $id . '" was not found.') - ->and($exception)->toBeInstanceOf(DomainException::class); - }); - - it('get the correct message from code', function (): void { - // GIVEN - $code = new BrandCode('AAA01'); - - // WHEN - $exception = BrandNotFoundException::fromCode($code); - - // THEN - expect($exception->getMessage())->toBe('Brand with code "AAA01" was not found.') - ->and($exception)->toBeInstanceOf(DomainException::class); - }); -}); diff --git a/tests/Unit/Domain/Models/Entities/BrandTest.php b/tests/Unit/Domain/Models/Entities/BrandTest.php index 61b5cc0..b88ad16 100644 --- a/tests/Unit/Domain/Models/Entities/BrandTest.php +++ b/tests/Unit/Domain/Models/Entities/BrandTest.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\Entities; -use DateTimeImmutable; -use DateTimeZone; use Domain\Models\Entities\Brand; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Brand\BrandId; @@ -13,8 +11,7 @@ use Domain\Models\ValueObjects\Brand\BrandName; use Ramsey\Uuid\Uuid; use Shared\Models\ValueObjects\Email; use Shared\Models\ValueObjects\PicturePath; -use Shared\Models\ValueObjects\RCS; -use stdClass; +use Shared\Models\ValueObjects\Rcs; covers(Brand::class); @@ -25,19 +22,55 @@ describe('brand', function (): void { $code = new BrandCode('AAA01'); $name = new BrandName('Name'); $logo = new PicturePath('files/picture.jpg'); - $rcs = new RCS('RCS PARIS 111 222 333'); + $rcs = new Rcs('RCS PARIS 111 222 333'); $contact = new Email('contact@example.com'); // WHEN - $brand = new Brand($id, $code, $name, $logo, $rcs, $contact); + $brand = Brand::hydrate($id, $code, $name, $logo, $rcs, $contact); // THEN expect($brand->id())->toBe($id) - ->and($brand->code())->toBe($code) - ->and($brand->name())->toBe($name) - ->and($brand->logo())->toBe($logo) - ->and($brand->rcs())->toBe($rcs) - ->and($brand->contact())->toBe($contact); + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logo) + ->and($brand->rcs)->toBe($rcs) + ->and($brand->contact)->toBe($contact); + }); + + it('creates a new brand with a freshly generated id', function (): void { + // GIVEN + $code = new BrandCode('AAA01'); + $name = new BrandName('Name'); + $logo = new PicturePath('files/picture.jpg'); + $rcs = new Rcs('RCS PARIS 111 222 333'); + $contact = new Email('contact@example.com'); + + // WHEN + $brand = Brand::create($code, $name, $logo, $rcs, $contact); + + // THEN + expect($brand->id())->toBeInstanceOf(BrandId::class) + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logo) + ->and($brand->rcs)->toBe($rcs) + ->and($brand->contact)->toBe($contact); + }); + + it('generates a different id for each created brand', function (): void { + // GIVEN + $code = new BrandCode('AAA01'); + $name = new BrandName('Name'); + $logo = new PicturePath('files/picture.jpg'); + $rcs = new Rcs('RCS PARIS 111 222 333'); + $contact = new Email('contact@example.com'); + + // WHEN + $first = Brand::create($code, $name, $logo, $rcs, $contact); + $second = Brand::create($code, $name, $logo, $rcs, $contact); + + // THEN + expect($first->id()->equals($second->id()))->toBeFalse(); }); it('updates a brand logo', function (): void { @@ -47,20 +80,20 @@ describe('brand', function (): void { $name = new BrandName('Name'); $logoOld = new PicturePath('files/picture-old.jpg'); $logoNew = new PicturePath('files/picture-new.jpg'); - $rcs = new RCS('RCS PARIS 111 222 333'); + $rcs = new Rcs('RCS PARIS 111 222 333'); $contact = new Email('contact@example.com'); - $brand = new Brand($id, $code, $name, $logoOld, $rcs, $contact); + $brand = Brand::hydrate($id, $code, $name, $logoOld, $rcs, $contact); // WHEN $brand = $brand->changeLogo($logoNew); // THEN expect($brand->id())->toBe($id) - ->and($brand->code())->toBe($code) - ->and($brand->name())->toBe($name) - ->and($brand->logo())->toBe($logoNew) - ->and($brand->rcs())->toBe($rcs) - ->and($brand->contact())->toBe($contact); + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logoNew) + ->and($brand->rcs)->toBe($rcs) + ->and($brand->contact)->toBe($contact); }); it('updates a brand rcs', function (): void { @@ -69,21 +102,21 @@ describe('brand', function (): void { $code = new BrandCode('AAA01'); $name = new BrandName('Name'); $logo = new PicturePath('files/picture.jpg'); - $rcsOld = new RCS('RCS PARIS 111 222 333'); - $rcsNew = new RCS('RCS NANTES 444 555 666'); + $rcsOld = new Rcs('RCS PARIS 111 222 333'); + $rcsNew = new Rcs('RCS NANTES 444 555 666'); $contact = new Email('contact@example.com'); - $brand = new Brand($id, $code, $name, $logo, $rcsOld, $contact); + $brand = Brand::hydrate($id, $code, $name, $logo, $rcsOld, $contact); // WHEN $brand = $brand->changeRcs($rcsNew); // THEN expect($brand->id())->toBe($id) - ->and($brand->code())->toBe($code) - ->and($brand->name())->toBe($name) - ->and($brand->logo())->toBe($logo) - ->and($brand->rcs())->toBe($rcsNew) - ->and($brand->contact())->toBe($contact); + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logo) + ->and($brand->rcs)->toBe($rcsNew) + ->and($brand->contact)->toBe($contact); }); it('updates a brand contact', function (): void { @@ -92,33 +125,33 @@ describe('brand', function (): void { $code = new BrandCode('AAA01'); $name = new BrandName('Name'); $logo = new PicturePath('files/picture.jpg'); - $rcs = new RCS('RCS PARIS 111 222 333'); + $rcs = new Rcs('RCS PARIS 111 222 333'); $contactOld = new Email('contact-old@example.com'); $contactNew = new Email('contact-new@example.com'); - $brand = new Brand($id, $code, $name, $logo, $rcs, $contactOld); + $brand = Brand::hydrate($id, $code, $name, $logo, $rcs, $contactOld); // WHEN $brand = $brand->changeContact($contactNew); // THEN expect($brand->id())->toBe($id) - ->and($brand->code())->toBe($code) - ->and($brand->name())->toBe($name) - ->and($brand->logo())->toBe($logo) - ->and($brand->rcs())->toBe($rcs) - ->and($brand->contact())->toBe($contactNew); + ->and($brand->code)->toBe($code) + ->and($brand->name)->toBe($name) + ->and($brand->logo)->toBe($logo) + ->and($brand->rcs)->toBe($rcs) + ->and($brand->contact)->toBe($contactNew); }); it('returns the creation date extracted from the id', function (): void { // GIVEN - $expected = new DateTimeImmutable('2024-06-15 12:34:56.789000', new DateTimeZone('UTC')); + $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $id = new BrandId(Uuid::uuid7($expected)); - $brand = new Brand( + $brand = Brand::hydrate( $id, new BrandCode('AAA01'), new BrandName('Name'), new PicturePath('files/picture.jpg'), - new RCS('RCS PARIS 111 222 333'), + new Rcs('RCS PARIS 111 222 333'), new Email('contact@example.com'), ); @@ -128,58 +161,4 @@ describe('brand', function (): void { // THEN expect($result->format('Y-m-d H:i:s.v'))->toBe($expected->format('Y-m-d H:i:s.v')); }); - - it('considers two brands with the same id as equal', function (): void { - // GIVEN - $id = BrandId::generate(); - - // WHEN - $first = new Brand( - $id, - new BrandCode('AAA01'), - new BrandName('Name 1'), - new PicturePath('files/picture-1.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact@example.com'), - ); - $second = new Brand( - $id, - new BrandCode('BBB02'), - new BrandName('Name 2'), - new PicturePath('files/picture-2.jpg'), - new RCS('RCS NANTES 444 555 666'), - new Email('contact@example.com'), - ); - - // THEN - expect($first->equals($second))->toBeTrue(); - }); - - it('considers a brand as not equal to', function (mixed $other): void { - // GIVEN - $brand = new Brand( - BrandId::generate(), - new BrandCode('AAA01'), - new BrandName('Name'), - new PicturePath('files/picture.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact@example.com'), - ); - - // WHEN - $result = $brand->equals($other); - - // THEN - expect($result)->toBeFalse(); - })->with([ - 'different brand id' => new Brand( - BrandId::generate(), - new BrandCode('AAA01'), - new BrandName('Name'), - new PicturePath('files/picture.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact@example.com'), - ), - 'non-brand value' => new stdClass(), - ]); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php index 201d016..c469636 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandCodeTest.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\ValueObjects\Brand; use Domain\Models\ValueObjects\Brand\BrandCode; -use DomainException; -use stdClass; covers(BrandCode::class); @@ -20,82 +18,56 @@ describe('brand code', function (): void { // THEN expect($code)->toBeInstanceOf(BrandCode::class) - ->and($code->value())->toBe($value); + ->and($code->value)->toBe($value); }); - - it('considers two brand codes with the same value as equal', function (): void { - // GIVEN - $first = new BrandCode('AAA01'); - $second = new BrandCode('AAA01'); - - // WHEN - $result = $first->equals($second); - - // THEN - expect($result)->toBeTrue(); - }); - - it('considers a brand code as not equal to', function (mixed $invalid): void { - // GIVEN - $code = new BrandCode('AAA01'); - - // WHEN - $result = $code->equals($invalid); - - // THEN - expect($result)->toBeFalse(); - })->with([ - 'different code' => new BrandCode('BBB02'), - 'non-code value' => new stdClass(), - ]); }); describe('brand code rules', function (): void { it('rejects empty strings', function (string $invalid): void { // WHEN / THEN - expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(DomainException::class); + expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(\DomainException::class); // GIVEN })->with(['', ' ', "\t", "\n"]); it('rejects numbers only', function (string $invalid): void { // WHEN / THEN - expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(DomainException::class); + expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(\DomainException::class); // GIVEN })->with(['12345', '67890']); it('rejects letters only', function (string $invalid): void { // WHEN / THEN - expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(DomainException::class); + expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(\DomainException::class); // GIVEN })->with(['ABCDE', 'FGHIJ']); it('rejects codes with special characters', function (string $invalid): void { // WHEN / THEN - expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(DomainException::class); + expect(fn (): BrandCode => new BrandCode($invalid))->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 / THEN - expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(DomainException::class); + expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(\DomainException::class); // GIVEN })->with(['ABC11', 'DEF22', 'GHI33']); it('rejects codes with lowercase letters', function (string $invalid): void { // WHEN / THEN - expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(DomainException::class); + expect(fn (): BrandCode => new BrandCode($invalid))->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 / THEN - expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(DomainException::class); + expect(fn (): BrandCode => new BrandCode($invalid))->toThrow(\DomainException::class); // GIVEN })->with([ diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php index 72e81a0..c3e2de2 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandIdTest.php @@ -4,14 +4,10 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\ValueObjects\Brand; -use DateTimeImmutable; -use DateTimeZone; use Domain\Models\ValueObjects\Brand\BrandId; -use DomainException; use Ramsey\Uuid\Lazy\LazyUuidFromString; use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidInterface; -use stdClass; covers(BrandId::class); @@ -38,7 +34,7 @@ describe('brand id', function (): void { $input = $invalid; // WHEN & THEN - expect(fn (): BrandId => new BrandId($input))->toThrow(DomainException::class); + expect(fn (): BrandId => new BrandId($input))->toThrow(\DomainException::class); })->with([ 'UUID v1' => Uuid::uuid1(), 'UUID v4' => Uuid::uuid4(), @@ -48,7 +44,7 @@ describe('brand id', function (): void { it('returns the datetime embedded in the v7 uuid', function (): void { // GIVEN - $expected = new DateTimeImmutable('2024-06-15 12:34:56.789000', new DateTimeZone('UTC')); + $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $uuid = Uuid::uuid7($expected); $id = new BrandId($uuid); @@ -71,6 +67,6 @@ describe('brand id', function (): void { expect($result)->toBeFalse(); })->with([ 'different id' => BrandId::generate(), - 'non-id value' => new stdClass(), + 'non-id value' => new \stdClass(), ]); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php index 279425a..2cc6013 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Models\ValueObjects\Brand; use Domain\Models\ValueObjects\Brand\BrandName; -use DomainException; -use stdClass; covers(BrandName::class); @@ -19,7 +17,7 @@ describe('brand name', function (): void { $name = new BrandName($value); // THEN - expect($name->value())->toBe('Name'); + expect($name->value)->toBe('Name'); }); it('rejects invalid brand name', function (): void { @@ -27,33 +25,17 @@ describe('brand name', function (): void { $value = ''; // GIVEN / THEN - expect(fn (): BrandName => new BrandName($value))->toThrow(DomainException::class); + expect(fn (): BrandName => new BrandName($value))->toThrow(\DomainException::class); }); it('rejects a name that exceeds the maximum allowed length', function (): void { // GIVEN - $pathValue = str_repeat('a', 2049); + $pathValue = str_repeat('a', 210); // WHEN $action = fn (): BrandName => new BrandName($pathValue); // THEN - expect($action)->toThrow(DomainException::class, 'Brand name exceeds the maximum allowed length.'); + expect($action)->toThrow(\DomainException::class, 'Brand name exceeds the maximum allowed length.'); }); - - it('considers a brand name as not equal to', function (mixed $invalid): void { - // GIVEN - $name = new BrandName('Name 01'); - - // WHEN - $result = $name->equals($invalid); - - // THEN - expect($result)->toBeFalse(); - - // GIVEN - })->with([ - 'different name' => new BrandName('Name 02'), - 'non-name value' => new stdClass(), - ]); }); diff --git a/tests/Unit/Domain/Services/BrandServiceTest.php b/tests/Unit/Domain/Services/BrandServiceTest.php deleted file mode 100644 index b0fa54c..0000000 --- a/tests/Unit/Domain/Services/BrandServiceTest.php +++ /dev/null @@ -1,366 +0,0 @@ -shouldReceive('existsByCode') - ->once() - ->with($code) - ->andReturn(false); - $mock->shouldReceive('save') - ->once(); - - $service = new BrandService($mock); - - // WHEN - $brand = $service->create($code, $name, $logo, $rcs, $contact); - - // THEN - expect($brand)->toBeInstanceOf(Brand::class) - ->and($brand->code())->toBe($code) - ->and($brand->name())->toBe($name); - }); - - it('throws when the brand code already exists', function (): void { - // GIVEN - $code = new BrandCode('AAA01'); - $name = new BrandName('Name'); - $logo = new PicturePath('files/picture.jpg'); - $rcs = new RCS('RCS PARIS 111 222 333'); - $contact = new Email('contact@example.com'); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('existsByCode') - ->once() - ->with($code) - ->andReturn(true); - $mock->shouldNotReceive('save'); - - $service = new BrandService($mock); - - // WHEN - $action = fn (): Brand => $service->create( - $code, - $name, - $logo, - $rcs, - $contact, - ); - - // THEN - expect($action)->toThrow(BrandCodeAlreadyExistsException::class); - }); - - it('gets a brand by id', function (): void { - // GIVEN - $id = BrandId::generate(); - $brand = new Brand( - $id, - new BrandCode('AAA01'), - new BrandName('Name'), - new PicturePath('files/picture.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact@example.com'), - ); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findById') - ->once() - ->with($id) - ->andReturn($brand); - - $service = new BrandService($mock); - - // WHEN - $result = $service->getById($id); - - // THEN - expect($result)->toBe($brand); - }); - - it('throws when the brand id is not found', function (): void { - // GIVEN - $id = BrandId::generate(); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findById') - ->once() - ->with($id) - ->andReturn(null); - - $service = new BrandService($mock); - - // WHEN - $action = fn (): Brand => $service->getById($id); - - // THEN - expect($action)->toThrow(BrandNotFoundException::class); - }); - - it('gets a brand by code', function (): void { - // GIVEN - $code = new BrandCode('AAA01'); - $brand = new Brand( - BrandId::generate(), - $code, - new BrandName('Name'), - new PicturePath('files/picture.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact@example.com'), - ); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findByCode') - ->once() - ->with($code) - ->andReturn($brand); - - $service = new BrandService($mock); - - // WHEN - $result = $service->getByCode($code); - - // THEN - expect($result)->toBe($brand); - }); - - it('throws when the brand code is not found', function (): void { - // GIVEN - $code = new BrandCode('AAA01'); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findByCode') - ->once() - ->with($code) - ->andReturn(null); - - $service = new BrandService($mock); - - // WHEN - $action = fn (): Brand => $service->getByCode($code); - - // THEN - expect($action)->toThrow(BrandNotFoundException::class); - }); - - it('updates the logo of a brand', function (): void { - // GIVEN - $id = BrandId::generate(); - $brand = new Brand( - $id, - new BrandCode('AAA01'), - new BrandName('Name'), - new PicturePath('files/picture-old.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact@example.com'), - ); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findById') - ->once() - ->with($id) - ->andReturn($brand); - $mock->shouldReceive('save') - ->once(); - - $service = new BrandService($mock); - - // WHEN - $newLogo = new PicturePath('files/picture-new.jpg'); - $updated = $service->updateLogo($id, $newLogo); - - // THEN - expect($updated)->not->toBe($brand) - ->and($updated->logo())->toBe($newLogo); - }); - - it('throws when updating the logo of a brand that does not exist', function (): void { - // GIVEN - $id = BrandId::generate(); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findById') - ->once() - ->with($id) - ->andReturn(null); - $mock->shouldNotReceive('save'); - - $service = new BrandService($mock); - - // WHEN - $newLogo = new PicturePath('files/picture-new.jpg'); - $action = fn (): Brand => $service->updateLogo($id, $newLogo); - - // THEN - expect($action)->toThrow(BrandNotFoundException::class); - }); - - it('updates the RCS text of a brand', function (): void { - // GIVEN - $id = BrandId::generate(); - $brand = new Brand( - $id, - new BrandCode('AAA01'), - new BrandName('Name'), - new PicturePath('files/picture-old.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact@example.com'), - ); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findById') - ->once() - ->with($id) - ->andReturn($brand); - $mock->shouldReceive('save') - ->once(); - - $service = new BrandService($mock); - - // WHEN - $newRcs = new RCS('RCS NANTES 444 555 666'); - $updated = $service->updateRcs($id, $newRcs); - - // THEN - expect($updated)->not->toBe($brand) - ->and($updated->rcs())->toBe($newRcs); - }); - - it('throws when updating the RCS text of a brand that does not exist', function (): void { - // GIVEN - $id = BrandId::generate(); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findById') - ->once() - ->with($id) - ->andReturn(null); - $mock->shouldNotReceive('save'); - - $service = new BrandService($mock); - - // WHEN - $newRcs = new RCS('RCS NANTES 444 555 666'); - $action = fn (): Brand => $service->updateRcs($id, $newRcs); - - // THEN - expect($action)->toThrow(BrandNotFoundException::class); - }); - - it('updates the contact of a brand', function (): void { - // GIVEN - $id = BrandId::generate(); - $brand = new Brand( - $id, - new BrandCode('AAA01'), - new BrandName('Name'), - new PicturePath('files/picture-old.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact-old@example.com'), - ); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findById') - ->once() - ->with($id) - ->andReturn($brand); - $mock->shouldReceive('save') - ->once(); - - $service = new BrandService($mock); - - // WHEN - $newContact = new Email('contact-new@example.com'); - $updated = $service->updateContact($id, $newContact); - - // THEN - expect($updated)->not->toBe($brand) - ->and($updated->contact())->toBe($newContact); - }); - - it('throws when updating the contact of a brand that does not exist', function (): void { - // GIVEN - $id = BrandId::generate(); - - $mock = Mockery::mock(BrandRepository::class); - $mock->shouldReceive('findById') - ->once() - ->with($id) - ->andReturn(null); - $mock->shouldNotReceive('save'); - - $service = new BrandService($mock); - - // WHEN - $newContact = new Email('contact-new@example.com'); - $action = fn (): Brand => $service->updateContact($id, $newContact); - - // THEN - expect($action)->toThrow(BrandNotFoundException::class); - }); - - it('lists brands with pagination and sorting', function (): void { - $brand1 = new Brand( - BrandId::generate(), - new BrandCode('AAA01'), - new BrandName('Name 01'), - new PicturePath('files/picture-1.jpg'), - new RCS('RCS PARIS 111 222 333'), - new Email('contact-1@example.com'), - ); - $brand2 = new Brand( - BrandId::generate(), - new BrandCode('BBB02'), - new BrandName('Name 02'), - new PicturePath('files/picture-2.jpg'), - new RCS('RCS NANTES 444 555 666'), - new Email('contact-2@example.com'), - ); - - $pagination = Pagination::ofPage(1, 25); - $sortField = SortField::NAME; - $sortDirection = SortDirection::ASC; - $paginated = new Paginated($pagination, [$brand1, $brand2], 2); - - $mock = Mockery::mock(BrandRepository::class); - $mock - ->shouldReceive('findAll') - ->once() - ->with($pagination, $sortField, $sortDirection) - ->andReturn($paginated); - - $service = new BrandService($mock); - - expect($service->getAll($pagination))->toBe($paginated); - }); -}); diff --git a/tests/Unit/Shared/Exceptions/InvalidUuidExceptionTest.php b/tests/Unit/Shared/DDD/Exceptions/InvalidUuidExceptionTest.php similarity index 57% rename from tests/Unit/Shared/Exceptions/InvalidUuidExceptionTest.php rename to tests/Unit/Shared/DDD/Exceptions/InvalidUuidExceptionTest.php index 8e85a9b..dddbb07 100644 --- a/tests/Unit/Shared/Exceptions/InvalidUuidExceptionTest.php +++ b/tests/Unit/Shared/DDD/Exceptions/InvalidUuidExceptionTest.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace Tests\Unit\Shared\Exceptions; +namespace Tests\Unit\Shared\DDD\Exceptions; -use Shared\Exceptions\InvalidUuidException; -use InvalidArgumentException; +use Shared\DDD\Exceptions\InvalidUuidException; covers(InvalidUuidException::class); @@ -15,10 +14,10 @@ describe('uuid invalid exception', function (): void { $uuid = ''; // WHEN - $exception = InvalidUuidException::forValue($uuid); + $exception = new InvalidUuidException($uuid); // THEN expect($exception->getMessage())->toBe("Invalid UUID: {$uuid}") - ->and($exception)->toBeInstanceOf(InvalidArgumentException::class); + ->and($exception)->toBeInstanceOf(\InvalidArgumentException::class); }); }); diff --git a/tests/Unit/Shared/Helpers/UuidHelperTest.php b/tests/Unit/Shared/Helpers/UuidHelperTest.php index e458ec1..6b80d45 100644 --- a/tests/Unit/Shared/Helpers/UuidHelperTest.php +++ b/tests/Unit/Shared/Helpers/UuidHelperTest.php @@ -4,11 +4,9 @@ declare(strict_types=1); namespace Tests\Unit\Shared\Helpers; -use DateTimeImmutable; -use DateTimeZone; use Ramsey\Uuid\Lazy\LazyUuidFromString; use Ramsey\Uuid\Uuid; -use Shared\Exceptions\InvalidUuidException; +use Shared\DDD\Exceptions\InvalidUuidException; use Shared\Helpers\UuidHelper; describe('uuid helper', function (): void { @@ -41,7 +39,7 @@ describe('uuid helper', function (): void { it('returns the timestamp from a v7 uuid', function (): void { // GIVEN - $expected = new DateTimeImmutable('2024-06-15 12:34:56.789000', new DateTimeZone('UTC')); + $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC')); $uuid = Uuid::uuid7($expected); // WHEN @@ -57,7 +55,7 @@ describe('uuid helper', function (): void { $uuid = Uuid::uuid4(); // WHEN / THEN - expect(fn (): DateTimeImmutable => UuidHelper::toDateTime($uuid)) + expect(fn (): \DateTimeImmutable => UuidHelper::toDateTime($uuid)) ->toThrow(InvalidUuidException::class); }); diff --git a/tests/Unit/Shared/Models/ValueObjects/EmailTest.php b/tests/Unit/Shared/Models/ValueObjects/EmailTest.php index 10a7830..fb45ab3 100644 --- a/tests/Unit/Shared/Models/ValueObjects/EmailTest.php +++ b/tests/Unit/Shared/Models/ValueObjects/EmailTest.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Shared\Models\ValueObjects; -use DomainException; use Shared\Models\ValueObjects\Email; -use stdClass; covers(Email::class); @@ -20,7 +18,7 @@ describe('email', function (): void { // THEN expect($email)->toBeInstanceOf(Email::class) - ->and($email->value())->toBe('contact@example.com'); + ->and($email->value)->toBe('contact@example.com'); }); it('rejects an empty email', function (): void { @@ -31,12 +29,12 @@ describe('email', function (): void { $action = fn (): Email => new Email($emailValue); // THEN - expect($action)->toThrow(DomainException::class, 'Email cannot be empty.'); + expect($action)->toThrow(\DomainException::class, 'Email cannot be empty.'); }); it('rejects invalid email formats', function (string $invalid): void { // GIVEN & WHEN & THEN - expect(fn (): Email => new Email($invalid))->toThrow(DomainException::class); + expect(fn (): Email => new Email($invalid))->toThrow(\DomainException::class); })->with([ '', ' ', @@ -45,34 +43,6 @@ describe('email', function (): void { 'contact@', 'contact@.com', 'contact@com', - str_repeat('a', 2048) . '@example.fr' - ]); - - it('considers two emails with the same value as equal', function (): void { - // GIVEN - $first = new Email('contact@example.fr'); - $second = new Email('contact@example.fr'); - - // WHEN - $result = $first->equals($second); - - // THEN - expect($result)->toBeTrue(); - }); - - it('considers an email as not equal to', function (mixed $invalid): void { - // GIVEN - $email = new Email('contact-1@example.fr'); - - // WHEN - $result = $email->equals($invalid); - - // THEN - expect($result)->toBeFalse(); - - // GIVEN - })->with([ - 'different email' => new Email('contact-2@example.fr'), - 'non-email value' => new stdClass(), + str_repeat('a', 200) . '@example.fr' ]); }); diff --git a/tests/Unit/Shared/Models/ValueObjects/PicturePathTest.php b/tests/Unit/Shared/Models/ValueObjects/PicturePathTest.php index db6847b..365837b 100644 --- a/tests/Unit/Shared/Models/ValueObjects/PicturePathTest.php +++ b/tests/Unit/Shared/Models/ValueObjects/PicturePathTest.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Shared\Models\ValueObjects; -use DomainException; use Shared\Models\ValueObjects\PicturePath; -use stdClass; covers(PicturePath::class); @@ -20,7 +18,7 @@ describe('picture path', function (): void { // THEN expect($path)->toBeInstanceOf(PicturePath::class) - ->and($path->value())->toBe($pathValue); + ->and($path->value)->toBe($pathValue); }); it('rejects an empty path', function (): void { @@ -31,30 +29,30 @@ describe('picture path', function (): void { $action = fn (): PicturePath => new PicturePath($pathValue); // THEN - expect($action)->toThrow(DomainException::class, 'Picture Path cannot be empty.'); + expect($action)->toThrow(\DomainException::class, 'Picture Path cannot be empty.'); }); it('rejects a path that exceeds the maximum allowed length', function (): void { // GIVEN - $pathValue = str_repeat('a', 2049) . '.jpg'; + $pathValue = str_repeat('a', 210) . '.jpg'; // WHEN $action = fn (): PicturePath => new PicturePath($pathValue); // THEN - expect($action)->toThrow(DomainException::class, 'Picture Path exceeds the maximum allowed length.'); + expect($action)->toThrow(\DomainException::class, 'Picture Path exceeds the maximum allowed length.'); }); it('accepts a path of exactly the maximum length', function (): void { // GIVEN - $pathValue = str_repeat('a', 2044) . '.jpg'; + $pathValue = str_repeat('a', 196) . '.jpg'; // WHEN $path = new PicturePath($pathValue); // THEN expect($path)->toBeInstanceOf(PicturePath::class) - ->and($path->value())->toHaveLength(2048); + ->and($path->value)->toHaveLength(200); }); it('rejects a path without an image extension', function (): void { @@ -65,34 +63,6 @@ describe('picture path', function (): void { $action = fn (): PicturePath => new PicturePath($pathValue); // THEN - expect($action)->toThrow(DomainException::class, 'Picture Path must end with one of: ' . implode(', ', PicturePath::ALLOWED_EXTENSIONS)); + expect($action)->toThrow(\DomainException::class, 'Picture Path must end with one of: ' . implode(', ', PicturePath::ALLOWED_EXTENSIONS)); }); - - it('considers two paths to be equal if they have the same value', function (): void { - // GIVEN - $first = new PicturePath('coupons/logo.png'); - $second = new PicturePath('coupons/logo.png'); - - // WHEN - $result = $first->equals($second); - - // THEN - expect($result)->toBeTrue(); - }); - - it('considers a logo as not equal to', function (mixed $invalid): void { - // GIVEN - $logo = new PicturePath('coupons/logo-1.png'); - - // WHEN - $result = $logo->equals($invalid); - - // THEN - expect($result)->toBeFalse(); - - // GIVEN - })->with([ - 'different picture path' => new PicturePath('coupons/logo-2.png'), - 'non-picture-path value' => new stdClass(), - ]); }); diff --git a/tests/Unit/Shared/Models/ValueObjects/RCSTest.php b/tests/Unit/Shared/Models/ValueObjects/RCSTest.php deleted file mode 100644 index 18fae82..0000000 --- a/tests/Unit/Shared/Models/ValueObjects/RCSTest.php +++ /dev/null @@ -1,92 +0,0 @@ -toBeInstanceOf(RCS::class) - ->and($rcs->value())->toBe($rcsValue); - }); - - it('rejects an empty RCS text', function (): void { - // GIVEN - $rcsValue = ''; - - // WHEN - $action = fn (): RCS => new RCS($rcsValue); - - // THEN - expect($action)->toThrow(DomainException::class, 'RCS text cannot be empty.'); - }); - - it('rejects invalid TCS text formats', function (string $invalid): void { - // GIVEN & WHEN & THEN - expect(fn (): RCS => new RCS($invalid)) - ->toThrow(DomainException::class); - })->with([ - '', - ' ', - 'Paris', - 'RCS PARIS', - '111 222 333', - 'RCS PARIS 111', - 'RCS PARIS 111 222', - 'PARIS 111 222 333', - 'RCS PARIS 1A1 2B2 3C3', - 'RCS PARIS 1111 2222 3333', - ]); - - it('rejects an RCS text that exceeds the maximum allowed length', function (): void { - // GIVEN - $rcsValue = str_repeat('a', 2049); - - // WHEN - $action = fn (): RCS => new RCS($rcsValue); - - // THEN - expect($action)->toThrow(DomainException::class, 'RCS text exceeds the maximum allowed length.'); - }); - - it('considers two RCS texts to be equal if they have the same value', function (): void { - // GIVEN - $first = new RCS('RCS PARIS 111 222 333'); - $second = new RCS('RCS PARIS 111 222 333'); - - // WHEN - $result = $first->equals($second); - - // THEN - expect($result)->toBeTrue(); - }); - - it('considers an RCS text as not equal to', function (mixed $invalid): void { - // GIVEN - $rcs = new RCS('RCS PARIS 111 222 333'); - - // WHEN - $result = $rcs->equals($invalid); - - // THEN - expect($result)->toBeFalse(); - - // GIVEN - })->with([ - 'different rcs text' => new RCS('RCS NANTES 444 555 666'), - 'non-rcs-text value' => new stdClass(), - ]); -}); diff --git a/tests/Unit/Shared/Models/ValueObjects/RawPictureTest.php b/tests/Unit/Shared/Models/ValueObjects/RawPictureTest.php new file mode 100644 index 0000000..d491485 --- /dev/null +++ b/tests/Unit/Shared/Models/ValueObjects/RawPictureTest.php @@ -0,0 +1,130 @@ +toBeInstanceOf(RawPicture::class) + ->and($picture->content)->toBe($content) + ->and($picture->originalName)->toBe($originalName); + }); + + it('rejects an empty content', function (): void { + // GIVEN + $content = ''; + + // WHEN + $action = fn (): RawPicture => new RawPicture($content, 'logo.png'); + + // THEN + expect($action)->toThrow(\DomainException::class); + }); + + it('rejects a content that exceeds the maximum allowed size', function (): void { + // GIVEN + $content = str_repeat('a', RawPicture::MAX_SIZE_IN_BYTES + 1); + + // WHEN + $action = fn (): RawPicture => new RawPicture($content, 'logo.png'); + + // THEN + expect($action)->toThrow(\DomainException::class); + }); + + it('accepts a content of exactly the maximum allowed size', function (): void { + // GIVEN + $content = str_repeat('a', RawPicture::MAX_SIZE_IN_BYTES); + + // WHEN + $picture = new RawPicture($content, 'logo.png'); + + // THEN + expect($picture)->toBeInstanceOf(RawPicture::class) + ->and(strlen($picture->content))->toBe(RawPicture::MAX_SIZE_IN_BYTES); + }); + + it('measures the size in bytes, not in characters', function (): void { + // GIVEN + $content = str_repeat('é', RawPicture::MAX_SIZE_IN_BYTES - 1); + + // WHEN + $action = fn (): RawPicture => new RawPicture($content, 'logo.png'); + + // THEN + expect($action)->toThrow(\DomainException::class); + }); + + it('rejects an empty original name', function (string $originalName): void { + // WHEN + $action = fn (): RawPicture => new RawPicture('binary-content', $originalName); + + // THEN + expect($action)->toThrow(\DomainException::class); + + // GIVEN + })->with([ + 'empty string' => '', + 'spaces only' => ' ', + 'whitespace only' => "\t\n ", + ]); + + it('rejects an original name without an allowed extension', function (string $originalName): void { + // WHEN + $action = fn (): RawPicture => new RawPicture('binary-content', $originalName); + + // THEN + expect($action)->toThrow(\DomainException::class); + + // GIVEN + })->with([ + 'no extension' => 'logo', + 'forbidden extension' => 'logo.gif', + 'executable extension' => 'logo.php', + 'trailing dot' => 'logo.', + 'double extension' => 'logo.png.exe', + ]); + + it('accepts every allowed extension', function (string $extension): void { + // GIVEN + $originalName = 'logo.' . $extension; + + // WHEN + $picture = new RawPicture('binary-content', $originalName); + + // THEN + expect($picture)->toBeInstanceOf(RawPicture::class) + ->and($picture->originalName)->toBe($originalName); + })->with(RawPicture::ALLOWED_EXTENSIONS); + + it('accepts an original name surrounded by whitespace and keeps it as provided', function (): void { + // GIVEN + $originalName = ' logo.png '; + + // WHEN + $picture = new RawPicture('binary-content', $originalName); + + // THEN + expect($picture->originalName)->toBe($originalName); + }); + + it('shares its allowed extensions with PicturePath', function (): void { + // GIVEN / WHEN / THEN + expect(RawPicture::ALLOWED_EXTENSIONS) + ->toBe(PicturePath::ALLOWED_EXTENSIONS); + }); +}); diff --git a/tests/Unit/Shared/Models/ValueObjects/RcsTest.php b/tests/Unit/Shared/Models/ValueObjects/RcsTest.php new file mode 100644 index 0000000..dd19b53 --- /dev/null +++ b/tests/Unit/Shared/Models/ValueObjects/RcsTest.php @@ -0,0 +1,51 @@ +toBeInstanceOf(Rcs::class) + ->and($rcs->value)->toBe($rcsValue); + }); + + it('rejects an empty Rcs text', function (): void { + // GIVEN + $rcsValue = ''; + + // WHEN + $action = fn (): Rcs => new Rcs($rcsValue); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Rcs text cannot be empty.'); + }); + + it('rejects invalid TCS text formats', function (string $invalid): void { + // GIVEN & WHEN & THEN + expect(fn (): Rcs => new Rcs($invalid)) + ->toThrow(\DomainException::class); + })->with([ + '', + ' ', + 'Paris', + 'RCS PARIS', + '111 222 333', + 'RCS PARIS 111', + 'RCS PARIS 111 222', + 'PARIS 111 222 333', + 'RCS PARIS 1A1 2B2 3C3', + 'RCS PARIS 1111 2222 3333', + ]); +});