fix: 🐛 sprint review reports for Brand processed

This commit is contained in:
Alexis Gousseau 2026-09-14 10:52:04 +02:00
parent 9b4a1934ff
commit c79fe7d20b
No known key found for this signature in database
GPG key ID: 7F3C786EE12DBB72
70 changed files with 1580 additions and 1273 deletions

View file

@ -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,

82
composer.lock generated
View file

@ -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",

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Create;
use Domain\Exceptions\BrandCodeAlreadyExistsException;
use Domain\Models\Entities\Brand;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Services\PictureStorage;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Brand\Create\Input, \Application\UseCases\Brand\Create\Output> */
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(),
);
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Create;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\RawPicture;
use Shared\Models\ValueObjects\Rcs;
final readonly class Input implements UseCaseInput
{
public function __construct(
public BrandCode $code,
public BrandName $name,
public RawPicture $logo,
public Rcs $rcs,
public Email $contact,
) {
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Create;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Shared\DDD\UseCases\Output as UseCaseOutput;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\Rcs;
final readonly class Output implements UseCaseOutput
{
public function __construct(
public BrandName $name,
public BrandCode $code,
public PicturePath $logo,
public Rcs $rcs,
public Email $contact,
public \DateTimeInterface $createdAt,
) {
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Find;
use Application\UseCases\Brand\Find\Output\BrandFound;
use Application\UseCases\Brand\Find\Output\BrandNotFound;
use Application\UseCases\Brand\Find\Output\Output;
use Domain\Models\Entities\Brand;
use Domain\Ports\Repositories\BrandRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Brand\Find\Input, \Application\UseCases\Brand\Find\Output\Output> */
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(),
);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Find;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Shared\DDD\UseCases\Input as UseCaseInput;
final readonly class Input implements UseCaseInput
{
public function __construct(
public BrandCode $code,
) {
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Find\Output;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\Rcs;
final readonly class BrandFound implements Output
{
public function __construct(
public BrandName $name,
public BrandCode $code,
public PicturePath $logo,
public Rcs $rcs,
public Email $contact,
public \DateTimeInterface $createdAt,
) {
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Find\Output;
final readonly class BrandNotFound implements Output
{
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Find\Output;
use Shared\DDD\UseCases\Output as UseCaseOutput;
interface Output extends UseCaseOutput
{
}

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\List;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Shared\Models\ValueObjects\PicturePath;
final readonly class BrandListItem
{
public function __construct(
public BrandCode $code,
public BrandName $name,
public PicturePath $logo,
) {
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\List;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Shared\Helpers\Pagination;
final readonly class Input implements UseCaseInput
{
public function __construct(
public Pagination $pagination,
public ?SortCriteria $sort = null,
) {
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\List;
use Domain\Models\Entities\Brand;
use Domain\Ports\Repositories\BrandRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\Helpers\Paginated;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Brand\List\Input, \Application\UseCases\Brand\List\Output> */
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(),
),
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\List;
use Shared\DDD\UseCases\Output as UseCaseOutput;
use Shared\Helpers\Paginated;
final readonly class Output implements UseCaseOutput
{
/** @param Paginated<BrandListItem> $brands */
public function __construct(
public Paginated $brands,
) {
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\List;
use Domain\Enums\Brand\SortField;
final readonly class SortCriteria
{
public function __construct(
public SortField $field = SortField::Name,
public \SortDirection $direction = \SortDirection::Ascending,
) {
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Update;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\Rcs;
use Webmozart\Assert\Assert;
final readonly class Input implements UseCaseInput
{
public function __construct(
public BrandCode $code,
public ?PicturePath $logo = null,
public ?Rcs $rcs = null,
public ?Email $contact = null,
) {
Assert::true(
$this->logo instanceof PicturePath
|| $this->rcs instanceof Rcs
|| $this->contact instanceof Email,
'At least one field must be provided to update the brand.',
);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Update;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Shared\DDD\UseCases\Output as UseCaseOutput;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\Rcs;
final readonly class Output implements UseCaseOutput
{
public function __construct(
public BrandName $name,
public BrandCode $code,
public PicturePath $logo,
public Rcs $rcs,
public Email $contact,
public \DateTimeInterface $createdAt,
) {
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Brand\Update;
use Domain\Models\Entities\Brand;
use Domain\Ports\Repositories\BrandRepository;
use Shared\DDD\Exceptions\NotFoundException;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\Rcs;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Brand\Update\Input, \Application\UseCases\Brand\Update\Output> */
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(),
);
}
}

View file

@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Enums\Brand;
enum SortDirection: string
{
case ASC = 'asc';
case DESC = 'desc';
}

View file

@ -6,6 +6,6 @@ namespace Domain\Enums\Brand;
enum SortField: string
{
case CREATED_AT = 'created_at';
case NAME = 'name';
case CreatedAt = 'created_at';
case Name = 'name';
}

View file

@ -5,15 +5,14 @@ declare(strict_types=1);
namespace Domain\Exceptions;
use Domain\Models\ValueObjects\Brand\BrandCode;
use DomainException;
final class BrandCodeAlreadyExistsException extends DomainException
final class BrandCodeAlreadyExistsException extends \DomainException
{
/**
* @param BrandCode $code Unique identification code.
*/
public function __construct(BrandCode $code)
{
parent::__construct(sprintf('Brand code "%s" already exists.', $code->value()));
parent::__construct(sprintf('Brand code "%s" already exists.', $code->value));
}
}

View file

@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Exceptions;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use DomainException;
final class BrandNotFoundException extends DomainException
{
/**
* Create the exception for a lookup by identifier.
*
* @param BrandId $id Identifier that was not found.
*/
public static function fromId(BrandId $id): self
{
return new self(sprintf('Brand with id "%s" was not found.', (string) $id));
}
/**
* Create the exception for a lookup by code.
*
* @param BrandCode $code Code that was not found.
*/
public static function fromCode(BrandCode $code): self
{
return new self(sprintf('Brand with code "%s" was not found.', $code->value()));
}
}

View file

@ -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,
);
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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.');
}
}
}

View file

@ -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<Brand>
*/
public function findAll(
Pagination $pagination,
SortField $sortField = SortField::NAME,
SortDirection $sortDirection = SortDirection::ASC,
?SortField $sortField = null,
?\SortDirection $sortDirection = null,
): Paginated;
/**

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Domain\Ports\Services;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\RawPicture;
interface PictureStorage
{
/**
* Persists the raw picture content and returns the resulting path.
*/
public function store(RawPicture $picture): PicturePath;
}

View file

@ -1,150 +0,0 @@
<?php
declare(strict_types=1);
namespace Domain\Services;
use Domain\Enums\Brand\SortDirection;
use Domain\Enums\Brand\SortField;
use Domain\Exceptions\BrandCodeAlreadyExistsException;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Ports\Repositories\BrandRepository;
use Shared\Helpers\Paginated;
use Shared\Helpers\Pagination;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\RCS;
final readonly class BrandService
{
/**
* @param BrandRepository $repository Repository of the brand domain.
*/
public function __construct(
private BrandRepository $repository,
) {
}
/**
* Create a brand with checking existing code.
*
* @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 create(
BrandCode $code,
BrandName $name,
PicturePath $logo,
RCS $rcs,
Email $contact,
): Brand {
if ($this->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<Brand>
*/
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;
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\Exceptions;
final class InvalidUuidException extends \InvalidArgumentException
{
public function __construct(string $uuid)
{
parent::__construct("Invalid UUID: {$uuid}");
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\Exceptions;
class NotFoundException extends \DomainException
{
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Shared\DDD;
namespace Shared\DDD\Models;
/**
* @template T of EntityId

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Shared\DDD;
namespace Shared\DDD\Models;
/**
* @template T of EntityId

View file

@ -2,18 +2,15 @@
declare(strict_types=1);
namespace Shared\DDD;
namespace Shared\DDD\Models;
use DomainException;
use Stringable;
abstract readonly class EntityId implements Stringable, Equatable
abstract readonly class EntityId implements \Stringable, Equatable
{
public function __construct(
public string $value
) {
if (trim($value) === '') {
throw new DomainException('EntityId value cannot be empty');
throw new \DomainException('EntityId value cannot be empty');
}
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Shared\DDD;
namespace Shared\DDD\Models;
interface Equatable
{

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\Models;
interface HasTimestampIdentifier
{
public function createdAt(): \DateTimeInterface;
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\Ports;
/**
* @template TId of \Shared\DDD\Models\EntityId
* @template TAggregate of \Shared\DDD\Models\AggregateRoot
*/
interface Repository
{
}

View file

@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace Shared\DDD;
/**
*/
interface Repository
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\UseCases;
interface Input
{
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\UseCases;
interface Output
{
}

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Shared\DDD\UseCases;
/**
* @template TInput of Input
* @template TOutput of Output
*/
interface UseCase
{
/**
* @param TInput $input
* @return TOutput
*/
public function execute(Input $input): Output;
}

View file

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Shared\Exceptions;
use InvalidArgumentException;
final class InvalidUuidException extends InvalidArgumentException
{
public static function forValue(string $uuid): self
{
return new self("Invalid UUID: {$uuid}");
}
}

View file

@ -4,12 +4,10 @@ declare(strict_types=1);
namespace Shared\Helpers;
use Stringable;
/**
* Immutable value object representing a pagination range.
*/
final readonly class Pagination implements Stringable
final readonly class Pagination implements \Stringable
{
public const int MAX_PAGE_SIZE = 10000;
@ -19,6 +17,18 @@ final readonly class Pagination implements Stringable
) {
}
/**
* Get the string representation of the pagination range.
*/
public function __toString(): string
{
return sprintf(
'Pagination{from=%d, to=%d}',
$this->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,
);
}
}

View file

@ -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'));
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace Shared\Models\ValueObjects;
use DomainException;
use Shared\DDD\Equatable;
/**
* Represents a valid RCS.
*/
final readonly class RCS implements Equatable
{
/**
* @param string $value A valid RCS.
* @throws DomainException
*/
public function __construct(
private string $value,
) {
$trimmed = trim($this->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;
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Shared\Models\ValueObjects;
/**
* Represents raw, not a stored picture.
*/
final readonly class RawPicture
{
public const array ALLOWED_EXTENSIONS = PicturePath::ALLOWED_EXTENSIONS;
public const int MAX_SIZE_IN_BYTES = 5 * 1024 * 1024;
/**
* @param string $content Binary content of the picture
* @param string $originalName Original filename, used to infer the extension
* @throws \DomainException
*/
public function __construct(
public string $content,
public string $originalName,
) {
if ($this->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));
}
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Shared\Models\ValueObjects;
/**
* Represents a valid Rcs.
* See: https://entreprendre.service-public.gouv.fr/vosdroits/F31190.
*/
final readonly class Rcs
{
private const string REGEX = '/^RCS\s+[A-ZÀ-ÖØ-Ý][A-ZÀ-ÖØ-Ý\s-]*\s+\d{3}\s\d{3}\s\d{3}$/u';
/**
* @param string $value A valid Rcs.
* @throws \DomainException
*/
public function __construct(
public string $value,
) {
$trimmed = trim($this->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.');
}
}
}

View file

@ -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')

View file

@ -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();

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Brand\Create;
use Application\UseCases\Brand\Create\CreateBrand;
use Application\UseCases\Brand\Create\Input;
use Application\UseCases\Brand\Create\Output;
use Domain\Exceptions\BrandCodeAlreadyExistsException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Ports\Services\PictureStorage;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\RawPicture;
use Shared\Models\ValueObjects\Rcs;
covers(CreateBrand::class);
describe('create brand use case', function (): void {
it('creates a brand when the code is unique and valid', function (): void {
// GIVEN
$rawPicture = new RawPicture(content: 'binary-content', originalName: 'picture.jpg');
$logoPath = new PicturePath('files/picture.jpg');
$input = new Input(
code: new BrandCode('AAA01'),
name: new BrandName('Name'),
logo: $rawPicture,
rcs: new Rcs('RCS PARIS 111 222 333'),
contact: new Email('contact@example.com'),
);
$pictureStorage = \Mockery::mock(PictureStorage::class);
$pictureStorage->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);
});
});

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Brand\Find;
use Application\UseCases\Brand\Find\FindBrand;
use Application\UseCases\Brand\Find\Input;
use Application\UseCases\Brand\Find\Output\BrandFound;
use Application\UseCases\Brand\Find\Output\BrandNotFound;
use Application\UseCases\Brand\Find\Output\Output;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Ports\Repositories\BrandRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\Rcs;
covers(FindBrand::class);
describe('find brand use case', function (): void {
it('finds a brand by its code', function (): void {
// GIVEN
$code = new BrandCode('AAA01');
$brand = Brand::hydrate(
BrandId::generate(),
$code,
new BrandName('Name'),
new PicturePath('files/picture.jpg'),
new Rcs('RCS PARIS 111 222 333'),
new Email('contact@example.com'),
);
$repository = \Mockery::mock(BrandRepository::class);
$repository->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);
});
});

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Brand\List;
use Application\UseCases\Brand\List\BrandListItem;
use Application\UseCases\Brand\List\Input;
use Application\UseCases\Brand\List\ListBrands;
use Application\UseCases\Brand\List\Output;
use Application\UseCases\Brand\List\SortCriteria;
use Domain\Enums\Brand\SortField;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Ports\Repositories\BrandRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\Helpers\Paginated;
use Shared\Helpers\Pagination;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\Rcs;
covers(ListBrands::class);
describe('list brands use case', function (): void {
it('maps repository brands to list items, preserving pagination and sort', function (): void {
// GIVEN
$pagination = Pagination::firstPage(25);
$brand1 = Brand::hydrate(
BrandId::generate(),
new BrandCode('AAA01'),
new BrandName('Name 1'),
new PicturePath('files/picture-1.jpg'),
new Rcs('RCS PARIS 111 222 333'),
new Email('contact-1@example.com'),
);
$brand2 = Brand::hydrate(
BrandId::generate(),
new BrandCode('BBB02'),
new BrandName('Name 2'),
new PicturePath('files/picture-2.jpg'),
new Rcs('RCS NANTES 444 555 666'),
new Email('contact-2@example.com'),
);
$paginated = new Paginated($pagination, [$brand1, $brand2], 2);
$sort = new SortCriteria(field: SortField::CreatedAt, direction: \SortDirection::Descending);
$input = new Input(pagination: $pagination, sort: $sort);
$repository = \Mockery::mock(BrandRepository::class);
$repository->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);
});
});

View file

@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Brand\Update;
use Application\UseCases\Brand\Update\Input;
use Application\UseCases\Brand\Update\Output;
use Application\UseCases\Brand\Update\UpdateBrand;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Ports\Repositories\BrandRepository;
use Shared\DDD\Exceptions\NotFoundException;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\Rcs;
covers(UpdateBrand::class);
$existingBrand = static fn (BrandCode $code): Brand => 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);
});
});

View file

@ -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);
});
});

View file

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Exceptions;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use DomainException;
covers(BrandNotFoundException::class);
describe('brand not found exception', function (): void {
it('get the correct message from id', function (): void {
// GIVEN
$id = BrandId::generate();
// WHEN
$exception = BrandNotFoundException::fromId($id);
// THEN
expect($exception->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);
});
});

View file

@ -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(),
]);
});

View file

@ -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([

View file

@ -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(),
]);
});

