diff --git a/src/Application/UseCases/PrintableCoupon/List/Input.php b/src/Application/UseCases/PrintableCoupon/List/Input.php new file mode 100644 index 0000000..7681a1d --- /dev/null +++ b/src/Application/UseCases/PrintableCoupon/List/Input.php @@ -0,0 +1,22 @@ + */ +final readonly class ListPrintableCoupons implements UseCase +{ + public function __construct( + private PrintableCouponRepository $repository, + private CouponRepository $couponRepository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $result = $this->repository->findAllAvailable( + pagination: $input->pagination, + campaignCode: $input->campaignCode, + crmCustomerHashcode: $input->crmCustomerHashcode, + sortCriteria: $input->sortCriteria, + ); + + $printableCoupons = $result->getItems(); + + /** @var array $picturesByCouponCode */ + $picturesByCouponCode = []; + + foreach ($printableCoupons as $printableCoupon) { + $couponCode = $printableCoupon->couponCode; + + if (isset($picturesByCouponCode[$couponCode->value])) { + continue; + } + + $coupon = $this->couponRepository->findByCode($couponCode) + ?? throw new \LogicException(sprintf('Coupon "%s" referenced by a printable coupon does not exist.', $couponCode->value)); + + $picturesByCouponCode[$couponCode->value] = $coupon->picture; + } + + $items = array_map( + static fn (PrintableCoupon $printableCoupon): PrintableCouponListItem => new PrintableCouponListItem( + code: $printableCoupon->code, + couponCode: $printableCoupon->couponCode, + couponName: $printableCoupon->couponName, + couponPicture: $picturesByCouponCode[$printableCoupon->couponCode->value], + distributableUntil: $printableCoupon->distributableUntil, + createdAt: $printableCoupon->createdAt(), + updatedAt: $printableCoupon->updatedAt(), + ), + $printableCoupons, + ); + + return new Output( + printableCoupons: new Paginated( + pagination: $result->getPagination(), + items: $items, + total: $result->getTotal(), + ), + ); + } +} diff --git a/src/Application/UseCases/PrintableCoupon/List/Output.php b/src/Application/UseCases/PrintableCoupon/List/Output.php new file mode 100644 index 0000000..c6ac9ed --- /dev/null +++ b/src/Application/UseCases/PrintableCoupon/List/Output.php @@ -0,0 +1,17 @@ + $printableCoupons */ + public function __construct( + public Paginated $printableCoupons, + ) { + } +} diff --git a/src/Application/UseCases/PrintableCoupon/List/PrintableCouponListItem.php b/src/Application/UseCases/PrintableCoupon/List/PrintableCouponListItem.php new file mode 100644 index 0000000..f7f50c6 --- /dev/null +++ b/src/Application/UseCases/PrintableCoupon/List/PrintableCouponListItem.php @@ -0,0 +1,24 @@ + + */ +final readonly class PrintableCoupon implements AggregateRoot, HasTimestampIdentifier +{ + private function __construct( + public PrintableCouponCode $code, + public CampaignCode $campaignCode, + public CouponCode $couponCode, + public CouponName $couponName, + public CrmHashcode $crmCustomerHashcode, + public \DateTimeInterface $distributableUntil, + public \DateTimeInterface $updatedAt, + ) { + } + + public function id(): PrintableCouponCode + { + return $this->code; + } + + public function createdAt(): \DateTimeInterface + { + return $this->code->toDateTime(); + } + + public function updatedAt(): \DateTimeInterface + { + return $this->updatedAt; + } + + public static function create( + CampaignCode $campaignCode, + CouponCode $couponCode, + CouponName $couponName, + CrmHashcode $crmCustomerHashcode, + \DateTimeInterface $distributableUntil, + ): self { + $code = PrintableCouponCode::generate(); + return new self( + code: $code, + campaignCode: $campaignCode, + couponCode: $couponCode, + couponName: $couponName, + crmCustomerHashcode: $crmCustomerHashcode, + distributableUntil: $distributableUntil, + updatedAt: $code->toDateTime(), + ); + } + + public static function hydrate( + PrintableCouponCode $code, + CampaignCode $campaignCode, + CouponCode $couponCode, + CouponName $couponName, + CrmHashcode $crmCustomerHashcode, + \DateTimeInterface $distributableUntil, + ): self { + return new self( + code: $code, + campaignCode: $campaignCode, + couponCode: $couponCode, + couponName: $couponName, + crmCustomerHashcode: $crmCustomerHashcode, + distributableUntil: $distributableUntil, + updatedAt: DatesHelper::now(), + ); + } +} diff --git a/src/Domain/Models/ValueObjects/CrmHashcode.php b/src/Domain/Models/ValueObjects/CrmHashcode.php new file mode 100644 index 0000000..195d915 --- /dev/null +++ b/src/Domain/Models/ValueObjects/CrmHashcode.php @@ -0,0 +1,28 @@ +value === $other->value; + } +} diff --git a/src/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCode.php b/src/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCode.php new file mode 100644 index 0000000..d21e226 --- /dev/null +++ b/src/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCode.php @@ -0,0 +1,47 @@ +value) !== 1) { + throw new \DomainException( + 'Marketing Direct Code must be exactly 13 numeric characters, e.g. 5425003944130.', + ); + } + } + + public static function fromInt(int $sequence): self + { + if ($sequence < 0) { + throw new \DomainException('Marketing Direct sequence must be positive.'); + } + + return new self(str_pad((string) $sequence, 13, '0', STR_PAD_LEFT)); + } + + public function toInt(): int + { + return (int) $this->value; + } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof self && $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCode.php b/src/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCode.php new file mode 100644 index 0000000..6e0291a --- /dev/null +++ b/src/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCode.php @@ -0,0 +1,16 @@ +id->toDateTime(); + } +} diff --git a/src/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponId.php b/src/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponId.php new file mode 100644 index 0000000..b04d121 --- /dev/null +++ b/src/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponId.php @@ -0,0 +1,16 @@ +value) === '') { + throw new \DomainException('Unprinted reason cannot be empty.'); + } + } +} diff --git a/src/Domain/Ports/Repositories/PrintableCouponRepository.php b/src/Domain/Ports/Repositories/PrintableCouponRepository.php new file mode 100644 index 0000000..4ead8e6 --- /dev/null +++ b/src/Domain/Ports/Repositories/PrintableCouponRepository.php @@ -0,0 +1,31 @@ + + */ +interface PrintableCouponRepository extends Repository +{ + /** + * @return Paginated + */ + public function findAllAvailable( + Pagination $pagination, + CampaignCode $campaignCode, + CrmHashcode $crmCustomerHashcode, + ?PrintableCouponSortCriteria $sortCriteria = null, + ): Paginated; + + public function save(PrintableCoupon $printableCoupon): void; +} diff --git a/tests/Unit/Application/UseCases/PrintableCoupon/List/ListPrintableCouponTest.php b/tests/Unit/Application/UseCases/PrintableCoupon/List/ListPrintableCouponTest.php new file mode 100644 index 0000000..f91bb83 --- /dev/null +++ b/tests/Unit/Application/UseCases/PrintableCoupon/List/ListPrintableCouponTest.php @@ -0,0 +1,279 @@ + new CouponPicture( + new PicturePath("files/{$name}.jpg"), + new PicturePath("files/{$name}-thumbnail.jpg"), + new PicturePath("files/{$name}-mobile.jpg"), + ); + + $createInput = fn (PrintableCouponSortCriteria | null $sortCriteria = null): Input => new Input( + pagination: Pagination::firstPage(), + campaignCode: new CampaignCode('AAA01'), + crmCustomerHashcode: new CrmHashcode(Uuid::uuid4()), + sortCriteria: $sortCriteria ?? new PrintableCouponSortCriteria( + field: PrintableCouponSortField::CreatedAt, + direction: \SortDirection::Descending, + ), + ); + + $createPrintableCoupon = fn (CouponCode $couponCode): PrintableCoupon => PrintableCoupon::hydrate( + code: PrintableCouponCode::generate(), + campaignCode: new CampaignCode('AAA01'), + couponCode: $couponCode, + couponName: new CouponName('Coupon name'), + crmCustomerHashcode: new CrmHashcode(Uuid::uuid4()), + distributableUntil: new DateTime('2027-01-01'), + ); + + $createCoupon = fn (CouponCode $code, CouponPicture $picture): Coupon => Coupon::create( + code: $code, + name: new CouponName('Coupon name'), + amount: new MoneyAmount(new Money(Currency::EUR, 100)), + picture: $picture, + 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 consectetur adipiscing elit.', + Country::FRANCE + ), + ]), + legalInformations: new CouponLegalInformationArray([ + new CouponLegalInformation( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit.', + Country::FRANCE + ), + ]), + processingCode: new ProcessingCodeArray([ + new ProcessingCode('5425003944130', Country::FRANCE), + ]), + brandCode: new BrandCode('AAA01'), + createdBy: ActorId::generate(), + products: new CouponProductArray([ + new IncoCode(35784), + new IncoCode(15522), + ]), + movements: new CouponMovementArray([ + new CouponMovement(1000) + ]), + quantity: new CouponQuantity(1000), + ); + + it('returns the printable coupons with the picture of their coupon', function () use ( + $createInput, + $createPicture, + $createCoupon, + $createPrintableCoupon, + ): void { + // GIVEN + $input = $createInput(); + $picture = $createPicture('picture'); + $couponCode = new CouponCode('AAA01'); + $printable = $createPrintableCoupon($couponCode); + $coupon = $createCoupon($couponCode, $picture); + + $paginated = new Paginated(pagination: $input->pagination, items: [$printable], total: 1); + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable') + ->once() + ->with($input->pagination, $input->campaignCode, $input->crmCustomerHashcode, $input->sortCriteria) + ->andReturn($paginated); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode') + ->once() + ->with($printable->couponCode) + ->andReturn($coupon); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $output = $useCase->execute($input); + + // THEN + $items = $output->printableCoupons->getItems(); + + expect($items)->toHaveCount(1) + ->and($output->printableCoupons->getTotal())->toBe(1) + ->and($items[0]->code)->toBe($printable->code) + ->and($items[0]->couponCode)->toBe($printable->couponCode) + ->and($items[0]->couponName)->toBe($printable->couponName) + ->and($items[0]->couponPicture)->toBe($picture) + ->and($items[0]->distributableUntil)->toBe($printable->distributableUntil); + }); + + it('forwards the sort criteria and keeps the order returned by the repository', function () use ( + $createInput, + $createPicture, + $createCoupon, + $createPrintableCoupon, + ): void { + // GIVEN + $sortCriteria = new PrintableCouponSortCriteria( + field: PrintableCouponSortField::Name, + direction: \SortDirection::Descending, + ); + $input = $createInput($sortCriteria); + + $code1 = new CouponCode('AAA01'); + $code2 = new CouponCode('BBB02'); + $picture1 = $createPicture('picture-1'); + $picture2 = $createPicture('picture-2'); + + $printableCoupon1 = $createPrintableCoupon($code1); + $printableCoupon2 = $createPrintableCoupon($code2); + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable') + ->once() + ->with($input->pagination, $input->campaignCode, $input->crmCustomerHashcode, $sortCriteria) + ->andReturn(new Paginated(pagination: $input->pagination, items: [$printableCoupon2, $printableCoupon1], total: 2)); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->with($code2)->once() + ->andReturn($createCoupon($code2, $picture2)); + $couponRepository->shouldReceive('findByCode')->with($code1)->once() + ->andReturn($createCoupon($code1, $picture1)); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $items = $useCase->execute($input)->printableCoupons->getItems(); + + // THEN + expect($items)->toHaveCount(2) + ->and($items[0]->code)->toBe($printableCoupon2->code) + ->and($items[0]->couponName)->toBe($printableCoupon2->couponName) + ->and($items[0]->couponPicture)->toBe($picture2) + ->and($items[1]->code)->toBe($printableCoupon1->code) + ->and($items[1]->couponName)->toBe($printableCoupon1->couponName) + ->and($items[1]->couponPicture)->toBe($picture1); + }); + + it('returns an empty list without error when there is no printable coupon', function () use ($createInput): void { + // GIVEN + $input = $createInput(); + $paginated = new Paginated(pagination: $input->pagination, items: [], total: 0); + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable')->once()->andReturn($paginated); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldNotReceive('findByCode'); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $output = $useCase->execute($input); + + // THEN + expect($output->printableCoupons->getItems())->toBe([]) + ->and($output->printableCoupons->getTotal())->toBe(0); + }); + + it('loads a shared coupon only once', function () use ( + $createInput, + $createPicture, + $createPrintableCoupon, + $createCoupon, + ): void { + // GIVEN + $input = $createInput(); + $picture = $createPicture('picture'); + $couponCode = new CouponCode('AAA01'); + $items = [$createPrintableCoupon($couponCode), $createPrintableCoupon($couponCode)]; + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable') + ->once() + ->andReturn(new Paginated(pagination: $input->pagination, items: $items, total: 2)); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode') + ->once() + ->andReturn($createCoupon($couponCode, $picture)); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $output = $useCase->execute($input); + + // THEN + expect($output->printableCoupons->getItems())->toHaveCount(2); + }); + + it('throws when the coupon of a printable coupon does not exist', function () use ($createInput, $createPrintableCoupon): void { + // GIVEN + $input = $createInput(); + $printable = $createPrintableCoupon(new CouponCode('BBB02')); + + $repository = \Mockery::mock(PrintableCouponRepository::class); + $repository->shouldReceive('findAllAvailable') + ->once() + ->andReturn(new Paginated(pagination: $input->pagination, items: [$printable], total: 1)); + + $couponRepository = \Mockery::mock(CouponRepository::class); + $couponRepository->shouldReceive('findByCode')->once()->andReturn(null); + + $useCase = new ListPrintableCoupons($repository, $couponRepository); + + // WHEN + $action = fn (): Output => $useCase->execute($input); + + // THEN + expect($action)->toThrow(\LogicException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/Entities/PrintableCouponTest.php b/tests/Unit/Domain/Models/Entities/PrintableCouponTest.php new file mode 100644 index 0000000..8fb94e5 --- /dev/null +++ b/tests/Unit/Domain/Models/Entities/PrintableCouponTest.php @@ -0,0 +1,122 @@ +campaignCode)->toBe($campaignCode) + ->and($printable->couponCode)->toBe($couponCode) + ->and($printable->couponName)->toBe($couponName) + ->and($printable->crmCustomerHashcode)->toBe($crmCustomerHashcode) + ->and($printable->distributableUntil)->toBe($distributableUntil); + }); + + it('generates a new code on each creation', function () use ( + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ): void { + // GIVEN / WHEN + $first = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil); + $second = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil); + + // THEN + expect($first->code)->toBeInstanceOf(PrintableCouponCode::class) + ->and($first->code->equals($second->code))->toBeFalse(); + }); + + it('exposes its code as identifier', function () use ( + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ): void { + // GIVEN + $printable = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil); + + // WHEN / THEN + expect($printable->id())->toBe($printable->code); + }); + + it('has an update date equal to its creation date', function () use ( + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ): void { + // GIVEN / WHEN + $printable = PrintableCoupon::create($campaignCode, $couponCode, $couponName, $crmCustomerHashcode, $distributableUntil); + + // THEN + expect($printable->updatedAt)->toEqual($printable->createdAt()) + ->and($printable->updatedAt())->toBe($printable->updatedAt); + }); + + it('hydrates a printable coupon with the given values', function () use ( + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ): void { + // GIVEN + $code = PrintableCouponCode::generate(); + + // WHEN + $printable = PrintableCoupon::hydrate( + $code, + $campaignCode, + $couponCode, + $couponName, + $crmCustomerHashcode, + $distributableUntil, + ); + + // THEN + expect($printable->code)->toBe($code) + ->and($printable->campaignCode)->toBe($campaignCode) + ->and($printable->couponCode)->toBe($couponCode) + ->and($printable->couponName)->toBe($couponName) + ->and($printable->crmCustomerHashcode)->toBe($crmCustomerHashcode) + ->and($printable->distributableUntil)->toBe($distributableUntil) + ->and($printable->createdAt())->toEqual($code->toDateTime()); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/CrmHashcodeTest.php b/tests/Unit/Domain/Models/ValueObjects/CrmHashcodeTest.php new file mode 100644 index 0000000..7f515fc --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/CrmHashcodeTest.php @@ -0,0 +1,49 @@ +value)->toBe($value); + }); + + it('is equal to a hashcode with the same value', function () use ($value): void { + // GIVEN + $hashcode = new CrmHashcode($value); + $other = new CrmHashcode($value); + + // WHEN / THEN + expect($hashcode->equals($other))->toBeTrue(); + }); + + it('is not equal to a hashcode with a different value', function () use ($value): void { + // GIVEN + $hashcode = new CrmHashcode($value); + $other = new CrmHashcode(Uuid::uuid4()); + + // WHEN / THEN + expect($hashcode->equals($other))->toBeFalse(); + }); + + it('is not equal to a value that is not a hashcode', function () use ($value): void { + // GIVEN + $hashcode = new CrmHashcode($value); + + // WHEN / THEN + expect($hashcode->equals($value))->toBeFalse() + ->and($hashcode->equals(null))->toBeFalse(); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCodeTest.php new file mode 100644 index 0000000..0bc9d6d --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/MarketingDirectCodeTest.php @@ -0,0 +1,32 @@ +value)->toBe($expected); + })->with([ + 'one' => [1, '0000000000001'], + 'zero' => [0, '0000000000000'], + 'full size' => [5425003944130, '5425003944130'], + ]); + + it('rejects a negative or too large sequence', function (int $sequence): void { + $action = fn (): MarketingDirectCode => MarketingDirectCode::fromInt($sequence); + + expect($action)->toThrow(\DomainException::class); + })->with([ + 'negative' => [-1], + 'too large' => [10_000_000_000_000], + ]); + + it('converts back to its integer', function (): void { + expect(MarketingDirectCode::fromInt(42)->toInt())->toBe(42); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCodeTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCodeTest.php new file mode 100644 index 0000000..ab786fb --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintableCouponCodeTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(PrintableCouponCode::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = PrintableCouponCode::generate(); + $second = PrintableCouponCode::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponIdTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponIdTest.php new file mode 100644 index 0000000..76c4f07 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponIdTest.php @@ -0,0 +1,28 @@ +toBeInstanceOf(PrintedCouponId::class); + }); + + it('generates different ids on each call', function (): void { + // WHEN + $first = PrintedCouponId::generate(); + $second = PrintedCouponId::generate(); + + // THEN + expect((string) $first)->not->toBe((string) $second); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponTest.php new file mode 100644 index 0000000..bae0a00 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/PrintedCouponTest.php @@ -0,0 +1,62 @@ +id)->toBe($id) + ->and($printed->printableCode)->toBe($printableCode) + ->and($printed->couponCode)->toBe($couponCode) + ->and($printed->campaignCode)->toBe($campaignCode) + ->and($printed->customer)->toBe($customer) + ->and($printed->printedAt)->toBe($printedAt); + }); + + it('derives the printing date from its id', function () use ($marketingCode, $printableCode, $couponCode, $campaignCode, $customer): void { + // GIVEN + $id = PrintedCouponId::generate(); + $printed = new PrintedCoupon( + $id, + $marketingCode, + $printableCode, + $couponCode, + $campaignCode, + $customer, + new \DateTimeImmutable('2020-01-01'), + ); + + // WHEN + $date = $printed->printedAt(); + + // THEN + expect($date)->toEqual($id->toDateTime()) + ->and($date)->not->toEqual($printed->printedAt); + }); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedCouponTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedCouponTest.php new file mode 100644 index 0000000..03d5789 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedCouponTest.php @@ -0,0 +1,69 @@ +printableCode)->toBe($printableCode) + ->and($unprinted->couponCode)->toBe($couponCode) + ->and($unprinted->campaignCode)->toBe($campaignCode) + ->and($unprinted->customer)->toBe($customer) + ->and($unprinted->unprintedReason)->toBe($reason); + }); + + it('keeps the reason as given without trimming it', function () use ($printableCode, $couponCode, $campaignCode, $customer): void { + // GIVEN + $reason = new UnprintedReason(' Printer failure '); + + // WHEN + $unprinted = new UnprintedCoupon($printableCode, $couponCode, $campaignCode, $customer, $reason); + + // THEN + expect($unprinted->unprintedReason)->toBe($reason); + }); + + it('rejects an empty reason', function (string $invalidReason) use ($printableCode, $couponCode, $campaignCode, $customer): void { + // GIVEN / WHEN + $action = fn (): UnprintedCoupon => new UnprintedCoupon( + $printableCode, + $couponCode, + $campaignCode, + $customer, + new UnprintedReason($invalidReason), + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Unprinted reason cannot be empty.'); + })->with([ + 'empty' => '', + 'spaces' => ' ', + 'tab' => "\t", + 'new lines' => "\n\r\n", + 'mixed spaces' => " \t\n ", + 'unicode spaces' => "\u{00A0}\u{2003}", + ]); +}); diff --git a/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedReasonTest.php b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedReasonTest.php new file mode 100644 index 0000000..c4cf3f9 --- /dev/null +++ b/tests/Unit/Domain/Models/ValueObjects/PrintableCoupon/UnprintedReasonTest.php @@ -0,0 +1,33 @@ +toBeInstanceOf(UnprintedReason::class) + ->and($reason->value)->toBe($value); + }); + + it('rejects an empty or whitespace-only reason', function (string $invalidValue): void { + // WHEN + $action = fn (): UnprintedReason => new UnprintedReason($invalidValue); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Unprinted reason cannot be empty.'); + })->with([ + 'empty' => '', + 'spaces' => ' ', + ]); +});