feature #15539 [API][Admin] Promotion coupons management(CRUD) (NoResponseMate, Wojdylak)

This PR was merged into the 1.13 branch.

Discussion
----------

| Q               | A                                                            |
|-----------------|--------------------------------------------------------------|
| Branch?         | 1.13 |
| Bug fix?        | no                                                       |
| New feature?    | yes                                                       |
| BC breaks?      | no                                                       |
| Deprecations?   | no |
| Related tickets | -                      |
| License         | MIT                                                          |

Commits
-------

47153967eb [API][Admin] Browsing coupons
41efb5f437 [API][Admin] Sorting coupons
cb3770b564 [API][Admin] Deleting coupons
9b884466f4 [API][Admin] Creating coupons
54b87d0913 [API][Admin] Updating coupons
ae13ab3969 [API][Admin] Validating coupons
854393e293 Add coupons to payload in PromotionCouponsTest
6d33557dc9 Add api test for coupon_unique_code_validation.feature
440a75ab83 Resolve phpstan issues
e6f4b2b344 Add PromotionCouponCannotBeRemoved to code mapping
c0ac149d0e [ApiBundle] Add promotion coupon endpoint as a subresource
41ed0f33d5 [ApiBundle] Add PromotionCouponPromotionFilter class
08251e45c3 [ApiBundle] Resolve phpstan issues
e82780e1c0 [ApiBundle] Add to test information abut PromotionCouponPromotionFilter
This commit is contained in:
Grzegorz Sadowski 2023-12-01 13:23:42 +01:00 committed by GitHub
commit 079766a2c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1588 additions and 53 deletions

View file

@ -10,7 +10,7 @@ Feature: Adding a new coupon
And it is coupon based promotion
And I am logged in as an administrator
@ui
@ui @api
Scenario: Adding a new coupon
When I want to create a new coupon for this promotion
And I specify its code as "SANTA2016"
@ -19,4 +19,4 @@ Feature: Adding a new coupon
And I make it valid until "21.04.2017"
And I add it
Then I should be notified that it has been successfully created
And there should be coupon with code "SANTA2016"
And there should be a "Christmas sale" promotion with a coupon code "SANTA2016"

View file

@ -9,8 +9,8 @@ Feature: Browsing promotion coupons
And the store has promotion "Christmas sale" with coupon "SANTA2016"
And I am logged in as an administrator
@ui
Scenario: Browsing coupons in store
@ui @api
Scenario: Browsing coupons of a promotion
When I want to view all coupons of this promotion
And there should be 1 coupon related to this promotion
And there should be coupon with code "SANTA2016"
And there should be a "Christmas sale" promotion with a coupon code "SANTA2016"

View file

@ -9,7 +9,7 @@ Feature: Coupon unique code validation
And the store has promotion "Christmas sale" with coupon "SANTA2016"
And I am logged in as an administrator
@ui
@api @ui
Scenario: Trying to add coupon with taken code
When I want to create a new coupon for this promotion
And I specify its code as "SANTA2016"

View file

@ -10,7 +10,7 @@ Feature: Coupon validation
And it is coupon based promotion
And I am logged in as an administrator
@ui
@ui @api
Scenario: Trying to add a new coupon without specifying its code
When I want to create a new coupon for this promotion
And I do not specify its code
@ -19,9 +19,9 @@ Feature: Coupon validation
And I make it valid until "26.03.2017"
And I try to add it
Then I should be notified that code is required
And there should be 0 coupon related to this promotion
And there should be 0 coupons related to this promotion
@ui
@ui @api
Scenario: Trying to add a new coupon with usage limit below one
When I want to create a new coupon for this promotion
And I specify its code as "SANTA2016"
@ -30,9 +30,9 @@ Feature: Coupon validation
And I make it valid until "26.03.2017"
And I try to add it
Then I should be notified that coupon usage limit must be at least one
And there should be 0 coupon related to this promotion
And there should be 0 coupons related to this promotion
@ui
@ui @api
Scenario: Trying to add a new coupon with per customer usage limit below one
When I want to create a new coupon for this promotion
And I specify its code as "SANTA2016"
@ -41,4 +41,27 @@ Feature: Coupon validation
And I make it valid until "26.03.2017"
And I try to add it
Then I should be notified that coupon usage limit per customer must be at least one
And there should be 0 coupon related to this promotion
And there should be 0 coupons related to this promotion
@api @no-ui
Scenario: Trying to add a new coupon with no promotion
When I want to create a new coupon
And I specify its code as "RANDOM"
And I limit its usage to 30 times
And I limit its per customer usage to 3 times
And I make it valid until "26.03.2017"
And I try to add it
Then I should be notified that promotion is required
And there should be no coupon with code "RANDOM"
@api @no-ui
Scenario: Trying to add a new coupon for a non-coupon based promotion
Given there is a promotion "Flash sale"
When I want to create a new coupon for this promotion
And I specify its code as "FAST-50"
And I limit its usage to 50 times
And I limit its per customer usage to 1 time
And I make it valid until "26.03.2017"
And I try to add it
Then I should be notified that only coupon based promotions can have coupons
And there should be 0 coupons related to this promotion

View file

@ -9,7 +9,7 @@ Feature: Deleting a coupon
And the store has promotion "Christmas sale" with coupon "SANTA2016"
And I am logged in as an administrator
@domain @ui
@domain @ui @api
Scenario: Deleted coupon should disappear from the registry
When I delete "SANTA2016" coupon related to this promotion
Then I should be notified that it has been successfully deleted

View file

@ -9,15 +9,15 @@ Feature: Editing promotion coupon
And the store has promotion "Christmas sale" with coupon "SANTA2016"
And I am logged in as an administrator
@ui
@ui @api
Scenario: Changing coupon expires date
When I want to modify the "SANTA2016" coupon for this promotion
And I change expires date to "21.05.2019"
And I change its expiration date to "21.05.2019"
And I save my changes
Then I should be notified that it has been successfully edited
And this coupon should be valid until "21.05.2019"
@ui
@ui @api
Scenario: Changing coupons usage limit
When I want to modify the "SANTA2016" coupon for this promotion
And I change its usage limit to 50
@ -25,7 +25,7 @@ Feature: Editing promotion coupon
Then I should be notified that it has been successfully edited
And this coupon should have 50 usage limit
@ui
@ui @api
Scenario: Changing coupons per customer usage limit
When I want to modify the "SANTA2016" coupon for this promotion
And I change its per customer usage limit to 20
@ -33,7 +33,15 @@ Feature: Editing promotion coupon
Then I should be notified that it has been successfully edited
And this coupon should have 20 per customer usage limit
@ui
Scenario: Seeing a disabled code field when editing a coupon
@ui @api
Scenario: Changing whether it can be reused from cancelled orders
When I want to modify the "SANTA2016" coupon for this promotion
Then the code field should be disabled
And I make it not reusable from cancelled orders
And I save my changes
Then I should be notified that it has been successfully edited
And this coupon should not be reusable from cancelled orders
@ui @api
Scenario: Being unable to change code of promotion coupon
When I want to modify the "SANTA2016" coupon for this promotion
Then I should not be able to edit its code

View file

