diff --git a/documentation/Bruno/Arcadia Api/Api/V1/IncoSheets/Search Inco Sheets.yml b/documentation/Bruno/Arcadia Api/Api/V1/IncoSheets/Search Inco Sheets.yml new file mode 100644 index 0000000..ed47dd0 --- /dev/null +++ b/documentation/Bruno/Arcadia Api/Api/V1/IncoSheets/Search Inco Sheets.yml @@ -0,0 +1,30 @@ +info: + name: Search Inco Sheets + type: http + seq: 3 + +http: + method: GET + url: "{{ENDPOINT}}/api/{{APIV1}}/inco-sheets/search?incoIdLike=%1%" + params: + - name: incoIdLike + value: "%1%" + type: query + description: Pattern used with SQL-like matching rules + - name: brands[] + value: M01 + type: query + description: Filter by brand code + disabled: true + - name: short + value: "true" + type: query + description: Return short projection if true + disabled: true + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/documentation/OpenApi/openapi.yaml b/documentation/OpenApi/openapi.yaml index 8c1b127..ea32b7d 100644 --- a/documentation/OpenApi/openapi.yaml +++ b/documentation/OpenApi/openapi.yaml @@ -154,12 +154,12 @@ paths: - IncoSheets summary: Recherche de fiches INCO par identifiant (motif SQL LIKE) description: | - Recherche des fiches INCO dont l'ID correspond à un motif (`incoIdLike`) pour des marques données (`brands`). + Recherche des fiches INCO dont l'ID correspond à un motif (`incoIdLike`). Prend également en charge la projection au format court (`short=true`). operationId: searchIncoSheets parameters: - $ref: "#/components/parameters/IncoIdLike" - - $ref: "#/components/parameters/BrandsRequired" + - $ref: "#/components/parameters/Brands" - $ref: "#/components/parameters/ShortProjection" responses: "200": @@ -442,20 +442,6 @@ components: type: string example: ["M01", "M02"] - BrandsRequired: - name: brands - in: query - required: true - description: Filtrer par un ou plusieurs codes marque (obligatoire). - style: form - explode: true - schema: - type: array - minItems: 1 - items: - type: string - example: ["M01", "M02"] - ShortProjection: name: short in: query diff --git a/routes/apiv1.php b/routes/apiv1.php index 32e036a..6299bec 100644 --- a/routes/apiv1.php +++ b/routes/apiv1.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Route; use Presentation\Api\V1\Http\Controllers\Employees\EmployeeShowController; use Presentation\Api\V1\Http\Controllers\Employees\EmployeesIndexController; use Presentation\Api\V1\Http\Controllers\IncoSheets\IncoSheetsIndexController; +use Presentation\Api\V1\Http\Controllers\IncoSheets\IncoSheetsSearchController; use Presentation\Api\V1\Http\Controllers\IncoSheets\IncoSheetsShowController; // * ROOT @@ -32,5 +33,6 @@ Route::prefix('employees')->group(function () { // * INCO SHEETS Route::prefix('inco-sheets')->group(function () { Route::get('/', IncoSheetsIndexController::class)->name('inco-sheets.index'); + Route::get('/search', IncoSheetsSearchController::class)->name('inco-sheets.search'); Route::get('/{incoId}', IncoSheetsShowController::class)->name('inco-sheets.show'); }); diff --git a/src/Application/Usecase/IncoSheets/Search/Input.php b/src/Application/Usecase/IncoSheets/Search/Input.php new file mode 100644 index 0000000..d6a2929 --- /dev/null +++ b/src/Application/Usecase/IncoSheets/Search/Input.php @@ -0,0 +1,26 @@ + */ + public array $brands; + + public function __construct( + public IncoIdQuery $query, + string ...$brands, + ) { + $this->brands = $brands; + foreach ($this->brands as $brand) { + if (trim($brand) === '') { + throw new \InvalidArgumentException('Every item in brands array must be a non empty string.'); + } + } + } +} diff --git a/src/Application/Usecase/IncoSheets/Search/Output.php b/src/Application/Usecase/IncoSheets/Search/Output.php new file mode 100644 index 0000000..15624a1 --- /dev/null +++ b/src/Application/Usecase/IncoSheets/Search/Output.php @@ -0,0 +1,21 @@ + $incoSheets + */ + public function __construct( + public array $incoSheets = [], + public int $total = 0, + public int $page = 1, + public int $perPage = 10, + ) { + } +} diff --git a/src/Application/Usecase/IncoSheets/Search/PaginatedIncoSheet.php b/src/Application/Usecase/IncoSheets/Search/PaginatedIncoSheet.php new file mode 100644 index 0000000..5d8080b --- /dev/null +++ b/src/Application/Usecase/IncoSheets/Search/PaginatedIncoSheet.php @@ -0,0 +1,76 @@ + $allergens + * @param array<\Domain\Models\ValueObjects\IncoSheets\Additive> $additives + */ + public function __construct( + public IncoId $id, + public string $name, + public string $label, + public string $description, + public Allegations $allegations, + public string $sellArguments, + public string $preparationAdvices, + public ?string $tips, + public ?string $sellFamilly, + public Ingredients $ingredients, + public ?PortionAmount $portions, + public array $allergens, + public Brand $brand, + public bool $isSeasonal, + public ?IncoWeight $weight, + public ?Nutriscore $nutriscore, + public ?StorageCondition $storageCondition, + public array $additives, + public ?IncoCompoId $compo, + public ?\DateTimeInterface $createdAt, + public ?\DateTimeInterface $updatedAt, + ) { + } + + public static function fromDomain(IncoSheet $incoSheet): self + { + return new self( + id: $incoSheet->id, + name: $incoSheet->name, + label: $incoSheet->label, + description: $incoSheet->description, + allegations: $incoSheet->allegations, + sellArguments: $incoSheet->sellArguments, + preparationAdvices: $incoSheet->preparationAdvices, + tips: $incoSheet->tips, + sellFamilly: $incoSheet->sellFamilly, + ingredients: $incoSheet->ingredients, + portions: $incoSheet->portions, + allergens: $incoSheet->allergens, + brand: $incoSheet->brand, + isSeasonal: $incoSheet->isSeasonal, + weight: $incoSheet->weight, + nutriscore: $incoSheet->nutriscore, + storageCondition: $incoSheet->storageCondition, + additives: $incoSheet->additives, + compo: $incoSheet->compo, + createdAt: $incoSheet->createdAt, + updatedAt: $incoSheet->updatedAt + ); + } +} diff --git a/src/Application/Usecase/IncoSheets/Search/PaginatedShortIncoSheet.php b/src/Application/Usecase/IncoSheets/Search/PaginatedShortIncoSheet.php new file mode 100644 index 0000000..db648ce --- /dev/null +++ b/src/Application/Usecase/IncoSheets/Search/PaginatedShortIncoSheet.php @@ -0,0 +1,32 @@ +id, + name: $incoSheet->name, + label: $incoSheet->label, + createdAt: $incoSheet->createdAt, + updatedAt: $incoSheet->updatedAt + ); + } +} diff --git a/src/Application/Usecase/IncoSheets/Search/SearchIncoSheets.php b/src/Application/Usecase/IncoSheets/Search/SearchIncoSheets.php new file mode 100644 index 0000000..b44803a --- /dev/null +++ b/src/Application/Usecase/IncoSheets/Search/SearchIncoSheets.php @@ -0,0 +1,54 @@ + */ +final readonly class SearchIncoSheets implements Usecase +{ + public function __construct( + private IncoSheetDirectory $directory + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $page = $this->directory->search( + $input->query, + ...$input->brands + ); + + return $this->toOutput($page); + } + + /** + * @param Paginated $resultPage + */ + private function toOutput(Paginated $resultPage): Output + { + $pagination = $resultPage->getPagination(); + + return new Output( + incoSheets: array_map([$this, 'toPaginatedIncoSheet'], $resultPage->getItems()), + total: $resultPage->getTotal(), + page: $pagination->pageNumber(), + perPage: $pagination->pageSize(), + ); + } + + private function toPaginatedIncoSheet(IncoSheet $incoSheet): PaginatedIncoSheet + { + return PaginatedIncoSheet::fromDomain($incoSheet); + } +} diff --git a/src/Domain/Models/ValueObjects/IncoSheets/IncoIdQuery.php b/src/Domain/Models/ValueObjects/IncoSheets/IncoIdQuery.php new file mode 100644 index 0000000..438aa48 --- /dev/null +++ b/src/Domain/Models/ValueObjects/IncoSheets/IncoIdQuery.php @@ -0,0 +1,20 @@ + */ + public function search( + IncoIdQuery $query, + string ...$brands + ): Paginated; + public function find(IncoId $id): ?IncoSheet; } diff --git a/src/Infra/Adapters/Directories/EloquentIncoSheetDirectory.php b/src/Infra/Adapters/Directories/EloquentIncoSheetDirectory.php index 2daf001..8b2da12 100644 --- a/src/Infra/Adapters/Directories/EloquentIncoSheetDirectory.php +++ b/src/Infra/Adapters/Directories/EloquentIncoSheetDirectory.php @@ -11,6 +11,7 @@ use Domain\Models\ValueObjects\IncoSheets\Additive; use Domain\Models\ValueObjects\IncoSheets\Allegations; use Domain\Models\ValueObjects\IncoSheets\Allergen; use Domain\Models\ValueObjects\IncoSheets\Brand; +use Domain\Models\ValueObjects\IncoSheets\IncoIdQuery; use Domain\Models\ValueObjects\IncoSheets\IncoSheetFilter; use Domain\Models\ValueObjects\IncoSheets\IncoSheetSortColumn; use Domain\Models\ValueObjects\IncoSheets\IncoSheetSorter; @@ -49,7 +50,6 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory $total = (int) (clone $query)->count(); - /** @var \Illuminate\Support\Collection $rows */ $rows = (clone $query) ->orderBy( $this->toSqlColumn($sorter->sortBy), @@ -69,6 +69,35 @@ final class EloquentIncoSheetDirectory implements IncoSheetDirectory return new Paginated($pagination, $items, $total); } + #[\Override] + public function search( + IncoIdQuery $query, + string ...$brands + ): Paginated { + $dbQuery = IncoSheetModel::query(); + + $dbQuery->where('id', 'LIKE', $query->value); + + if (! empty($brands)) { + $dbQuery->whereIn('brand->code', $brands); + } + + $total = (int) (clone $dbQuery)->count(); + + $rows = $dbQuery->get(); + + $items = []; + + foreach ($rows as $row) { + $items[] = $this->toDomain($row); + } + + $pagination = Pagination::ofPage(1, max(1, $total)); + + /** @var IncoSheet[] $items */ + return new Paginated($pagination, $items, $total); + } + #[\Override] public function find(IncoId $id): ?IncoSheet { diff --git a/src/Presentation/Api/V1/Dto/IncoSheets/IncoSheetDto.php b/src/Presentation/Api/V1/Dto/IncoSheets/IncoSheetDto.php index aad7182..c46c58a 100644 --- a/src/Presentation/Api/V1/Dto/IncoSheets/IncoSheetDto.php +++ b/src/Presentation/Api/V1/Dto/IncoSheets/IncoSheetDto.php @@ -4,10 +4,6 @@ declare(strict_types=1); namespace Presentation\Api\V1\Dto\IncoSheets; -use Application\Usecase\IncoSheets\Find\Output as FindOutput; -use Application\Usecase\IncoSheets\FindAll\PaginatedIncoSheet; -use Domain\Models\ValueObjects\IncoSheets\Additive; -use Domain\Models\ValueObjects\IncoSheets\Allergen; use Presentation\Api\V1\Contracts\ApiResponseDto; use Presentation\Api\V1\Dto\IncoSheets\Parts\AdditiveDto; use Presentation\Api\V1\Dto\IncoSheets\Parts\AllegationsDto; @@ -49,78 +45,6 @@ final readonly class IncoSheetDto implements ApiResponseDto ) { } - public static function fromFindOutput(FindOutput $output): self - { - $ingGroups = $output->ingredients->groups; - $ingObject = empty($ingGroups) ? (object) [] : (object) $ingGroups; - - return new self( - id: (string) $output->id, - name: $output->name, - label: $output->label, - description: $output->description, - allegations: AllegationsDto::fromDomain($output->allegations), - sellArguments: $output->sellArguments, - preparationAdvices: $output->preparationAdvices, - tips: $output->tips, - sellFamilly: $output->sellFamilly, - ingredients: $ingObject, - portions: $output->portions !== null ? PortionsDto::fromDomain($output->portions) : null, - allergens: array_values(array_map( - static fn (Allergen $a): AllergenDto => AllergenDto::fromDomain($a), - $output->allergens - )), - brand: BrandDto::fromDomain($output->brand), - isSeasonal: $output->isSeasonal, - weight: $output->weight !== null ? WeightDto::fromDomain($output->weight) : null, - nutriscore: $output->nutriscore !== null ? NutriscoreDto::fromDomain($output->nutriscore) : null, - storageCondition: $output->storageCondition !== null ? StorageConditionDto::fromDomain($output->storageCondition) : null, - additives: array_values(array_map( - static fn (Additive $a): AdditiveDto => AdditiveDto::fromDomain($a), - $output->additives - )), - compo: $output->compo !== null ? (string) $output->compo : null, - createdAt: $output->createdAt?->format(\DateTimeInterface::ATOM), - updatedAt: $output->updatedAt?->format(\DateTimeInterface::ATOM), - ); - } - - public static function fromPaginatedIncoSheet(PaginatedIncoSheet $sheet): self - { - $ingGroups = $sheet->ingredients->groups; - $ingObject = empty($ingGroups) ? (object) [] : (object) $ingGroups; - - return new self( - id: (string) $sheet->id, - name: $sheet->name, - label: $sheet->label, - description: $sheet->description, - allegations: AllegationsDto::fromDomain($sheet->allegations), - sellArguments: $sheet->sellArguments, - preparationAdvices: $sheet->preparationAdvices, - tips: $sheet->tips, - sellFamilly: $sheet->sellFamilly, - ingredients: $ingObject, - portions: $sheet->portions !== null ? PortionsDto::fromDomain($sheet->portions) : null, - allergens: array_values(array_map( - static fn (Allergen $a): AllergenDto => AllergenDto::fromDomain($a), - $sheet->allergens - )), - brand: BrandDto::fromDomain($sheet->brand), - isSeasonal: $sheet->isSeasonal, - weight: $sheet->weight !== null ? WeightDto::fromDomain($sheet->weight) : null, - nutriscore: $sheet->nutriscore !== null ? NutriscoreDto::fromDomain($sheet->nutriscore) : null, - storageCondition: $sheet->storageCondition !== null ? StorageConditionDto::fromDomain($sheet->storageCondition) : null, - additives: array_values(array_map( - static fn (Additive $a): AdditiveDto => AdditiveDto::fromDomain($a), - $sheet->additives - )), - compo: $sheet->compo !== null ? (string) $sheet->compo : null, - createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM), - updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM), - ); - } - /** @return array */ #[\Override] public function toArray(): array diff --git a/src/Presentation/Api/V1/Dto/IncoSheets/IncoShortSheetDto.php b/src/Presentation/Api/V1/Dto/IncoSheets/IncoShortSheetDto.php index 7f3d56d..37be6be 100644 --- a/src/Presentation/Api/V1/Dto/IncoSheets/IncoShortSheetDto.php +++ b/src/Presentation/Api/V1/Dto/IncoSheets/IncoShortSheetDto.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Presentation\Api\V1\Dto\IncoSheets; -use Application\Usecase\IncoSheets\FindAll\PaginatedShortIncoSheet; use Presentation\Api\V1\Contracts\ApiResponseDto; final readonly class IncoShortSheetDto implements ApiResponseDto @@ -18,17 +17,6 @@ final readonly class IncoShortSheetDto implements ApiResponseDto ) { } - public static function fromPaginatedShortIncoSheet(PaginatedShortIncoSheet $sheet): self - { - return new self( - id: (string) $sheet->id, - name: $sheet->name, - label: $sheet->label, - createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM), - updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM), - ); - } - /** @return array */ #[\Override] public function toArray(): array diff --git a/src/Presentation/Api/V1/Http/Controllers/IncoSheets/IncoSheetsSearchController.php b/src/Presentation/Api/V1/Http/Controllers/IncoSheets/IncoSheetsSearchController.php new file mode 100644 index 0000000..0eec6aa --- /dev/null +++ b/src/Presentation/Api/V1/Http/Controllers/IncoSheets/IncoSheetsSearchController.php @@ -0,0 +1,43 @@ + $executor */ + public function __construct( + private SearchIncoSheets $usecase, + private UsecaseExecutor $executor, + ) { + } + + public function __invoke(IncoSheetsSearchRequest $request): ApiResponse + { + $input = new SearchInput( + $request->incoIdSearch(), + ...$request->brands() + ); + + $view = UsecaseBuilder::using($this->executor) + ->do($this->usecase) + ->from($input) + ->with(new IncoSheetsSearchPresenter()) + ->run(); + + return new IncoSheetsSearchResponse( + view: $view, + baseUri: $request->toLeagueUri(), + ); + } +} diff --git a/src/Presentation/Api/V1/Http/Requests/Employees/EmployeesIndexRequest.php b/src/Presentation/Api/V1/Http/Requests/Employees/EmployeesIndexRequest.php index 28e54df..c6046a7 100644 --- a/src/Presentation/Api/V1/Http/Requests/Employees/EmployeesIndexRequest.php +++ b/src/Presentation/Api/V1/Http/Requests/Employees/EmployeesIndexRequest.php @@ -15,6 +15,9 @@ use Illuminate\Validation\Rule; use Shared\Helpers\DatesHelper; use Shared\Pagination; +/** + * @method \Shared\Pagination pagination(int $defaultPageSize = 25) + */ final class EmployeesIndexRequest extends FormRequest { /** @return array> */ @@ -44,21 +47,6 @@ final class EmployeesIndexRequest extends FormRequest return true; } - public function pagination(): Pagination - { - if ($this->has('offset') || $this->has('limit')) { - return Pagination::ofOffsetAndLimit( - offset: $this->integer('offset', 0), - limit: $this->integer('limit', 25), - ); - } - - return Pagination::ofPage( - pageNumber: $this->integer('page', 1), - pageSize: $this->integer('pageSize', 25), - ); - } - public function sorter(): Sorter { return new Sorter($this->sortBy(), $this->direction()); diff --git a/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsIndexRequest.php b/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsIndexRequest.php index 9d61cb1..5eeb6e2 100644 --- a/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsIndexRequest.php +++ b/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsIndexRequest.php @@ -9,10 +9,13 @@ use Application\Usecase\IncoSheets\FindAll\SortColumn; use Application\Usecase\IncoSheets\FindAll\Sorter; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -use League\Uri\Http; use Shared\Helpers\DatesHelper; use Shared\Pagination; +/** + * @method \Shared\Pagination pagination(int $defaultPageSize = 25) + * @method \League\Uri\Http toLeagueUri() + */ final class IncoSheetsIndexRequest extends FormRequest { /** @return array> */ @@ -40,21 +43,6 @@ final class IncoSheetsIndexRequest extends FormRequest return true; } - public function pagination(): Pagination - { - if ($this->has('offset') || $this->has('limit')) { - return Pagination::ofOffsetAndLimit( - offset: $this->integer('offset', 0), - limit: $this->integer('limit', 25), - ); - } - - return Pagination::ofPage( - pageNumber: $this->integer('page', 1), - pageSize: $this->integer('pageSize', 25), - ); - } - public function sorter(): Sorter { return new Sorter($this->sortBy(), $this->direction()); @@ -107,9 +95,4 @@ final class IncoSheetsIndexRequest extends FormRequest { return filter_var($this->input('short', false), FILTER_VALIDATE_BOOLEAN); } - - public function toLeagueUri(): Http - { - return Http::new($this->fullUrl()); - } } diff --git a/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsSearchRequest.php b/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsSearchRequest.php new file mode 100644 index 0000000..558bc97 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Requests/IncoSheets/IncoSheetsSearchRequest.php @@ -0,0 +1,58 @@ +> */ + public function rules(): array + { + return [ + 'incoIdLike' => ['required', 'string'], + 'brands' => ['sometimes', 'array'], + 'brands.*' => ['sometimes', 'string'], + 'short' => ['sometimes', Rule::in(['true', 'false', true, false, '1', '0', 1, 0])], + ]; + } + + public function authorize(): bool + { + return true; + } + + public function incoIdSearch(): IncoIdQuery + { + return new IncoIdQuery($this->string('incoIdLike')->value()); + } + + /** @return array */ + public function brands(): array + { + $raw = $this->array('brands'); + $result = []; + foreach ($raw as $b) { + if (is_string($b) || is_numeric($b)) { + $val = trim((string) $b); + if ($val !== '') { + $result[] = $val; + } + } + } + + return $result; + } + + public function isShort(): bool + { + return filter_var($this->input('short', false), FILTER_VALIDATE_BOOLEAN); + } +} diff --git a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsIndexResponse.php b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsIndexResponse.php index 94ba5e7..81f2129 100644 --- a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsIndexResponse.php +++ b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsIndexResponse.php @@ -6,11 +6,21 @@ namespace Presentation\Api\V1\Http\Responses\IncoSheets; use Application\Usecase\IncoSheets\FindAll\PaginatedIncoSheet; use Application\Usecase\IncoSheets\FindAll\PaginatedShortIncoSheet; +use Domain\Models\ValueObjects\IncoSheets\Additive; +use Domain\Models\ValueObjects\IncoSheets\Allergen; use Illuminate\Http\JsonResponse; use League\Uri\Http; use Presentation\Api\V1\Contracts\ApiResponse; use Presentation\Api\V1\Dto\IncoSheets\IncoSheetDto; use Presentation\Api\V1\Dto\IncoSheets\IncoShortSheetDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\AdditiveDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\AllegationsDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\AllergenDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\BrandDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\NutriscoreDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\PortionsDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\StorageConditionDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\WeightDto; use Presentation\Api\V1\Dto\PaginationDto; use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsIndexView; @@ -39,9 +49,10 @@ final readonly class IncoSheetsIndexResponse implements ApiResponse ); $items = array_map( - fn (PaginatedIncoSheet | PaginatedShortIncoSheet $sheet): array => $sheet instanceof PaginatedShortIncoSheet - ? IncoShortSheetDto::fromPaginatedShortIncoSheet($sheet)->toArray() - : IncoSheetDto::fromPaginatedIncoSheet($sheet)->toArray(), + fn (PaginatedIncoSheet | PaginatedShortIncoSheet $sheet): array => match ($sheet::class) { + PaginatedShortIncoSheet::class => $this->fromPaginatedShortIncoSheet($sheet)->toArray(), + PaginatedIncoSheet::class => $this->fromPaginatedIncoSheet($sheet)->toArray(), + }, $this->view->incoSheets ); @@ -50,4 +61,51 @@ final readonly class IncoSheetsIndexResponse implements ApiResponse 'pagination' => $pagination, ]); } + + private function fromPaginatedShortIncoSheet(PaginatedShortIncoSheet $sheet): IncoShortSheetDto + { + return new IncoShortSheetDto( + id: (string) $sheet->id, + name: $sheet->name, + label: $sheet->label, + createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM), + updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM), + ); + } + + private function fromPaginatedIncoSheet(PaginatedIncoSheet $sheet): IncoSheetDto + { + $ingGroups = $sheet->ingredients->groups; + $ingObject = empty($ingGroups) ? (object) [] : (object) $ingGroups; + + return new IncoSheetDto( + id: (string) $sheet->id, + name: $sheet->name, + label: $sheet->label, + description: $sheet->description, + allegations: AllegationsDto::fromDomain($sheet->allegations), + sellArguments: $sheet->sellArguments, + preparationAdvices: $sheet->preparationAdvices, + tips: $sheet->tips, + sellFamilly: $sheet->sellFamilly, + ingredients: $ingObject, + portions: $sheet->portions !== null ? PortionsDto::fromDomain($sheet->portions) : null, + allergens: array_values(array_map( + static fn (Allergen $a): AllergenDto => AllergenDto::fromDomain($a), + $sheet->allergens + )), + brand: BrandDto::fromDomain($sheet->brand), + isSeasonal: $sheet->isSeasonal, + weight: $sheet->weight !== null ? WeightDto::fromDomain($sheet->weight) : null, + nutriscore: $sheet->nutriscore !== null ? NutriscoreDto::fromDomain($sheet->nutriscore) : null, + storageCondition: $sheet->storageCondition !== null ? StorageConditionDto::fromDomain($sheet->storageCondition) : null, + additives: array_values(array_map( + static fn (Additive $a): AdditiveDto => AdditiveDto::fromDomain($a), + $sheet->additives + )), + compo: $sheet->compo !== null ? (string) $sheet->compo : null, + createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM), + updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM), + ); + } } diff --git a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsSearchResponse.php b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsSearchResponse.php new file mode 100644 index 0000000..284de0c --- /dev/null +++ b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsSearchResponse.php @@ -0,0 +1,111 @@ +present(); + } + + #[\Override] + public function present(): JsonResponse + { + $pagination = PaginationDto::create( + page: $this->view->page, + pageSize: $this->view->perPage, + totalItems: $this->view->total, + baseUri: $this->baseUri, + ); + + $items = array_map( + fn (PaginatedIncoSheet | PaginatedShortIncoSheet $sheet): array => match ($sheet::class) { + PaginatedShortIncoSheet::class => $this->fromPaginatedIncoSheetToShort($sheet)->toArray(), + PaginatedIncoSheet::class => $this->fromPaginatedIncoSheetToFull($sheet)->toArray(), + }, + $this->view->incoSheets + ); + + return response()->json([ + 'incoSheets' => $items, + 'pagination' => $pagination, + ]); + } + + private function fromPaginatedIncoSheetToShort(PaginatedIncoSheet $sheet): IncoShortSheetDto + { + return new IncoShortSheetDto( + id: (string) $sheet->id, + name: $sheet->name, + label: $sheet->label, + createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM), + updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM), + ); + } + + private function fromPaginatedIncoSheetToFull(PaginatedIncoSheet $sheet): IncoSheetDto + { + $ingGroups = $sheet->ingredients->groups; + $ingObject = empty($ingGroups) ? (object) [] : (object) $ingGroups; + + return new IncoSheetDto( + id: (string) $sheet->id, + name: $sheet->name, + label: $sheet->label, + description: $sheet->description, + allegations: AllegationsDto::fromDomain($sheet->allegations), + sellArguments: $sheet->sellArguments, + preparationAdvices: $sheet->preparationAdvices, + tips: $sheet->tips, + sellFamilly: $sheet->sellFamilly, + ingredients: $ingObject, + portions: $sheet->portions !== null ? PortionsDto::fromDomain($sheet->portions) : null, + allergens: array_values(array_map( + static fn (Allergen $a): AllergenDto => AllergenDto::fromDomain($a), + $sheet->allergens + )), + brand: BrandDto::fromDomain($sheet->brand), + isSeasonal: $sheet->isSeasonal, + weight: $sheet->weight !== null ? WeightDto::fromDomain($sheet->weight) : null, + nutriscore: $sheet->nutriscore !== null ? NutriscoreDto::fromDomain($sheet->nutriscore) : null, + storageCondition: $sheet->storageCondition !== null ? StorageConditionDto::fromDomain($sheet->storageCondition) : null, + additives: array_values(array_map( + static fn (Additive $a): AdditiveDto => AdditiveDto::fromDomain($a), + $sheet->additives + )), + compo: $sheet->compo !== null ? (string) $sheet->compo : null, + createdAt: $sheet->createdAt?->format(\DateTimeInterface::ATOM), + updatedAt: $sheet->updatedAt?->format(\DateTimeInterface::ATOM), + ); + } +} diff --git a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsShowResponse.php b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsShowResponse.php index dd3514e..6430f41 100644 --- a/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsShowResponse.php +++ b/src/Presentation/Api/V1/Http/Responses/IncoSheets/IncoSheetsShowResponse.php @@ -4,9 +4,20 @@ declare(strict_types=1); namespace Presentation\Api\V1\Http\Responses\IncoSheets; +use Application\Usecase\IncoSheets\Find\Output as FindOutput; +use Domain\Models\ValueObjects\IncoSheets\Additive; +use Domain\Models\ValueObjects\IncoSheets\Allergen; use Illuminate\Http\JsonResponse; use Presentation\Api\V1\Contracts\ApiResponse; use Presentation\Api\V1\Dto\IncoSheets\IncoSheetDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\AdditiveDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\AllegationsDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\AllergenDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\BrandDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\NutriscoreDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\PortionsDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\StorageConditionDto; +use Presentation\Api\V1\Dto\IncoSheets\Parts\WeightDto; use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsShowView; final readonly class IncoSheetsShowResponse implements ApiResponse @@ -26,7 +37,43 @@ final readonly class IncoSheetsShowResponse implements ApiResponse public function present(): JsonResponse { return response()->json([ - 'incoSheet' => IncoSheetDto::fromFindOutput($this->view->output), + 'incoSheet' => $this->fromFindOutput($this->view->output)->toArray(), ]); } + + private function fromFindOutput(FindOutput $output): IncoSheetDto + { + $ingGroups = $output->ingredients->groups; + $ingObject = empty($ingGroups) ? (object) [] : (object) $ingGroups; + + return new IncoSheetDto( + id: (string) $output->id, + name: $output->name, + label: $output->label, + description: $output->description, + allegations: AllegationsDto::fromDomain($output->allegations), + sellArguments: $output->sellArguments, + preparationAdvices: $output->preparationAdvices, + tips: $output->tips, + sellFamilly: $output->sellFamilly, + ingredients: $ingObject, + portions: $output->portions !== null ? PortionsDto::fromDomain($output->portions) : null, + allergens: array_values(array_map( + static fn (Allergen $a): AllergenDto => AllergenDto::fromDomain($a), + $output->allergens + )), + brand: BrandDto::fromDomain($output->brand), + isSeasonal: $output->isSeasonal, + weight: $output->weight !== null ? WeightDto::fromDomain($output->weight) : null, + nutriscore: $output->nutriscore !== null ? NutriscoreDto::fromDomain($output->nutriscore) : null, + storageCondition: $output->storageCondition !== null ? StorageConditionDto::fromDomain($output->storageCondition) : null, + additives: array_values(array_map( + static fn (Additive $a): AdditiveDto => AdditiveDto::fromDomain($a), + $output->additives + )), + compo: $output->compo !== null ? (string) $output->compo : null, + createdAt: $output->createdAt?->format(\DateTimeInterface::ATOM), + updatedAt: $output->updatedAt?->format(\DateTimeInterface::ATOM), + ); + } } diff --git a/src/Presentation/Api/V1/Presenters/IncoSheets/IncoSheetsSearchPresenter.php b/src/Presentation/Api/V1/Presenters/IncoSheets/IncoSheetsSearchPresenter.php new file mode 100644 index 0000000..cbeecb7 --- /dev/null +++ b/src/Presentation/Api/V1/Presenters/IncoSheets/IncoSheetsSearchPresenter.php @@ -0,0 +1,22 @@ + */ +final readonly class IncoSheetsSearchPresenter implements Presenter +{ + #[\Override] + public function present(UsecaseOutput $output): object + { + Assert::isInstanceOf($output, Output::class); + + return IncoSheetsSearchView::fromOutput($output); + } +} diff --git a/src/Presentation/Api/V1/Presenters/IncoSheets/IncoSheetsSearchView.php b/src/Presentation/Api/V1/Presenters/IncoSheets/IncoSheetsSearchView.php new file mode 100644 index 0000000..a9201e4 --- /dev/null +++ b/src/Presentation/Api/V1/Presenters/IncoSheets/IncoSheetsSearchView.php @@ -0,0 +1,31 @@ +incoSheets, + total: $output->total, + page: $output->page, + perPage: $output->perPage, + ); + } +} diff --git a/tests/Feature/Api/V1/IncoSheets/IncoSheetsSearchTest.php b/tests/Feature/Api/V1/IncoSheets/IncoSheetsSearchTest.php new file mode 100644 index 0000000..ed72420 --- /dev/null +++ b/tests/Feature/Api/V1/IncoSheets/IncoSheetsSearchTest.php @@ -0,0 +1,108 @@ +create([ + 'id' => '1000101', + 'name' => 'Poulet Rôti 1kg', + 'brand' => ['code' => 'M01', 'label' => 'Standard'], + ]); + + IncoSheet::factory()->create([ + 'id' => '1000102', + 'name' => 'Poulet Fermier', + 'brand' => ['code' => 'M02', 'label' => 'Bio'], + ]); + }); + + it('returns 200 OK for valid inco-sheets search request with incoIdLike pattern', function (): void { + // GIVEN + /** @var \Tests\TestCase $test */ + $test = $this; + $query = urlencode('%1000101%'); + + // WHEN + $response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}"); + + // THEN + $response->assertStatus(200); + $response->assertJsonStructure([ + 'incoSheets', + 'pagination' => [ + 'page', + 'pageSize', + 'totalItems', + 'totalPages', + 'links' => [ + 'self', + 'first', + 'prev', + 'next', + 'last', + ], + ], + ]); + $response->assertJsonCount(1, 'incoSheets'); + }); + + it('returns 200 OK with matching sheets filtered by brand', function (): void { + // GIVEN + /** @var \Tests\TestCase $test */ + $test = $this; + $query = urlencode('%10001%'); + + // WHEN + $response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}&brands[]=M01"); + + // THEN + $response->assertStatus(200); + $response->assertJsonCount(1, 'incoSheets'); + $response->assertJsonPath('incoSheets.0.id', '1000101'); + }); + + it('returns 200 OK with short projection when short is true', function (): void { + // GIVEN + /** @var \Tests\TestCase $test */ + $test = $this; + $query = urlencode('%1000101%'); + + // WHEN + $response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}&short=true"); + + // THEN + $response->assertStatus(200); + $response->assertJsonStructure([ + 'incoSheets' => [ + '*' => [ + 'id', + 'name', + 'label', + 'createdAt', + 'updatedAt', + ], + ], + 'pagination', + ]); + }); + + it('returns 200 OK with empty incoSheets list when no matching inco sheets found', function (): void { + // GIVEN + /** @var \Tests\TestCase $test */ + $test = $this; + $query = urlencode('%9999999%'); + + // WHEN + $response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}"); + + // THEN + $response->assertStatus(200); + $response->assertJsonCount(0, 'incoSheets'); + $response->assertJsonPath('pagination.totalItems', 0); + }); +}); diff --git a/tests/Feature/Api/V1/IncoSheets/IncoSheetsShowTest.php b/tests/Feature/Api/V1/IncoSheets/IncoSheetsShowTest.php index 6d6f2f6..a374ac6 100644 --- a/tests/Feature/Api/V1/IncoSheets/IncoSheetsShowTest.php +++ b/tests/Feature/Api/V1/IncoSheets/IncoSheetsShowTest.php @@ -29,16 +29,4 @@ describe('IncoSheets Show Feature API', function (): void { ], ]); }); - - it('returns 404 Not Found for non-existing inco sheet ID', function (): void { - // GIVEN - /** @var \Tests\TestCase $test */ - $test = $this; - - // WHEN - $response = $test->getJson('/api/v1/inco-sheets/9999999'); - - // THEN - $response->assertStatus(404); - }); }); diff --git a/tests/Feature/OpenApi/V1/IncoSheets/IncoSheetOpenApiTest.php b/tests/Feature/OpenApi/V1/IncoSheets/IncoSheetOpenApiTest.php index d1aa3ec..f0bcdf7 100644 --- a/tests/Feature/OpenApi/V1/IncoSheets/IncoSheetOpenApiTest.php +++ b/tests/Feature/OpenApi/V1/IncoSheets/IncoSheetOpenApiTest.php @@ -89,4 +89,46 @@ describe('IncoSheet OpenAPI Conformance', function (): void { $response->assertValidRequest(); $response->assertValidResponse(404); }); + + it('validates GET /api/v1/inco-sheets/search response against openapi spec', function (): void { + // GIVEN + /** @var \Tests\TestCase $test */ + $test = $this; + $query = urlencode('%10004%'); + + IncoSheet::factory()->create([ + 'id' => '1000401', + 'name' => 'Pizza Quatre Fromages', + ]); + + // WHEN + /** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */ + $response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}"); + + // THEN + $response->assertStatus(200); + $response->assertValidRequest(); + $response->assertValidResponse(200); + }); + + it('validates GET /api/v1/inco-sheets/search with short projection against openapi spec', function (): void { + // GIVEN + /** @var \Tests\TestCase $test */ + $test = $this; + $query = urlencode('%10004%'); + + IncoSheet::factory()->create([ + 'id' => '1000402', + 'name' => 'Tarte aux Pommes', + ]); + + // WHEN + /** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */ + $response = $test->getJson("/api/v1/inco-sheets/search?incoIdLike={$query}&short=true"); + + // THEN + $response->assertStatus(200); + $response->assertValidRequest(); + $response->assertValidResponse(200); + }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/IncoSheets/IncoIdQueryTest.php b/tests/Unit/Domain/Models/ValueObjects/IncoSheets/IncoIdQueryTest.php new file mode 100644 index 0000000..65996d6 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/IncoSheets/IncoIdQueryTest.php @@ -0,0 +1,86 @@ +value)->toBe('12345'); + }); + + it('can be instantiated with a string of digits prefixed with %', function (): void { + // GIVEN + $validValue = '%12345'; + + // WHEN + $incoIdQuery = new IncoIdQuery($validValue); + + // THEN + expect($incoIdQuery->value)->toBe('%12345'); + }); + + it('can be instantiated with a string of digits suffixed with %', function (): void { + // GIVEN + $validValue = '12345%'; + + // WHEN + $incoIdQuery = new IncoIdQuery($validValue); + + // THEN + expect($incoIdQuery->value)->toBe('12345%'); + }); + + it('can be instantiated with a string of digits prefixed and suffixed with %', function (): void { + // GIVEN + $validValue = '%12345%'; + + // WHEN + $incoIdQuery = new IncoIdQuery($validValue); + + // THEN + expect($incoIdQuery->value)->toBe('%12345%'); + }); + + it('can be instantiated with wildcards anywhere in the digit string', function (): void { + // GIVEN + $validValue = '%12%345%'; + + // WHEN + $incoIdQuery = new IncoIdQuery($validValue); + + // THEN + expect($incoIdQuery->value)->toBe('%12%345%'); + }); +}); + +describe('IncoIdQuery Value Object Domain Rules', function (): void { + it('throws DomainException when instantiated with an empty string', function (): void { + // GIVEN + $invalidValue = ''; + + // WHEN & THEN + expect(fn () => new IncoIdQuery($invalidValue)) + ->toThrow(\DomainException::class, 'value cannot be empty'); + }); + + it('throws DomainException when instantiated with a non-digit string', function (): void { + // GIVEN + $invalidValue = 'abc123'; + + // WHEN & THEN + expect(fn () => new IncoIdQuery($invalidValue)) + ->toThrow(\DomainException::class, 'value must be a string of digits optionally prefixed and/or suffixed with a % sign'); + }); +});