View file

@ -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(),
]);
});

View file

@ -1,366 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Services;
use Domain\Services\BrandService;
use Domain\Enums\Brand\SortDirection;
use Domain\Enums\Brand\SortField;
use Domain\Models\Entities\Brand;
use Domain\Exceptions\BrandCodeAlreadyExistsException;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Ports\Repositories\BrandRepository;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Mockery;
use Shared\Helpers\Paginated;
use Shared\Helpers\Pagination;
use Shared\Models\ValueObjects\Email;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\RCS;
covers(BrandService::class);
describe('brand service', function (): void {
it('creates a 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');
$mock = Mockery::mock(BrandRepository::class);
$mock->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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});

View file

@ -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'
]);
});

View file

@ -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(),
]);
});

View file

@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Models\ValueObjects;
use DomainException;
use Shared\Models\ValueObjects\RCS;
use stdClass;
covers(RCS::class);
describe('RCS text', function (): void {
it('creates a valid RCS', function (): void {
// GIVEN
$rcsValue = 'RCS PARIS 111 222 333';
// WHEN
$rcs = new RCS($rcsValue);
// THEN
expect($rcs)->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(),
]);
});

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Models\ValueObjects;
use Shared\Models\ValueObjects\PicturePath;
use Shared\Models\ValueObjects\RawPicture;
covers(RawPicture::class);
describe('raw picture', function (): void {
it('creates a valid RawPicture', function (): void {
// GIVEN
$content = 'binary-content';
$originalName = 'logo.png';
// WHEN
$picture = new RawPicture($content, $originalName);
// THEN
expect($picture)->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);
});
});

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Shared\Models\ValueObjects;
use Shared\Models\ValueObjects\Rcs;
covers(Rcs::class);
describe('Rcs text', function (): void {
it('creates a valid Rcs', function (): void {
// GIVEN
$rcsValue = 'RCS PARIS 111 222 333';
// WHEN
$rcs = new Rcs($rcsValue);
// THEN
expect($rcs)->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',
]);
});