@ -15,7 +15,7 @@ Feature: Not being able to delete a coupon which is in use
And the customer chose "Free" shipping method to "United States" with "Cash on Delivery" payment
And I am logged in as an administrator
@domain @ui
@domain @ui @api
Scenario: Being unable to delete a used coupon
When I try to delete "SANTA2016" coupon related to this promotion
Then I should be notified that it is in use and cannot be deleted

View file

@ -19,69 +19,69 @@ Feature: Sorting listed coupons
And this coupon expires on "20-02-2023"
And I am logged in as an administrator
@ui
@ui @api
Scenario: Coupons are sorted by descending number of uses by default
When I want to view all coupons of this promotion
Then I should see 3 coupons on the list
And the first coupon should have code "Y"
@ui
@ui @api
Scenario: Changing the number of uses sorting order to ascending
Given I am browsing coupons of this promotion
When I sort coupons by ascending number of uses
Then I should see 3 coupons on the list
And the first coupon should have code "X"
@ui
@ui @api
Scenario: Sorting coupons by code in descending order
Given I am browsing coupons of this promotion
When I sort coupons by descending code
Then I should see 3 coupons on the list
And the first coupon should have code "Z"
@ui
@ui @api
Scenario: Sorting coupons by code in ascending order
Given I am browsing coupons of this promotion
When I sort coupons by ascending code
Then I should see 3 coupons on the list
And the first coupon should have code "X"
@ui @no-postgres
@ui @no-postgres @api
Scenario: Sorting coupons by usage limit in descending order
Given I am browsing coupons of this promotion
When I sort coupons by descending usage limit
Then I should see 3 coupons on the list
And the first coupon should have code "X"
@ui @no-postgres
@ui @no-postgres @api
Scenario: Sorting coupons by usage limit in ascending order
Given I am browsing coupons of this promotion
When I sort coupons by ascending usage limit
Then I should see 3 coupons on the list
And the first coupon should have code "Z"
@ui @no-postgres
@ui @no-postgres @api
Scenario: Sorting coupons by usage limit per customer in descending order
Given I am browsing coupons of this promotion
When I sort coupons by descending usage limit per customer
Then I should see 3 coupons on the list
And the first coupon should have code "X"
@ui @no-postgres
@ui @no-postgres @api
Scenario: Sorting coupons by usage limit per customer in ascending order
Given I am browsing coupons of this promotion
When I sort coupons by ascending usage limit per customer
Then I should see 3 coupons on the list
And the first coupon should have code "Y"
@ui @no-postgres
@ui @no-postgres @api
Scenario: Sorting coupons by expiration date in descending order
Given I am browsing coupons of this promotion
When I sort coupons by descending expiration date
Then I should see 3 coupons on the list
And the first coupon should have code "Z"
@ui @no-postgres
@ui @no-postgres @api
Scenario: Sorting coupons by expiration date in ascending order
Given I am browsing coupons of this promotion
When I sort coupons by ascending expiration date

View file

