[Api][Promotion] Add archivedAt to promotion

This commit is contained in:
Wojdylak 2024-02-17 20:31:29 +01:00
parent d50ec85545
commit 09b1a09f9d
No known key found for this signature in database
GPG key ID: 7509E560A6821ABE
25 changed files with 475 additions and 9 deletions

View file

@ -10,14 +10,14 @@ Feature: Cart promotions integrity
And there is a promotion "Christmas sale"
And this promotion gives "$10.00" discount to every order
@ui
@api @ui
Scenario: Promotion is applied to the cart when it is valid
When I add product "PHP T-Shirt" to the cart
Then I should be on my cart summary page
And I should be notified that the product has been successfully added
And my cart total should be "$90.00"
@ui
@api @ui
Scenario: Archived promotion is not applied to the cart
Given the promotion "Christmas sale" is archived
When I add product "PHP T-Shirt" to the cart

View file

@ -10,7 +10,7 @@ Feature: Archiving promotions
And there is also a promotion "New Year sale"
And I am logged in as an administrator
@ui
@api @ui
Scenario: Archiving a promotion
When I browse promotions
And I archive the "Christmas sale" promotion
@ -22,7 +22,7 @@ Feature: Archiving promotions
When I archive the "Christmas sale" promotion
Then the promotion "Christmas sale" should still exist in the registry
@ui
@api @ui
Scenario: Seeing only archived promotions
Given the promotion "Christmas sale" is archived
When I browse promotions
@ -31,7 +31,7 @@ Feature: Archiving promotions
And I should see the promotion "Christmas sale" in the list
And I should not see the promotion "New Year sale" in the list
@ui
@api @ui
Scenario: Restoring an archival promotion
Given the promotion "Christmas sale" is archived
When I browse promotions

View file

