From 296f8012e75f9f91c80dd3df14222765e4471d73 Mon Sep 17 00:00:00 2001 From: K Muller Date: Wed, 23 Sep 2026 11:34:44 +0200 Subject: [PATCH] feat: :sparkles: add the rejection method for campaign request --- .../UseCases/CampaignRequest/Reject/Input.php | 18 ++ .../Reject/Output/CampaignRequestNotFound.php | 9 + .../Reject/Output/CampaignRequestRejected.php | 20 +++ .../CampaignRequest/Reject/Output/Output.php | 11 ++ .../Reject/RejectCampaignRequest.php | 51 ++++++ .../Models/Entities/CampaignRequest.php | 21 +++ .../CampaignRequestRepository.php | 2 + .../Reject/RejectCampaignRequestTest.php | 163 ++++++++++++++++++ .../Models/Entities/CampaignRequestTest.php | 78 +++++++++ 9 files changed, 373 insertions(+) create mode 100644 src/Application/UseCases/CampaignRequest/Reject/Input.php create mode 100644 src/Application/UseCases/CampaignRequest/Reject/Output/CampaignRequestNotFound.php create mode 100644 src/Application/UseCases/CampaignRequest/Reject/Output/CampaignRequestRejected.php create mode 100644 src/Application/UseCases/CampaignRequest/Reject/Output/Output.php create mode 100644 src/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequest.php create mode 100644 tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php diff --git a/src/Application/UseCases/CampaignRequest/Reject/Input.php b/src/Application/UseCases/CampaignRequest/Reject/Input.php new file mode 100644 index 0000000..d1783ff --- /dev/null +++ b/src/Application/UseCases/CampaignRequest/Reject/Input.php @@ -0,0 +1,18 @@ + */ +final readonly class RejectCampaignRequest implements UseCase, Transactional +{ + public function __construct( + private CampaignRequestRepository $repository, + ) { + } + + #[\Override] + public function execute(BaseInput $input): Output + { + Assert::isInstanceOf($input, Input::class); + + $campaignRequest = $this->repository->findById($input->id); + + if (! $campaignRequest instanceof CampaignRequest) { + return new CampaignRequestNotFound(); + } + + $campaignRequest = $campaignRequest->reject($input->reason); + + $this->repository->save($campaignRequest); + + Assert::isInstanceOf($campaignRequest->rejectionReason, RejectionReason::class); + Assert::notNull($campaignRequest->updatedAt()); + + return new CampaignRequestRejected( + id: $campaignRequest->id(), + status: $campaignRequest->status, + rejectionReason: $campaignRequest->rejectionReason, + updatedAt: $campaignRequest->updatedAt(), + ); + } +} diff --git a/src/Domain/Models/Entities/CampaignRequest.php b/src/Domain/Models/Entities/CampaignRequest.php index a007125..bdf1de0 100644 --- a/src/Domain/Models/Entities/CampaignRequest.php +++ b/src/Domain/Models/Entities/CampaignRequest.php @@ -14,6 +14,7 @@ use Domain\Models\ValueObjects\CampaignRequest\RejectionReason; use Domain\Models\ValueObjects\PrintRules; use Shared\DDD\Models\AggregateRoot; use Shared\DDD\Models\HasTimestampIdentifier; +use Shared\Helpers\DatesHelper; /** * @implements \Shared\DDD\Models\AggregateRoot<\Domain\Models\ValueObjects\CampaignRequest\CampaignRequestId> @@ -82,4 +83,24 @@ final readonly class CampaignRequest implements AggregateRoot, HasTimestampIdent updatedAt: $updatedAt, ); } + + public function reject(RejectionReason $reason): self + { + if ($this->status !== CampaignStatus::CREATED) { + throw new \DomainException('Only a created campaign request can be rejected.'); + } + + return new self( + id: $this->id, + code: $this->code, + applicant: $this->applicant, + country: $this->country, + campaignType: $this->campaignType, + printRules: $this->printRules, + requestedCoupon: $this->requestedCoupon, + status: CampaignStatus::REJECTED, + rejectionReason: $reason, + updatedAt: DatesHelper::now(), + ); + } } diff --git a/src/Domain/Ports/Repositories/CampaignRequestRepository.php b/src/Domain/Ports/Repositories/CampaignRequestRepository.php index b421c0c..a218598 100644 --- a/src/Domain/Ports/Repositories/CampaignRequestRepository.php +++ b/src/Domain/Ports/Repositories/CampaignRequestRepository.php @@ -14,4 +14,6 @@ use Shared\DDD\Ports\Repository; interface CampaignRequestRepository extends Repository { public function findById(CampaignRequestId $id): ?CampaignRequest; + + public function save(CampaignRequest $campaignRequest): void; } diff --git a/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php b/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php new file mode 100644 index 0000000..23c3b30 --- /dev/null +++ b/tests/Unit/Application/UseCases/CampaignRequest/Reject/RejectCampaignRequestTest.php @@ -0,0 +1,163 @@ +requestedCoupon = RequestedCoupon::hydrate( + RequestedCouponId::generate(), + new CouponName('Coupon A'), + new CouponProductName('Product A'), + 100, + 10, + new Period(new DateTime('2026-01-01'), new DateTime('2026-02-01')), + new MoneyAmount(new Money(Currency::EUR, 300)), + new ProcessingCode('PC1'), + new CouponPicture(new PicturePath('coupons/product.jpg'), new PicturePath('coupons/thumbnail.jpg')), + new GS1('01234567890128', BarcodeFormat::EAN13), + new CouponValidity(30, DateTime::now()), + ); + }); + + it('rejects a campaign request and persists it', function (): void { + // GIVEN + CarbonImmutable::setTestNow('2026-06-15 12:00:00'); + $id = CampaignRequestId::generate(); + $campaignRequest = CampaignRequest::hydrate( + $id, + new BrandCode('AAA01'), + new Applicant('John', 'Doe', new Email('john.doe@example.com')), + Country::FR, + CampaignType::CLUB, + new PrintRules(maxPerCustomer: 3), + $this->requestedCoupon, + CampaignStatus::CREATED, + ); + $reason = new RejectionReason('Missing legal documents.'); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn($campaignRequest); + $repository->shouldReceive('save')->once()->with(\Mockery::on( + fn (CampaignRequest $saved): bool => $saved->status === CampaignStatus::REJECTED + && $saved->rejectionReason === $reason + && $saved->updatedAt !== null, + )); + + $useCase = new RejectCampaignRequest($repository); + + // WHEN + $result = $useCase->execute(new Input(id: $id, reason: $reason)); + + // THEN + expect($result)->toBeInstanceOf(CampaignRequestRejected::class) + ->and($result->id)->toBe($id) + ->and($result->status)->toBe(CampaignStatus::REJECTED) + ->and($result->rejectionReason)->toBe($reason) + ->and($result->updatedAt->format('Y-m-d H:i:s'))->toBe('2026-06-15 12:00:00'); + }); + + it('returns CampaignRequestNotFound when the id is not found', function (): void { + // GIVEN + $id = CampaignRequestId::generate(); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn(null); + $repository->shouldNotReceive('save'); + + $useCase = new RejectCampaignRequest($repository); + + // WHEN + $result = $useCase->execute(new Input(id: $id, reason: new RejectionReason('Missing legal documents.'))); + + // THEN + expect($result)->toBeInstanceOf(CampaignRequestNotFound::class); + }); + + it('propagates the domain exception when the campaign request cannot be rejected', function (): void { + // GIVEN + $id = CampaignRequestId::generate(); + $campaignRequest = CampaignRequest::hydrate( + $id, + new BrandCode('AAA01'), + new Applicant('John', 'Doe', new Email('john.doe@example.com')), + Country::FR, + CampaignType::CLUB, + new PrintRules(maxPerCustomer: 3), + $this->requestedCoupon, + CampaignStatus::TRANSFORMED, + ); + + $repository = \Mockery::mock(CampaignRequestRepository::class); + $repository->shouldReceive('findById')->once()->with($id)->andReturn($campaignRequest); + $repository->shouldNotReceive('save'); + + $useCase = new RejectCampaignRequest($repository); + + // WHEN + $action = fn (): Output => $useCase->execute( + new Input(id: $id, reason: new RejectionReason('Missing legal documents.')) + ); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Only a created campaign request can be rejected.'); + }); +}); + +describe('reject campaign request use case assertions', function (): void { + it('rejects an input of the wrong type', function (): void { + // GIVEN + $repository = \Mockery::mock(CampaignRequestRepository::class); + $useCase = new RejectCampaignRequest($repository); + $invalidInput = new class () implements BaseInput + { + }; + + // WHEN + $action = fn (): Output => $useCase->execute($invalidInput); + + // THEN + expect($action)->toThrow(\InvalidArgumentException::class); + }); +}); diff --git a/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php b/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php index 07d213d..94e6262 100644 --- a/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php +++ b/tests/Unit/Domain/Models/Entities/CampaignRequestTest.php @@ -30,10 +30,15 @@ use Shared\Enums\Currency; use Shared\ValueObjects\DateTime; use Shared\ValueObjects\Email; use Shared\ValueObjects\Money; +use Carbon\CarbonImmutable; covers(CampaignRequest::class); describe('campaign request', function (): void { + afterEach(function (): void { + CarbonImmutable::setTestNow(); + }); + beforeEach(function (): void { $this->requestedCoupon = RequestedCoupon::hydrate( RequestedCouponId::generate(), @@ -189,6 +194,79 @@ describe('campaign request', function (): void { ); }); + it('rejects a campaign request and sets the rejection reason and updatedAt', function (): void { + // GIVEN + CarbonImmutable::setTestNow('2026-06-15 12:00:00'); + $campaignRequest = CampaignRequest::hydrate( + CampaignRequestId::generate(), + new BrandCode('AAA01'), + new Applicant('John', 'Doe', new Email('john.doe@example.com')), + Country::FR, + CampaignType::CLUB, + new PrintRules(maxPerCustomer: 3), + $this->requestedCoupon, + CampaignStatus::CREATED, + ); + $reason = new RejectionReason('Missing legal documents.'); + + // WHEN + $rejected = $campaignRequest->reject($reason); + + // THEN + expect($rejected->status)->toBe(CampaignStatus::REJECTED) + ->and($rejected->rejectionReason)->toBe($reason) + ->and($rejected->id())->toBe($campaignRequest->id()) + ->and($rejected->code)->toBe($campaignRequest->code) + ->and($rejected->applicant)->toBe($campaignRequest->applicant) + ->and($rejected->country)->toBe($campaignRequest->country) + ->and($rejected->campaignType)->toBe($campaignRequest->campaignType) + ->and($rejected->printRules)->toBe($campaignRequest->printRules) + ->and($rejected->requestedCoupon)->toBe($campaignRequest->requestedCoupon) + ->and($rejected->updatedAt)->not->toBeNull() + ->and($rejected->updatedAt()?->format('Y-m-d H:i:s'))->toBe('2026-06-15 12:00:00'); + }); + + it('rejects rejecting a transformed campaign request', function (): void { + // GIVEN + $campaignRequest = CampaignRequest::hydrate( + CampaignRequestId::generate(), + new BrandCode('AAA01'), + new Applicant('John', 'Doe', new Email('john.doe@example.com')), + Country::FR, + CampaignType::CLUB, + new PrintRules(maxPerCustomer: 3), + $this->requestedCoupon, + CampaignStatus::TRANSFORMED, + ); + + // WHEN + $action = fn (): CampaignRequest => $campaignRequest->reject(new RejectionReason('Missing legal documents.')); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Only a created campaign request can be rejected.'); + }); + + it('rejects rejecting an already rejected campaign request', function (): void { + // GIVEN + $campaignRequest = CampaignRequest::hydrate( + CampaignRequestId::generate(), + new BrandCode('AAA01'), + new Applicant('John', 'Doe', new Email('john.doe@example.com')), + Country::FR, + CampaignType::CLUB, + new PrintRules(maxPerCustomer: 3), + $this->requestedCoupon, + CampaignStatus::REJECTED, + new RejectionReason('First reason.'), + ); + + // WHEN + $action = fn (): CampaignRequest => $campaignRequest->reject(new RejectionReason('Second reason.')); + + // THEN + expect($action)->toThrow(\DomainException::class, 'Only a created campaign request can be rejected.'); + }); + it('returns the creation date extracted from the id', function (): void { // GIVEN $expected = new \DateTimeImmutable('2024-06-15 12:34:56.789000', new \DateTimeZone('UTC'));