@ -0,0 +1,466 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Behat\Context\Api\Admin;
use Behat\Behat\Context\Context;
use Sylius\Behat\Client\ApiClientInterface;
use Sylius\Behat\Client\ResponseCheckerInterface;
use Sylius\Behat\Context\Api\Resources;
use Sylius\Behat\Service\Converter\SectionAwareIriConverterInterface;
use Sylius\Component\Core\Model\PromotionCouponInterface;
use Sylius\Component\Core\Model\PromotionInterface;
use Webmozart\Assert\Assert;
final class ManagingPromotionCouponsContext implements Context
{
public function __construct(
private ApiClientInterface $client,
private ResponseCheckerInterface $responseChecker,
private SectionAwareIriConverterInterface $sectionAwareIriConverter,
) {
}
/**
* @Given /^I am browsing coupons of (this promotion)$/
* @When /^I want to view all coupons of (this promotion)$/
*/
public function iWantToViewAllCouponsOfThisPromotion(PromotionInterface $promotion): void
{
$this->client->index(Resources::PROMOTION_COUPONS);
$this->client->addFilter(
'promotion',
$this->sectionAwareIriConverter->getIriFromResourceInSection($promotion, 'admin'),
);
$this->client->filter();
}
/**
* @When I want to create a new coupon
*/
public function iWantToCreateANewCoupon(): void
{
$this->client->buildCreateRequest(Resources::PROMOTION_COUPONS);
}
/**
* @When /^I want to create a new coupon for (this promotion)$/
*/
public function iWantToCreateANewCouponForPromotion(PromotionInterface $promotion): void
{
$this->client->buildCreateRequest(Resources::PROMOTION_COUPONS);
$this->client->addRequestData(
'promotion',
$this->sectionAwareIriConverter->getIriFromResourceInSection($promotion, 'admin'),
);
}
/**
* @When I want to modify the :coupon coupon for this promotion
*/
public function iWantToModifyTheCouponOfThisPromotion(PromotionCouponInterface $coupon): void
{
$this->client->buildUpdateRequest(Resources::PROMOTION_COUPONS, $coupon->getCode());
}
/**
* @When I (try to) delete :coupon coupon related to this promotion
*/
public function iDeleteCouponRelatedToThisPromotion(PromotionCouponInterface $coupon): void
{
$this->client->delete(Resources::PROMOTION_COUPONS, $coupon->getCode());
}
/**
* @When I specify its code as :code
*/
public function iSpecifyItsCodeAs(string $code): void
{
$this->client->addRequestData('code', $code);
}
/**
* @When I limit its usage to :times time(s)
* @When I change its usage limit to :times
*/
public function iLimitItsUsageToTimes(int $times): void
{
$this->client->addRequestData('usageLimit', $times);
}
/**
* @When I limit its per customer usage to :times time(s)
* @When I change its per customer usage limit to :times
*/
public function iLimitItsPerCustomerUsageToTimes(int $times): void
{
$this->client->addRequestData('perCustomerUsageLimit', $times);
}
/**
* @When I make it valid until :date
* @When I change its expiration date to :date
*/
public function iMakeItValidUntil(\DateTime $date): void
{
$this->client->addRequestData('expiresAt', $date->format('d-m-Y'));
}
/**
* @When I make it not reusable from cancelled orders
*/
public function iMakeItNotReusableFromCancelledOrders(): void
{
$this->client->addRequestData('reusableFromCancelledOrders', false);
}
/**
* @When I do not specify its :field
*/
public function iDoNotSpecifyIts(): void
{
// Intentionally left blank
}
/**
* @When I (try to) add it
*/
public function iAddIt(): void
{
$this->client->create();
}
/**
* @When /^I sort coupons by (ascending|descending) number of uses$/
*/
public function iSortCouponsByNumberOfUses(string $order): void
{
$this->sortBy($order, 'used');
}
/**
* @When /^I sort coupons by (ascending|descending) code$/
*/
public function iSortCouponsByCode(string $order): void
{
$this->sortBy($order, 'code');
}
/**
* @When /^I sort coupons by (ascending|descending) usage limit$/
*/
public function iSortCouponsByUsageLimit(string $order): void
{
$this->sortBy($order, 'usageLimit');
}
/**
* @When /^I sort coupons by (ascending|descending) usage limit per customer$/
*/
public function iSortCouponsByPerCustomerUsageLimit(string $order): void
{
$this->sortBy($order, 'perCustomerUsageLimit');
}
/**
* @When /^I sort coupons by (ascending|descending) expiration date$/
*/
public function iSortCouponsByExpirationDate(string $order): void
{
$this->sortBy($order, 'expiresAt');
}
/**
* @Then /^there should(?:| still) be (\d+) coupons? related to (this promotion)$/
*/
public function thereShouldBeCountCouponsRelatedToThisPromotion(int $count, PromotionInterface $promotion): void
{
$coupons = $this->responseChecker->getCollection(
$this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()),
);
Assert::same(count($coupons), $count);
}
/**
* @Then there should be a :promotion promotion with a coupon code :code
*/
public function thereShouldBeACouponWithCode(PromotionInterface $promotion, string $code): void
{
Assert::true($this->responseChecker->hasItemWithValue(
$this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()),
'code',
$code,
));
}
/**
* @Then there should be no coupon with code :code
*/
public function thereShouldBeNoCouponWithCode(string $code): void
{
Assert::false($this->responseChecker->hasItemWithValue(
$this->client->index(Resources::PROMOTION_COUPONS),
'code',
$code,
));
}
/**
* @Then I should see :count coupons on the list
*/
public function iShouldSeeCountCouponsOnTheList(int $count): void
{
Assert::same($this->responseChecker->countCollectionItems($this->client->getLastResponse()), $count);
}
/**
* @Then the first coupon should have code :code
*/
public function theFirstCouponShouldHaveCode(string $code): void
{
Assert::true($this->responseChecker->hasItemOnPositionWithValue(
$this->client->getLastResponse(),
0,
'code',
$code,
));
}
/**
* @Then I should be notified that it has been successfully created
*/
public function iShouldBeNotifiedThatItHasBeenSuccessfullyCreated(): void
{
Assert::true(
$this->responseChecker->isCreationSuccessful($this->client->getLastResponse()),
'Promotion coupon could not be created',
);
}
/**
* @Then this coupon should be valid until :date
*/
public function thisCouponShouldBeValidUntil(\DateTime $date): void
{
$actualDate = \DateTime::createFromFormat(
'Y-m-d h:i:s',
$this->responseChecker->getValue($this->client->getLastResponse(), 'expiresAt'),
);
Assert::same(
$actualDate->format('Y-m-d'),
$date->format('Y-m-d'),
);
}
/**
* @Then this coupon should have :limit usage limit
*/
public function thisCouponShouldHaveUsageLimit(int $limit): void
{
Assert::same(
$this->responseChecker->getValue($this->client->getLastResponse(), 'usageLimit'),
$limit,
);
}
/**
* @Then this coupon should have :limit per customer usage limit
*/
public function thisCouponShouldHavePerCustomerUsageLimit(int $limit): void
{
Assert::same(
$this->responseChecker->getValue($this->client->getLastResponse(), 'perCustomerUsageLimit'),
$limit,
);
}
/**
* @Then this coupon should not be reusable from cancelled orders
*/
public function thisCouponShouldNotBeReusableFromCancelledOrders(): void
{
Assert::false($this->responseChecker->getValue(
$this->client->getLastResponse(),
'reusableFromCancelledOrders',
));
}
/**
* @Then I should be notified that it has been successfully deleted
*/
public function iShouldBeNotifiedThatItHasBeenSuccessfullyDeleted(): void
{
Assert::true(
$this->responseChecker->isDeletionSuccessful($this->client->getLastResponse()),
'Promotion coupon could not be deleted',
);
}
/**
* @Then /^(this coupon) should no longer exist in the coupon registry$/
*/
public function couponShouldNotExistInTheRegistry(PromotionCouponInterface $coupon): void
{
Assert::false($this->responseChecker->hasItemWithValue(
$this->client->index(Resources::PROMOTION_COUPONS),
'code',
$coupon->getCode(),
));
}
/**
* @Then /^(this coupon) should still exist in the registry$/
*/
public function couponShouldStillExistInTheRegistry(PromotionCouponInterface $coupon): void
{
Assert::true($this->responseChecker->hasItemWithValue(
$this->client->index(Resources::PROMOTION_COUPONS),
'code',
$coupon->getCode(),
));
}
/**
* @Then /^there should still be only one coupon with code "([^"]+)" related to (this promotion)$/
*/
public function thereShouldStillBeOnlyOneCouponWithCodeRelatedTo(string $code, PromotionInterface $promotion): void
{
$coupons = $this->responseChecker->getCollectionItemsWithValue(
$this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()),
'code',
$code
);
Assert::count($coupons, 1);
}
/**
* @Then I should be notified that it is in use and cannot be deleted
*/
public function iShouldBeNotifiedThatItIsInUseAndCannotBeDeleted(): void
{
Assert::contains(
$this->responseChecker->getError($this->client->getLastResponse()),
'Cannot delete, the promotion coupon is in use.',
);
}
/**
* @Then I should be notified that code is required
*/
public function iShouldBeNotifiedThatCodeIsRequired(): void
{
$response = $this->client->getLastResponse();
Assert::false(
$this->responseChecker->isCreationSuccessful($response),
'Coupon has been created successfully, but it should not',
);
Assert::same($this->responseChecker->getError($response), 'code: Please enter coupon code.');
}
/**
* @Then I should be notified that coupon usage limit must be at least one
*/
public function iShouldBeNotifiedThatCouponUsageLimitMustBeAtLeastOne(): void
{
$response = $this->client->getLastResponse();
Assert::false(
$this->responseChecker->isCreationSuccessful($response),
'Coupon has been created successfully, but it should not',
);
Assert::same(
$this->responseChecker->getError($response),
'usageLimit: Coupon usage limit must be at least 1.',
);
}
/**
* @Then I should be notified that coupon usage limit per customer must be at least one
*/
public function iShouldBeNotifiedThatCouponUsageLimitPerCustomerMustBeAtLeastOne(): void
{
$response = $this->client->getLastResponse();
Assert::false(
$this->responseChecker->isCreationSuccessful($response),
'Coupon has been created successfully, but it should not',
);
Assert::same(
$this->responseChecker->getError($response),
'perCustomerUsageLimit: Coupon usage limit per customer must be at least 1.',
);
}
/**
* @Then I should be notified that promotion is required
*/
public function iShouldBeNotifiedThatPromotionIsRequired(): void
{
$response = $this->client->getLastResponse();
Assert::false(
$this->responseChecker->isCreationSuccessful($response),
'Coupon has been created successfully, but it should not',
);
Assert::same(
$this->responseChecker->getError($response),
'promotion: Please provide a promotion for this coupon.',
);
}
/**
* @Then I should be notified that only coupon based promotions can have coupons
*/
public function iShouldBeNotifiedThatOnlyCouponBasedPromotionsCanHaveCoupons(): void
{
$response = $this->client->getLastResponse();
Assert::false(
$this->responseChecker->isCreationSuccessful($response),
'Coupon has been created successfully, but it should not',
);
Assert::same(
$this->responseChecker->getError($response),
'promotion: Only coupon based promotions can have coupons.',
);
}
/**
* @Then I should be notified that coupon with this code already exists
*/
public function iShouldBeNotifiedThatCouponWithThisCodeAlreadyExists(): void
{
$response = $this->client->getLastResponse();
Assert::false(
$this->responseChecker->isCreationSuccessful($response),
'Coupon has been created successfully, but it should not',
);
Assert::same(
$this->responseChecker->getError($response),
'code: This coupon already exists.',
);
}
/**
* @Then I should not be able to edit its code
*/
public function iShouldNotBeAbleToEditItsCode(): void
{
$this->client->updateRequestData(['code' => 'NEW_CODE']);
Assert::false($this->responseChecker->hasValue($this->client->update(), 'code', 'NEW_CODE'));
}
private function sortBy(string $order, string $field): void
{
$this->client->sort([$field => str_starts_with($order, 'de') ? 'desc' : 'asc']);
}
}