@ -34,6 +34,7 @@ use Sylius\Component\Core\Promotion\Checker\Rule\HasTaxonRuleChecker;
use Sylius\Component\Core\Promotion\Checker\Rule\TotalOfItemsFromTaxonRuleChecker;
use Sylius\Component\Customer\Model\CustomerGroupInterface;
use Sylius\Component\Promotion\Checker\Rule\ItemTotalRuleChecker;
use Symfony\Component\HttpFoundation\Request;
use Webmozart\Assert\Assert;
final class ManagingPromotionsContext implements Context
@ -73,6 +74,23 @@ final class ManagingPromotionsContext implements Context
$this->client->buildUpdateRequest(Resources::PROMOTIONS, $promotion->getCode());
}
/**
* @When I archive the :promotion promotion
*/
public function iArchiveThePromotion(PromotionInterface $promotion): void
{
$this->client->customItemAction(Resources::PROMOTIONS, $promotion->getCode(), Request::METHOD_PATCH, 'archive');
$this->client->index(Resources::PROMOTIONS);
}
/**
* @When I restore the :promotion promotion
*/
public function iRestoreThePromotion(PromotionInterface $promotion): void
{
$this->client->customItemAction(Resources::PROMOTIONS, $promotion->getCode(), Request::METHOD_PATCH, 'restore');
}
/**
* @When I specify its :field as :value
* @When I do not specify its :field
@ -410,6 +428,15 @@ final class ManagingPromotionsContext implements Context
$this->client->filter();
}
/**
* @When I filter archival promotions
*/
public function iFilterArchivalPromotions(): void
{
$this->client->addFilter('exists[archivedAt]', true);
$this->client->filter();
}
/**
* @When I add it
* @When I try to add it
@ -456,6 +483,17 @@ final class ManagingPromotionsContext implements Context
);
}
/**
* @Then I should not see the promotion :promotionName in the list
*/
public function iShouldNotSeeThePromotionInTheList(string $promotionName): void
{
Assert::false(
$this->responseChecker->hasItemWithValue($this->client->getLastResponse(), 'name', $promotionName),
sprintf('Promotion with name %s does not exist', $promotionName),
);
}
/**
* @Then /^(this promotion) should be coupon based$/
*/
@ -847,6 +885,14 @@ final class ManagingPromotionsContext implements Context
);
}
/**
* @Then I should be viewing non archival promotions
*/
public function iShouldBeViewingNonArchivalPromotions(): void
{
$this->client->index(Resources::PROMOTIONS);
}
private function addToRequestAction(string $type, array $configuration): void
{
$data['actions'][] = [

View file

@ -51,7 +51,7 @@ final class ManagingPromotionsContext implements Context
}
/**
* @When /^I archive the ("[^"]+" promotion)$/
* @When I archive the :promotion promotion
*/
public function iArchiveThePromotion(PromotionInterface $promotion): void
{

View file

@ -282,7 +282,7 @@ final class PromotionContext implements Context
}
/**
* @Given /^the (promotion "[^"]+") is archived$/
* @Given the promotion :promotion is archived
*/
public function thisPromotionIsArchived(PromotionInterface $promotion): void
{

View file

@ -23,6 +23,7 @@ default:
- sylius.behat.context.transform.lexical
- sylius.behat.context.transform.product
- sylius.behat.context.transform.product_variant
- sylius.behat.context.transform.promotion
- sylius.behat.context.transform.shared_storage
- sylius.behat.context.transform.shipping_category

View file

@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Applicator;
use Sylius\Calendar\Provider\DateTimeProviderInterface;
use Sylius\Component\Core\Model\PromotionInterface;
/** @experimental */
final class ArchivingPromotionApplicator implements ArchivingPromotionApplicatorInterface
{
public function __construct(private DateTimeProviderInterface $calendar)
{
}
public function archive(PromotionInterface $data): PromotionInterface
{
$data->setArchivedAt($this->calendar->now());
return $data;
}
public function restore(PromotionInterface $data): PromotionInterface
{
$data->setArchivedAt(null);
return $data;
}
}

View file

@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Applicator;
use Sylius\Component\Core\Model\PromotionInterface;
/** @experimental */
interface ArchivingPromotionApplicatorInterface
{
public function archive(PromotionInterface $data): PromotionInterface;
public function restore(PromotionInterface $data): PromotionInterface;
}

View file

@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\ContextAwareQueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
/** @experimental */
final class HideArchivedPromotionExtension implements ContextAwareQueryCollectionExtensionInterface
{
public function __construct(private string $promotionClass)
{
}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
string $operationName = null,
array $context = [],
): void {
if ($this->promotionClass !== $resourceClass) {
return;
}
if (isset($context['filters']['exists']['archivedAt'])) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere($queryBuilder->expr()->isNull(sprintf('%s.archivedAt', $rootAlias)));
}
}

View file

@ -41,6 +41,8 @@
<attribute>admin:promotion:show</attribute>
<attribute>sylius:admin:promotion:show</attribute>
</attribute>
<!-- It auto-turned on when adding PATCH itemOperations: https://github.com/api-platform/core/issues/3600 -->
<attribute name="skip_null_values">false</attribute>
</attribute>
<attribute name="openapi_context">
<attribute name="description">
@ -81,10 +83,13 @@ Example configuration for `order_fixed_discount` action type:
<attribute>admin:promotion:index</attribute>
<attribute>sylius:admin:promotion:index</attribute>
</attribute>
<!-- It auto-turned on when adding PATCH itemOperations: https://github.com/api-platform/core/issues/3600 -->
<attribute name="skip_null_values">false</attribute>
</attribute>
<attribute name="filters">
<attribute>sylius.api.promotion_coupon_search_filter</attribute>
<attribute>sylius.api.promotion_order_filter</attribute>
<attribute>sylius.api.exists_filter.archived_at</attribute>
</attribute>
</collectionOperation>
</collectionOperations>
@ -97,6 +102,8 @@ Example configuration for `order_fixed_discount` action type:
<attribute>admin:promotion:show</attribute>
<attribute>sylius:admin:promotion:show</attribute>
</attribute>
<!-- It auto-turned on when adding PATCH itemOperations: https://github.com/api-platform/core/issues/3600 -->
<attribute name="skip_null_values">false</attribute>
</attribute>
</itemOperation>
@ -113,6 +120,8 @@ Example configuration for `order_fixed_discount` action type:
<attribute>admin:promotion:show</attribute>
<attribute>sylius:admin:promotion:show</attribute>
</attribute>
<!-- It auto-turned on when adding PATCH itemOperations: https://github.com/api-platform/core/issues/3600 -->
<attribute name="skip_null_values">false</attribute>
</attribute>
<attribute name="openapi_context">
<attribute name="description">
@ -146,6 +155,37 @@ Example configuration for `order_fixed_discount` action type:
</attribute>
</itemOperation>
<itemOperation name="admin_archive">
<attribute name="method">PATCH</attribute>
<attribute name="path">/promotions/{code}/archive</attribute>
<attribute name="input">false</attribute>
<attribute name="controller">Sylius\Bundle\ApiBundle\Applicator\ArchivingPromotionApplicatorInterface::archive</attribute>
<attribute name="openapi_context">
<attribute name="summary">Archives Promotion</attribute>
</attribute>
<attribute name="normalization_context">
<attribute name="groups">
<attribute>admin:promotion:read</attribute>
<attribute>sylius:admin:promotion:read</attribute>
</attribute>
</attribute>
</itemOperation>
<itemOperation name="admin_restore">
<attribute name="method">PATCH</attribute>
<attribute name="path">/promotions/{code}/restore</attribute>
<attribute name="input">false</attribute>
<attribute name="controller">Sylius\Bundle\ApiBundle\Applicator\ArchivingPromotionApplicatorInterface::restore</attribute>
<attribute name="openapi_context">
<attribute name="summary">Restores Archived Promotion</attribute>
</attribute>
<attribute name="normalization_context">
<attribute name="groups">
<attribute>admin:promotion:read</attribute>
<attribute>sylius:admin:promotion:read</attribute>
</attribute>
</attribute>
</itemOperation>
<itemOperation name="admin_delete">
<attribute name="method">DELETE</attribute>
</itemOperation>

View file

@ -198,5 +198,10 @@
<group>admin:promotion:update</group>
<group>sylius:admin:promotion:update</group>
</attribute>
<attribute name="archivedAt">
<group>admin:promotion:read</group>
<group>sylius:admin:promotion:read</group>
</attribute>
</class>
</serializer>

View file

@ -33,5 +33,10 @@
<service id="Sylius\Bundle\ApiBundle\Applicator\ProductReviewStateMachineTransitionApplicatorInterface" class="Sylius\Bundle\ApiBundle\Applicator\ProductReviewStateMachineTransitionApplicator">
<argument id="sm.factory" type="service" />
</service>
<service id="sylius.api.applicator.archiving_promotion" class="Sylius\Bundle\ApiBundle\Applicator\ArchivingPromotionApplicator">
<argument type="service" id="Sylius\Calendar\Provider\DateTimeProviderInterface" />
</service>
<service id="Sylius\Bundle\ApiBundle\Applicator\ArchivingPromotionApplicatorInterface" alias="sylius.api.applicator.archiving_promotion" />
</services>
</container>

View file

@ -140,5 +140,10 @@
<argument type="service" id="Sylius\Bundle\ApiBundle\Context\UserContextInterface" />
<tag name="api_platform.doctrine.orm.query_extension.item" />
</service>
<service id="Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension\HideArchivedPromotionExtension">
<argument>%sylius.model.promotion.class%</argument>
<tag name="api_platform.doctrine.orm.query_extension.collection" />
</service>
</services>
</container>

View file

@ -410,5 +410,12 @@
</argument>
<tag name="api_platform.filter" />
</service>
<service id="sylius.api.exists_filter.archived_at" parent="api_platform.doctrine.orm.exists_filter" public="true">
<argument type="collection">
<argument key="archivedAt" />
</argument>
<tag name="api_platform.filter" />
</service>
</services>
</container>

View file

@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\Applicator;
use PhpSpec\ObjectBehavior;
use Sylius\Calendar\Provider\DateTimeProviderInterface;
use Sylius\Component\Core\Model\PromotionInterface;
final class ArchivingPromotionApplicatorSpec extends ObjectBehavior
{
function let(DateTimeProviderInterface $calendar)
{
$this->beConstructedWith($calendar);
}
function it_archives_promotion(
DateTimeProviderInterface $calendar,
PromotionInterface $promotion,
): void {
$now = new \DateTime();
$calendar->now()->willReturn($now);
$promotion->setArchivedAt($now)->shouldBeCalledOnce();
$this->archive($promotion)->shouldReturn($promotion);
}
function it_restores_promotion(
PromotionInterface $promotion,
): void {
$promotion->setArchivedAt(null)->shouldBeCalledOnce();
$this->restore($promotion)->shouldReturn($promotion);
}
}

View file

@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\Doctrine\QueryCollectionExtension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use PhpSpec\ObjectBehavior;
final class HideArchivedPromotionExtensionSpec extends ObjectBehavior
{
function let()
{
$this->beConstructedWith('promotionClass');
}
function it_does_nothing_if_current_resource_is_not_a_promotion(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
): void {
$queryBuilder->getRootAliases()->shouldNotBeCalled();
$queryBuilder->andWhere()->shouldNotBeCalled();
$this->applyToCollection($queryBuilder, $queryNameGenerator, 'taxonClass', 'get', []);
}
function it_does_nothing_if_archived_at_filter_is_already_applied(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
): void {
$queryBuilder->getRootAliases()->shouldNotBeCalled();
$queryBuilder->andWhere()->shouldNotBeCalled();
$this->applyToCollection($queryBuilder, $queryNameGenerator, 'promotionClass', 'get', ['filters' => ['exists' => ['archivedAt' => 'true']]]);
}
function it_filters_archived_promotions(
QueryBuilder $queryBuilder,
Expr $expr,
QueryNameGeneratorInterface $queryNameGenerator,
): void {
$queryBuilder->getRootAliases()->willReturn(['o']);
$expr->isNull('o.archivedAt')->willReturn('o.archivedAt IS NULL');
$queryBuilder->expr()->willReturn($expr);
$queryBuilder->andWhere('o.archivedAt IS NULL')->shouldBeCalled();
$this->applyToCollection($queryBuilder, $queryNameGenerator, 'promotionClass', 'get', []);
}
}

View file

@ -576,6 +576,56 @@ final class PromotionsTest extends JsonApiTestCase
);
}
/** @test */
public function it_archives_a_promotion(): void
{
$fixtures = $this->loadFixturesFromFiles([
'authentication/api_administrator.yaml',
'channel.yaml',
'promotion/promotion.yaml'
]);
/** @var PromotionInterface $promotion */
$promotion = $fixtures['promotion_50_off'];
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/admin/promotions/%s/archive', $promotion->getCode()),
server: $this->headerBuilder()->withJsonLdAccept()->withAdminUserAuthorization('api@example.com')->build(),
);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion/archive_promotion',
Response::HTTP_OK,
);
}
/** @test */
public function it_restore_a_promotion(): void
{
$fixtures = $this->loadFixturesFromFiles([
'authentication/api_administrator.yaml',
'channel.yaml',
'promotion/promotion.yaml'
]);
/** @var PromotionInterface $promotion */
$promotion = $fixtures['promotion_back_to_school'];
$this->client->request(
method: 'PATCH',
uri: sprintf('/api/v2/admin/promotions/%s/restore', $promotion->getCode()),
server: $this->headerBuilder()->withJsonLdAccept()->withAdminUserAuthorization('api@example.com')->build(),
);
$this->assertResponse(
$this->client->getResponse(),
'admin/promotion/restore_promotion',
Response::HTTP_OK,
);
}
/** @test */
public function it_deletes_a_promotion(): void
{

View file

@ -23,6 +23,18 @@ Sylius\Component\Core\Model\Promotion:
couponBased: true
translations:
- '@promotion_1_off_en'
promotion_back_to_school:
code: 'back_to_school'
name: 'Back to school'
description: 'Get 10% off on all school supplies'
channels: ['@channel_web', '@channel_mobile']
priority: 3
exclusive: false
appliesToDiscounted: true
couponBased: false
archivedAt: <dateTimeBetween('-1 month', 'now')>
translations:
- '@promotion_back_to_school_en'
Sylius\Component\Promotion\Model\PromotionTranslation:
promotion_50_off_en:
@ -33,6 +45,10 @@ Sylius\Component\Promotion\Model\PromotionTranslation:
locale: 'en_US'
label: '1$ off every item!'
translatable: '@promotion_1_off'
promotion_back_to_school_en:
locale: 'en_US'
label: 'Back to school sale!'
translatable: '@promotion_back_to_school'
Sylius\Component\Core\Model\PromotionCoupon:
promotion_1_off_coupon_1:

View file

@ -0,0 +1,32 @@
{
"@context": "\/api\/v2\/contexts\/Promotion",
"@id": "\/api\/v2\/admin\/promotions\/50_off",
"@type": "Promotion",
"channels": [
"\/api\/v2\/admin\/channels\/MOBILE"
],
"id": @integer@,
"code": "50_off",
"name": "50% Off on your first order",
"description": "Get 50% off of your first purchase",
"priority": 1,
"exclusive": true,
"usageLimit": 1,
"used": 0,
"couponBased": false,
"coupons": [],
"rules": [],
"actions": [],
"appliesToDiscounted": false,
"archivedAt": @date@,
"createdAt": @date@,
"updatedAt": @date@,
"translations": {
"en_US": {
"@id": "\/api\/v2\/admin\/promotion-translations\/@integer@",
"@type": "PromotionTranslation",
"id": @integer@,
"label": "-50% on first order!"
}
}
}

View file

@ -20,6 +20,7 @@
"coupons": [],
"rules": [],
"actions": [],
"archivedAt": null,
"createdAt": @date@,
"updatedAt": @date@,
"translations": {

View file

@ -28,6 +28,7 @@
],
"rules": [],
"actions": [],
"archivedAt": null,
"createdAt": @date@,
"updatedAt": @date@,
"translations": {
@ -60,6 +61,7 @@
"coupons": [],
"rules": [],
"actions": [],
"archivedAt": null,
"createdAt": @date@,
"updatedAt": @date@,
"translations": {
@ -75,7 +77,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],exists[archivedAt]}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
@ -95,6 +97,12 @@
"variable": "order[priority]",
"property": "priority",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "exists[archivedAt]",
"property": "archivedAt",
"required": false
}
]
}

