feat(IncoSheets): add search functionality for inco sheets

- Introduced IncoIdQuery value object for validating search queries.
- Implemented search method in IncoSheetDirectory and EloquentIncoSheetDirectory.
- Created IncoSheetsSearchController to handle search requests.
- Added IncoSheetsSearchRequest for validating incoming search parameters.
- Developed IncoSheetsSearchResponse for formatting search results.
- Added tests for search functionality in IncoSheetsSearchTest.
- Updated OpenAPI tests to validate search endpoint.
This commit is contained in:
Jérémy Munsch 2026-08-14 15:05:59 +02:00
parent ef8de85088
commit 4d24e5c3fa
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
26 changed files with 917 additions and 157 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Application\Usecase\IncoSheets\Search;
use Domain\Models\ValueObjects\IncoSheets\IncoIdQuery;
use Shared\DDD\Usecase\Input as UsecaseInput;
final readonly class Input implements UsecaseInput
{
/** @var array<string> */
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.');
}
}
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Application\Usecase\IncoSheets\Search;
use Shared\DDD\Usecase\Output as UsecaseOutput;
final readonly class Output implements UsecaseOutput
{
/**
* @param array<PaginatedIncoSheet> $incoSheets
*/
public function __construct(
public array $incoSheets = [],
public int $total = 0,
public int $page = 1,
public int $perPage = 10,
) {
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Application\Usecase\IncoSheets\Search;
use Domain\Models\Entities\IncoSheet;
use Domain\Models\ValueObjects\Entities\IncoCompoId;
use Domain\Models\ValueObjects\Entities\IncoId;
use Domain\Models\ValueObjects\IncoSheets\Allegations;
use Domain\Models\ValueObjects\IncoSheets\Brand;
use Domain\Models\ValueObjects\IncoSheets\IncoWeight;
use Domain\Models\ValueObjects\IncoSheets\Ingredients;
use Domain\Models\ValueObjects\IncoSheets\Nutriscore;
use Domain\Models\ValueObjects\IncoSheets\PortionAmount;
use Domain\Models\ValueObjects\IncoSheets\StorageCondition;
use Shared\DDD\Usecase\Output as UsecaseOutput;
final readonly class PaginatedIncoSheet implements UsecaseOutput
{
/**
* @param array<\Domain\Models\ValueObjects\IncoSheets\Allergen> $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
);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Application\Usecase\IncoSheets\Search;
use Domain\Models\Entities\IncoSheet;
use Domain\Models\ValueObjects\Entities\IncoId;
use Shared\DDD\Usecase\Output as UsecaseOutput;
final readonly class PaginatedShortIncoSheet implements UsecaseOutput
{
public function __construct(
public IncoId $id,
public string $name,
public string $label,
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,
createdAt: $incoSheet->createdAt,
updatedAt: $incoSheet->updatedAt
);
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Application\Usecase\IncoSheets\Search;
use Domain\Models\Entities\IncoSheet;
use Domain\Ports\Directories\IncoSheetDirectory;
use Shared\DDD\Usecase\Input as BaseInput;
use Shared\DDD\Usecase\Usecase;
use Shared\Paginated;
use Webmozart\Assert\Assert;
/** @implements Usecase<\Shared\DDD\Usecase\Input, \Shared\DDD\Usecase\Output> */
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<IncoSheet> $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);
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\IncoSheets;
final readonly class IncoIdQuery
{
public function __construct(
public string $value,
) {
if (empty($value)) {
throw new \DomainException('value cannot be empty');
}
if (! preg_match('/^[%0-9]+$/', $value)) {
throw new \DomainException('value must be a string of digits optionally prefixed and/or suffixed with a % sign');
}
}
}

View file

@ -6,6 +6,7 @@ namespace Domain\Ports\Directories;
use Domain\Models\Entities\IncoSheet;
use Domain\Models\ValueObjects\Entities\IncoId;
use Domain\Models\ValueObjects\IncoSheets\IncoIdQuery;
use Domain\Models\ValueObjects\IncoSheets\IncoSheetFilter;
use Domain\Models\ValueObjects\IncoSheets\IncoSheetSorter;
use Shared\DDD\Directory;
@ -22,5 +23,11 @@ interface IncoSheetDirectory extends Directory
?IncoSheetFilter $filter = null
): Paginated;
/** @return Paginated<IncoSheet> */
public function search(
IncoIdQuery $query,
string ...$brands
): Paginated;
public function find(IncoId $id): ?IncoSheet;
}