View file

@ -71,6 +71,8 @@ final class Resources
public const PROMOTIONS = 'promotions';
public const PROMOTION_COUPONS = 'promotion-coupons';
public const PROVINCES = 'provinces';
public const SHIPMENTS = 'shipments';

View file

@ -98,7 +98,7 @@ final class ManagingPromotionCouponsContext implements Context
}
/**
* @When /^I limit generated coupons usage to (\d+) times$/
* @When /^I limit generated coupons usage to (\d+) times?$/
*/
public function iSetGeneratedCouponsUsageLimitTo(int $limit)
{
@ -123,7 +123,7 @@ final class ManagingPromotionCouponsContext implements Context
}
/**
* @When I limit its usage to :limit times
* @When I limit its usage to :limit time(s)
*/
public function iLimitItsUsageLimitTo(int $limit)
{
@ -149,7 +149,7 @@ final class ManagingPromotionCouponsContext implements Context
}
/**
* @When /^I limit its per customer usage to ([^"]+) times$/
* @When /^I limit its per customer usage to ([^"]+) times?$/
*/
public function iLimitItsPerCustomerUsageLimitTo(int $limit)
{
@ -164,6 +164,14 @@ final class ManagingPromotionCouponsContext implements Context
$this->updatePage->setCustomerUsageLimit($limit);
}
/**
* @When I make it not reusable from cancelled orders
*/
public function iMakeItReusableFromCancelledOrders(): void
{
$this->updatePage->toggleReusableFromCancelledOrders(false);
}
/**
* @When I make it valid until :date
*/
@ -173,9 +181,9 @@ final class ManagingPromotionCouponsContext implements Context
}
/**
* @When I change expires date to :date
* @When I change its expiration date to :date
*/
public function iChangeExpiresDateTo(\DateTimeInterface $date)
public function iChangeItsExpirationDateTo(\DateTimeInterface $date)
{
$this->updatePage->setExpiresAt($date);
}
@ -275,9 +283,7 @@ final class ManagingPromotionCouponsContext implements Context
}
/**
* @Then /^there should be (0|1) coupon related to (this promotion)$/
* @Then /^there should be (\b(?![01]\b)\d{1,9}\b) coupons related to (this promotion)$/
* @Then /^there should still be (\d+) coupons related to (this promotion)$/
* @Then /^there should(?:| still) be (\d+) coupons? related to (this promotion)$/
*/
public function thereShouldBeCouponRelatedTo(int $number, PromotionInterface $promotion): void
{
@ -315,9 +321,10 @@ final class ManagingPromotionCouponsContext implements Context
}
/**
* @Then /^there should be coupon with code "([^"]+)"$/
* @Then there should be a coupon with code :code
* @Then there should be a :promotion promotion with a coupon code :code
*/
public function thereShouldBeCouponWithCode($code)
public function thereShouldBeCouponWithCode(string $code): void
{
Assert::true($this->indexPage->isSingleResourceOnPage(['code' => $code]));
}
@ -325,9 +332,9 @@ final class ManagingPromotionCouponsContext implements Context
/**
* @Then this coupon should be valid until :date
*/
public function thisCouponShouldBeValidUntil(\DateTimeInterface $date)
public function thisCouponShouldBeValidUntil(\DateTime $date)
{
Assert::true($this->indexPage->isSingleResourceOnPage(['expiresAt' => date('d-m-Y', $date->getTimestamp())]));
Assert::true($this->indexPage->isSingleResourceOnPage(['expiresAt' => $date->format('d-m-Y')]));
}
/**
@ -355,9 +362,20 @@ final class ManagingPromotionCouponsContext implements Context
}
/**
* @Then /^(this coupon) should not be reusable from cancelled orders$/
*/
public function thisCouponShouldBeReusableFromCancelledOrders(PromotionCouponInterface $coupon): void
{
$this->updatePage->open(['id' => $coupon->getId(), 'promotionId' => $coupon->getPromotion()->getId()]);
Assert::false($this->updatePage->isReusableFromCancelledOrders());
}
/**
* @Then I should not be able to edit its code
* @Then the code field should be disabled
*/
public function theCodeFieldShouldBeDisabled()
public function iShouldNotBeAbleToEditItsCode(): void
{
Assert::true($this->updatePage->isCodeDisabled());
}

View file

@ -16,6 +16,7 @@ namespace Sylius\Behat\Page\Admin\PromotionCoupon;
use Behat\Mink\Element\NodeElement;
use Sylius\Behat\Behaviour\ChecksCodeImmutability;
use Sylius\Behat\Page\Admin\Crud\UpdatePage as BaseUpdatePage;
use Webmozart\Assert\Assert;
class UpdatePage extends BaseUpdatePage implements UpdatePageInterface
{
@ -38,6 +39,20 @@ class UpdatePage extends BaseUpdatePage implements UpdatePageInterface
$this->getDocument()->fillField('Usage limit', $limit);
}
public function isReusableFromCancelledOrders(): bool
{
return $this->getElement('reusable_from_cancelled_orders')->isChecked();
}
public function toggleReusableFromCancelledOrders(bool $reusable): void
{
$toggle = $this->getElement('reusable_from_cancelled_orders');
Assert::notSame($toggle->isChecked(), $reusable);
$reusable ? $toggle->check() : $toggle->uncheck();
}
protected function getCodeElement(): NodeElement
{
return $this->getElement('code');
@ -50,6 +65,7 @@ class UpdatePage extends BaseUpdatePage implements UpdatePageInterface
'expires_at' => '#sylius_promotion_coupon_expiresAt',
'usage_limit' => '#sylius_promotion_coupon_usageLimit',
'per_customer_usage_limit' => '#sylius_promotion_coupon_perCustomerUsageLimit',
'reusable_from_cancelled_orders' => '#sylius_promotion_coupon_reusableFromCancelledOrders',
]);
}
}

View file

@ -24,4 +24,8 @@ interface UpdatePageInterface extends BaseUpdatePageInterface
public function setExpiresAt(\DateTimeInterface $date): void;
public function setUsageLimit(string $limit): void;
public function isReusableFromCancelledOrders(): bool;
public function toggleReusableFromCancelledOrders(bool $reusable): void;
}

View file

@ -290,5 +290,11 @@
<argument type="service" id="Sylius\Behat\Client\ResponseCheckerInterface" />
<argument type="service" id="sylius.behat.shared_storage" />
</service>
<service id="sylius.behat.context.api.admin.managing_promotion_coupons" class="Sylius\Behat\Context\Api\Admin\ManagingPromotionCouponsContext">
<argument type="service" id="sylius.behat.api_platform_client.admin" />
<argument type="service" id="Sylius\Behat\Client\ResponseCheckerInterface" />
<argument type="service" id="sylius.behat.section_iri_converter" />
</service>
</services>
</container>

View file

