From 1f8f03e6d828f5d5fcb12a2f081d3bd67121e53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Thu, 20 Aug 2026 17:35:47 +0200 Subject: [PATCH] feat(brand): add Brand use cases, API v1 endpoints, console command, OpenAPI spec and Bruno collection --- .../Arcadia Api/Api/V1/Brands/Get Brand.yml | 20 +++ .../Arcadia Api/Api/V1/Brands/List Brands.yml | 34 +++++ .../Arcadia Api/Api/V1/Brands/folder.yml | 7 + .../Arcadia Api/environments/Local Dev.yml | 3 + documentation/OpenApi/openapi.yaml | 141 ++++++++++++++++++ routes/apiv1.php | 8 + .../Arcadia/Usecase/Brands/Find/FindBrand.php | 67 +++++++++ .../Arcadia/Usecase/Brands/Find/Input.php | 27 ++++ .../Arcadia/Usecase/Brands/Find/Output.php | 23 +++ .../Usecase/Brands/FindAll/BrandItem.php | 34 +++++ .../Usecase/Brands/FindAll/FindAllBrands.php | 45 ++++++ .../Arcadia/Usecase/Brands/FindAll/Input.php | 16 ++ .../Arcadia/Usecase/Brands/FindAll/Output.php | 21 +++ .../Api/V1/Dto/Brands/BrandDto.php | 51 +++++++ .../Brands/BrandShowController.php | 44 ++++++ .../Brands/BrandsIndexController.php | 44 ++++++ .../Http/Requests/Brands/BrandShowRequest.php | 44 ++++++ .../Requests/Brands/BrandsIndexRequest.php | 26 ++++ .../Responses/Brands/BrandShowResponse.php | 32 ++++ .../Responses/Brands/BrandsIndexResponse.php | 47 ++++++ .../Presenters/Brands/BrandShowPresenter.php | 28 ++++ .../V1/Presenters/Brands/BrandShowView.php | 22 +++ .../Brands/BrandsIndexPresenter.php | 27 ++++ .../V1/Presenters/Brands/BrandsIndexView.php | 21 +++ .../Console/Commands/ListBrandsCommand.php | 76 ++++++++++ .../Console/Presenters/Brands/BrandRow.php | 17 +++ .../Presenters/Brands/ListBrandsPresenter.php | 44 ++++++ .../Presenters/Brands/ListBrandsView.php | 27 ++++ .../Commands/ListBrandsCommandTest.php | 76 ++++++++++ .../OpenApi/V1/Brands/BrandOpenApiTest.php | 129 ++++++++++++++++ .../Usecase/Brands/Find/FindBrandTest.php | 115 ++++++++++++++ .../Brands/FindAll/FindAllBrandsTest.php | 122 +++++++++++++++ .../Api/V1/Dto/Brands/BrandDtoTest.php | 78 ++++++++++ .../Requests/Brands/BrandShowRequestTest.php | 49 ++++++ .../Brands/BrandShowPresenterTest.php | 51 +++++++ .../Brands/BrandsIndexPresenterTest.php | 47 ++++++ .../Brands/ListBrandsPresenterTest.php | 71 +++++++++ 37 files changed, 1734 insertions(+) create mode 100644 documentation/Bruno/Arcadia Api/Api/V1/Brands/Get Brand.yml create mode 100644 documentation/Bruno/Arcadia Api/Api/V1/Brands/List Brands.yml create mode 100644 documentation/Bruno/Arcadia Api/Api/V1/Brands/folder.yml create mode 100644 src/Application/Arcadia/Usecase/Brands/Find/FindBrand.php create mode 100644 src/Application/Arcadia/Usecase/Brands/Find/Input.php create mode 100644 src/Application/Arcadia/Usecase/Brands/Find/Output.php create mode 100644 src/Application/Arcadia/Usecase/Brands/FindAll/BrandItem.php create mode 100644 src/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrands.php create mode 100644 src/Application/Arcadia/Usecase/Brands/FindAll/Input.php create mode 100644 src/Application/Arcadia/Usecase/Brands/FindAll/Output.php create mode 100644 src/Presentation/Api/V1/Dto/Brands/BrandDto.php create mode 100644 src/Presentation/Api/V1/Http/Controllers/Brands/BrandShowController.php create mode 100644 src/Presentation/Api/V1/Http/Controllers/Brands/BrandsIndexController.php create mode 100644 src/Presentation/Api/V1/Http/Requests/Brands/BrandShowRequest.php create mode 100644 src/Presentation/Api/V1/Http/Requests/Brands/BrandsIndexRequest.php create mode 100644 src/Presentation/Api/V1/Http/Responses/Brands/BrandShowResponse.php create mode 100644 src/Presentation/Api/V1/Http/Responses/Brands/BrandsIndexResponse.php create mode 100644 src/Presentation/Api/V1/Presenters/Brands/BrandShowPresenter.php create mode 100644 src/Presentation/Api/V1/Presenters/Brands/BrandShowView.php create mode 100644 src/Presentation/Api/V1/Presenters/Brands/BrandsIndexPresenter.php create mode 100644 src/Presentation/Api/V1/Presenters/Brands/BrandsIndexView.php create mode 100644 src/Presentation/Console/Commands/ListBrandsCommand.php create mode 100644 src/Presentation/Console/Presenters/Brands/BrandRow.php create mode 100644 src/Presentation/Console/Presenters/Brands/ListBrandsPresenter.php create mode 100644 src/Presentation/Console/Presenters/Brands/ListBrandsView.php create mode 100644 tests/Feature/Console/Commands/ListBrandsCommandTest.php create mode 100644 tests/Feature/OpenApi/V1/Brands/BrandOpenApiTest.php create mode 100644 tests/Unit/Application/Arcadia/Usecase/Brands/Find/FindBrandTest.php create mode 100644 tests/Unit/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrandsTest.php create mode 100644 tests/Unit/Presentation/Api/V1/Dto/Brands/BrandDtoTest.php create mode 100644 tests/Unit/Presentation/Api/V1/Http/Requests/Brands/BrandShowRequestTest.php create mode 100644 tests/Unit/Presentation/Api/V1/Presenters/Brands/BrandShowPresenterTest.php create mode 100644 tests/Unit/Presentation/Api/V1/Presenters/Brands/BrandsIndexPresenterTest.php create mode 100644 tests/Unit/Presentation/Console/Presenters/Brands/ListBrandsPresenterTest.php diff --git a/documentation/Bruno/Arcadia Api/Api/V1/Brands/Get Brand.yml b/documentation/Bruno/Arcadia Api/Api/V1/Brands/Get Brand.yml new file mode 100644 index 0000000..b640b63 --- /dev/null +++ b/documentation/Bruno/Arcadia Api/Api/V1/Brands/Get Brand.yml @@ -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 diff --git a/documentation/Bruno/Arcadia Api/Api/V1/Brands/List Brands.yml b/documentation/Bruno/Arcadia Api/Api/V1/Brands/List Brands.yml new file mode 100644 index 0000000..035ae8a --- /dev/null +++ b/documentation/Bruno/Arcadia Api/Api/V1/Brands/List Brands.yml @@ -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 diff --git a/documentation/Bruno/Arcadia Api/Api/V1/Brands/folder.yml b/documentation/Bruno/Arcadia Api/Api/V1/Brands/folder.yml new file mode 100644 index 0000000..133331b --- /dev/null +++ b/documentation/Bruno/Arcadia Api/Api/V1/Brands/folder.yml @@ -0,0 +1,7 @@ +info: + name: Brands + type: folder + seq: 2 + +request: + auth: inherit diff --git a/documentation/Bruno/Arcadia Api/environments/Local Dev.yml b/documentation/Bruno/Arcadia Api/environments/Local Dev.yml index a18caa8..a03af9e 100644 --- a/documentation/Bruno/Arcadia Api/environments/Local Dev.yml +++ b/documentation/Bruno/Arcadia Api/environments/Local Dev.yml @@ -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 diff --git a/documentation/OpenApi/openapi.yaml b/documentation/OpenApi/openapi.yaml index b351401..6dc7835 100644 --- a/documentation/OpenApi/openapi.yaml +++ b/documentation/OpenApi/openapi.yaml @@ -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. diff --git a/routes/apiv1.php b/routes/apiv1.php index 632e2aa..0fd58a1 100644 --- a/routes/apiv1.php +++ b/routes/apiv1.php @@ -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'); diff --git a/src/Application/Arcadia/Usecase/Brands/Find/FindBrand.php b/src/Application/Arcadia/Usecase/Brands/Find/FindBrand.php new file mode 100644 index 0000000..50db367 --- /dev/null +++ b/src/Application/Arcadia/Usecase/Brands/Find/FindBrand.php @@ -0,0 +1,67 @@ + */ +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(), + ); + } +} diff --git a/src/Application/Arcadia/Usecase/Brands/Find/Input.php b/src/Application/Arcadia/Usecase/Brands/Find/Input.php new file mode 100644 index 0000000..c143d0b --- /dev/null +++ b/src/Application/Arcadia/Usecase/Brands/Find/Input.php @@ -0,0 +1,27 @@ +id(), + name: $brand->name, + code: $brand->code, + arcadiaId: $brand->arcadiaId, + createdAt: $brand->createdAt(), + ); + } +} diff --git a/src/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrands.php b/src/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrands.php new file mode 100644 index 0000000..0d49fcf --- /dev/null +++ b/src/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrands.php @@ -0,0 +1,45 @@ + */ +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(), + ); + } +} diff --git a/src/Application/Arcadia/Usecase/Brands/FindAll/Input.php b/src/Application/Arcadia/Usecase/Brands/FindAll/Input.php new file mode 100644 index 0000000..97f2979 --- /dev/null +++ b/src/Application/Arcadia/Usecase/Brands/FindAll/Input.php @@ -0,0 +1,16 @@ + $brands + */ + public function __construct( + public array $brands, + public int $total, + public int $page, + public int $perPage, + ) { + } +} diff --git a/src/Presentation/Api/V1/Dto/Brands/BrandDto.php b/src/Presentation/Api/V1/Dto/Brands/BrandDto.php new file mode 100644 index 0000000..96600b0 --- /dev/null +++ b/src/Presentation/Api/V1/Dto/Brands/BrandDto.php @@ -0,0 +1,51 @@ +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 */ + #[\Override] + public function toArray(): array + { + /** @var array */ + return get_object_vars($this); + } +} diff --git a/src/Presentation/Api/V1/Http/Controllers/Brands/BrandShowController.php b/src/Presentation/Api/V1/Http/Controllers/Brands/BrandShowController.php new file mode 100644 index 0000000..8619a85 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Controllers/Brands/BrandShowController.php @@ -0,0 +1,44 @@ +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(), + ); + } +} diff --git a/src/Presentation/Api/V1/Http/Controllers/Brands/BrandsIndexController.php b/src/Presentation/Api/V1/Http/Controllers/Brands/BrandsIndexController.php new file mode 100644 index 0000000..0293014 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Controllers/Brands/BrandsIndexController.php @@ -0,0 +1,44 @@ +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(), + ); + } +} diff --git a/src/Presentation/Api/V1/Http/Requests/Brands/BrandShowRequest.php b/src/Presentation/Api/V1/Http/Requests/Brands/BrandShowRequest.php new file mode 100644 index 0000000..78c4269 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Requests/Brands/BrandShowRequest.php @@ -0,0 +1,44 @@ +> */ + 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)); + } +} diff --git a/src/Presentation/Api/V1/Http/Requests/Brands/BrandsIndexRequest.php b/src/Presentation/Api/V1/Http/Requests/Brands/BrandsIndexRequest.php new file mode 100644 index 0000000..1d4c3e1 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Requests/Brands/BrandsIndexRequest.php @@ -0,0 +1,26 @@ +> */ + 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], + ]; + } +} diff --git a/src/Presentation/Api/V1/Http/Responses/Brands/BrandShowResponse.php b/src/Presentation/Api/V1/Http/Responses/Brands/BrandShowResponse.php new file mode 100644 index 0000000..f72b966 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Responses/Brands/BrandShowResponse.php @@ -0,0 +1,32 @@ +present(); + } + + #[\Override] + public function present(): JsonResponse + { + return response()->json([ + 'brand' => BrandDto::fromView($this->view)->toArray(), + ]); + } +} diff --git a/src/Presentation/Api/V1/Http/Responses/Brands/BrandsIndexResponse.php b/src/Presentation/Api/V1/Http/Responses/Brands/BrandsIndexResponse.php new file mode 100644 index 0000000..0fb5ab6 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Responses/Brands/BrandsIndexResponse.php @@ -0,0 +1,47 @@ +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, + ]); + } +} diff --git a/src/Presentation/Api/V1/Presenters/Brands/BrandShowPresenter.php b/src/Presentation/Api/V1/Presenters/Brands/BrandShowPresenter.php new file mode 100644 index 0000000..afc45f5 --- /dev/null +++ b/src/Presentation/Api/V1/Presenters/Brands/BrandShowPresenter.php @@ -0,0 +1,28 @@ + */ +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, + ); + } +} diff --git a/src/Presentation/Api/V1/Presenters/Brands/BrandShowView.php b/src/Presentation/Api/V1/Presenters/Brands/BrandShowView.php new file mode 100644 index 0000000..599cabf --- /dev/null +++ b/src/Presentation/Api/V1/Presenters/Brands/BrandShowView.php @@ -0,0 +1,22 @@ + */ +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, + ); + } +} diff --git a/src/Presentation/Api/V1/Presenters/Brands/BrandsIndexView.php b/src/Presentation/Api/V1/Presenters/Brands/BrandsIndexView.php new file mode 100644 index 0000000..8866e8a --- /dev/null +++ b/src/Presentation/Api/V1/Presenters/Brands/BrandsIndexView.php @@ -0,0 +1,21 @@ + $brands + */ + public function __construct( + public array $brands, + public int $total, + public int $page, + public int $perPage, + ) { + } +} diff --git a/src/Presentation/Console/Commands/ListBrandsCommand.php b/src/Presentation/Console/Commands/ListBrandsCommand.php new file mode 100644 index 0000000..e897ad2 --- /dev/null +++ b/src/Presentation/Console/Commands/ListBrandsCommand.php @@ -0,0 +1,76 @@ +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) + ); + } +} diff --git a/src/Presentation/Console/Presenters/Brands/BrandRow.php b/src/Presentation/Console/Presenters/Brands/BrandRow.php new file mode 100644 index 0000000..983d0fc --- /dev/null +++ b/src/Presentation/Console/Presenters/Brands/BrandRow.php @@ -0,0 +1,17 @@ + */ +final readonly class ListBrandsPresenter implements Presenter +{ + #[\Override] + public function present(UsecaseOutput $output): object + { + Assert::isInstanceOf($output, Output::class); + + /** @var list $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, + ); + } +} diff --git a/src/Presentation/Console/Presenters/Brands/ListBrandsView.php b/src/Presentation/Console/Presenters/Brands/ListBrandsView.php new file mode 100644 index 0000000..c459910 --- /dev/null +++ b/src/Presentation/Console/Presenters/Brands/ListBrandsView.php @@ -0,0 +1,27 @@ + $headers + * @param list $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); + } +} diff --git a/tests/Feature/Console/Commands/ListBrandsCommandTest.php b/tests/Feature/Console/Commands/ListBrandsCommandTest.php new file mode 100644 index 0000000..fc07977 --- /dev/null +++ b/tests/Feature/Console/Commands/ListBrandsCommandTest.php @@ -0,0 +1,76 @@ +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(); + }); +}); diff --git a/tests/Feature/OpenApi/V1/Brands/BrandOpenApiTest.php b/tests/Feature/OpenApi/V1/Brands/BrandOpenApiTest.php new file mode 100644 index 0000000..10e35be --- /dev/null +++ b/tests/Feature/OpenApi/V1/Brands/BrandOpenApiTest.php @@ -0,0 +1,129 @@ +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); + }); +}); diff --git a/tests/Unit/Application/Arcadia/Usecase/Brands/Find/FindBrandTest.php b/tests/Unit/Application/Arcadia/Usecase/Brands/Find/FindBrandTest.php new file mode 100644 index 0000000..702e111 --- /dev/null +++ b/tests/Unit/Application/Arcadia/Usecase/Brands/Find/FindBrandTest.php @@ -0,0 +1,115 @@ +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."); + }); +}); diff --git a/tests/Unit/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrandsTest.php b/tests/Unit/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrandsTest.php new file mode 100644 index 0000000..99a3bd2 --- /dev/null +++ b/tests/Unit/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrandsTest.php @@ -0,0 +1,122 @@ +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); + }); +}); diff --git a/tests/Unit/Presentation/Api/V1/Dto/Brands/BrandDtoTest.php b/tests/Unit/Presentation/Api/V1/Dto/Brands/BrandDtoTest.php new file mode 100644 index 0000000..bbc48a2 --- /dev/null +++ b/tests/Unit/Presentation/Api/V1/Dto/Brands/BrandDtoTest.php @@ -0,0 +1,78 @@ +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); + }); +}); diff --git a/tests/Unit/Presentation/Api/V1/Http/Requests/Brands/BrandShowRequestTest.php b/tests/Unit/Presentation/Api/V1/Http/Requests/Brands/BrandShowRequestTest.php new file mode 100644 index 0000000..6b8336a --- /dev/null +++ b/tests/Unit/Presentation/Api/V1/Http/Requests/Brands/BrandShowRequestTest.php @@ -0,0 +1,49 @@ +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'); + }); +}); diff --git a/tests/Unit/Presentation/Api/V1/Presenters/Brands/BrandShowPresenterTest.php b/tests/Unit/Presentation/Api/V1/Presenters/Brands/BrandShowPresenterTest.php new file mode 100644 index 0000000..212ec15 --- /dev/null +++ b/tests/Unit/Presentation/Api/V1/Presenters/Brands/BrandShowPresenterTest.php @@ -0,0 +1,51 @@ +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); + }); +}); diff --git a/tests/Unit/Presentation/Api/V1/Presenters/Brands/BrandsIndexPresenterTest.php b/tests/Unit/Presentation/Api/V1/Presenters/Brands/BrandsIndexPresenterTest.php new file mode 100644 index 0000000..07b1d35 --- /dev/null +++ b/tests/Unit/Presentation/Api/V1/Presenters/Brands/BrandsIndexPresenterTest.php @@ -0,0 +1,47 @@ +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); + }); +}); diff --git a/tests/Unit/Presentation/Console/Presenters/Brands/ListBrandsPresenterTest.php b/tests/Unit/Presentation/Console/Presenters/Brands/ListBrandsPresenterTest.php new file mode 100644 index 0000000..73e0af0 --- /dev/null +++ b/tests/Unit/Presentation/Console/Presenters/Brands/ListBrandsPresenterTest.php @@ -0,0 +1,71 @@ +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(); + }); +});