View file

@ -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<int, IncoSheetModel> $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
{

View file

@ -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<string, mixed> */
#[\Override]
public function toArray(): array

View file

@ -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<string, mixed> */
#[\Override]
public function toArray(): array

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Controllers\IncoSheets;
use Application\Usecase\IncoSheets\Search\Input as SearchInput;
use Application\Usecase\IncoSheets\Search\SearchIncoSheets;
use Presentation\Api\V1\Contracts\ApiResponse;
use Presentation\Api\V1\Http\Requests\IncoSheets\IncoSheetsSearchRequest;
use Presentation\Api\V1\Http\Responses\IncoSheets\IncoSheetsSearchResponse;
use Presentation\Api\V1\Presenters\IncoSheets\IncoSheetsSearchPresenter;
use Shared\DDD\Usecase\Builder\UsecaseBuilder;
use Shared\DDD\Usecase\Builder\UsecaseExecutor;
final readonly class IncoSheetsSearchController
{
/** @param UsecaseExecutor<\Shared\DDD\Usecase\Input, \Shared\DDD\Usecase\Output> $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(),
);
}
}

View file

@ -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<string, array<int, mixed>> */
@ -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());

View file

@ -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<string, array<int, mixed>> */
@ -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());
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Requests\IncoSheets;
use Domain\Models\ValueObjects\IncoSheets\IncoIdQuery;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @method \League\Uri\Http toLeagueUri()
*/
final class IncoSheetsSearchRequest extends FormRequest
{
/** @return array<string, array<int, mixed>> */
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<string> */
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);
}
}

View file

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

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Responses\IncoSheets;
use Application\Usecase\IncoSheets\Search\PaginatedIncoSheet;
use Application\Usecase\IncoSheets\Search\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\IncoSheetsSearchView;
final readonly class IncoSheetsSearchResponse implements ApiResponse
{
public function __construct(
private IncoSheetsSearchView $view,
private Http $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,
);
$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),
);
}
}

View file

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

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Presenters\IncoSheets;
use Application\Usecase\IncoSheets\Search\Output;
use Shared\DDD\Usecase\Output as UsecaseOutput;
use Shared\DDD\Usecase\Presenter;
use Webmozart\Assert\Assert;
/** @implements Presenter<\Shared\DDD\Usecase\Output, IncoSheetsSearchView> */
final readonly class IncoSheetsSearchPresenter implements Presenter
{
#[\Override]
public function present(UsecaseOutput $output): object
{
Assert::isInstanceOf($output, Output::class);
return IncoSheetsSearchView::fromOutput($output);
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Presenters\IncoSheets;
use Application\Usecase\IncoSheets\Search\Output;
final readonly class IncoSheetsSearchView
{
/**
* @param \Application\Usecase\IncoSheets\Search\PaginatedIncoSheet[] $incoSheets
*/
public function __construct(
public array $incoSheets,
public int $total,
public int $page,
public int $perPage,
) {
}
public static function fromOutput(Output $output): self
{
return new self(
incoSheets: $output->incoSheets,
total: $output->total,
page: $output->page,
perPage: $output->perPage,
);
}
}

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\IncoSheets;
use Infra\Database\Models\IncoSheet;
describe('IncoSheets Search Feature API', function (): void {
beforeEach(function (): void {
IncoSheet::factory()->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);
});
});

View file

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

View file

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

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Models\ValueObjects\IncoSheets;
use Domain\Models\ValueObjects\IncoSheets\IncoIdQuery;
covers(IncoIdQuery::class);
describe('IncoIdQuery Value Object Instantiation', function (): void {
it('can be instantiated with a valid string of digits', 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 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');
});
});