@ -49,6 +49,7 @@ imports:
- suites/api/promotion/applying_promotion_coupon.yml
- suites/api/promotion/applying_promotion_rules.yml
- suites/api/promotion/managing_catalog_promotions.yml
- suites/api/promotion/managing_promotion_coupons.yml
- suites/api/promotion/managing_promotions.yml
- suites/api/promotion/receiving_discount.yml
- suites/api/promotion/removing_catalog_promotions.yml

View file

@ -0,0 +1,35 @@
# This file is part of the Sylius package.
# (c) Sylius Sp. z o.o.
default:
suites:
api_managing_promotion_coupons:
contexts:
- sylius.behat.context.hook.doctrine_orm
- sylius.behat.context.transform.coupon
- sylius.behat.context.transform.address
- sylius.behat.context.transform.customer
- sylius.behat.context.transform.date_time
- sylius.behat.context.transform.lexical
- sylius.behat.context.transform.payment
- sylius.behat.context.transform.product
- sylius.behat.context.transform.promotion
- sylius.behat.context.transform.shipping_method
- sylius.behat.context.transform.shared_storage
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.payment
- sylius.behat.context.setup.product
- sylius.behat.context.setup.promotion
- sylius.behat.context.setup.shipping
- sylius.behat.context.setup.order
- sylius.behat.context.setup.admin_api_security
- sylius.behat.context.api.admin.managing_promotion_coupons
- sylius.behat.context.api.admin.response
- sylius.behat.context.api.admin.save
filters:
tags: "@managing_promotion_coupons&&@api"
javascript: false

View file

@ -28,6 +28,5 @@ default:
- sylius.behat.context.domain.notification
- sylius.behat.context.domain.security
filters:
tags: "@managing_promotion_coupons&&@domain"

View file

@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\DataPersister;
use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Sylius\Bundle\ApiBundle\Exception\PromotionCouponCannotBeRemoved;
use Sylius\Component\Core\Model\PromotionCouponInterface;
/** @experimental */
final class PromotionCouponDataPersister implements ContextAwareDataPersisterInterface
{
public function __construct(
private ContextAwareDataPersisterInterface $decoratedDataPersister,
) {
}
/** @param array<array-key, mixed> $context */
public function supports($data, array $context = []): bool
{
return $data instanceof PromotionCouponInterface;
}
/** @param array<array-key, mixed> $context */
public function persist($data, array $context = [])
{
return $this->decoratedDataPersister->persist($data, $context);
}
/** @param array<array-key, mixed> $context */
public function remove($data, array $context = []): void
{
try {
$this->decoratedDataPersister->remove($data, $context);
} catch (ForeignKeyConstraintViolationException) {
throw new PromotionCouponCannotBeRemoved();
}
}
}

View file

