Merge branch 'coupon-list' into 'coupon'

feat: ✨ add ListCoupons Use Case and tests

See merge request ldc-web/projets/mesbr!58
This commit is contained in:
Alexis Gousseau 2026-10-13 16:06:55 +02:00
commit c3a3d1cb83
9 changed files with 386 additions and 1 deletions

View file

@ -73,8 +73,8 @@ final readonly class CreateCoupon implements UseCase, Transactional
products: $coupon->products,
movements: $coupon->movements,
quantity: $coupon->quantity,
availabilityCalendar: $coupon->availabilityCalendar,
createdAt: $coupon->createdAt(),
availabilityCalendar: $coupon->availabilityCalendar,
);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\List;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
final readonly class CouponListItem
{
public function __construct(
public CouponCode $code,
public CouponName $name,
public BrandCode $brandCode,
public CouponPicture $picture,
public CouponDiffusion $diffusion,
public ?CouponAvailabilityCalendar $availabilityCalendar = null,
) {
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\List;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponSortCriteria;
use Shared\DDD\UseCases\Input as UseCaseInput;
use Shared\ValueObjects\Pagination\Pagination;
final readonly class Input implements UseCaseInput
{
public function __construct(
public Pagination $pagination,
public ?CouponSortCriteria $sort = null,
public ?BrandCode $brandCode = null,
) {
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\List;
use Domain\Models\Entities\Coupon;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\DDD\UseCases\UseCase;
use Shared\ValueObjects\Pagination\Paginated;
use Webmozart\Assert\Assert;
/** @implements UseCase<\Application\UseCases\Coupon\List\Input, \Application\UseCases\Coupon\List\Output> */
final readonly class ListCoupons implements UseCase
{
public function __construct(
private CouponRepository $repository,
) {
}
#[\Override]
public function execute(BaseInput $input): Output
{
Assert::isInstanceOf($input, Input::class);
$result = $this->repository->findAll(
pagination: $input->pagination,
sortCriteria: $input->sort,
brandCode: $input->brandCode,
);
$items = array_map(
static fn (Coupon $coupon): CouponListItem => new CouponListItem(
code: $coupon->code,
name: $coupon->name,
brandCode: $coupon->brandCode,
picture: $coupon->picture,
diffusion: $coupon->diffusion,
availabilityCalendar: $coupon->availabilityCalendar,
),
$result->getItems(),
);
return new Output(
coupons: new Paginated(
pagination: $result->getPagination(),
items: $items,
total: $result->getTotal(),
),
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\UseCases\Coupon\List;
use Shared\DDD\UseCases\Output as UseCaseOutput;
use Shared\ValueObjects\Pagination\Paginated;
final readonly class Output implements UseCaseOutput
{
/** @param Paginated<CouponListItem> $coupons */
public function __construct(
public Paginated $coupons,
) {
}
}

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
final readonly class CouponSortCriteria
{
public function __construct(
public CouponSortField $field = CouponSortField::CreatedAt,
public \SortDirection $direction = \SortDirection::Ascending,
) {
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Domain\Models\ValueObjects\Coupon;
enum CouponSortField: string
{
case BrandCode = 'brand_code';
case Diffusion = 'diffusion';
case CreatedAt = 'created_at';
}

View file

@ -5,8 +5,12 @@ declare(strict_types=1);
namespace Domain\Ports\Repositories;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponSortCriteria;
use Shared\DDD\Ports\Repository;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
/**
* @extends \Shared\DDD\Ports\Repository<\Domain\Models\ValueObjects\Coupon\CouponId, \Domain\Models\Entities\Coupon>
@ -17,5 +21,14 @@ interface CouponRepository extends Repository
public function findByCode(CouponCode $code): ?Coupon;
/**
* @return Paginated<Coupon>
*/
public function findAll(
Pagination $pagination,
?CouponSortCriteria $sortCriteria = null,
?BrandCode $brandCode = null,
): Paginated;
public function save(Coupon $brand): void;
}

View file

@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\UseCases\Coupon\List;
use Application\UseCases\Coupon\List\CouponListItem;
use Application\UseCases\Coupon\List\Input;
use Application\UseCases\Coupon\List\ListCoupons;
use Application\UseCases\Coupon\List\Output;
use DateTime;
use Domain\Models\Entities\Coupon;
use Domain\Models\ValueObjects\Actor\ActorId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Coupon\CouponAvailabilityCalendar;
use Domain\Models\ValueObjects\Coupon\CouponCode;
use Domain\Models\ValueObjects\Coupon\CouponDiffusion;
use Domain\Models\ValueObjects\Coupon\CouponId;
use Domain\Models\ValueObjects\Coupon\CouponIncoCode;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformation;
use Domain\Models\ValueObjects\Coupon\CouponLegalInformationArray;
use Domain\Models\ValueObjects\Coupon\CouponMovement;
use Domain\Models\ValueObjects\Coupon\CouponMovementArray;
use Domain\Models\ValueObjects\Coupon\CouponName;
use Domain\Models\ValueObjects\Coupon\CouponPicture;
use Domain\Models\ValueObjects\Coupon\CouponProcessingCode;
use Domain\Models\ValueObjects\Coupon\CouponProcessingCodeArray;
use Domain\Models\ValueObjects\Coupon\CouponProductArray;
use Domain\Models\ValueObjects\Coupon\CouponQuantity;
use Domain\Models\ValueObjects\Coupon\CouponSortCriteria;
use Domain\Models\ValueObjects\Coupon\CouponSortField;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformation;
use Domain\Models\ValueObjects\Coupon\CouponValidityInformationArray;
use Domain\Models\ValueObjects\MoneyAmount;
use Domain\Models\ValueObjects\Period;
use Domain\Models\ValueObjects\PicturePath;
use Domain\Ports\Repositories\CouponRepository;
use Shared\DDD\UseCases\Input as BaseInput;
use Shared\Enums\Country;
use Shared\Enums\Currency;
use Shared\ValueObjects\Money;
use Shared\ValueObjects\Pagination\Paginated;
use Shared\ValueObjects\Pagination\Pagination;
covers(ListCoupons::class, Input::class, Output::class, CouponListItem::class);
describe('list coupons use case', function (): void {
$coupon1 = Coupon::hydrate(
id: CouponId::generate(),
code: new CouponCode('AAA01'),
name: new CouponName('Coupon 1'),
amount: new MoneyAmount(new Money(Currency::EUR, 300)),
picture: new CouponPicture(
new PicturePath('files/product-1.jpg'),
new PicturePath('files/thumbnail-1.jpg'),
new PicturePath('files/mobileThumbnail-1.jpg')
),
diffusion: new CouponDiffusion(new Period(
new DateTime('2026-01-01'),
new DateTime('2026-02-01'),
)),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet.', Country::FRANCE),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet.', Country::FRANCE),
]),
processingCode: new CouponProcessingCodeArray([
new CouponProcessingCode('ABC1234567890', Country::FRANCE),
]),
brandCode: new BrandCode('AAA01'),
createdBy: ActorId::generate(),
products: new CouponProductArray([new CouponIncoCode(35784)]),
movements: new CouponMovementArray([new CouponMovement(100)]),
quantity: new CouponQuantity(100),
availabilityCalendar: new CouponAvailabilityCalendar([
new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')),
]),
);
$coupon2 = Coupon::hydrate(
id: CouponId::generate(),
code: new CouponCode('BBB02'),
name: new CouponName('Coupon 2'),
amount: new MoneyAmount(new Money(Currency::EUR, 300)),
picture: new CouponPicture(
new PicturePath('files/product-2.jpg'),
new PicturePath('files/thumbnail-2.jpg'),
new PicturePath('files/mobileThumbnail-2.jpg')
),
diffusion: new CouponDiffusion(new Period(
new DateTime('2027-01-01'),
new DateTime('2027-02-01'),
)),
validityInformations: new CouponValidityInformationArray([
new CouponValidityInformation('Lorem ipsum dolor sit amet.', Country::FRANCE),
]),
legalInformations: new CouponLegalInformationArray([
new CouponLegalInformation('Lorem ipsum dolor sit amet.', Country::FRANCE),
]),
processingCode: new CouponProcessingCodeArray([
new CouponProcessingCode('DEF0123456789', Country::FRANCE),
]),
brandCode: new BrandCode('BBB02'),
createdBy: ActorId::generate(),
products: new CouponProductArray([new CouponIncoCode(35784)]),
movements: new CouponMovementArray([new CouponMovement(100)]),
quantity: new CouponQuantity(100),
);
$pagination = Pagination::firstPage();
$brandCode = new BrandCode('AAA01');
it('maps repository coupons to list items, preserving pagination and sort', function () use (
$coupon1,
$coupon2,
$pagination,
$brandCode,
): void {
// GIVEN
$paginated = new Paginated($pagination, [$coupon1, $coupon2], 2);
$sort = new CouponSortCriteria();
$input = new Input(pagination: $pagination, sort: $sort, brandCode: $brandCode);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($pagination, CouponSortCriteria::class, $brandCode)
->andReturn($paginated);
$useCase = new ListCoupons($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class);
$items = $result->coupons->getItems();
expect($items)->toHaveCount(2)
->and($items[0])->toBeInstanceOf(CouponListItem::class)
->and($items[0]->code)->toBe($coupon1->code)
->and($items[0]->name)->toBe($coupon1->name)
->and($items[0]->brandCode)->toBe($coupon1->brandCode)
->and($items[0]->picture)->toBe($coupon1->picture)
->and($items[0]->diffusion)->toBe($coupon1->diffusion)
->and($items[0]->availabilityCalendar)->toBe($coupon1->availabilityCalendar)
->and($items[1]->code)->toBe($coupon2->code)
->and($items[1]->brandCode)->toBe($coupon2->brandCode)
->and($items[1]->availabilityCalendar)->toBeNull()
->and($result->coupons->getTotal())->toBe(2)
->and($result->coupons->getPagination())->toBe($pagination);
});
it('maps repository coupons to list items, preserving specific pagination and sort', function () use (
$coupon1,
$coupon2,
$pagination,
): void {
// GIVEN
$paginated = new Paginated($pagination, [$coupon2, $coupon1], 2);
$sort = new CouponSortCriteria(field: CouponSortField::BrandCode, direction: \SortDirection::Descending);
$input = new Input(pagination: $pagination, sort: $sort);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($pagination, CouponSortCriteria::class, null)
->andReturn($paginated);
$useCase = new ListCoupons($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result)->toBeInstanceOf(Output::class);
$items = $result->coupons->getItems();
expect($items)->toHaveCount(2)
->and($items[1])->toBeInstanceOf(CouponListItem::class)
->and($items[1]->code)->toBe($coupon1->code)
->and($items[1]->name)->toBe($coupon1->name)
->and($items[1]->brandCode)->toBe($coupon1->brandCode)
->and($items[1]->picture)->toBe($coupon1->picture)
->and($items[1]->diffusion)->toBe($coupon1->diffusion)
->and($items[1]->availabilityCalendar)->toBe($coupon1->availabilityCalendar)
->and($items[0]->code)->toBe($coupon2->code)
->and($items[0]->brandCode)->toBe($coupon2->brandCode)
->and($items[0]->availabilityCalendar)->toBeNull()
->and($result->coupons->getTotal())->toBe(2)
->and($result->coupons->getPagination())->toBe($pagination);
});
it('returns an empty list without error when there are no coupons', function (): void {
// GIVEN
$pagination = Pagination::firstPage();
$paginated = new Paginated($pagination, [], 0);
$input = new Input(pagination: $pagination);
$repository = \Mockery::mock(CouponRepository::class);
$repository->shouldReceive('findAll')
->once()
->with($pagination, null, null)
->andReturn($paginated);
$useCase = new ListCoupons($repository);
// WHEN
$result = $useCase->execute($input);
// THEN
expect($result->coupons->getItems())->toBe([])
->and($result->coupons->getTotal())->toBe(0);
});
});
describe('list coupons use case assertions', function (): void {
it('rejects an input of the wrong type', function (): void {
// GIVEN
$repository = \Mockery::mock(CouponRepository::class);
$useCase = new ListCoupons($repository);
$invalidInput = new class () implements BaseInput
{
};
// WHEN
$action = fn (): Output => $useCase->execute($invalidInput);
// THEN
expect($action)->toThrow(\InvalidArgumentException::class);
});
});