From 47153967eb7818d33e6ec9b05cdccc14f5ea4cae Mon Sep 17 00:00:00 2001 From: Jan Goralski Date: Thu, 16 Nov 2023 12:31:15 +0100 Subject: [PATCH 01/14] [API][Admin] Browsing coupons --- ...oupon.feature => browsing_coupons.feature} | 6 +- .../Admin/ManagingPromotionCouponsContext.php | 58 ++++++++++++++++++ src/Sylius/Behat/Context/Api/Resources.php | 2 + .../Admin/ManagingPromotionCouponsContext.php | 6 +- .../config/services/contexts/api/admin.xml | 6 ++ src/Sylius/Behat/Resources/config/suites.yml | 1 + .../promotion/managing_promotion_coupons.yml | 22 +++++++ .../config/api_resources/PromotionCoupon.xml | 22 ++++++- .../config/serialization/PromotionCoupon.xml | 55 +++++++++++++++++ tests/Api/Admin/PromotionCouponsTest.php | 61 +++++++++++++++++++ .../DataFixtures/ORM/promotion/promotion.yaml | 17 ++++++ .../get_promotion_coupon_response.json | 14 +++++ .../get_promotion_coupons_response.json | 34 +++++++++++ 13 files changed, 295 insertions(+), 9 deletions(-) rename features/promotion/managing_coupons/{browsing_coupon.feature => browsing_coupons.feature} (81%) create mode 100644 src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php create mode 100644 src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml create mode 100644 src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml create mode 100644 tests/Api/Admin/PromotionCouponsTest.php create mode 100644 tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupon_response.json create mode 100644 tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json diff --git a/features/promotion/managing_coupons/browsing_coupon.feature b/features/promotion/managing_coupons/browsing_coupons.feature similarity index 81% rename from features/promotion/managing_coupons/browsing_coupon.feature rename to features/promotion/managing_coupons/browsing_coupons.feature index c94595a8f72..824307fb4e2 100644 --- a/features/promotion/managing_coupons/browsing_coupon.feature +++ b/features/promotion/managing_coupons/browsing_coupons.feature @@ -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 coupon with code "SANTA2016" diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php new file mode 100644 index 00000000000..88e3a65da00 --- /dev/null +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php @@ -0,0 +1,58 @@ +client->index(Resources::PROMOTION_COUPONS); + $this->client->addFilter( + 'promotion', + $this->sectionAwareIriConverter->getIriFromResourceInSection($promotion, 'admin'), + ); + $this->client->filter(); + } + + /** + * @Then /^there should(?:| still) be (\d+) coupons? related to (this promotion)$/ + */ + public function thereShouldBeCountCouponsRelatedToThisPromotion(int $count, PromotionInterface $promotion): void + { + $coupons = $this->responseChecker->getCollectionItemsWithValue( + $this->client->getLastResponse(), + 'promotion', + $this->sectionAwareIriConverter->getIriFromResourceInSection($promotion, 'admin'), + ); + + Assert::same(count($coupons), $count); + } + + /** + * @Then there should be a coupon with code :code + */ + public function thereShouldBeACouponWithCode(string $code): void + { + Assert::true($this->responseChecker->hasItemWithValue($this->client->getLastResponse(), 'code', $code)); + } +} diff --git a/src/Sylius/Behat/Context/Api/Resources.php b/src/Sylius/Behat/Context/Api/Resources.php index edd14cef201..b6874650827 100644 --- a/src/Sylius/Behat/Context/Api/Resources.php +++ b/src/Sylius/Behat/Context/Api/Resources.php @@ -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'; diff --git a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php index d4373f32085..3736be2fa2e 100644 --- a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php @@ -275,9 +275,7 @@ public function iSortCouponsByExpirationDate(string $order): void } /** - * @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,7 +313,7 @@ public function iShouldSeeASingleCouponInTheList(): void } /** - * @Then /^there should be coupon with code "([^"]+)"$/ + * @Then there should be a coupon with code :code */ public function thereShouldBeCouponWithCode($code) { diff --git a/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml b/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml index a18420f1509..2395287ecaa 100644 --- a/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml +++ b/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml @@ -290,5 +290,11 @@ + + + + + + diff --git a/src/Sylius/Behat/Resources/config/suites.yml b/src/Sylius/Behat/Resources/config/suites.yml index ce0c23d6fd4..92c39b2c0db 100644 --- a/src/Sylius/Behat/Resources/config/suites.yml +++ b/src/Sylius/Behat/Resources/config/suites.yml @@ -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 diff --git a/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml new file mode 100644 index 00000000000..55f56e3f3c7 --- /dev/null +++ b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml @@ -0,0 +1,22 @@ +# 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.promotion + - sylius.behat.context.transform.shared_storage + + - sylius.behat.context.setup.channel + - sylius.behat.context.setup.promotion + - sylius.behat.context.setup.admin_api_security + + - sylius.behat.context.api.admin.managing_promotion_coupons + + filters: + tags: "@managing_promotion_coupons&&@api" + javascript: false diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml index cb50a6781c6..5c460e593d9 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml @@ -18,7 +18,16 @@ admin - + sylius + + + + GET + + admin:promotion_coupon:read + + + @@ -29,6 +38,15 @@ - + + + + + + + + + + diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml new file mode 100644 index 00000000000..fccfcd4fe60 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml @@ -0,0 +1,55 @@ + + + + + + + + admin:promotion_coupon:read + + + + admin:promotion_coupon:read + + + + admin:promotion_coupon:read + + + + admin:promotion_coupon:read + + + + admin:promotion_coupon:read + + + + admin:promotion_coupon:read + + + + admin:promotion_coupon:read + + + + admin:promotion_coupon:read + + + + admin:promotion_coupon:read + + + diff --git a/tests/Api/Admin/PromotionCouponsTest.php b/tests/Api/Admin/PromotionCouponsTest.php new file mode 100644 index 00000000000..6e57eade4c9 --- /dev/null +++ b/tests/Api/Admin/PromotionCouponsTest.php @@ -0,0 +1,61 @@ +loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion.yaml']); + $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER); + + /** @var PromotionInterface $promotion */ + $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.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, + ); + } +} diff --git a/tests/Api/DataFixtures/ORM/promotion/promotion.yaml b/tests/Api/DataFixtures/ORM/promotion/promotion.yaml index 7749bac08b5..bb4496f4ba2 100644 --- a/tests/Api/DataFixtures/ORM/promotion/promotion.yaml +++ b/tests/Api/DataFixtures/ORM/promotion/promotion.yaml @@ -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: diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupon_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupon_response.json new file mode 100644 index 00000000000..0356364fdc8 --- /dev/null +++ b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupon_response.json @@ -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 +} diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json new file mode 100644 index 00000000000..7cbba08874d --- /dev/null +++ b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json @@ -0,0 +1,34 @@ +{ + "@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 +} From 41efb5f4373df27be41c8e2d97b374988a7963df Mon Sep 17 00:00:00 2001 From: Jan Goralski Date: Thu, 16 Nov 2023 14:33:03 +0100 Subject: [PATCH 02/14] [API][Admin] Sorting coupons --- .../managing_coupons/sorting_coupons.feature | 20 +++--- .../Admin/ManagingPromotionCouponsContext.php | 67 +++++++++++++++++++ .../promotion/managing_promotion_coupons.yml | 1 + .../config/api_resources/PromotionCoupon.xml | 6 ++ .../Resources/config/services/filters.xml | 11 +++ .../get_promotion_coupons_response.json | 39 ++++++++++- 6 files changed, 133 insertions(+), 11 deletions(-) diff --git a/features/promotion/managing_coupons/sorting_coupons.feature b/features/promotion/managing_coupons/sorting_coupons.feature index b3c00f78099..e1eb8ba9d76 100644 --- a/features/promotion/managing_coupons/sorting_coupons.feature +++ b/features/promotion/managing_coupons/sorting_coupons.feature @@ -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 diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php index 88e3a65da00..1a37fbf6435 100644 --- a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php @@ -22,6 +22,7 @@ public function __construct( } /** + * @Given /^I am browsing coupons of (this promotion)$/ * @When /^I want to view all coupons of (this promotion)$/ */ public function iWantToViewAllCouponsOfThisPromotion(PromotionInterface $promotion): void @@ -34,6 +35,46 @@ public function iWantToViewAllCouponsOfThisPromotion(PromotionInterface $promoti $this->client->filter(); } + /** + * @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)$/ */ @@ -55,4 +96,30 @@ public function thereShouldBeACouponWithCode(string $code): void { Assert::true($this->responseChecker->hasItemWithValue($this->client->getLastResponse(), '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, + )); + } + + private function sortBy(string $order, string $field): void + { + $this->client->sort([$field => str_starts_with($order, 'de') ? 'desc' : 'asc']); + } } diff --git a/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml index 55f56e3f3c7..b39ec4817fb 100644 --- a/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml +++ b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml @@ -8,6 +8,7 @@ default: - sylius.behat.context.hook.doctrine_orm - sylius.behat.context.transform.coupon + - sylius.behat.context.transform.date_time - sylius.behat.context.transform.promotion - sylius.behat.context.transform.shared_storage diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml index 5c460e593d9..130733b19e6 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml @@ -23,9 +23,15 @@ GET + + sylius.api.promotion_coupon_order_filter + admin:promotion_coupon:read + + DESC + diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml index c47196b10eb..5cbb194b3c2 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml @@ -273,5 +273,16 @@ + + + + + + + + + + + diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json index 7cbba08874d..39c745ee342 100644 --- a/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json +++ b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json @@ -30,5 +30,42 @@ "updatedAt": @date@ } ], - "hydra:totalItems": 2 + "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]}", + "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 + } + ] + } } From cb3770b564c407ae059d67ea891602d35dd47237 Mon Sep 17 00:00:00 2001 From: Jan Goralski Date: Thu, 16 Nov 2023 15:37:31 +0100 Subject: [PATCH 03/14] [API][Admin] Deleting coupons --- .../managing_coupons/deleting_coupon.feature | 2 +- ...venting_deletion_of_coupons_in_use.feature | 2 +- phpstan-baseline.neon | 20 ++++++ .../Admin/ManagingPromotionCouponsContext.php | 65 ++++++++++++++++++ .../promotion/managing_promotion_coupons.yml | 10 +++ .../promotion/managing_promotion_coupons.yml | 1 - .../PromotionCouponDataPersister.php | 47 +++++++++++++ .../PromotionCouponCannotBeRemoved.php | 26 ++++++++ .../config/api_resources/PromotionCoupon.xml | 4 ++ .../config/services/data_persisters.xml | 5 ++ .../PromotionCouponDataPersisterSpec.php | 66 +++++++++++++++++++ tests/Api/Admin/PromotionCouponsTest.php | 22 ++++++- 12 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php create mode 100644 src/Sylius/Bundle/ApiBundle/Exception/PromotionCouponCannotBeRemoved.php create mode 100644 src/Sylius/Bundle/ApiBundle/spec/DataPersister/PromotionCouponDataPersisterSpec.php diff --git a/features/promotion/managing_coupons/deleting_coupon.feature b/features/promotion/managing_coupons/deleting_coupon.feature index efe196ac6f3..08a737d4102 100644 --- a/features/promotion/managing_coupons/deleting_coupon.feature +++ b/features/promotion/managing_coupons/deleting_coupon.feature @@ -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 diff --git a/features/promotion/managing_coupons/preventing_deletion_of_coupons_in_use.feature b/features/promotion/managing_coupons/preventing_deletion_of_coupons_in_use.feature index 527ffe64b53..77d37799c1d 100644 --- a/features/promotion/managing_coupons/preventing_deletion_of_coupons_in_use.feature +++ b/features/promotion/managing_coupons/preventing_deletion_of_coupons_in_use.feature @@ -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 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 783d53f57d2..f34e5cc3856 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -805,6 +805,26 @@ parameters: count: 1 path: src/Sylius/Bundle/ApiBundle/DataPersister/ProductTaxonDataPersister.php + - + message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\PromotionCouponDataPersister\\:\\:persist\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php + + - + message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\PromotionCouponDataPersister\\:\\:remove\\(\\) has no return type specified\\.$#" + count: 1 + path: src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php + + - + message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\PromotionCouponDataPersister\\:\\:remove\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php + + - + message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\PromotionCouponDataPersister\\:\\:supports\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php + - message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\ProductTaxonDataPersister\\:\\:remove\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" count: 1 diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php index 1a37fbf6435..1e74f82b34d 100644 --- a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php @@ -1,5 +1,14 @@ client->filter(); } + /** + * @When /^I delete ("[^"]+" coupon) related to this promotion$/ + * @When /^I try to delete ("[^"]+" coupon) related to this promotion$/ + */ + public function iDeleteCouponRelatedToThisPromotion(PromotionCouponInterface $coupon) + { + $this->client->delete(Resources::PROMOTION_COUPONS, $coupon->getCode()); + } + /** * @When /^I sort coupons by (ascending|descending) number of uses$/ */ @@ -118,6 +137,52 @@ public function theFirstCouponShouldHaveCode(string $code): void )); } + /** + * @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) + { + 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) + { + Assert::true($this->responseChecker->hasItemWithValue( + $this->client->index(Resources::PROMOTION_COUPONS), + 'code', + $coupon->getCode(), + )); + } + + /** + * @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.', + ); + } + private function sortBy(string $order, string $field): void { $this->client->sort([$field => str_starts_with($order, 'de') ? 'desc' : 'asc']); diff --git a/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml index b39ec4817fb..aee3762ad90 100644 --- a/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml +++ b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml @@ -8,12 +8,22 @@ default: - 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 diff --git a/src/Sylius/Behat/Resources/config/suites/domain/promotion/managing_promotion_coupons.yml b/src/Sylius/Behat/Resources/config/suites/domain/promotion/managing_promotion_coupons.yml index d377ae99897..460c94b06b2 100644 --- a/src/Sylius/Behat/Resources/config/suites/domain/promotion/managing_promotion_coupons.yml +++ b/src/Sylius/Behat/Resources/config/suites/domain/promotion/managing_promotion_coupons.yml @@ -28,6 +28,5 @@ default: - sylius.behat.context.domain.notification - sylius.behat.context.domain.security - filters: tags: "@managing_promotion_coupons&&@domain" diff --git a/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php b/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php new file mode 100644 index 00000000000..a2e8bb1d031 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php @@ -0,0 +1,47 @@ +decoratedDataPersister->persist($data, $context); + } + + public function remove($data, array $context = []) + { + try { + return $this->decoratedDataPersister->remove($data, $context); + } catch (ForeignKeyConstraintViolationException) { + throw new PromotionCouponCannotBeRemoved(); + } + } +} diff --git a/src/Sylius/Bundle/ApiBundle/Exception/PromotionCouponCannotBeRemoved.php b/src/Sylius/Bundle/ApiBundle/Exception/PromotionCouponCannotBeRemoved.php new file mode 100644 index 00000000000..21d84fe82b7 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/Exception/PromotionCouponCannotBeRemoved.php @@ -0,0 +1,26 @@ +admin:promotion_coupon:read + + + DELETE + diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml index 6a54784b8c0..bc5248e1185 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml @@ -85,6 +85,11 @@ + + + + + diff --git a/src/Sylius/Bundle/ApiBundle/spec/DataPersister/PromotionCouponDataPersisterSpec.php b/src/Sylius/Bundle/ApiBundle/spec/DataPersister/PromotionCouponDataPersisterSpec.php new file mode 100644 index 00000000000..d1e7742ad04 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/spec/DataPersister/PromotionCouponDataPersisterSpec.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/tests/Api/Admin/PromotionCouponsTest.php b/tests/Api/Admin/PromotionCouponsTest.php index 6e57eade4c9..c57fca2a6e4 100644 --- a/tests/Api/Admin/PromotionCouponsTest.php +++ b/tests/Api/Admin/PromotionCouponsTest.php @@ -13,7 +13,7 @@ namespace Sylius\Tests\Api\Admin; -use Sylius\Component\Core\Model\PromotionInterface; +use Sylius\Component\Core\Model\PromotionCouponInterface; use Sylius\Tests\Api\JsonApiTestCase; use Sylius\Tests\Api\Utils\AdminUserLoginTrait; use Symfony\Component\HttpFoundation\Response; @@ -28,7 +28,7 @@ public function it_gets_a_promotion_coupon(): void $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion.yaml']); $header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER); - /** @var PromotionInterface $promotion */ + /** @var PromotionCouponInterface $coupon */ $coupon = $fixtures['promotion_1_off_coupon_1']; $this->client->request( @@ -58,4 +58,22 @@ public function it_gets_promotion_coupons(): void Response::HTTP_OK, ); } + + /** @test */ + public function it_removes_a_promotion_coupon(): void + { + $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', '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); + } } From 9b884466f447e020ada79e0e35abc32cc094adf5 Mon Sep 17 00:00:00 2001 From: Jan Goralski Date: Thu, 16 Nov 2023 16:30:48 +0100 Subject: [PATCH 04/14] [API][Admin] Creating coupons --- .../managing_coupons/adding_coupon.feature | 4 +- .../Admin/ManagingPromotionCouponsContext.php | 75 ++++++++++++++++++- .../config/api_resources/PromotionCoupon.xml | 10 +++ .../config/serialization/PromotionCoupon.xml | 6 ++ tests/Api/Admin/PromotionCouponsTest.php | 31 ++++++++ .../post_promotion_coupon_response.json | 14 ++++ 6 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json diff --git a/features/promotion/managing_coupons/adding_coupon.feature b/features/promotion/managing_coupons/adding_coupon.feature index 8acc91cfb0d..e22ba914a52 100644 --- a/features/promotion/managing_coupons/adding_coupon.feature +++ b/features/promotion/managing_coupons/adding_coupon.feature @@ -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 coupon with code "SANTA2016" diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php index 1e74f82b34d..8907a7e7e34 100644 --- a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php @@ -45,15 +45,67 @@ public function iWantToViewAllCouponsOfThisPromotion(PromotionInterface $promoti $this->client->filter(); } + /** + * @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 delete ("[^"]+" coupon) related to this promotion$/ * @When /^I try to delete ("[^"]+" coupon) related to this promotion$/ */ - public function iDeleteCouponRelatedToThisPromotion(PromotionCouponInterface $coupon) + 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 times + */ + public function iLimitItsUsageToTimes(int $times): void + { + $this->client->addRequestData('usageLimit', $times); + } + + /** + * @When I limit its per customer usage to :times times + */ + public function iLimitItsPerCustomerUsageToTimes(int $times): void + { + $this->client->addRequestData('perCustomerUsageLimit', $times); + } + + /** + * @When I make it valid until :date + */ + public function iMakeItValidUntil(\DateTime $date): void + { + $this->client->addRequestData('expiresAt', $date->format('d-m-Y')); + } + + /** + * @When I add it + */ + public function iAddIt(): void + { + $this->client->create(); + } + /** * @When /^I sort coupons by (ascending|descending) number of uses$/ */ @@ -113,7 +165,11 @@ public function thereShouldBeCountCouponsRelatedToThisPromotion(int $count, Prom */ public function thereShouldBeACouponWithCode(string $code): void { - Assert::true($this->responseChecker->hasItemWithValue($this->client->getLastResponse(), 'code', $code)); + Assert::true($this->responseChecker->hasItemWithValue( + $this->client->index(Resources::PROMOTION_COUPONS), + 'code', + $code, + )); } /** @@ -137,6 +193,17 @@ public function theFirstCouponShouldHaveCode(string $code): void )); } + /** + * @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 I should be notified that it has been successfully deleted */ @@ -151,7 +218,7 @@ public function iShouldBeNotifiedThatItHasBeenSuccessfullyDeleted(): void /** * @Then /^(this coupon) should no longer exist in the coupon registry$/ */ - public function couponShouldNotExistInTheRegistry(PromotionCouponInterface $coupon) + public function couponShouldNotExistInTheRegistry(PromotionCouponInterface $coupon): void { Assert::false($this->responseChecker->hasItemWithValue( $this->client->index(Resources::PROMOTION_COUPONS), @@ -163,7 +230,7 @@ public function couponShouldNotExistInTheRegistry(PromotionCouponInterface $coup /** * @Then /^(this coupon) should still exist in the registry$/ */ - public function couponShouldStillExistInTheRegistry(PromotionCouponInterface $coupon) + public function couponShouldStillExistInTheRegistry(PromotionCouponInterface $coupon): void { Assert::true($this->responseChecker->hasItemWithValue( $this->client->index(Resources::PROMOTION_COUPONS), diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml index ab0604048e3..3609ab06289 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml @@ -33,6 +33,16 @@ DESC + + + POST + + admin:promotion_coupon:read + + + admin:promotion_coupon:create + + diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml index fccfcd4fe60..4d4968770e3 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml @@ -26,14 +26,17 @@ admin:promotion_coupon:read + admin:promotion_coupon:create admin:promotion_coupon:read + admin:promotion_coupon:create admin:promotion_coupon:read + admin:promotion_coupon:create @@ -42,14 +45,17 @@ admin:promotion_coupon:read + admin:promotion_coupon:create admin:promotion_coupon:read + admin:promotion_coupon:create admin:promotion_coupon:read + admin:promotion_coupon:create diff --git a/tests/Api/Admin/PromotionCouponsTest.php b/tests/Api/Admin/PromotionCouponsTest.php index c57fca2a6e4..1e74daff624 100644 --- a/tests/Api/Admin/PromotionCouponsTest.php +++ b/tests/Api/Admin/PromotionCouponsTest.php @@ -14,6 +14,7 @@ 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; @@ -59,6 +60,36 @@ public function it_gets_promotion_coupons(): void ); } + /** @test */ + public function it_creates_a_promotion_coupon(): void + { + $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', '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' => true, + '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_removes_a_promotion_coupon(): void { diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json new file mode 100644 index 00000000000..a6caa1278bf --- /dev/null +++ b/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json @@ -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": true, + "promotion": "\/api\/v2\/admin\/promotions\/dollar_off", + "createdAt": @date@, + "updatedAt": @date@, + "expiresAt": @date@ +} From 54b87d09132df56933bc051b4299f6e9989b2683 Mon Sep 17 00:00:00 2001 From: Jan Goralski Date: Fri, 17 Nov 2023 11:03:42 +0100 Subject: [PATCH 05/14] [API][Admin] Updating coupons --- .../managing_coupons/editing_coupon.feature | 18 +++-- .../Admin/ManagingPromotionCouponsContext.php | 68 +++++++++++++++++++ .../Admin/ManagingPromotionCouponsContext.php | 26 +++++-- .../Page/Admin/PromotionCoupon/UpdatePage.php | 16 +++++ .../PromotionCoupon/UpdatePageInterface.php | 4 ++ .../promotion/managing_promotion_coupons.yml | 2 + .../config/api_resources/PromotionCoupon.xml | 10 +++ .../config/serialization/PromotionCoupon.xml | 4 ++ tests/Api/Admin/PromotionCouponsTest.php | 30 +++++++- .../post_promotion_coupon_response.json | 2 +- .../put_promotion_coupon_response.json | 14 ++++ 11 files changed, 183 insertions(+), 11 deletions(-) create mode 100644 tests/Api/Responses/Expected/admin/promotion_coupon/put_promotion_coupon_response.json diff --git a/features/promotion/managing_coupons/editing_coupon.feature b/features/promotion/managing_coupons/editing_coupon.feature index 0bbe347fdd5..d3d3954f4db 100644 --- a/features/promotion/managing_coupons/editing_coupon.feature +++ b/features/promotion/managing_coupons/editing_coupon.feature @@ -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 + @ui @api + Scenario: Changing whether it can be reused from cancelled orders + When I want to modify the "SANTA2016" coupon for this promotion + 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 @no-api Scenario: Seeing a disabled code field when editing a coupon When I want to modify the "SANTA2016" coupon for this promotion Then the code field should be disabled diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php index 8907a7e7e34..16d0b4132ae 100644 --- a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php @@ -57,6 +57,14 @@ public function iWantToCreateANewCouponForPromotion(PromotionInterface $promotio ); } + /** + * @When /^I want to modify the ("[^"]+" coupon) for this promotion$/ + */ + public function iWantToModifyTheCouponOfThisPromotion(PromotionCouponInterface $coupon): void + { + $this->client->buildUpdateRequest(Resources::PROMOTION_COUPONS, $coupon->getCode()); + } + /** * @When /^I delete ("[^"]+" coupon) related to this promotion$/ * @When /^I try to delete ("[^"]+" coupon) related to this promotion$/ @@ -76,6 +84,7 @@ public function iSpecifyItsCodeAs(string $code): void /** * @When I limit its usage to :times times + * @When I change its usage limit to :times */ public function iLimitItsUsageToTimes(int $times): void { @@ -84,6 +93,7 @@ public function iLimitItsUsageToTimes(int $times): void /** * @When I limit its per customer usage to :times times + * @When I change its per customer usage limit to :times */ public function iLimitItsPerCustomerUsageToTimes(int $times): void { @@ -92,12 +102,21 @@ public function iLimitItsPerCustomerUsageToTimes(int $times): void /** * @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 add it */ @@ -204,6 +223,55 @@ public function iShouldBeNotifiedThatItHasBeenSuccessfullyCreated(): void ); } + /** + * @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 */ diff --git a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php index 3736be2fa2e..1b44c1541b4 100644 --- a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php @@ -164,6 +164,14 @@ public function iChangeItsPerCustomerUsageLimitTo(int $limit) $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 @@ public function iMakeItValidUntil(\DateTimeInterface $date) } /** - * @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); } @@ -323,9 +331,9 @@ public function thereShouldBeCouponWithCode($code) /** * @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')])); } /** @@ -352,6 +360,16 @@ public function thisCouponShouldHavePerCustomerUsageLimit($limit) Assert::true($this->indexPage->isSingleResourceOnPage(['perCustomerUsageLimit' => $limit])); } + /** + * @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 the code field should be disabled */ diff --git a/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePage.php b/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePage.php index 73ebd9ae60d..3cc55455401 100644 --- a/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePage.php +++ b/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePage.php @@ -16,6 +16,7 @@ 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 @@ public function setUsageLimit(string $limit): void $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 @@ protected function getDefinedElements(): array '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', ]); } } diff --git a/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePageInterface.php b/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePageInterface.php index 9cfc8f1113b..9376fee036b 100644 --- a/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePageInterface.php +++ b/src/Sylius/Behat/Page/Admin/PromotionCoupon/UpdatePageInterface.php @@ -24,4 +24,8 @@ public function setCustomerUsageLimit(int $limit): void; public function setExpiresAt(\DateTimeInterface $date): void; public function setUsageLimit(string $limit): void; + + public function isReusableFromCancelledOrders(): bool; + + public function toggleReusableFromCancelledOrders(bool $reusable): void; } diff --git a/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml index aee3762ad90..37509d15ec7 100644 --- a/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml +++ b/src/Sylius/Behat/Resources/config/suites/api/promotion/managing_promotion_coupons.yml @@ -27,6 +27,8 @@ default: - 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" diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml index 3609ab06289..edb02c979d4 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml @@ -53,6 +53,16 @@ + + PUT + + admin:promotion_coupon:read + + + admin:promotion_coupon:update + + + DELETE diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml index 4d4968770e3..1783c72ef62 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/serialization/PromotionCoupon.xml @@ -32,11 +32,13 @@ admin:promotion_coupon:read admin:promotion_coupon:create + admin:promotion_coupon:update admin:promotion_coupon:read admin:promotion_coupon:create + admin:promotion_coupon:update @@ -46,6 +48,7 @@ admin:promotion_coupon:read admin:promotion_coupon:create + admin:promotion_coupon:update @@ -56,6 +59,7 @@ admin:promotion_coupon:read admin:promotion_coupon:create + admin:promotion_coupon:update diff --git a/tests/Api/Admin/PromotionCouponsTest.php b/tests/Api/Admin/PromotionCouponsTest.php index 1e74daff624..b80cbed69a4 100644 --- a/tests/Api/Admin/PromotionCouponsTest.php +++ b/tests/Api/Admin/PromotionCouponsTest.php @@ -77,7 +77,7 @@ public function it_creates_a_promotion_coupon(): void 'code' => 'XYZ3', 'usageLimit' => 100, 'perCustomerUsageLimit' => 3, - 'reusableFromCancelledOrders' => true, + 'reusableFromCancelledOrders' => false, 'expiresAt' => '23-12-2023', 'promotion' => 'api/v2/admin/promotions/' . $promotion->getCode(), ], JSON_THROW_ON_ERROR) @@ -90,6 +90,34 @@ public function it_creates_a_promotion_coupon(): void ); } + /** @test */ + public function it_updates_a_promotion_coupon(): void + { + $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', '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 { diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json index a6caa1278bf..8542e47a235 100644 --- a/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json +++ b/tests/Api/Responses/Expected/admin/promotion_coupon/post_promotion_coupon_response.json @@ -6,7 +6,7 @@ "usageLimit": 100, "perCustomerUsageLimit": 3, "used": 0, - "reusableFromCancelledOrders": true, + "reusableFromCancelledOrders": false, "promotion": "\/api\/v2\/admin\/promotions\/dollar_off", "createdAt": @date@, "updatedAt": @date@, diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/put_promotion_coupon_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/put_promotion_coupon_response.json new file mode 100644 index 00000000000..51cdf78a1bd --- /dev/null +++ b/tests/Api/Responses/Expected/admin/promotion_coupon/put_promotion_coupon_response.json @@ -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" +} From ae13ab3969f02433b04042736d46afb879f23897 Mon Sep 17 00:00:00 2001 From: Jan Goralski Date: Fri, 17 Nov 2023 13:02:08 +0100 Subject: [PATCH 06/14] [API][Admin] Validating coupons --- .../coupon_validation.feature | 35 +++++- .../Admin/ManagingPromotionCouponsContext.php | 113 +++++++++++++++++- .../Admin/ManagingPromotionCouponsContext.php | 6 +- .../Resources/config/services/validators.xml | 4 + .../config/validation/PromotionCoupon.xml | 10 ++ .../Resources/translations/validators.en.yml | 3 + .../Constraints/PromotionNotCouponBased.php | 31 +++++ .../PromotionNotCouponBasedValidator.php | 49 ++++++++ .../PromotionNotCouponBasedValidatorSpec.php | 109 +++++++++++++++++ 9 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 src/Sylius/Bundle/PromotionBundle/Validator/Constraints/PromotionNotCouponBased.php create mode 100644 src/Sylius/Bundle/PromotionBundle/Validator/PromotionNotCouponBasedValidator.php create mode 100644 src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php diff --git a/features/promotion/managing_coupons/coupon_validation.feature b/features/promotion/managing_coupons/coupon_validation.feature index 282d5bd5e1d..9bed0c47635 100644 --- a/features/promotion/managing_coupons/coupon_validation.feature +++ b/features/promotion/managing_coupons/coupon_validation.feature @@ -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 + Given 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 diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php index 16d0b4132ae..16f1da203f9 100644 --- a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php @@ -45,6 +45,14 @@ public function iWantToViewAllCouponsOfThisPromotion(PromotionInterface $promoti $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)$/ */ @@ -83,7 +91,7 @@ public function iSpecifyItsCodeAs(string $code): void } /** - * @When I limit its usage to :times times + * @When I limit its usage to :times time(s) * @When I change its usage limit to :times */ public function iLimitItsUsageToTimes(int $times): void @@ -92,7 +100,7 @@ public function iLimitItsUsageToTimes(int $times): void } /** - * @When I limit its per customer usage to :times 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 @@ -118,7 +126,15 @@ public function iMakeItNotReusableFromCancelledOrders(): void } /** - * @When I add it + * @When I do not specify its :field + */ + public function iDoNotSpecifyIts(): void + { + // Intentionally left blank + } + + /** + * @When I (try to) add it */ public function iAddIt(): void { @@ -171,7 +187,7 @@ public function iSortCouponsByExpirationDate(string $order): void public function thereShouldBeCountCouponsRelatedToThisPromotion(int $count, PromotionInterface $promotion): void { $coupons = $this->responseChecker->getCollectionItemsWithValue( - $this->client->getLastResponse(), + $this->client->index(Resources::PROMOTION_COUPONS), 'promotion', $this->sectionAwareIriConverter->getIriFromResourceInSection($promotion, 'admin'), ); @@ -191,6 +207,18 @@ public function thereShouldBeACouponWithCode(string $code): void )); } + /** + * @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 */ @@ -318,6 +346,83 @@ public function iShouldBeNotifiedThatItIsInUseAndCannotBeDeleted(): void ); } + /** + * @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.', + ); + } + private function sortBy(string $order, string $field): void { $this->client->sort([$field => str_starts_with($order, 'de') ? 'desc' : 'asc']); diff --git a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php index 1b44c1541b4..5b83f39a5d6 100644 --- a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php @@ -98,7 +98,7 @@ public function specifySuffixAs(string $suffix): void } /** - * @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 @@ public function iSpecifyItsCodeAs($code = null) } /** - * @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 @@ public function iSpecifyItsAmountAs(?int $amount = null): void } /** - * @When /^I limit its per customer usage to ([^"]+) times$/ + * @When /^I limit its per customer usage to ([^"]+) times?$/ */ public function iLimitItsPerCustomerUsageLimitTo(int $limit) { diff --git a/src/Sylius/Bundle/PromotionBundle/Resources/config/services/validators.xml b/src/Sylius/Bundle/PromotionBundle/Resources/config/services/validators.xml index 42f3083e7dd..716dfa57c9c 100644 --- a/src/Sylius/Bundle/PromotionBundle/Resources/config/services/validators.xml +++ b/src/Sylius/Bundle/PromotionBundle/Resources/config/services/validators.xml @@ -50,5 +50,9 @@ %sylius.promotion_rules% + + + + diff --git a/src/Sylius/Bundle/PromotionBundle/Resources/config/validation/PromotionCoupon.xml b/src/Sylius/Bundle/PromotionBundle/Resources/config/validation/PromotionCoupon.xml index 91ed8fd9826..5b906d06b2f 100644 --- a/src/Sylius/Bundle/PromotionBundle/Resources/config/validation/PromotionCoupon.xml +++ b/src/Sylius/Bundle/PromotionBundle/Resources/config/validation/PromotionCoupon.xml @@ -18,6 +18,16 @@ + + + + + + + + + + diff --git a/src/Sylius/Bundle/PromotionBundle/Resources/translations/validators.en.yml b/src/Sylius/Bundle/PromotionBundle/Resources/translations/validators.en.yml index 543d8f1f0e0..d2158aa2d13 100644 --- a/src/Sylius/Bundle/PromotionBundle/Resources/translations/validators.en.yml +++ b/src/Sylius/Bundle/PromotionBundle/Resources/translations/validators.en.yml @@ -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: diff --git a/src/Sylius/Bundle/PromotionBundle/Validator/Constraints/PromotionNotCouponBased.php b/src/Sylius/Bundle/PromotionBundle/Validator/Constraints/PromotionNotCouponBased.php new file mode 100644 index 00000000000..8f53f5abfad --- /dev/null +++ b/src/Sylius/Bundle/PromotionBundle/Validator/Constraints/PromotionNotCouponBased.php @@ -0,0 +1,31 @@ +getPromotion(); + if (null === $promotion) { + return; + } + + if (!$promotion->isCouponBased()) { + $this->context + ->buildViolation($constraint->message) + ->atPath('promotion') + ->addViolation() + ; + } + } +} diff --git a/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php b/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php new file mode 100644 index 00000000000..e8737e05f87 --- /dev/null +++ b/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php @@ -0,0 +1,109 @@ +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(\InvalidArgumentException::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(\InvalidArgumentException::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); + } +} From 854393e2936173c12466c2b38f066d5b4835be8f Mon Sep 17 00:00:00 2001 From: Wojdylak Date: Thu, 23 Nov 2023 16:04:37 +0100 Subject: [PATCH 07/14] Add coupons to payload in PromotionCouponsTest --- tests/Api/Admin/PromotionCouponsTest.php | 10 +++++----- .../admin/promotion/get_promotion_response.json | 4 +++- .../admin/promotion/get_promotions_response.json | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/Api/Admin/PromotionCouponsTest.php b/tests/Api/Admin/PromotionCouponsTest.php index b80cbed69a4..bc2eb4ce6a6 100644 --- a/tests/Api/Admin/PromotionCouponsTest.php +++ b/tests/Api/Admin/PromotionCouponsTest.php @@ -26,7 +26,7 @@ final class PromotionCouponsTest extends JsonApiTestCase /** @test */ public function it_gets_a_promotion_coupon(): void { - $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion.yaml']); + $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 */ @@ -48,7 +48,7 @@ public function it_gets_a_promotion_coupon(): void /** @test */ public function it_gets_promotion_coupons(): void { - $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion.yaml']); + $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); @@ -63,7 +63,7 @@ public function it_gets_promotion_coupons(): void /** @test */ public function it_creates_a_promotion_coupon(): void { - $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion.yaml']); + $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 */ @@ -93,7 +93,7 @@ public function it_creates_a_promotion_coupon(): void /** @test */ public function it_updates_a_promotion_coupon(): void { - $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion.yaml']); + $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 */ @@ -121,7 +121,7 @@ public function it_updates_a_promotion_coupon(): void /** @test */ public function it_removes_a_promotion_coupon(): void { - $fixtures = $this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml', 'promotion.yaml']); + $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 */ diff --git a/tests/Api/Responses/Expected/admin/promotion/get_promotion_response.json b/tests/Api/Responses/Expected/admin/promotion/get_promotion_response.json index 59910304922..457730d357d 100644 --- a/tests/Api/Responses/Expected/admin/promotion/get_promotion_response.json +++ b/tests/Api/Responses/Expected/admin/promotion/get_promotion_response.json @@ -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, diff --git a/tests/Api/Responses/Expected/admin/promotion/get_promotions_response.json b/tests/Api/Responses/Expected/admin/promotion/get_promotions_response.json index 42e62fd1705..1b267930390 100644 --- a/tests/Api/Responses/Expected/admin/promotion/get_promotions_response.json +++ b/tests/Api/Responses/Expected/admin/promotion/get_promotions_response.json @@ -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": [ { From 6d33557dc9081c973b189098dfc2310336cb8e7d Mon Sep 17 00:00:00 2001 From: Wojdylak Date: Thu, 23 Nov 2023 16:05:29 +0100 Subject: [PATCH 08/14] Add api test for coupon_unique_code_validation.feature --- .../coupon_unique_code_validation.feature | 2 +- .../managing_coupons/editing_coupon.feature | 6 +-- .../Admin/ManagingPromotionCouponsContext.php | 37 +++++++++++++++++++ .../Admin/ManagingPromotionCouponsContext.php | 3 +- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/features/promotion/managing_coupons/coupon_unique_code_validation.feature b/features/promotion/managing_coupons/coupon_unique_code_validation.feature index 4f2a1d82bf3..9331d10ee5e 100644 --- a/features/promotion/managing_coupons/coupon_unique_code_validation.feature +++ b/features/promotion/managing_coupons/coupon_unique_code_validation.feature @@ -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" diff --git a/features/promotion/managing_coupons/editing_coupon.feature b/features/promotion/managing_coupons/editing_coupon.feature index d3d3954f4db..c1e4afe187c 100644 --- a/features/promotion/managing_coupons/editing_coupon.feature +++ b/features/promotion/managing_coupons/editing_coupon.feature @@ -41,7 +41,7 @@ Feature: Editing promotion coupon Then I should be notified that it has been successfully edited And this coupon should not be reusable from cancelled orders - @ui @no-api - Scenario: Seeing a disabled code field when editing a coupon + @ui @api + Scenario: Being unable to change code of promotion coupon When I want to modify the "SANTA2016" coupon for this promotion - Then the code field should be disabled + Then I should not be able to edit its code diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php index 16f1da203f9..4462da9e915 100644 --- a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php @@ -335,6 +335,17 @@ public function couponShouldStillExistInTheRegistry(PromotionCouponInterface $co )); } + /** + * @Then /^there should still be only one coupon with code "([^"]+)" related to (this promotion)$/ + */ + public function thereShouldStillBeOnlyOneCouponWithCodeRelatedTo(string $code, PromotionInterface $promotion): void + { + Assert::count( + $this->responseChecker->getCollectionItemsWithValue($this->client->index(Resources::PROMOTION_COUPONS), 'code', $code), + 1 + ); + } + /** * @Then I should be notified that it is in use and cannot be deleted */ @@ -423,6 +434,32 @@ public function iShouldBeNotifiedThatOnlyCouponBasedPromotionsCanHaveCoupons(): ); } + /** + * @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']); diff --git a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php index 5b83f39a5d6..7c38e13d138 100644 --- a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php @@ -371,9 +371,10 @@ public function thisCouponShouldBeReusableFromCancelledOrders(PromotionCouponInt } /** + * @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()); } From 440a75ab83ce5cefee6f2d37107520b04839450f Mon Sep 17 00:00:00 2001 From: Wojdylak Date: Thu, 23 Nov 2023 16:13:23 +0100 Subject: [PATCH 09/14] Resolve phpstan issues --- phpstan-baseline.neon | 20 ------------------- .../PromotionCouponDataPersister.php | 7 +++++-- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f34e5cc3856..783d53f57d2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -805,26 +805,6 @@ parameters: count: 1 path: src/Sylius/Bundle/ApiBundle/DataPersister/ProductTaxonDataPersister.php - - - message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\PromotionCouponDataPersister\\:\\:persist\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php - - - - message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\PromotionCouponDataPersister\\:\\:remove\\(\\) has no return type specified\\.$#" - count: 1 - path: src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php - - - - message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\PromotionCouponDataPersister\\:\\:remove\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php - - - - message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\PromotionCouponDataPersister\\:\\:supports\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" - count: 1 - path: src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php - - message: "#^Method Sylius\\\\Bundle\\\\ApiBundle\\\\DataPersister\\\\ProductTaxonDataPersister\\:\\:remove\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" count: 1 diff --git a/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php b/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php index a2e8bb1d031..efda67fdafd 100644 --- a/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php +++ b/src/Sylius/Bundle/ApiBundle/DataPersister/PromotionCouponDataPersister.php @@ -26,20 +26,23 @@ public function __construct( ) { } + /** @param array $context */ public function supports($data, array $context = []): bool { return $data instanceof PromotionCouponInterface; } + /** @param array $context */ public function persist($data, array $context = []) { return $this->decoratedDataPersister->persist($data, $context); } - public function remove($data, array $context = []) + /** @param array $context */ + public function remove($data, array $context = []): void { try { - return $this->decoratedDataPersister->remove($data, $context); + $this->decoratedDataPersister->remove($data, $context); } catch (ForeignKeyConstraintViolationException) { throw new PromotionCouponCannotBeRemoved(); } From e6f4b2b3441e5c82377d9422f36318c11ec9f904 Mon Sep 17 00:00:00 2001 From: Wojdylak Date: Fri, 24 Nov 2023 08:47:07 +0100 Subject: [PATCH 10/14] Add PromotionCouponCannotBeRemoved to code mapping --- .../Resources/config/app/config.yaml | 1 + tests/Api/Admin/PromotionCouponsTest.php | 23 +++++++++++++++++++ .../ORM/promotion/promotion_order.yaml | 1 + 3 files changed, 25 insertions(+) diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/app/config.yaml b/src/Sylius/Bundle/ApiBundle/Resources/config/app/config.yaml index 42c3fbad41c..04acbb12754 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/app/config.yaml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/app/config.yaml @@ -61,6 +61,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 diff --git a/tests/Api/Admin/PromotionCouponsTest.php b/tests/Api/Admin/PromotionCouponsTest.php index bc2eb4ce6a6..2396a8b8e62 100644 --- a/tests/Api/Admin/PromotionCouponsTest.php +++ b/tests/Api/Admin/PromotionCouponsTest.php @@ -135,4 +135,27 @@ public function it_removes_a_promotion_coupon(): void $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); + } } diff --git a/tests/Api/DataFixtures/ORM/promotion/promotion_order.yaml b/tests/Api/DataFixtures/ORM/promotion/promotion_order.yaml index 6a7fd111ec0..c920d549528 100644 --- a/tests/Api/DataFixtures/ORM/promotion/promotion_order.yaml +++ b/tests/Api/DataFixtures/ORM/promotion/promotion_order.yaml @@ -6,3 +6,4 @@ Sylius\Component\Core\Model\Order: state: "new" tokenValue: "token" promotions: ['@promotion_50_off'] + promotionCoupon: '@promotion_1_off_coupon_1' From c0ac149d0e9b6eacdb92cc1d816591b4c89595b7 Mon Sep 17 00:00:00 2001 From: Wojdylak Date: Fri, 24 Nov 2023 19:16:07 +0100 Subject: [PATCH 11/14] [ApiBundle] Add promotion coupon endpoint as a subresource --- .../managing_coupons/adding_coupon.feature | 2 +- .../managing_coupons/browsing_coupons.feature | 2 +- .../coupon_validation.feature | 2 +- .../Admin/ManagingPromotionCouponsContext.php | 27 +++++++-------- .../Admin/ManagingPromotionCouponsContext.php | 3 +- .../config/api_resources/Promotion.xml | 11 +++++- .../config/api_resources/PromotionCoupon.xml | 8 +++++ .../PromotionNotCouponBasedValidator.php | 13 ++++--- .../PromotionNotCouponBasedValidatorSpec.php | 6 ++-- tests/Api/Admin/PromotionsTest.php | 22 ++++++++++++ .../get_promotion_coupons_response.json | 34 +++++++++++++++++++ 11 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 tests/Api/Responses/Expected/admin/promotion/get_promotion_coupons_response.json diff --git a/features/promotion/managing_coupons/adding_coupon.feature b/features/promotion/managing_coupons/adding_coupon.feature index e22ba914a52..5f7c5ece9b3 100644 --- a/features/promotion/managing_coupons/adding_coupon.feature +++ b/features/promotion/managing_coupons/adding_coupon.feature @@ -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 a coupon with code "SANTA2016" + And there should be a "Christmas sale" promotion with a coupon code "SANTA2016" diff --git a/features/promotion/managing_coupons/browsing_coupons.feature b/features/promotion/managing_coupons/browsing_coupons.feature index 824307fb4e2..e514a73c6be 100644 --- a/features/promotion/managing_coupons/browsing_coupons.feature +++ b/features/promotion/managing_coupons/browsing_coupons.feature @@ -13,4 +13,4 @@ Feature: Browsing promotion coupons 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 a coupon with code "SANTA2016" + And there should be a "Christmas sale" promotion with a coupon code "SANTA2016" diff --git a/features/promotion/managing_coupons/coupon_validation.feature b/features/promotion/managing_coupons/coupon_validation.feature index 9bed0c47635..d1c42498094 100644 --- a/features/promotion/managing_coupons/coupon_validation.feature +++ b/features/promotion/managing_coupons/coupon_validation.feature @@ -45,7 +45,7 @@ Feature: Coupon validation @api @no-ui Scenario: Trying to add a new coupon with no promotion - Given I want to create a new coupon + 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 diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php index 4462da9e915..d13e56e2e7a 100644 --- a/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingPromotionCouponsContext.php @@ -66,7 +66,7 @@ public function iWantToCreateANewCouponForPromotion(PromotionInterface $promotio } /** - * @When /^I want to modify the ("[^"]+" coupon) for this promotion$/ + * @When I want to modify the :coupon coupon for this promotion */ public function iWantToModifyTheCouponOfThisPromotion(PromotionCouponInterface $coupon): void { @@ -74,8 +74,7 @@ public function iWantToModifyTheCouponOfThisPromotion(PromotionCouponInterface $ } /** - * @When /^I delete ("[^"]+" coupon) related to this promotion$/ - * @When /^I try to delete ("[^"]+" coupon) related to this promotion$/ + * @When I (try to) delete :coupon coupon related to this promotion */ public function iDeleteCouponRelatedToThisPromotion(PromotionCouponInterface $coupon): void { @@ -186,22 +185,19 @@ public function iSortCouponsByExpirationDate(string $order): void */ public function thereShouldBeCountCouponsRelatedToThisPromotion(int $count, PromotionInterface $promotion): void { - $coupons = $this->responseChecker->getCollectionItemsWithValue( - $this->client->index(Resources::PROMOTION_COUPONS), - 'promotion', - $this->sectionAwareIriConverter->getIriFromResourceInSection($promotion, 'admin'), + $coupons = $this->responseChecker->getCollection( + $this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()), ); - Assert::same(count($coupons), $count); } /** - * @Then there should be a coupon with code :code + * @Then there should be a :promotion promotion with a coupon code :code */ - public function thereShouldBeACouponWithCode(string $code): void + public function thereShouldBeACouponWithCode(PromotionInterface $promotion, string $code): void { Assert::true($this->responseChecker->hasItemWithValue( - $this->client->index(Resources::PROMOTION_COUPONS), + $this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()), 'code', $code, )); @@ -340,10 +336,13 @@ public function couponShouldStillExistInTheRegistry(PromotionCouponInterface $co */ public function thereShouldStillBeOnlyOneCouponWithCodeRelatedTo(string $code, PromotionInterface $promotion): void { - Assert::count( - $this->responseChecker->getCollectionItemsWithValue($this->client->index(Resources::PROMOTION_COUPONS), 'code', $code), - 1 + $coupons = $this->responseChecker->getCollectionItemsWithValue( + $this->client->subResourceIndex(Resources::PROMOTIONS, Resources::PROMOTION_COUPONS, $promotion->getCode()), + 'code', + $code ); + + Assert::count($coupons, 1); } /** diff --git a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php index 7c38e13d138..c3977d7783e 100644 --- a/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php +++ b/src/Sylius/Behat/Context/Ui/Admin/ManagingPromotionCouponsContext.php @@ -322,8 +322,9 @@ public function iShouldSeeASingleCouponInTheList(): void /** * @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])); } diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Promotion.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Promotion.xml index 4e76202e919..71c5ef7f2a7 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Promotion.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Promotion.xml @@ -133,6 +133,13 @@ Example configuration for `order_fixed_discount` action type: + + + GET + /admin/promotions/{code}/promotion-coupons + + + @@ -144,7 +151,9 @@ Example configuration for `order_fixed_discount` action type: - + + + diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml index edb02c979d4..220fa2ef6fa 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml @@ -68,6 +68,14 @@ + + + + admin:promotion_coupon:read + + + + diff --git a/src/Sylius/Bundle/PromotionBundle/Validator/PromotionNotCouponBasedValidator.php b/src/Sylius/Bundle/PromotionBundle/Validator/PromotionNotCouponBasedValidator.php index dd33bb9f959..3020c4ecf08 100644 --- a/src/Sylius/Bundle/PromotionBundle/Validator/PromotionNotCouponBasedValidator.php +++ b/src/Sylius/Bundle/PromotionBundle/Validator/PromotionNotCouponBasedValidator.php @@ -17,7 +17,8 @@ use Sylius\Component\Promotion\Model\PromotionCouponInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; -use Webmozart\Assert\Assert; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; final class PromotionNotCouponBasedValidator extends ConstraintValidator { @@ -27,11 +28,13 @@ public function validate(mixed $value, Constraint $constraint): void return; } - /** @var PromotionNotCouponBased $constraint */ - Assert::isInstanceOf($constraint, PromotionNotCouponBased::class); + if (!$constraint instanceof PromotionNotCouponBased) { + throw new UnexpectedTypeException($constraint, PromotionNotCouponBased::class); + } - /** @var PromotionCouponInterface $value */ - Assert::isInstanceOf($value, PromotionCouponInterface::class); + if (!$value instanceof PromotionCouponInterface) { + throw new UnexpectedValueException($value, PromotionCouponInterface::class); + } $promotion = $value->getPromotion(); if (null === $promotion) { diff --git a/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php b/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php index e8737e05f87..b83b5229b07 100644 --- a/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php +++ b/src/Sylius/Bundle/PromotionBundle/spec/Validator/PromotionNotCouponBasedValidatorSpec.php @@ -21,6 +21,8 @@ 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 @@ -50,7 +52,7 @@ function it_throws_an_exception_when_constraint_is_not_promotion_not_coupon_base $context->buildViolation(Argument::any())->shouldNotBeCalled(); $this - ->shouldThrow(\InvalidArgumentException::class) + ->shouldThrow(UnexpectedTypeException::class) ->during('validate', [$coupon, $constraint]) ; } @@ -60,7 +62,7 @@ function it_throws_an_exception_when_value_is_not_a_coupon(ExecutionContextInter $context->buildViolation(Argument::any())->shouldNotBeCalled(); $this - ->shouldThrow(\InvalidArgumentException::class) + ->shouldThrow(UnexpectedValueException::class) ->during('validate', [new \stdClass(), new PromotionNotCouponBased()]) ; } diff --git a/tests/Api/Admin/PromotionsTest.php b/tests/Api/Admin/PromotionsTest.php index b86f1e8dfde..c3585c04b15 100644 --- a/tests/Api/Admin/PromotionsTest.php +++ b/tests/Api/Admin/PromotionsTest.php @@ -72,6 +72,28 @@ public function it_gets_promotions(): void ); } + /** @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 { diff --git a/tests/Api/Responses/Expected/admin/promotion/get_promotion_coupons_response.json b/tests/Api/Responses/Expected/admin/promotion/get_promotion_coupons_response.json new file mode 100644 index 00000000000..cd555764d0b --- /dev/null +++ b/tests/Api/Responses/Expected/admin/promotion/get_promotion_coupons_response.json @@ -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 +} From 41ed0f33d555b61f3ea0e2d3dc2cb85c9a43bba5 Mon Sep 17 00:00:00 2001 From: Wojdylak Date: Fri, 24 Nov 2023 19:16:25 +0100 Subject: [PATCH 12/14] [ApiBundle] Add PromotionCouponPromotionFilter class --- .../PromotionCouponPromotionFilter.php | 84 +++++++++++++++++++ .../config/api_resources/PromotionCoupon.xml | 1 + .../Resources/config/services/filters.xml | 6 ++ 3 files changed, 91 insertions(+) create mode 100644 src/Sylius/Bundle/ApiBundle/Filter/Doctrine/PromotionCouponPromotionFilter.php diff --git a/src/Sylius/Bundle/ApiBundle/Filter/Doctrine/PromotionCouponPromotionFilter.php b/src/Sylius/Bundle/ApiBundle/Filter/Doctrine/PromotionCouponPromotionFilter.php new file mode 100644 index 00000000000..5de2f02e334 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/Filter/Doctrine/PromotionCouponPromotionFilter.php @@ -0,0 +1,84 @@ +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) + ; + } + + 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', + ], + ], + ]; + } +} diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml index 220fa2ef6fa..21cb126e1cf 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/PromotionCoupon.xml @@ -25,6 +25,7 @@ GET sylius.api.promotion_coupon_order_filter + Sylius\Bundle\ApiBundle\Filter\Doctrine\PromotionCouponPromotionFilter admin:promotion_coupon:read diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml index 5cbb194b3c2..e12e77a4368 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/filters.xml @@ -284,5 +284,11 @@ + + + + + + From 08251e45c38625b3cbe6cac613e7fd9165df4a4b Mon Sep 17 00:00:00 2001 From: Wojdylak Date: Fri, 24 Nov 2023 19:29:21 +0100 Subject: [PATCH 13/14] [ApiBundle] Resolve phpstan issues --- .../Filter/Doctrine/PromotionCouponPromotionFilter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Sylius/Bundle/ApiBundle/Filter/Doctrine/PromotionCouponPromotionFilter.php b/src/Sylius/Bundle/ApiBundle/Filter/Doctrine/PromotionCouponPromotionFilter.php index 5de2f02e334..3c0e2b23635 100644 --- a/src/Sylius/Bundle/ApiBundle/Filter/Doctrine/PromotionCouponPromotionFilter.php +++ b/src/Sylius/Bundle/ApiBundle/Filter/Doctrine/PromotionCouponPromotionFilter.php @@ -27,6 +27,7 @@ final class PromotionCouponPromotionFilter extends AbstractContextAwareFilter { const PROPERTY = 'promotion'; + /** @param array $properties */ public function __construct( private IriConverterInterface $iriConverter, ManagerRegistry $managerRegistry, @@ -67,6 +68,7 @@ protected function filterProperty( ; } + /** @return array */ public function getDescription(string $resourceClass): array { return [ From e82780e1c0484ea2ea02ae18d860323ac21f9f5e Mon Sep 17 00:00:00 2001 From: Wojdylak Date: Fri, 24 Nov 2023 19:44:32 +0100 Subject: [PATCH 14/14] [ApiBundle] Add to test information abut PromotionCouponPromotionFilter --- .../get_promotion_coupons_response.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json index 39c745ee342..237bffff856 100644 --- a/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json +++ b/tests/Api/Responses/Expected/admin/promotion_coupon/get_promotion_coupons_response.json @@ -33,11 +33,11 @@ "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]}", + "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", + "@type": "IriTemplateMapping", "variable": "order[code]", "property": "code", "required": false @@ -65,6 +65,12 @@ "variable": "order[used]", "property": "used", "required": false + }, + { + "@type": "IriTemplateMapping", + "variable": "promotion", + "property": null, + "required": false } ] }