@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Exception;
/** @experimental */
final class PromotionCouponCannotBeRemoved extends \RuntimeException
{
public function __construct(
string $message = 'Cannot delete, the promotion coupon is in use.',
int $code = 0,
\Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}

View file

@ -0,0 +1,86 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Filter\Doctrine;
use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
final class PromotionCouponPromotionFilter extends AbstractContextAwareFilter
{
const PROPERTY = 'promotion';
/** @param array<array-key, mixed> $properties */
public function __construct(
private IriConverterInterface $iriConverter,
ManagerRegistry $managerRegistry,
?RequestStack $requestStack = null,
LoggerInterface $logger = null,
array $properties = null,
NameConverterInterface $nameConverter = null,
) {
parent::__construct($managerRegistry, $requestStack, $logger, $properties, $nameConverter);
}
protected function filterProperty(
string $property,
$value,
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
): void {
if (self::PROPERTY !== $property) {
return;
}
$promotion = $this->iriConverter->getResourceFromIri($value);
$parameterName = $queryNameGenerator->generateParameterName(':promotion');
$promotionJoinAlias = $queryNameGenerator->generateJoinAlias('promotion');
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->innerJoin(
sprintf('%s.promotion', $rootAlias),
$promotionJoinAlias,
Join::WITH,
$queryBuilder->expr()->eq(sprintf('%s.id', $promotionJoinAlias), $parameterName),
)
->setParameter($parameterName, $promotion)
;
}
/** @return array<string, mixed> */
public function getDescription(string $resourceClass): array
{
return [
self::PROPERTY => [
'type' => 'string',
'required' => false,
'property' => null,
'description' => 'Get a collection of promotion coupons for promotion',
'schema' => [
'type' => 'string',
],
],
];
}
}

View file

@ -133,6 +133,13 @@ Example configuration for `order_fixed_discount` action type:
</itemOperation>
</itemOperations>
<subresourceOperations>
<subresourceOperation name="coupons_get_subresource">
<attribute name="method">GET</attribute>
<attribute name="path">/admin/promotions/{code}/promotion-coupons</attribute>
</subresourceOperation>
</subresourceOperations>
<property name="id" identifier="false" writable="false" />
<property name="code" identifier="true" readable="true" />
<property name="name" readable="true" />
@ -144,7 +151,9 @@ Example configuration for `order_fixed_discount` action type:
<property name="startsAt" readable="true" />
<property name="endsAt" readable="true" />
<property name="couponBased" readable="true" />
<property name="coupons" readable="true" />
<property name="coupons" readable="true" writable="true">
<subresource resourceClass="%sylius.model.promotion_coupon.class%" collection="true" />
</property>
<property name="rules" readable="true" />
<property name="actions" readable="true" />
<property name="channels" readable="true" />

View file

@ -18,7 +18,33 @@
<resource class="%sylius.model.promotion_coupon.class%" shortName="PromotionCoupon">
<attribute name="route_prefix">admin</attribute>
<collectionOperations/>
<attribute name="validation_groups">sylius</attribute>
<collectionOperations>
<collectionOperation name="admin_get">
<attribute name="method">GET</attribute>
<attribute name="filters">
<attribute>sylius.api.promotion_coupon_order_filter</attribute>
<attribute>Sylius\Bundle\ApiBundle\Filter\Doctrine\PromotionCouponPromotionFilter</attribute>
</attribute>
<attribute name="normalization_context">
<attribute name="groups">admin:promotion_coupon:read</attribute>
</attribute>
<attribute name="order">
<attribute name="used">DESC</attribute>
</attribute>
</collectionOperation>
<collectionOperation name="admin_post">
<attribute name="method">POST</attribute>
<attribute name="normalization_context">
<attribute name="groups">admin:promotion_coupon:read</attribute>
</attribute>
<attribute name="denormalization_context">
<attribute name="groups">admin:promotion_coupon:create</attribute>
</attribute>
</collectionOperation>
</collectionOperations>
<itemOperations>
<itemOperation name="admin_get">
@ -27,8 +53,39 @@
<attribute name="groups">admin:promotion_coupon:read</attribute>
</attribute>
</itemOperation>
<itemOperation name="admin_put">
<attribute name="method">PUT</attribute>
<attribute name="normalization_context">
<attribute name="groups">admin:promotion_coupon:read</attribute>
</attribute>
<attribute name="denormalization_context">
<attribute name="groups">admin:promotion_coupon:update</attribute>
</attribute>
</itemOperation>
<itemOperation name="admin_delete">
<attribute name="method">DELETE</attribute>
</itemOperation>
</itemOperations>
<property name="id" identifier="true" writable="false" />
<subresourceOperations>
<subresourceOperation name="api_promotions_coupons_get_subresource">
<attribute name="normalization_context">
<attribute name="groups">admin:promotion_coupon:read</attribute>
</attribute>
</subresourceOperation>
</subresourceOperations>
<property name="id" identifier="false" writable="false" />
<property name="code" identifier="true" readable="true" />
<property name="usageLimit" readable="true" />
<property name="perCustomerUsageLimit" readable="true" />
<property name="used" readable="true" />
<property name="reusableFromCancelledOrders" readable="true" />
<property name="promotion" readable="true" />
<property name="expiresAt" readable="true" />
<property name="createdAt" readable="true" />
<property name="updatedAt" readable="true" />
</resource>
</resources>

View file

@ -62,6 +62,7 @@ api_platform:
Sylius\Bundle\ApiBundle\Exception\ProductAttributeCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProductCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProductVariantCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\PromotionCouponCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProvinceCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ShippingMethodCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\TaxonCannotBeRemoved: 422

View file

@ -0,0 +1,65 @@
<?xml version="1.0" ?>
<!--
This file is part of the Sylius package.
(c) Sylius Sp. z o.o.
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
-->
<serializer xmlns="http://symfony.com/schema/dic/serializer-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/serializer-mapping https://symfony.com/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd"
>
<class name="Sylius\Component\Core\Model\PromotionCoupon">
<attribute name="createdAt">
<group>admin:promotion_coupon:read</group>
</attribute>
<attribute name="updatedAt">
<group>admin:promotion_coupon:read</group>
</attribute>
<attribute name="code">
<group>admin:promotion_coupon:read</group>
<group>admin:promotion_coupon:create</group>
</attribute>
<attribute name="usageLimit">
<group>admin:promotion_coupon:read</group>
<group>admin:promotion_coupon:create</group>
<group>admin:promotion_coupon:update</group>
</attribute>
<attribute name="perCustomerUsageLimit">
<group>admin:promotion_coupon:read</group>
<group>admin:promotion_coupon:create</group>
<group>admin:promotion_coupon:update</group>
</attribute>
<attribute name="used">
<group>admin:promotion_coupon:read</group>
</attribute>
<attribute name="reusableFromCancelledOrders">
<group>admin:promotion_coupon:read</group>
<group>admin:promotion_coupon:create</group>
<group>admin:promotion_coupon:update</group>
</attribute>
<attribute name="promotion">
<group>admin:promotion_coupon:read</group>
<group>admin:promotion_coupon:create</group>
</attribute>
<attribute name="expiresAt">
<group>admin:promotion_coupon:read</group>
<group>admin:promotion_coupon:create</group>
<group>admin:promotion_coupon:update</group>
</attribute>
</class>
</serializer>

View file

@ -85,6 +85,11 @@
<tag name="api_platform.data_persister" />
</service>
<service id="Sylius\Bundle\ApiBundle\DataPersister\PromotionCouponDataPersister">
<argument type="service" id="api_platform.doctrine.orm.data_persister" />
<tag name="api_platform.data_persister" />
</service>
<service id="Sylius\Bundle\ApiBundle\DataPersister\TranslatableDataPersister">
<argument type="service" id="sylius.translation_locale_provider" />
<tag name="api_platform.data_persister" priority="128" />

View file

@ -315,5 +315,22 @@
</argument>
<tag name="api_platform.filter" />
</service>
<service id="sylius.api.promotion_coupon_order_filter" parent="api_platform.doctrine.orm.order_filter" public="true">
<argument type="collection">
<argument key="code" />
<argument key="expiresAt" />
<argument key="usageLimit" />
<argument key="perCustomerUsageLimit" />
<argument key="used" />
</argument>
<tag name="api_platform.filter" />
</service>
<service id="Sylius\Bundle\ApiBundle\Filter\Doctrine\PromotionCouponPromotionFilter" public="true">
<argument type="service" id="api_platform.symfony.iri_converter" />
<argument type="service" id="doctrine" />
<tag name="api_platform.filter" />
</service>
</services>
</container>

View file

@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\DataPersister;
use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Exception\PromotionCouponCannotBeRemoved;
use Sylius\Component\Core\Model\PromotionCouponInterface;
final class PromotionCouponDataPersisterSpec extends ObjectBehavior
{
function let(ContextAwareDataPersisterInterface $dataPersister): void
{
$this->beConstructedWith($dataPersister);
}
function it_is_a_context_aware_persister(): void
{
$this->shouldImplement(ContextAwareDataPersisterInterface::class);
}
function it_supports_only_promotion_coupon(PromotionCouponInterface $coupon): void
{
$this->supports(new \stdClass())->shouldReturn(false);
$this->supports($coupon)->shouldReturn(true);
}
function it_uses_inner_persister_to_persist_promotion_coupon(
ContextAwareDataPersisterInterface $dataPersister,
PromotionCouponInterface $coupon,
): void {
$dataPersister->persist($coupon, [])->shouldBeCalled();
$this->persist($coupon);
}
function it_throws_cannot_be_removed_exception_if_constraint_fails_on_removal(
ContextAwareDataPersisterInterface $dataPersister,
PromotionCouponInterface $coupon,
): void {
$dataPersister->remove($coupon, [])->willThrow(ForeignKeyConstraintViolationException::class);
$this->shouldThrow(PromotionCouponCannotBeRemoved::class)->during('remove', [$coupon]);
}
function it_uses_inner_persister_to_remove_promotion_coupon(
ContextAwareDataPersisterInterface $dataPersister,
PromotionCouponInterface $coupon,
): void {
$dataPersister->remove($coupon, [])->shouldBeCalled();
$this->remove($coupon);
}
}

View file

@ -50,5 +50,9 @@
<argument>%sylius.promotion_rules%</argument>
<tag name="validator.constraint_validator" alias="sylius_promotion_rule" />
</service>
<service id="Sylius\Bundle\PromotionBundle\Validator\PromotionNotCouponBasedValidator">
<tag name="validator.constraint_validator" alias="sylius_promotion_not_coupon_based_validator" />
</service>
</services>
</container>

View file

@ -18,6 +18,16 @@
<option name="message">sylius.promotion_coupon.code.unique</option>
<option name="groups">sylius</option>
</constraint>
<constraint name="Sylius\Bundle\PromotionBundle\Validator\Constraints\PromotionNotCouponBased">
<option name="groups">sylius</option>
</constraint>
<property name="promotion">
<constraint name="NotBlank">
<option name="message">sylius.promotion_coupon.promotion.not_blank</option>
<option name="groups">sylius</option>
</constraint>
</property>
<property name="code">
<constraint name="NotBlank">

View file

@ -47,6 +47,9 @@ sylius:
regex: Coupon code can only be comprised of letters, numbers, dashes and underscores.
unique: This coupon already exists.
is_invalid: Coupon code is invalid.
promotion:
not_blank: Please provide a promotion for this coupon.
not_coupon_based: Only coupon based promotions can have coupons.
usage_limit:
min: Coupon usage limit must be at least {{ limit }}.
promotion_coupon_generator_instruction:

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\PromotionBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
final class PromotionNotCouponBased extends Constraint
{
public string $message = 'sylius.promotion_coupon.promotion.not_coupon_based';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
public function validatedBy(): string
{
return 'sylius_promotion_not_coupon_based_validator';
}
}

View file

@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\PromotionBundle\Validator;
use Sylius\Bundle\PromotionBundle\Validator\Constraints\PromotionNotCouponBased;
use Sylius\Component\Promotion\Model\PromotionCouponInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
final class PromotionNotCouponBasedValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (null === $value) {
return;
}
if (!$constraint instanceof PromotionNotCouponBased) {
throw new UnexpectedTypeException($constraint, PromotionNotCouponBased::class);
}
if (!$value instanceof PromotionCouponInterface) {
throw new UnexpectedValueException($value, PromotionCouponInterface::class);
}
$promotion = $value->getPromotion();
if (null === $promotion) {
return;
}
if (!$promotion->isCouponBased()) {
$this->context
->buildViolation($constraint->message)
->atPath('promotion')
->addViolation()
;
}
}
}

