fix: 🐛 sprint review reports for Brand processed
This commit is contained in:
parent
9b4a1934ff
commit
c79fe7d20b
70 changed files with 1580 additions and 1273 deletions
|
|
@ -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
82
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
54
src/Application/UseCases/Brand/Create/CreateBrand.php
Normal file
54
src/Application/UseCases/Brand/Create/CreateBrand.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
24
src/Application/UseCases/Brand/Create/Input.php
Normal file
24
src/Application/UseCases/Brand/Create/Input.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
25
src/Application/UseCases/Brand/Create/Output.php
Normal file
25
src/Application/UseCases/Brand/Create/Output.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
44
src/Application/UseCases/Brand/Find/FindBrand.php
Normal file
44
src/Application/UseCases/Brand/Find/FindBrand.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/Application/UseCases/Brand/Find/Input.php
Normal file
16
src/Application/UseCases/Brand/Find/Input.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
24
src/Application/UseCases/Brand/Find/Output/BrandFound.php
Normal file
24
src/Application/UseCases/Brand/Find/Output/BrandFound.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Application\UseCases\Brand\Find\Output;
|
||||
|
||||
final readonly class BrandNotFound implements Output
|
||||
{
|
||||
}
|
||||
11
src/Application/UseCases/Brand/Find/Output/Output.php
Normal file
11
src/Application/UseCases/Brand/Find/Output/Output.php
Normal 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
|
||||
{
|
||||
}
|
||||
19
src/Application/UseCases/Brand/List/BrandListItem.php
Normal file
19
src/Application/UseCases/Brand/List/BrandListItem.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
17
src/Application/UseCases/Brand/List/Input.php
Normal file
17
src/Application/UseCases/Brand/List/Input.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
50
src/Application/UseCases/Brand/List/ListBrands.php
Normal file
50
src/Application/UseCases/Brand/List/ListBrands.php
Normal 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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
17
src/Application/UseCases/Brand/List/Output.php
Normal file
17
src/Application/UseCases/Brand/List/Output.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
16
src/Application/UseCases/Brand/List/SortCriteria.php
Normal file
16
src/Application/UseCases/Brand/List/SortCriteria.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
29
src/Application/UseCases/Brand/Update/Input.php
Normal file
29
src/Application/UseCases/Brand/Update/Input.php
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/Application/UseCases/Brand/Update/Output.php
Normal file
25
src/Application/UseCases/Brand/Update/Output.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
59
src/Application/UseCases/Brand/Update/UpdateBrand.php
Normal file
59
src/Application/UseCases/Brand/Update/UpdateBrand.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domain\Enums\Brand;
|
||||
|
||||
enum SortDirection: string
|
||||
{
|
||||
case ASC = 'asc';
|
||||
case DESC = 'desc';
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
16
src/Domain/Ports/Services/PictureStorage.php
Normal file
16
src/Domain/Ports/Services/PictureStorage.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
13
src/Shared/DDD/Exceptions/InvalidUuidException.php
Normal file
13
src/Shared/DDD/Exceptions/InvalidUuidException.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
9
src/Shared/DDD/Exceptions/NotFoundException.php
Normal file
9
src/Shared/DDD/Exceptions/NotFoundException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD\Exceptions;
|
||||
|
||||
class NotFoundException extends \DomainException
|
||||
{
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD;
|
||||
namespace Shared\DDD\Models;
|
||||
|
||||
/**
|
||||
* @template T of EntityId
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD;
|
||||
namespace Shared\DDD\Models;
|
||||
|
||||
/**
|
||||
* @template T of EntityId
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD;
|
||||
namespace Shared\DDD\Models;
|
||||
|
||||
interface Equatable
|
||||
{
|
||||
10
src/Shared/DDD/Models/HasTimestampIdentifier.php
Normal file
10
src/Shared/DDD/Models/HasTimestampIdentifier.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD\Models;
|
||||
|
||||
interface HasTimestampIdentifier
|
||||
{
|
||||
public function createdAt(): \DateTimeInterface;
|
||||
}
|
||||
13
src/Shared/DDD/Ports/Repository.php
Normal file
13
src/Shared/DDD/Ports/Repository.php
Normal 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
|
||||
{
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD;
|
||||
|
||||
/**
|
||||
*/
|
||||
interface Repository
|
||||
{
|
||||
}
|
||||
9
src/Shared/DDD/UseCases/Input.php
Normal file
9
src/Shared/DDD/UseCases/Input.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD\UseCases;
|
||||
|
||||
interface Input
|
||||
{
|
||||
}
|
||||
9
src/Shared/DDD/UseCases/Output.php
Normal file
9
src/Shared/DDD/UseCases/Output.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shared\DDD\UseCases;
|
||||
|
||||
interface Output
|
||||
{
|
||||
}
|
||||
18
src/Shared/DDD/UseCases/UseCase.php
Normal file
18
src/Shared/DDD/UseCases/UseCase.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
44
src/Shared/Models/ValueObjects/RawPicture.php
Normal file
44
src/Shared/Models/ValueObjects/RawPicture.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Shared/Models/ValueObjects/Rcs.php
Normal file
32
src/Shared/Models/ValueObjects/Rcs.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
82
tests/Unit/Application/UseCases/Brand/Find/FindBrandTest.php
Normal file
82
tests/Unit/Application/UseCases/Brand/Find/FindBrandTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
112
tests/Unit/Application/UseCases/Brand/List/ListBrandsTest.php
Normal file
112
tests/Unit/Application/UseCases/Brand/List/ListBrandsTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
118
tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php
Normal file
118
tests/Unit/Application/UseCases/Brand/Update/UpdateBrandTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
130
tests/Unit/Shared/Models/ValueObjects/RawPictureTest.php
Normal file
130
tests/Unit/Shared/Models/ValueObjects/RawPictureTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
51
tests/Unit/Shared/Models/ValueObjects/RcsTest.php
Normal file
51
tests/Unit/Shared/Models/ValueObjects/RcsTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue