feat(brand): add Brand use cases, API v1 endpoints, console command, OpenAPI spec and Bruno collection

This commit is contained in:
Jérémy Munsch 2026-08-20 17:35:47 +02:00
parent 20ec692160
commit 1f8f03e6d8
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
37 changed files with 1734 additions and 0 deletions

View file

@ -0,0 +1,20 @@
info:
name: Get Brand
type: http
seq: 2
http:
method: GET
url: "{{ENDPOINT}}/api/{{APIV1}}/brands/:identifier"
params:
- name: identifier
value: "{{BRAND_IDENTIFIER}}"
type: path
description: "Brand UUID v7 or public code (e.g. LGL01)"
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View file

@ -0,0 +1,34 @@
info:
name: List Brands
type: http
seq: 1
http:
method: GET
url: "{{ENDPOINT}}/api/{{APIV1}}/brands?page=1&pageSize=25"
params:
- name: page
value: "1"
type: query
description: 1-based page index
- name: pageSize
value: "25"
type: query
description: Number of items per page (max 10000)
- name: offset
value: "0"
type: query
description: Offset for offset-based pagination
disabled: true
- name: limit
value: "25"
type: query
description: Limit for offset-based pagination
disabled: true
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View file

@ -0,0 +1,7 @@
info:
name: Brands
type: folder
seq: 2
request:
auth: inherit

View file

@ -12,6 +12,9 @@ variables:
- name: INCOSHEET_ID
value: "1"
description: Arcadia ID of an IncoSheet
- name: BRAND_IDENTIFIER
value: LGL01
description: Brand public code (e.g. LGL01) or UUID
- name: CLIENT_IDENTIFIER
value: arcadia_id_00000000000000000000000000000001
description: API Client Identifier for HMAC Authentication

View file

@ -24,6 +24,8 @@ security:
tags:
- name: Employees
description: Gestion et consultation du référentiel RH des employés.
- name: Brands
description: Consultation et référentiel des marques.
- name: IncoSheets
description: Consultation et recherche des fiches produits INCO.
- name: IncoComposition
@ -149,6 +151,78 @@ paths:
"503":
$ref: "#/components/responses/ServiceUnavailable"
/brands:
get:
tags:
- Brands
summary: Liste de toutes les marques
description: |
Retourne la liste de toutes les marques connues du système Arcadia.
Les marques non encore enregistrées localement sont automatiquement initialisées
et pourvues d'un code public unique (trigramme + 2 chiffres).
operationId: listBrands
parameters:
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PageSize"
- $ref: "#/components/parameters/Offset"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: Liste des marques récupérée avec succès.
content:
application/json:
schema:
$ref: "#/components/schemas/BrandListResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"422":
$ref: "#/components/responses/UnprocessableEntity"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalServerError"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/brands/{identifier}:
get:
tags:
- Brands
summary: Détail d'une marque par son identifiant UUID ou code public
description: |
Retourne la fiche d'une marque spécifiée soit par son identifiant unique UUID v7,
soit par son code public (ex: LGL01).
operationId: getBrand
parameters:
- $ref: "#/components/parameters/BrandIdentifierPath"
responses:
"200":
description: Fiche marque trouvée.
content:
application/json:
schema:
$ref: "#/components/schemas/BrandItemResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"422":
$ref: "#/components/responses/UnprocessableEntity"
"429":
$ref: "#/components/responses/TooManyRequests"
"500":
$ref: "#/components/responses/InternalServerError"
"503":
$ref: "#/components/responses/ServiceUnavailable"
/inco-sheets/search:
get:
tags:
@ -276,6 +350,15 @@ components:
description: Signature HMAC-SHA256 calculée avec la clé secrète partagée.
parameters:
BrandIdentifierPath:
name: identifier
in: path
required: true
description: Identifiant UUID v7 ou code public (ex. LGL01) de la marque.
schema:
type: string
example: "LGL01"
EmployeeIdPath:
name: employeeId
in: path
@ -542,6 +625,64 @@ components:
$ref: "#/components/schemas/ProblemDetails"
schemas:
Brand:
type: object
description: Représentation canonique d'une marque dans Arcadia API.
required:
- id
- code
- name
- arcadiaId
- createdAt
properties:
id:
type: string
format: uuid
description: Identifiant UUID v7 unique de la marque.
example: "01918a3c-1b77-7000-8000-000000000001"
code:
type: string
pattern: '^[A-Z]{3}\d{2}$'
description: Code public unique trigramme + 2 chiffres.
example: "LGL01"
name:
type: string
description: Nom / libellé de la marque.
example: "Le Gaulois"
arcadiaId:
type: integer
description: Identifiant numérique amont Arcadia.
example: 1
createdAt:
type: string
format: date-time
description: Date et heure de création (ISO 8601).
example: "2024-01-15T08:00:00Z"
BrandListResponse:
type: object
description: Enveloppe de réponse pour la liste paginée des marques.
required:
- brands
- pagination
properties:
brands:
type: array
description: Collection des marques de la page.
items:
$ref: "#/components/schemas/Brand"
pagination:
$ref: "#/components/schemas/PaginationMeta"
BrandItemResponse:
type: object
description: Enveloppe de réponse pour le détail d'une marque.
required:
- brand
properties:
brand:
$ref: "#/components/schemas/Brand"
Employee:
type: object
description: Représentation canonique d'un employé dans le métamodèle Arcadia.

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route;
use Presentation\Api\V1\Http\Controllers\Brands\BrandShowController;
use Presentation\Api\V1\Http\Controllers\Brands\BrandsIndexController;
use Presentation\Api\V1\Http\Controllers\Employees\EmployeeShowController;
use Presentation\Api\V1\Http\Controllers\Employees\EmployeesIndexController;
use Presentation\Api\V1\Http\Controllers\IncoCompositions\IncoCompositionShowController;
@ -42,6 +44,12 @@ Route::middleware('auth.hmac')->group(function (): void {
Route::get('/{employeeId}', EmployeeShowController::class)->name('employees.show');
});
// * BRANDS
Route::prefix('brands')->group(function (): void {
Route::get('/', BrandsIndexController::class)->name('brands.index');
Route::get('/{identifier}', BrandShowController::class)->name('brands.show');
});
// * INCO SHEETS
Route::prefix('inco-sheets')->group(function (): void {
Route::get('/', IncoSheetsIndexController::class)->name('inco-sheets.index');

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Usecase\Brands\Find;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Ports\Repositories\BrandRepository;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Arcadia\Usecase\Brands\Find\Input, \Application\Arcadia\Usecase\Brands\Find\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);
return match (true) {
$input->identifier instanceof BrandId => $this->findById($input->identifier),
$input->identifier instanceof BrandCode => $this->findByCode($input->identifier),
};
}
private function findById(BrandId $id): Output
{
$brand = $this->repository->find($id);
if ($brand === null) {
throw BrandNotFoundException::forBrand($id);
}
return new Output(
id: $brand->id(),
name: $brand->name,
code: $brand->code,
arcadiaId: $brand->arcadiaId,
createdAt: $brand->createdAt(),
);
}
private function findByCode(BrandCode $code): Output
{
$brand = $this->repository->findByCode($code);
if ($brand === null) {
throw BrandNotFoundException::forBrandCode($code);
}
return new Output(
id: $brand->id(),
name: $brand->name,
code: $brand->code,
arcadiaId: $brand->arcadiaId,
createdAt: $brand->createdAt(),
);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Usecase\Brands\Find;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Shared\DDD\Usecase\Input as UsecaseInput;
final readonly class Input implements UsecaseInput
{
public function __construct(
public BrandId | BrandCode $identifier,
) {
}
public static function byId(BrandId $id): self
{
return new self($id);
}
public static function byCode(BrandCode $code): self
{
return new self($code);
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Usecase\Brands\Find;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Shared\DDD\Usecase\Output as UsecaseOutput;
final readonly class Output implements UsecaseOutput
{
public function __construct(
public BrandId $id,
public BrandName $name,
public BrandCode $code,
public ArcadiaId $arcadiaId,
public \DateTimeInterface $createdAt,
) {
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Usecase\Brands\FindAll;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
final readonly class BrandItem
{
public function __construct(
public BrandId $id,
public BrandName $name,
public BrandCode $code,
public ArcadiaId $arcadiaId,
public \DateTimeInterface $createdAt,
) {
}
public static function fromDomain(Brand $brand): self
{
return new self(
id: $brand->id(),
name: $brand->name,
code: $brand->code,
arcadiaId: $brand->arcadiaId,
createdAt: $brand->createdAt(),
);
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Usecase\Brands\FindAll;
use Domain\Models\Entities\Brand;
use Domain\Services\BrandResolver;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Application\Arcadia\Usecase\Brands\FindAll\Input, \Application\Arcadia\Usecase\Brands\FindAll\Output> */
final readonly class FindAllBrands implements Usecase
{
public function __construct(
private BrandResolver $brandResolver,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$brands = $this->brandResolver->resolveAll();
$brandItems = array_map(
static fn (Brand $brand): BrandItem => BrandItem::fromDomain($brand),
$brands
);
$total = count($brandItems);
$offset = $input->pagination->from();
$limit = $input->pagination->pageSize();
$paged = array_values(array_slice($brandItems, $offset, $limit));
return new Output(
brands: $paged,
total: $total,
page: $input->pagination->pageNumber(),
perPage: $input->pagination->pageSize(),
);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Usecase\Brands\FindAll;
use Shared\DDD\Usecase\Input as UsecaseInput;
use Shared\Pagination;
final readonly class Input implements UsecaseInput
{
public function __construct(
public Pagination $pagination,
) {
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Application\Arcadia\Usecase\Brands\FindAll;
use Shared\DDD\Usecase\Output as UsecaseOutput;
final readonly class Output implements UsecaseOutput
{
/**
* @param list<BrandItem> $brands
*/
public function __construct(
public array $brands,
public int $total,
public int $page,
public int $perPage,
) {
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Dto\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Presentation\Api\V1\Contracts\ApiResponseDto;
use Presentation\Api\V1\Presenters\Brands\BrandShowView;
final readonly class BrandDto implements ApiResponseDto
{
public function __construct(
public string $id,
public string $code,
public string $name,
public int $arcadiaId,
public string $createdAt,
) {
}
public static function fromView(BrandShowView $view): self
{
return new self(
id: $view->id->value,
code: $view->code->value,
name: $view->name->value,
arcadiaId: $view->arcadiaId->value,
createdAt: $view->createdAt->format(\DateTimeInterface::ATOM),
);
}
public static function fromBrandItem(BrandItem $item): self
{
return new self(
id: $item->id->value,
code: $item->code->value,
name: $item->name->value,
arcadiaId: $item->arcadiaId->value,
createdAt: $item->createdAt->format(\DateTimeInterface::ATOM),
);
}
/** @return array<string, mixed> */
#[\Override]
public function toArray(): array
{
/** @var array<string, mixed> */
return get_object_vars($this);
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Controllers\Brands;
use Application\Arcadia\Usecase\Brands\Find\FindBrand;
use Application\Arcadia\Usecase\Brands\Find\Input;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Http\Requests\Brands\BrandShowRequest;
use Presentation\Api\V1\Http\Responses\Brands\BrandShowResponse;
use Presentation\Api\V1\Presenters\Brands\BrandShowPresenter;
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
final readonly class BrandShowController
{
public function __construct(
private FindBrand $usecase,
private UsecaseExecutor $executor,
private BrandShowPresenter $presenter,
) {
}
public function __invoke(BrandShowRequest $request): ApiResponse
{
$input = $this->inputFrom($request);
$response = UsecaseBuilder::using($this->executor)
->do($this->usecase)
->from($input)
->with($this->presenter)
->run();
return new BrandShowResponse($response);
}
private function inputFrom(BrandShowRequest $request): Input
{
return new Input(
identifier: $request->identifier(),
);
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Controllers\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\FindAllBrands;
use Application\Arcadia\Usecase\Brands\FindAll\Input;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Http\Requests\Brands\BrandsIndexRequest;
use Presentation\Api\V1\Http\Responses\Brands\BrandsIndexResponse;
use Presentation\Api\V1\Presenters\Brands\BrandsIndexPresenter;
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
final readonly class BrandsIndexController
{
public function __construct(
private FindAllBrands $usecase,
private UsecaseExecutor $executor,
private BrandsIndexPresenter $presenter,
) {
}
public function __invoke(BrandsIndexRequest $request): ApiResponse
{
$input = $this->inputFrom($request);
$response = UsecaseBuilder::using($this->executor)
->do($this->usecase)
->from($input)
->with($this->presenter)
->run();
return new BrandsIndexResponse($response, $request->toUri());
}
private function inputFrom(BrandsIndexRequest $request): Input
{
return new Input(
pagination: $request->pagination(),
);
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Requests\Brands;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Illuminate\Foundation\Http\FormRequest;
use Ramsey\Uuid\Uuid;
final class BrandShowRequest extends FormRequest
{
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'identifier' => [
'required',
'string',
'regex:/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[A-Z]{3}\d{2})$/i',
],
];
}
#[\Override]
protected function prepareForValidation(): void
{
$this->merge([
'identifier' => $this->route('identifier'),
]);
}
public function identifier(): BrandId | BrandCode
{
$val = $this->string('identifier')->value();
if (Uuid::isValid($val)) {
return new BrandId(Uuid::fromString($val));
}
return new BrandCode(strtoupper($val));
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Requests\Brands;
use Illuminate\Foundation\Http\FormRequest;
use Shared\Pagination;
/**
* @method \Shared\Pagination pagination(int $defaultPageSize = 25)
* @method \Uri\Rfc3986\Uri toUri()
*/
final class BrandsIndexRequest extends FormRequest
{
/** @return array<string, array<int, mixed>> */
public function rules(): array
{
return [
'offset' => ['sometimes', 'integer', 'min:0'],
'limit' => ['sometimes', 'integer', 'min:1', 'max:' . Pagination::MAX_PAGE_SIZE],
'page' => ['sometimes', 'integer', 'min:1'],
'pageSize' => ['sometimes', 'integer', 'min:1', 'max:' . Pagination::MAX_PAGE_SIZE],
];
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Responses\Brands;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Dto\Brands\BrandDto;
use Presentation\Api\V1\Presenters\Brands\BrandShowView;
final readonly class BrandShowResponse implements ApiResponse
{
public function __construct(
private BrandShowView $view,
) {
}
#[\Override]
public function toResponse($request): JsonResponse
{
return $this->present();
}
#[\Override]
public function present(): JsonResponse
{
return response()->json([
'brand' => BrandDto::fromView($this->view)->toArray(),
]);
}
}

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Responses\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Illuminate\Http\JsonResponse;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Dto\Brands\BrandDto;
use Presentation\Api\V1\Dto\PaginationDto;
use Presentation\Api\V1\Presenters\Brands\BrandsIndexView;
use Uri\Rfc3986\Uri;
final readonly class BrandsIndexResponse implements ApiResponse
{
public function __construct(
private BrandsIndexView $view,
private Uri $baseUri,
) {
}
#[\Override]
public function toResponse($request): JsonResponse
{
return $this->present();
}
#[\Override]
public function present(): JsonResponse
{
$pagination = PaginationDto::create(
page: $this->view->page,
pageSize: $this->view->perPage,
totalItems: $this->view->total,
baseUri: $this->baseUri,
);
return response()->json([
'brands' => array_map(
static fn (BrandItem $item): array => BrandDto::fromBrandItem($item)->toArray(),
$this->view->brands
),
'pagination' => $pagination,
]);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Presenters\Brands;
use Application\Arcadia\Usecase\Brands\Find\Output;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Webmozart\Assert\Assert;
/** @implements Presenter<Output, BrandShowView> */
final readonly class BrandShowPresenter implements Presenter
{
#[\Override]
public function present(UsecaseOutput $output): object
{
Assert::isInstanceOf($output, Output::class);
return new BrandShowView(
id: $output->id,
name: $output->name,
code: $output->code,
arcadiaId: $output->arcadiaId,
createdAt: $output->createdAt,
);
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Presenters\Brands;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
final readonly class BrandShowView
{
public function __construct(
public BrandId $id,
public BrandName $name,
public BrandCode $code,
public ArcadiaId $arcadiaId,
public \DateTimeInterface $createdAt,
) {
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Presenters\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\Output;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Webmozart\Assert\Assert;
/** @implements Presenter<Output, BrandsIndexView> */
final readonly class BrandsIndexPresenter implements Presenter
{
#[\Override]
public function present(UsecaseOutput $output): object
{
Assert::isInstanceOf($output, Output::class);
return new BrandsIndexView(
brands: $output->brands,
total: $output->total,
page: $output->page,
perPage: $output->perPage,
);
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Presenters\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
final readonly class BrandsIndexView
{
/**
* @param list<BrandItem> $brands
*/
public function __construct(
public array $brands,
public int $total,
public int $page,
public int $perPage,
) {
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Commands;
use Application\Arcadia\Usecase\Brands\FindAll\FindAllBrands;
use Application\Arcadia\Usecase\Brands\FindAll\Input;
use Illuminate\Console\Command;
use Presentation\Console\Presenters\Brands\BrandRow;
use Presentation\Console\Presenters\Brands\ListBrandsPresenter;
use Presentation\Console\Presenters\Brands\ListBrandsView;
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
use Shared\Pagination;
final class ListBrandsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'arcadia:brand:list
{--page=1 : The page number}
{--per-page=25 : The number of brands per page}';
/**
* @var string
*/
protected $description = 'List all Arcadia brands';
public function handle(
FindAllBrands $usecase,
UsecaseExecutor $executor,
ListBrandsPresenter $presenter,
): int {
$view = UsecaseBuilder::using($executor)
->do($usecase)
->from($this->getInput())
->with($presenter)
->run();
return $this->renderView($view);
}
private function getInput(): Input
{
return new Input($this->getPaginationFromOptions());
}
private function renderView(ListBrandsView $view): int
{
if ($view->isEmpty()) {
$this->info('No brands found.');
return self::SUCCESS;
}
$tableRows = array_map(
static fn (BrandRow $row): array => array_values(get_object_vars($row)),
$view->rows
);
$this->table($view->headers, $tableRows);
$this->info("Showing page {$view->page}/{$view->totalPages} (Total: {$view->total} brand(s))");
return self::SUCCESS;
}
private function getPaginationFromOptions(): Pagination
{
return Pagination::ofPage(
max(1, (int) $this->option('page')),
clamp(1, (int) $this->option('per-page'), Pagination::MAX_PAGE_SIZE)
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Brands;
final readonly class BrandRow
{
public function __construct(
public string $id,
public string $code,
public string $name,
public int $arcadiaId,
public string $createdAt,
) {
}
}

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Application\Arcadia\Usecase\Brands\FindAll\Output;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Webmozart\Assert\Assert;
/** @implements Presenter<Output, ListBrandsView> */
final readonly class ListBrandsPresenter implements Presenter
{
#[\Override]
public function present(UsecaseOutput $output): object
{
Assert::isInstanceOf($output, Output::class);
/** @var list<BrandRow> $rows */
$rows = array_values(array_map(
static fn (BrandItem $brand): BrandRow => new BrandRow(
id: $brand->id->value,
code: $brand->code->value,
name: $brand->name->value,
arcadiaId: $brand->arcadiaId->value,
createdAt: $brand->createdAt->format('Y-m-d H:i:s'),
),
$output->brands
));
$totalPages = $output->perPage > 0 ? (int) ceil($output->total / $output->perPage) : 0;
return new ListBrandsView(
headers: ['ID (UUID)', 'Code', 'Name', 'Arcadia ID', 'Created At'],
rows: $rows,
total: $output->total,
page: $output->page,
perPage: $output->perPage,
totalPages: $totalPages,
);
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Presentation\Console\Presenters\Brands;
final readonly class ListBrandsView
{
/**
* @param list<string> $headers
* @param list<BrandRow> $rows
*/
public function __construct(
public array $headers,
public array $rows,
public int $total,
public int $page,
public int $perPage,
public int $totalPages,
) {
}
public function isEmpty(): bool
{
return empty($this->rows);
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Console\Commands;
use Infra\Database\Models\ArcadiaBrand;
use Infra\Database\Models\Brand;
use Presentation\Console\Commands\ListBrandsCommand;
use Ramsey\Uuid\Uuid;
covers(ListBrandsCommand::class);
describe('ListBrandsCommand', function (): void {
beforeEach(function (): void {
Brand::query()->delete();
ArcadiaBrand::query()->delete();
});
it('outputs no brands found when arcadia database is empty', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
// WHEN
$this->artisan('arcadia:brand:list')
// THEN
->expectsOutput('No brands found.')
->assertSuccessful();
});
it('outputs table of brands and synchronizes missing ones', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
ArcadiaBrand::factory()->create([
'code' => 1,
'libelle' => 'Le Gaulois',
]);
ArcadiaBrand::factory()->create([
'code' => 2,
'libelle' => 'Maître CoQ',
]);
$existingUuid = Uuid::uuid7()->toString();
Brand::factory()->create([
'id' => $existingUuid,
'arcadia_id' => 1,
'uwil_public_id' => 'LGL01',
]);
// WHEN
$this->artisan('arcadia:brand:list')
// THEN
->expectsOutput('Showing page 1/1 (Total: 2 brand(s))')
->assertSuccessful();
// Check that brand 2 was auto-created in database
expect(Brand::query()->where('arcadia_id', 2)->exists())->toBeTrue();
});
it('respects pagination options', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
for ($i = 1; $i <= 3; $i++) {
ArcadiaBrand::factory()->create([
'code' => $i,
'libelle' => "Brand {$i}",
]);
}
// WHEN
$this->artisan('arcadia:brand:list', ['--page' => '2', '--per-page' => '2'])
// THEN
->expectsOutput('Showing page 2/2 (Total: 3 brand(s))')
->assertSuccessful();
});
});

View file

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\OpenApi\V1\Brands;
use Infra\Database\Models\ArcadiaBrand;
use Infra\Database\Models\Brand;
use Ramsey\Uuid\Uuid;
use Spectator\Spectator;
beforeEach(function (): void {
/** @var \Tests\TestCase $this */
Spectator::reset();
Spectator::using('openapi.yaml');
Brand::query()->delete();
ArcadiaBrand::query()->delete();
});
describe('Brand OpenAPI Conformance', function (): void {
it('validates GET /api/v1/brands list response against openapi spec', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
ArcadiaBrand::factory()->create([
'code' => 1,
'libelle' => 'Le Gaulois',
]);
ArcadiaBrand::factory()->create([
'code' => 2,
'libelle' => 'Maître CoQ',
]);
// WHEN
/** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */
$response = $test->getJson('/api/v1/brands');
// THEN
$response->assertStatus(200);
$response->assertValidRequest();
$response->assertValidResponse(200);
});
it('validates GET /api/v1/brands/{identifier} detail response by UUID against openapi spec', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
$arcadia = ArcadiaBrand::factory()->create([
'code' => 1,
'libelle' => 'Le Gaulois',
]);
$uuid = Uuid::uuid7()->toString();
Brand::factory()->create([
'id' => $uuid,
'arcadia_id' => $arcadia->code,
'uwil_public_id' => 'LGL01',
]);
// WHEN
/** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */
$response = $test->getJson("/api/v1/brands/{$uuid}");
// THEN
$response->assertStatus(200);
$response->assertValidRequest();
$response->assertValidResponse(200);
});
it('validates GET /api/v1/brands/{identifier} detail response by BrandCode against openapi spec', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
$arcadia = ArcadiaBrand::factory()->create([
'code' => 2,
'libelle' => 'Maître CoQ',
]);
Brand::factory()->create([
'id' => Uuid::uuid7()->toString(),
'arcadia_id' => $arcadia->code,
'uwil_public_id' => 'MCQ02',
]);
// WHEN
/** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */
$response = $test->getJson('/api/v1/brands/MCQ02');
// THEN
$response->assertStatus(200);
$response->assertValidRequest();
$response->assertValidResponse(200);
});
it('validates GET /api/v1/brands/{identifier} not found response against openapi spec', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
$unexistingId = Uuid::uuid7()->toString();
// WHEN
/** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */
$response = $test->getJson("/api/v1/brands/{$unexistingId}");
// THEN
$response->assertStatus(404);
$response->assertValidRequest();
$response->assertValidResponse(404);
});
it('validates GET /api/v1/brands/{identifier} validation error for invalid identifier', function (): void {
// GIVEN
/** @var \Tests\TestCase $test */
$test = $this;
$invalidId = 'invalid-format-123';
// WHEN
/** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */
$response = $test->getJson("/api/v1/brands/{$invalidId}");
// THEN
$response->assertStatus(422);
$response->assertValidResponse(422);
});
});

View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Arcadia\Usecase\Brands\Find;
use Application\Arcadia\Usecase\Brands\Find\FindBrand;
use Application\Arcadia\Usecase\Brands\Find\Input;
use Application\Arcadia\Usecase\Brands\Find\Output;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Infra\Adapters\Repositories\InMemoryBrandRepository;
use Ramsey\Uuid\Uuid;
covers(
FindBrand::class,
Input::class,
Output::class,
);
describe('FindBrand Usecase Unit Tests', function (): void {
$repository = new InMemoryBrandRepository();
beforeEach(function () use ($repository): void {
$repository->clear();
});
it('executes successfully and returns Output when brand exists by BrandId', function () use ($repository): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$brandName = new BrandName('Le Gaulois');
$brandCode = new BrandCode('LGL01');
$arcadiaId = new ArcadiaId(1);
$brand = Brand::hydrate(
id: $brandId,
name: $brandName,
code: $brandCode,
arcadiaId: $arcadiaId,
);
$repository->add($brand);
$usecase = new FindBrand($repository);
$input = Input::byId($brandId);
// WHEN
$result = $usecase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->id)->toBe($brandId)
->and($result->name)->toBe($brandName)
->and($result->code)->toBe($brandCode)
->and($result->arcadiaId)->toBe($arcadiaId);
});
it('executes successfully and returns Output when brand exists by BrandCode', function () use ($repository): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$brandName = new BrandName('Maître CoQ');
$brandCode = new BrandCode('MCQ02');
$arcadiaId = new ArcadiaId(2);
$brand = Brand::hydrate(
id: $brandId,
name: $brandName,
code: $brandCode,
arcadiaId: $arcadiaId,
);
$repository->add($brand);
$usecase = new FindBrand($repository);
$input = Input::byCode($brandCode);
// WHEN
$result = $usecase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->id)->toBe($brandId)
->and($result->name)->toBe($brandName)
->and($result->code)->toBe($brandCode)
->and($result->arcadiaId)->toBe($arcadiaId);
});
it('throws BrandNotFoundException when brand is not found by BrandId', function () use ($repository): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$usecase = new FindBrand($repository);
$input = Input::byId($brandId);
// WHEN
$execution = fn () => $usecase->execute($input);
// THEN
expect($execution)->toThrow(BrandNotFoundException::class, "Brand '{$brandId->value}' not found.");
});
it('throws BrandNotFoundException when brand is not found by BrandCode', function () use ($repository): void {
// GIVEN
$brandCode = new BrandCode('LGL98');
$usecase = new FindBrand($repository);
$input = Input::byCode($brandCode);
// WHEN
$execution = fn () => $usecase->execute($input);
// THEN
expect($execution)->toThrow(BrandNotFoundException::class, "Brand with code '{$brandCode->value}' not found.");
});
});

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Arcadia\Usecase\Brands\FindAll;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Application\Arcadia\Usecase\Brands\FindAll\FindAllBrands;
use Application\Arcadia\Usecase\Brands\FindAll\Input;
use Application\Arcadia\Usecase\Brands\FindAll\Output;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Services\BrandCodeGenerator;
use Domain\Services\BrandResolver;
use Infra\Adapters\Directories\InMemoryArcadiaBrandDirectory;
use Infra\Adapters\Repositories\InMemoryBrandRepository;
use Psr\Log\NullLogger;
use Ramsey\Uuid\Uuid;
use Shared\Pagination;
covers(
FindAllBrands::class,
Input::class,
Output::class,
BrandItem::class,
);
describe('FindAllBrands Usecase Unit Tests', function (): void {
$arcadiaDirectory = new InMemoryArcadiaBrandDirectory();
$brandRepository = new InMemoryBrandRepository();
$codeGenerator = new BrandCodeGenerator();
$logger = new NullLogger();
beforeEach(function () use ($arcadiaDirectory, $brandRepository): void {
$arcadiaDirectory->clear();
$brandRepository->clear();
});
it('returns existing brands and generates missing ones automatically', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$arcadia1 = new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois'));
$arcadia2 = new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ'));
$arcadiaDirectory->add($arcadia1, $arcadia2);
// Already existing brand in our repository for Arcadia 1
$existingBrand = Brand::hydrate(
id: new BrandId(Uuid::uuid7()),
name: new BrandName('Le Gaulois'),
code: new BrandCode('LGL01'),
arcadiaId: new ArcadiaId(1),
);
$brandRepository->add($existingBrand);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$usecase = new FindAllBrands($resolver);
$input = new Input(Pagination::ofPage(1, 25));
// WHEN
$result = $usecase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->brands)->toHaveCount(2)
->and($result->total)->toBe(2)
->and($result->page)->toBe(1)
->and($result->perPage)->toBe(25);
// First item was the existing one
expect($result->brands[0]->id)->toBe($existingBrand->id())
->and($result->brands[0]->code->value)->toBe('LGL01')
->and($result->brands[0]->name->value)->toBe('Le Gaulois');
// Second item was generated and persisted
expect($result->brands[1]->arcadiaId->value)->toBe(2)
->and($result->brands[1]->name->value)->toBe('Maître CoQ')
->and(str_starts_with($result->brands[1]->code->value, 'MCQ'))->toBeTrue();
// Check that the second brand was persisted in repository
expect($brandRepository->findByArcadiaId(new ArcadiaId(2)))->not->toBeNull();
});
it('slices brands according to pagination', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$arcadia1 = new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois'));
$arcadia2 = new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ'));
$arcadia3 = new ArcadiaBrand(new ArcadiaId(3), new BrandName('Loué'));
$arcadiaDirectory->add($arcadia1, $arcadia2, $arcadia3);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$usecase = new FindAllBrands($resolver);
$input = new Input(Pagination::ofPage(2, 2));
// WHEN
$result = $usecase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->brands)->toHaveCount(1)
->and($result->total)->toBe(3)
->and($result->page)->toBe(2)
->and($result->perPage)->toBe(2);
});
it('returns an empty list when there are no arcadia brands', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
// GIVEN
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$usecase = new FindAllBrands($resolver);
$input = new Input(Pagination::ofPage(1, 25));
// WHEN
$result = $usecase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class)
->and($result->brands)->toBeEmpty()
->and($result->total)->toBe(0);
});
});

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Presentation\Api\V1\Dto\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Presentation\Api\V1\Dto\Brands\BrandDto;
use Presentation\Api\V1\Presenters\Brands\BrandShowView;
use Ramsey\Uuid\Uuid;
covers(BrandDto::class);
describe('BrandDto Unit Tests', function (): void {
it('creates BrandDto from BrandShowView', function (): void {
// GIVEN
$id = new BrandId(Uuid::uuid7());
$name = new BrandName('Le Gaulois');
$code = new BrandCode('LGL01');
$arcadiaId = new ArcadiaId(1);
$createdAt = new \DateTimeImmutable('2024-01-15T08:00:00Z');
$view = new BrandShowView(
id: $id,
name: $name,
code: $code,
arcadiaId: $arcadiaId,
createdAt: $createdAt,
);
// WHEN
$dto = BrandDto::fromView($view);
// THEN
expect($dto->id)->toBe((string) $id)
->and($dto->name)->toBe('Le Gaulois')
->and($dto->code)->toBe('LGL01')
->and($dto->arcadiaId)->toBe(1)
->and($dto->createdAt)->toBe($createdAt->format(\DateTimeInterface::ATOM))
->and($dto->toArray())->toBe([
'id' => (string) $id,
'code' => 'LGL01',
'name' => 'Le Gaulois',
'arcadiaId' => 1,
'createdAt' => $createdAt->format(\DateTimeInterface::ATOM),
]);
});
it('creates BrandDto from BrandItem', function (): void {
// GIVEN
$id = new BrandId(Uuid::uuid7());
$name = new BrandName('Maître CoQ');
$code = new BrandCode('MCQ02');
$arcadiaId = new ArcadiaId(2);
$createdAt = new \DateTimeImmutable('2024-01-15T08:00:00Z');
$item = new BrandItem(
id: $id,
name: $name,
code: $code,
arcadiaId: $arcadiaId,
createdAt: $createdAt,
);
// WHEN
$dto = BrandDto::fromBrandItem($item);
// THEN
expect($dto->id)->toBe((string) $id)
->and($dto->name)->toBe('Maître CoQ')
->and($dto->code)->toBe('MCQ02')
->and($dto->arcadiaId)->toBe(2);
});
});

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Presentation\Api\V1\Http\Requests\Brands;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Presentation\Api\V1\Http\Requests\Brands\BrandShowRequest;
use Ramsey\Uuid\Uuid;
covers(BrandShowRequest::class);
describe('BrandShowRequest Unit Tests', function (): void {
it('returns validation rules', function (): void {
// GIVEN
$request = new BrandShowRequest();
// WHEN & THEN
expect($request->rules())->toHaveKey('identifier');
});
it('parses UUID identifier into BrandId', function (): void {
// GIVEN
$uuid = Uuid::uuid7()->toString();
$request = new BrandShowRequest();
$request->merge(['identifier' => $uuid]);
// WHEN
$identifier = $request->identifier();
// THEN
expect($identifier)->toBeInstanceOf(BrandId::class)
->and($identifier->value)->toBe($uuid);
});
it('parses code identifier into BrandCode', function (): void {
// GIVEN
$request = new BrandShowRequest();
$request->merge(['identifier' => 'LGL01']);
// WHEN
$identifier = $request->identifier();
// THEN
expect($identifier)->toBeInstanceOf(BrandCode::class)
->and($identifier->value)->toBe('LGL01');
});
});

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Presentation\Api\V1\Presenters\Brands;
use Application\Arcadia\Usecase\Brands\Find\Output;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Presentation\Api\V1\Presenters\Brands\BrandShowPresenter;
use Presentation\Api\V1\Presenters\Brands\BrandShowView;
use Ramsey\Uuid\Uuid;
covers(
BrandShowPresenter::class,
BrandShowView::class,
);
describe('BrandShowPresenter Unit Tests', function (): void {
it('transforms Output into BrandShowView', function (): void {
// GIVEN
$id = new BrandId(Uuid::uuid7());
$name = new BrandName('Le Gaulois');
$code = new BrandCode('LGL01');
$arcadiaId = new ArcadiaId(1);
$createdAt = new \DateTimeImmutable('2024-01-15T08:00:00Z');
$output = new Output(
id: $id,
name: $name,
code: $code,
arcadiaId: $arcadiaId,
createdAt: $createdAt,
);
$presenter = new BrandShowPresenter();
// WHEN
$view = $presenter->present($output);
// THEN
expect($view)->toBeInstanceOf(BrandShowView::class)
->and($view->id)->toBe($id)
->and($view->name)->toBe($name)
->and($view->code)->toBe($code)
->and($view->arcadiaId)->toBe($arcadiaId)
->and($view->createdAt)->toBe($createdAt);
});
});

View file

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Presentation\Api\V1\Presenters\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Application\Arcadia\Usecase\Brands\FindAll\Output;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Presentation\Api\V1\Presenters\Brands\BrandsIndexPresenter;
use Presentation\Api\V1\Presenters\Brands\BrandsIndexView;
use Ramsey\Uuid\Uuid;
covers(
BrandsIndexPresenter::class,
BrandsIndexView::class,
);
describe('BrandsIndexPresenter Unit Tests', function (): void {
it('transforms Output into BrandsIndexView', function (): void {
// GIVEN
$item1 = new BrandItem(
id: new BrandId(Uuid::uuid7()),
name: new BrandName('Le Gaulois'),
code: new BrandCode('LGL01'),
arcadiaId: new ArcadiaId(1),
createdAt: new \DateTimeImmutable('2024-01-15T08:00:00Z'),
);
$output = new Output([$item1], 1, 1, 25);
$presenter = new BrandsIndexPresenter();
// WHEN
$view = $presenter->present($output);
// THEN
expect($view)->toBeInstanceOf(BrandsIndexView::class)
->and($view->brands)->toHaveCount(1)
->and($view->brands[0])->toBe($item1)
->and($view->total)->toBe(1)
->and($view->page)->toBe(1)
->and($view->perPage)->toBe(25);
});
});

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Presentation\Console\Presenters\Brands;
use Application\Arcadia\Usecase\Brands\FindAll\BrandItem;
use Application\Arcadia\Usecase\Brands\FindAll\Output;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Presentation\Console\Presenters\Brands\BrandRow;
use Presentation\Console\Presenters\Brands\ListBrandsPresenter;
use Presentation\Console\Presenters\Brands\ListBrandsView;
use Ramsey\Uuid\Uuid;
covers(
ListBrandsPresenter::class,
ListBrandsView::class,
BrandRow::class,
);
describe('ListBrandsPresenter Unit Tests', function (): void {
it('transforms Output into ListBrandsView with formatted rows', function (): void {
// GIVEN
$id = new BrandId(Uuid::uuid7());
$name = new BrandName('Le Gaulois');
$code = new BrandCode('LGL01');
$arcadiaId = new ArcadiaId(1);
$createdAt = new \DateTimeImmutable('2024-01-15 08:00:00');
$item = new BrandItem($id, $name, $code, $arcadiaId, $createdAt);
$output = new Output([$item], 1, 1, 25);
$presenter = new ListBrandsPresenter();
// WHEN
$view = $presenter->present($output);
// THEN
expect($view)->toBeInstanceOf(ListBrandsView::class)
->and($view->isEmpty())->toBeFalse()
->and($view->total)->toBe(1)
->and($view->page)->toBe(1)
->and($view->perPage)->toBe(25)
->and($view->totalPages)->toBe(1)
->and($view->rows)->toHaveCount(1)
->and($view->rows[0]->id)->toBe((string) $id)
->and($view->rows[0]->code)->toBe('LGL01')
->and($view->rows[0]->name)->toBe('Le Gaulois')
->and($view->rows[0]->arcadiaId)->toBe(1)
->and($view->rows[0]->createdAt)->toBe('2024-01-15 08:00:00');
});
it('returns empty view when no brands in output', function (): void {
// GIVEN
$output = new Output([], 0, 1, 25);
$presenter = new ListBrandsPresenter();
// WHEN
$view = $presenter->present($output);
// THEN
expect($view)->toBeInstanceOf(ListBrandsView::class)
->and($view->isEmpty())->toBeTrue()
->and($view->total)->toBe(0)
->and($view->totalPages)->toBe(0)
->and($view->rows)->toBeEmpty();
});
});