View file

@ -0,0 +1,111 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\PromotionBundle\Validator;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\PromotionBundle\Validator\Constraints\PromotionNotCouponBased;
use Sylius\Component\Promotion\Model\PromotionCouponInterface;
use Sylius\Component\Promotion\Model\PromotionInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;
final class PromotionNotCouponBasedValidatorSpec extends ObjectBehavior
{
function let(ExecutionContextInterface $context): void
{
$this->initialize($context);
}
function it_is_a_constraint_validator(): void
{
$this->shouldHaveType(ConstraintValidator::class);
}
function it_does_nothing_when_value_is_null(ExecutionContextInterface $context): void
{
$context->buildViolation(Argument::any())->shouldNotBeCalled();
$this->validate(null, new PromotionNotCouponBased());
}
function it_throws_an_exception_when_constraint_is_not_promotion_not_coupon_based(
ExecutionContextInterface $context,
PromotionCouponInterface $coupon,
Constraint $constraint,
): void {
$context->buildViolation(Argument::any())->shouldNotBeCalled();
$this
->shouldThrow(UnexpectedTypeException::class)
->during('validate', [$coupon, $constraint])
;
}
function it_throws_an_exception_when_value_is_not_a_coupon(ExecutionContextInterface $context): void
{
$context->buildViolation(Argument::any())->shouldNotBeCalled();
$this
->shouldThrow(UnexpectedValueException::class)
->during('validate', [new \stdClass(), new PromotionNotCouponBased()])
;
}
function it_does_nothing_when_coupon_has_no_promotion(
ExecutionContextInterface $context,
PromotionCouponInterface $coupon,
): void {
$context->buildViolation(Argument::any())->shouldNotBeCalled();
$coupon->getPromotion()->willReturn(null);
$this->validate($coupon, new PromotionNotCouponBased());
}
function it_does_nothing_when_coupon_has_promotion_and_its_coupon_based(
ExecutionContextInterface $context,
PromotionCouponInterface $coupon,
PromotionInterface $promotion,
): void {
$context->buildViolation(Argument::any())->shouldNotBeCalled();
$promotion->isCouponBased()->willReturn(true);
$coupon->getPromotion()->willReturn($promotion);
$this->validate($coupon, new PromotionNotCouponBased());
}
function it_adds_violation_when_coupon_has_promotion_but_its_not_coupon_based(
ExecutionContextInterface $context,
ConstraintViolationBuilderInterface $violationBuilder,
PromotionCouponInterface $coupon,
PromotionInterface $promotion,
): void {
$constraint = new PromotionNotCouponBased();
$context->buildViolation($constraint->message)->willReturn($violationBuilder);
$violationBuilder->atPath('promotion')->willReturn($violationBuilder);
$violationBuilder->addViolation()->shouldBeCalled();
$promotion->isCouponBased()->willReturn(false);
$coupon->getPromotion()->willReturn($promotion);
$this->validate($coupon, $constraint);
}
}

View file

@ -0,0 +1,161 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Tests\Api\Admin;
use Sylius\Component\Core\Model\PromotionCouponInterface;
use Sylius\Component\Core\Model\PromotionInterface;
use Sylius\Tests\Api\JsonApiTestCase;
use Sylius\Tests\Api\Utils\AdminUserLoginTrait;
use Symfony\Component\HttpFoundation\Response;
final class PromotionCouponsTest extends JsonApiTestCase
{
use AdminUserLoginTrait;
/** @test */
public function it_gets_a_promotion_coupon(): void
{
$fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var PromotionCouponInterface $coupon */
$coupon = $fixtures['promotion_1_off_coupon_1'];
$this->client->request(
method: 'GET',
uri: sprintf('/api/v2/admin/promotion-coupons/%s', $coupon->getCode()),
server: $header,
);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion_coupon/get_promotion_coupon_response',
Response::HTTP_OK,
);
}
/** @test */
public function it_gets_promotion_coupons(): void
{
$this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
$this->client->request(method: 'GET', uri: '/api/v2/admin/promotion-coupons', server: $header);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion_coupon/get_promotion_coupons_response',
Response::HTTP_OK,
);
}
/** @test */
public function it_creates_a_promotion_coupon(): void
{
$fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var PromotionInterface $promotion */
$promotion = $fixtures['promotion_1_off'];
$this->client->request(
method: 'POST',
uri: '/api/v2/admin/promotion-coupons',
server: $header,
content: json_encode([
'code' => 'XYZ3',
'usageLimit' => 100,
'perCustomerUsageLimit' => 3,
'reusableFromCancelledOrders' => false,
'expiresAt' => '23-12-2023',
'promotion' => 'api/v2/admin/promotions/' . $promotion->getCode(),
], JSON_THROW_ON_ERROR)
);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion_coupon/post_promotion_coupon_response',
Response::HTTP_CREATED,
);
}
/** @test */
public function it_updates_a_promotion_coupon(): void
{
$fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var PromotionCouponInterface $coupon */
$coupon = $fixtures['promotion_1_off_coupon_1'];
$this->client->request(
method: 'PUT',
uri: '/api/v2/admin/promotion-coupons/' . $coupon->getCode(),
server: $header,
content: json_encode([
'usageLimit' => 1000,
'perCustomerUsageLimit' => 5,
'reusableFromCancelledOrders' => false,
'expiresAt' => '2020-01-01 12:00:00',
], JSON_THROW_ON_ERROR)
);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion_coupon/put_promotion_coupon_response',
Response::HTTP_OK,
);
}
/** @test */
public function it_removes_a_promotion_coupon(): void
{
$fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var PromotionCouponInterface $coupon */
$coupon = $fixtures['promotion_1_off_coupon_1'];
$this->client->request(
method: 'DELETE',
uri: '/api/v2/admin/promotion-coupons/' . $coupon->getCode(),
server: $header,
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_NO_CONTENT);
}
/** @test */
public function it_does_not_delete_the_promotion_coupon_in_use(): void
{
$fixtures = $this->loadFixturesFromFiles([
'authentication/api_administrator.yaml',
'channel.yaml',
'promotion/promotion.yaml',
'promotion/promotion_order.yaml',
]);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var PromotionCouponInterface $coupon */
$coupon = $fixtures['promotion_1_off_coupon_1'];
$this->client->request(
method: 'DELETE',
uri: '/api/v2/admin/promotion-coupons/' . $coupon->getCode(),
server: $header,
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
}

View file

@ -72,6 +72,28 @@ final class PromotionsTest extends JsonApiTestCase
);
}
/** @test */
public function it_gets_promotion_coupons(): void
{
$fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion/promotion.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var PromotionInterface $promotion */
$promotion = $fixtures['promotion_1_off'];
$this->client->request(
method: 'GET',
uri: sprintf('/api/v2/admin/promotions/%s/promotion-coupons', $promotion->getCode()),
server: $header
);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion/get_promotion_coupons_response',
Response::HTTP_OK,
);
}
/** @test */
public function it_creates_promotion(): void
{

View file

@ -33,3 +33,20 @@ Sylius\Component\Promotion\Model\PromotionTranslation:
locale: 'en_US'
label: '1$ off every item!'
translatable: '@promotion_1_off'
Sylius\Component\Core\Model\PromotionCoupon:
promotion_1_off_coupon_1:
code: 'XYZ1'
usageLimit: 2
perCustomerUsageLimit: 1
used: 1
reusableFromCancelledOrders: true
promotion: '@promotion_1_off'
expiresAt: null
promotion_1_off_coupon_2:
code: 'XYZ2'
usageLimit: null
perCustomerUsageLimit: null
reusableFromCancelledOrders: false
promotion: '@promotion_1_off'
expiresAt: <dateTimeBetween('now', '+20 days')>

View file

@ -6,3 +6,4 @@ Sylius\Component\Core\Model\Order:
state: "new"
tokenValue: "token"
promotions: ['@promotion_50_off']
promotionCoupon: '@promotion_1_off_coupon_1'

View file

@ -0,0 +1,34 @@
{
"@context": "\/api\/v2\/contexts\/PromotionCoupon",
"@id": "\/api\/v2\/admin\/promotions/dollar_off/promotion-coupons",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
"@type": "PromotionCoupon",
"perCustomerUsageLimit": 1,
"reusableFromCancelledOrders": true,
"code": "XYZ1",
"usageLimit": 2,
"used": 1,
"promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
"expiresAt": null,
"createdAt": @date@,
"updatedAt": @date@
},
{
"@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ2",
"@type": "PromotionCoupon",
"perCustomerUsageLimit": null,
"reusableFromCancelledOrders": false,
"code": "XYZ2",
"usageLimit": null,
"used": 0,
"promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
"expiresAt": @date@,
"createdAt": @date@,
"updatedAt": @date@
}
],
"hydra:totalItems": 2
}