View file

@ -13,6 +13,7 @@
"exclusive": true,
"usageLimit": 3,
"used": 0,
"archivedAt": null,
"startsAt": "2023-10-04 12:30:00",
"endsAt": "2023-11-04 12:30:00",
"couponBased": true,

View file

@ -13,6 +13,7 @@
"exclusive": true,
"usageLimit": 11,
"used": 0,
"archivedAt": null,
"startsAt": null,
"endsAt": null,
"couponBased": false,

View file

@ -9,7 +9,7 @@
"code": "50_off",
"name": "50% Off on your first order",
"description": "Get 50% off of your first purchase",
"priority": 2,
"priority": 3,
"exclusive": true,
"usageLimit": 1,
"used": 0,
@ -20,6 +20,7 @@
"rules": [],
"actions": [],
"appliesToDiscounted": false,
"archivedAt": null,
"createdAt": @date@,
"updatedAt": @date@,
"translations": {

View file

@ -0,0 +1,31 @@
{
"@context": "\/api\/v2\/contexts\/Promotion",
"@id": "\/api\/v2\/admin\/promotions\/back_to_school",
"@type": "Promotion",
"channels": [
"\/api\/v2\/admin\/channels\/WEB",
"\/api\/v2\/admin\/channels\/MOBILE"
],
"id": @integer@,
"code": "back_to_school",
"name": "Back to school",
"description": "Get 10% off on all school supplies",
"priority": 3,
"exclusive": false,
"used": 0,
"couponBased": false,
"coupons": [],
"rules": [],
"actions": [],
"appliesToDiscounted": true,
"createdAt": @date@,
"updatedAt": @date@,
"translations": {
"en_US": {
"@id": "\/api\/v2\/admin\/promotion-translations\/@integer@",
"@type": "PromotionTranslation",
"id": @integer@,
"label": "Back to school sale!"
}
}
}