This commit is contained in:
Rafał Jaskulski 2026-06-03 07:42:54 +00:00 committed by GitHub
commit 6a9202fcfc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 353 additions and 55 deletions

View file

@ -21,7 +21,7 @@ use Sylius\Behat\Context\Api\Resources;
use Sylius\Behat\Service\SharedStorageInterface;
use Webmozart\Assert\Assert;
final class PromotionContext implements Context
final readonly class PromotionContext implements Context
{
public function __construct(
private ApiClientInterface $client,
@ -34,7 +34,11 @@ final class PromotionContext implements Context
#[When('I remove coupon from my cart')]
public function iUseCouponWithCode(?string $couponCode = null): void
{
$this->useCouponCode($couponCode);
$this->client
->buildUpdateRequest(Resources::ORDERS, $this->getCartTokenValue())
->setRequestData(['couponCode' => $couponCode])
->update()
;
}
/**
@ -44,23 +48,22 @@ final class PromotionContext implements Context
{
$response = $this->client->getLastResponse();
$availableErrors = [
'couponCode: Coupon code is invalid.',
'couponCode: Coupon code has expired.',
'couponCode: Coupon code is not valid for this order.',
];
Assert::same($response->getStatusCode(), 422);
Assert::same($this->responseChecker->getError($response), 'couponCode: Coupon code is invalid.');
Assert::inArray($this->responseChecker->getError($response), $availableErrors);
}
private function getCartTokenValue(): ?string
private function getCartTokenValue(): string
{
if ($this->sharedStorage->has('cart_token')) {
return $this->sharedStorage->get('cart_token');
if (!$this->sharedStorage->has('cart_token')) {
throw new \RuntimeException('There is no cart token set in shared storage.');
}
return null;
}
private function useCouponCode(?string $couponCode): void
{
$this->client->buildUpdateRequest(Resources::ORDERS, $this->getCartTokenValue());
$this->client->setRequestData(['couponCode' => $couponCode]);
$this->client->update();
return $this->sharedStorage->get('cart_token');
}
}

View file

@ -18,8 +18,11 @@ use Symfony\Component\Validator\Constraint;
#[\Attribute]
final class PromotionCouponEligibility extends Constraint
{
/** @var string */
public $message = 'sylius.promotion_coupon.is_invalid';
public string $invalid = 'sylius.promotion_coupon.is_invalid';
public string $expired = 'sylius.promotion_coupon.expired';
public string $ineligible = 'sylius.promotion_coupon.ineligible';
public function validatedBy(): string
{

View file

@ -25,10 +25,14 @@ use Webmozart\Assert\Assert;
final class PromotionCouponEligibilityValidator extends ConstraintValidator
{
/**
* @param PromotionCouponRepositoryInterface<PromotionCouponInterface> $promotionCouponRepository
* @param OrderRepositoryInterface<OrderInterface> $orderRepository
*/
public function __construct(
private PromotionCouponRepositoryInterface $promotionCouponRepository,
private OrderRepositoryInterface $orderRepository,
private AppliedCouponEligibilityCheckerInterface $appliedCouponEligibilityChecker,
private readonly PromotionCouponRepositoryInterface $promotionCouponRepository,
private readonly OrderRepositoryInterface $orderRepository,
private readonly AppliedCouponEligibilityCheckerInterface $appliedCouponEligibilityChecker,
) {
}
@ -43,29 +47,44 @@ final class PromotionCouponEligibilityValidator extends ConstraintValidator
return;
}
/** @var PromotionCouponInterface|null $promotionCoupon */
$promotionCoupon = $this->promotionCouponRepository->findOneBy(['code' => $value->couponCode]);
if (!$promotionCoupon instanceof PromotionCouponInterface) {
$this->context
->buildViolation($constraint->message)
->atPath('couponCode')
->addViolation()
;
if ($promotionCoupon === null) {
$this->addViolation($constraint->invalid, 'COUPON_INVALID');
return;
}
/** @var OrderInterface $cart */
$expirationDate = $promotionCoupon->getExpiresAt();
if ($expirationDate !== null && $expirationDate < new \DateTime()) {
$this->addViolation($constraint->expired, 'COUPON_EXPIRED');
return;
}
/** @var OrderInterface|null $cart */
$cart = $this->orderRepository->findCartByTokenValue($value->orderTokenValue);
if ($cart === null) {
throw new \InvalidArgumentException('Cart with given token supposed to exist at this point.');
}
$cart->setPromotionCoupon($promotionCoupon);
if (!$this->appliedCouponEligibilityChecker->isEligible($promotionCoupon, $cart)) {
$this->context
->buildViolation($constraint->message)
->atPath('couponCode')
->addViolation()
;
$this->addViolation($constraint->ineligible, 'PROMOTION_INELIGIBLE');
}
}
private function addViolation(string $message, string $code): void
{
$this->context
->buildViolation($message)
->atPath('couponCode')
->setCode($code)
->addViolation()
;
}
}

View file

@ -54,18 +54,87 @@ final class PromotionCouponEligibilityValidatorSpec extends ObjectBehavior
;
}
function it_does_not_add_violation_if_promotion_coupon_is_eligible(
function it_does_not_add_violation_if_coupon_code_was_not_provided(
PromotionCouponRepositoryInterface $promotionCouponRepository,
OrderRepositoryInterface $orderRepository,
AppliedCouponEligibilityCheckerInterface $appliedCouponEligibilityChecker,
ExecutionContextInterface $executionContext,
): void {
$this->initialize($executionContext);
$value = new UpdateCart(orderTokenValue: 'token', couponCode: null);
$constraint = new PromotionCouponEligibility();
$promotionCouponRepository->findOneBy(Argument::any())->shouldNotBeCalled();
$orderRepository->findCartByTokenValue(Argument::any())->shouldNotBeCalled();
$appliedCouponEligibilityChecker->isEligible(Argument::cetera())->shouldNotBeCalled();
$executionContext->buildViolation(Argument::any())->shouldNotBeCalled();
$this->validate($value, $constraint);
}
function it_adds_violation_if_coupon_code_was_provided_but_it_does_not_exist(
PromotionCouponRepositoryInterface $promotionCouponRepository,
OrderRepositoryInterface $orderRepository,
AppliedCouponEligibilityCheckerInterface $appliedCouponEligibilityChecker,
ExecutionContextInterface $executionContext,
ConstraintViolationBuilderInterface $constraintViolationBuilder,
): void {
$this->initialize($executionContext);
$value = new UpdateCart(orderTokenValue: 'token', couponCode: 'couponCode');
$constraint = new PromotionCouponEligibility();
$promotionCouponRepository->findOneBy(['code' => 'couponCode'])->willReturn(null);
$orderRepository->findCartByTokenValue('token')->shouldNotBeCalled();
$appliedCouponEligibilityChecker->isEligible(Argument::cetera())->shouldNotBeCalled();
$executionContext->buildViolation($constraint->invalid)->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->atPath('couponCode')->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->setCode('COUPON_INVALID')->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->addViolation()->shouldBeCalled();
$this->validate($value, $constraint);
}
function it_adds_violation_if_coupon_code_was_provided_but_it_is_expired(
PromotionCouponRepositoryInterface $promotionCouponRepository,
OrderRepositoryInterface $orderRepository,
AppliedCouponEligibilityCheckerInterface $appliedCouponEligibilityChecker,
ConstraintViolationBuilderInterface $constraintViolationBuilder,
ExecutionContextInterface $executionContext,
PromotionCouponInterface $promotionCoupon,
): void {
$this->initialize($executionContext);
$value = new UpdateCart(orderTokenValue: 'token', couponCode: 'couponCode');
$constraint = new PromotionCouponEligibility();
$promotionCoupon->getExpiresAt()->willReturn(new \DateTime('-1 day'));
$promotionCouponRepository->findOneBy(['code' => 'couponCode'])->willReturn($promotionCoupon);
$orderRepository->findCartByTokenValue('token')->shouldNotBeCalled();
$appliedCouponEligibilityChecker->isEligible(Argument::cetera())->shouldNotBeCalled();
$executionContext->buildViolation($constraint->expired)->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->atPath('couponCode')->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->setCode('COUPON_EXPIRED')->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->addViolation()->shouldBeCalled();
$this->validate($value, $constraint);
}
function it_does_not_add_violation_if_promotion_with_given_coupon_is_eligible(
PromotionCouponRepositoryInterface $promotionCouponRepository,
OrderRepositoryInterface $orderRepository,
AppliedCouponEligibilityCheckerInterface $appliedCouponEligibilityChecker,
PromotionCouponInterface $promotionCoupon,
OrderRepositoryInterface $orderRepository,
OrderInterface $cart,
ExecutionContextInterface $executionContext,
): void {
$this->initialize($executionContext);
$constraint = new PromotionCouponEligibility();
$value = new UpdateCart(couponCode: 'couponCode', orderTokenValue: 'token');
$value = new UpdateCart(orderTokenValue: 'token', couponCode: 'couponCode');
$promotionCouponRepository->findOneBy(['code' => 'couponCode'])->willReturn($promotionCoupon);
$orderRepository->findCartByTokenValue('token')->willReturn($cart);
@ -79,21 +148,21 @@ final class PromotionCouponEligibilityValidatorSpec extends ObjectBehavior
$this->validate($value, $constraint);
}
function it_adds_violation_if_promotion_coupon_is_not_eligible(
function it_adds_violation_if_promotion_with_given_coupon_is_not_eligible(
PromotionCouponRepositoryInterface $promotionCouponRepository,
OrderRepositoryInterface $orderRepository,
AppliedCouponEligibilityCheckerInterface $appliedCouponEligibilityChecker,
PromotionCouponInterface $promotionCoupon,
OrderRepositoryInterface $orderRepository,
OrderInterface $cart,
ExecutionContextInterface $executionContext,
ConstraintViolationBuilderInterface $constraintViolationBuilder,
): void {
$this->initialize($executionContext);
$value = new UpdateCart(orderTokenValue: 'token', couponCode: 'couponCode');
$constraint = new PromotionCouponEligibility();
$constraint->message = 'message';
$value = new UpdateCart(couponCode: 'couponCode', orderTokenValue: 'token');
$promotionCoupon->getExpiresAt()->willReturn(null);
$promotionCouponRepository->findOneBy(['code' => 'couponCode'])->willReturn($promotionCoupon);
$orderRepository->findCartByTokenValue('token')->willReturn($cart);
@ -101,33 +170,35 @@ final class PromotionCouponEligibilityValidatorSpec extends ObjectBehavior
$appliedCouponEligibilityChecker->isEligible($promotionCoupon, $cart)->willReturn(false);
$executionContext->buildViolation($constraint->message)->willReturn($constraintViolationBuilder);
$executionContext->buildViolation($constraint->ineligible)->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->atPath('couponCode')->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->setCode('PROMOTION_INELIGIBLE')->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->addViolation()->shouldBeCalled();
$this->validate($value, $constraint);
}
function it_adds_violation_if_promotion_coupon_is_not_instance_of_promotion_coupon_interface(
function it_throws_an_exception_if_cart_with_given_token_does_not_exist(
PromotionCouponRepositoryInterface $promotionCouponRepository,
OrderRepositoryInterface $orderRepository,
AppliedCouponEligibilityCheckerInterface $appliedCouponEligibilityChecker,
PromotionCouponInterface $promotionCoupon,
ExecutionContextInterface $executionContext,
ConstraintViolationBuilderInterface $constraintViolationBuilder,
): void {
$this->initialize($executionContext);
$value = new UpdateCart(orderTokenValue: 'token', couponCode: 'couponCode');
$constraint = new PromotionCouponEligibility();
$constraint->message = 'message';
$value = new UpdateCart(couponCode: 'couponCode', orderTokenValue: 'token');
$promotionCoupon->getExpiresAt()->willReturn(null);
$promotionCouponRepository->findOneBy(['code' => 'couponCode'])->willReturn($promotionCoupon);
$appliedCouponEligibilityChecker->isEligible(Argument::cetera())->shouldNotBeCalled();
$promotionCouponRepository->findOneBy(['code' => 'couponCode'])->willReturn(null);
$orderRepository->findCartByTokenValue('token')->willReturn(null);
$executionContext->buildViolation($constraint->message)->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->atPath('couponCode')->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->addViolation()->shouldBeCalled();
$orderRepository->findCartByTokenValue('token')->shouldNotBeCalled();
$this->validate($value, $constraint);
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [$value, $constraint])
;
}
}

View file

@ -58,6 +58,8 @@ sylius:
not_blank: Please enter coupon code.
regex: Coupon code can only be comprised of letters, numbers, dashes and underscores.
unique: This coupon already exists.
expired: Coupon code has expired.
ineligible: Coupon code is not valid for this order.
is_invalid: Coupon code is invalid.
promotion:
not_blank: Please provide a promotion for this coupon.

View file

@ -3,7 +3,17 @@ Sylius\Component\Core\Model\Product:
code: 'CAP'
channels: ['@channel_web']
currentLocale: 'en_US'
product_socks:
code: 'SOCKS'
channels: ['@channel_web']
Sylius\Component\Core\Model\ProductVariant:
product_variant_cap_blue:
code: 'CAP_BLUE'
product: '@product_cap'
currentLocale: 'en_US'
channelPricings:
WEB: '@channel_pricing_cap_blue_web'
Sylius\Component\Core\Model\ChannelPricing:
channel_pricing_cap_blue_web:
channelCode: 'WEB'
price: 2000
originalPrice: 3000

View file

@ -0,0 +1,29 @@
Sylius\Component\Core\Model\Promotion:
promotion_active:
code: 'promotion_active'
name: 'Active promotion with expired coupon'
description: 'This promotion is active but the coupon has already expired'
channels: ['@channel_web']
priority: 1
exclusive: true
appliesToDiscounted: false
usageLimit: 1
used: 0
couponBased: true
translations:
- '@promotion_active_en'
Sylius\Component\Promotion\Model\PromotionTranslation:
promotion_active_en:
locale: 'en_US'
label: 'Active promotion with expired coupon'
translatable: '@promotion_active'
Sylius\Component\Core\Model\PromotionCoupon:
promotion_coupon_expired:
code: 'PROMOTION_COUPON_EXPIRED'
usageLimit: null
perCustomerUsageLimit: null
reusableFromCancelledOrders: false
promotion: '@promotion_active'
expiresAt: <dateTimeBetween('-2 month', '-1 month')>

View file

@ -0,0 +1,30 @@
Sylius\Component\Core\Model\Promotion:
promotion_ended:
code: 'promotion_ended'
name: 'Ended promotion with valid coupon'
description: 'This promotion has already ended but the coupon is still valid'
channels: ['@channel_web']
priority: 1
exclusive: true
appliesToDiscounted: false
usageLimit: 1
used: 0
couponBased: true
endsAt: <dateTimeBetween('-1 month', '-1 day')>
translations:
- '@promotion_ended_en'
Sylius\Component\Promotion\Model\PromotionTranslation:
promotion_ended_en:
locale: 'en_US'
label: 'Ended promotion with valid coupon'
translatable: '@promotion_ended'
Sylius\Component\Core\Model\PromotionCoupon:
promotion_coupon_valid:
code: 'PROMOTION_COUPON_VALID'
usageLimit: null
perCustomerUsageLimit: null
reusableFromCancelledOrders: false
promotion: '@promotion_ended'
expiresAt: <dateTimeBetween('+1 month', '+2 month')>

View file

@ -0,0 +1,43 @@
Sylius\Component\Core\Model\Promotion:
promotion_ineligible:
code: 'promotion_ineligible'
name: 'Ineligible promotion'
description: 'This promotion is not eligible for the cart that contains more than 10 items'
channels: ['@channel_web']
priority: 1
exclusive: true
appliesToDiscounted: false
usageLimit: 1
used: 0
couponBased: true
translations:
- '@promotion_ineligible_en'
Sylius\Component\Promotion\Model\PromotionTranslation:
promotion_ineligible_en:
locale: 'en_US'
label: 'Ineligible'
translatable: '@promotion_ineligible'
Sylius\Component\Core\Model\PromotionCoupon:
promotion_coupon_ineligible:
code: 'ELIGIBLE_COUPON_WITH_INELIGIBLE_PROMOTION'
usageLimit: null
perCustomerUsageLimit: null
reusableFromCancelledOrders: false
promotion: '@promotion_ineligible'
Sylius\Component\Promotion\Model\PromotionRule:
promotion_rule_cart_quantity_more_than_10:
type: "cart_quantity"
promotion: "@promotion_ineligible"
configuration:
count: 5
Sylius\Component\Promotion\Model\PromotionAction:
promotion_action_unit_fixed_discount_100:
type: "unit_fixed_discount"
promotion: "@promotion_ineligible"
configuration:
WEB:
amount: 100

View file

@ -24,6 +24,7 @@ final class CartTest extends JsonApiTestCase
protected function setUp(): void
{
$this->setUpOrderPlacer();
$this->setUpDefaultPutHeaders();
parent::setUp();
}
@ -598,6 +599,93 @@ final class CartTest extends JsonApiTestCase
);
}
/** @test */
public function it_does_not_apply_invalid_coupon_code(): void
{
$this->loadFixturesFromFiles([
'promotion/channel.yaml',
'promotion/product.yaml',
]);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('CAP_BLUE', 1, $tokenValue);
$this->requestPut(
uri: sprintf('/api/v2/shop/orders/%s', $tokenValue),
body: ['couponCode' => 'INVALID'],
);
$this->assertResponseViolations($this->client->getResponse(), [
['propertyPath' => 'couponCode', 'message' => 'Coupon code is invalid.'],
]);
}
/** @test */
public function it_does_not_apply_expired_coupon_code(): void
{
$this->loadFixturesFromFiles([
'promotion/channel.yaml',
'promotion/product.yaml',
'promotion/promotion_coupon_expired.yaml',
]);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('CAP_BLUE', 1, $tokenValue);
$this->requestPut(
uri: sprintf('/api/v2/shop/orders/%s', $tokenValue),
body: ['couponCode' => 'PROMOTION_COUPON_EXPIRED'],
);
$this->assertResponseViolations($this->client->getResponse(), [
['propertyPath' => 'couponCode', 'message' => 'Coupon code has expired.'],
]);
}
/** @test */
public function it_does_not_apply_a_valid_coupon_code_of_ended_promotion(): void
{
$this->loadFixturesFromFiles([
'promotion/channel.yaml',
'promotion/promotion_ended_with_valid_coupon.yaml',
'promotion/product.yaml',
]);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('CAP_BLUE', 1, $tokenValue);
$this->requestPut(
uri: sprintf('/api/v2/shop/orders/%s', $tokenValue),
body: ['couponCode' => 'PROMOTION_COUPON_VALID'],
);
$this->assertResponseViolations($this->client->getResponse(), [
['propertyPath' => 'couponCode', 'message' => 'Coupon code is not valid for this order.'],
]);
}
/** @test */
public function it_does_not_apply_valid_coupon_with_ineligible_promotion(): void
{
$this->loadFixturesFromFiles([
'promotion/channel.yaml',
'promotion/promotion_ineligible.yaml',
'promotion/product.yaml',
]);
$tokenValue = $this->pickUpCart();
$this->addItemToCart('CAP_BLUE', 1, $tokenValue);
$this->requestPut(
uri: sprintf('/api/v2/shop/orders/%s', $tokenValue),
body: ['couponCode' => 'ELIGIBLE_COUPON_WITH_INELIGIBLE_PROMOTION'],
);
$this->assertResponseViolations($this->client->getResponse(), [
['propertyPath' => 'couponCode', 'message' => 'Coupon code is not valid for this order.'],
]);
}
/** @test */
public function it_deletes_cart(): void
{