View file

@ -6,7 +6,9 @@
"code": "50_off",
"name": "50% Off on your first order",
"description": "Get 50% off of your first purchase",
"channels": ["\/api\/v2\/admin\/channels\/MOBILE"],
"channels": [
"\/api\/v2\/admin\/channels\/MOBILE"
],
"priority": 1,
"exclusive": true,
"appliesToDiscounted": false,

View file

@ -10,7 +10,10 @@
"code": "dollar_off",
"name": "1 dollar off each item",
"description": "Get 1 dollar off every item when buying more than 10",
"channels": ["\/api\/v2\/admin\/channels\/WEB", "\/api\/v2\/admin\/channels\/MOBILE"],
"channels": [
"\/api\/v2\/admin\/channels\/WEB",
"\/api\/v2\/admin\/channels\/MOBILE"
],
"priority": 2,
"exclusive": false,
"appliesToDiscounted": true,
@ -19,7 +22,10 @@
"couponBased": true,
"startsAt": null,
"endsAt": null,
"coupons": [],
"coupons": [
"\/api\/v2\/admin\/promotion-coupons\/XYZ1",
"\/api\/v2\/admin\/promotion-coupons\/XYZ2"
],
"rules": [],
"actions": [],
"createdAt": @date@,
@ -40,7 +46,9 @@
"code": "50_off",
"name": "50% Off on your first order",
"description": "Get 50% off of your first purchase",
"channels": ["\/api\/v2\/admin\/channels\/MOBILE"],
"channels": [
"\/api\/v2\/admin\/channels\/MOBILE"
],
"priority": 1,
"exclusive": true,
"appliesToDiscounted": false,
@ -67,7 +75,7 @@
"hydra:totalItems": 2,
"hydra:search": {
"@type": "hydra:IriTemplate",
"hydra:template": "/api/v2/admin/promotions{?coupons.code,coupons.code[],order[priority]}",
"hydra:template": "\/api\/v2\/admin\/promotions{?coupons.code,coupons.code[],order[priority]}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{

View file

@ -0,0 +1,14 @@
{
"@context": "\/api\/v2\/contexts\/PromotionCoupon",
"@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
"@type": "PromotionCoupon",
"code": "XYZ1",
"usageLimit": 2,
"perCustomerUsageLimit": 1,
"used": 1,
"reusableFromCancelledOrders": true,
"promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
"createdAt": @date@,
"updatedAt": @date@,
"expiresAt": null
}

View file

@ -0,0 +1,77 @@
{
"@context": "\/api\/v2\/contexts\/PromotionCoupon",
"@id": "\/api\/v2\/admin\/promotion-coupons",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
"@type": "PromotionCoupon",
"perCustomerUsageLimit": 1,
"reusableFromCancelledOrders": true,
"code": "XYZ1",
"usageLimit": 2,
"used": 1,
"promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
"expiresAt": null,
"createdAt": @date@,
"updatedAt": @date@
},
{
"@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ2",
"@type": "PromotionCoupon",
"perCustomerUsageLimit": null,
"reusableFromCancelledOrders": false,
"code": "XYZ2",
"usageLimit": null,
"used": 0,
"promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
"expiresAt": @date@,
"createdAt": @date@,
"updatedAt": @date@
}
],
"hydra:totalItems": 2,
"hydra:search": {
"@type": "hydra:IriTemplate",
"hydra:template": "\/api\/v2\/admin\/promotion-coupons{?order[code],order[expiresAt],order[usageLimit],order[perCustomerUsageLimit],order[used],promotion}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
"@type": "IriTemplateMapping",
"variable": "order[code]",
"property": "code",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[expiresAt]",
"property": "expiresAt",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[usageLimit]",
"property": "usageLimit",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[perCustomerUsageLimit]",
"property": "perCustomerUsageLimit",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "order[used]",
"property": "used",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "promotion",
"property": null,
"required": false
}
]
}
}

View file

@ -0,0 +1,14 @@
{
"@context": "\/api\/v2\/contexts\/PromotionCoupon",
"@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ3",
"@type": "PromotionCoupon",
"code": "XYZ3",
"usageLimit": 100,
"perCustomerUsageLimit": 3,
"used": 0,
"reusableFromCancelledOrders": false,
"promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
"createdAt": @date@,
"updatedAt": @date@,
"expiresAt": @date@
}

View file

@ -0,0 +1,14 @@
{
"@context": "\/api\/v2\/contexts\/PromotionCoupon",
"@id": "\/api\/v2\/admin\/promotion-coupons\/XYZ1",
"@type": "PromotionCoupon",
"code": "XYZ1",
"usageLimit": 1000,
"perCustomerUsageLimit": 5,
"used": 1,
"reusableFromCancelledOrders": false,
"promotion": "\/api\/v2\/admin\/promotions\/dollar_off",
"createdAt": @date@,
"updatedAt": @date@,
"expiresAt": "2020-01-01 12:00:00"
}