Refactor checking whether the channel can be deleted

This commit is contained in:
Jacob Tobiasz 2023-11-06 14:56:40 +01:00
parent 14a94c043b
commit 8d4214d18c
No known key found for this signature in database
GPG key ID: 3F84290201B006E0
17 changed files with 310 additions and 171 deletions

View file

@ -24,5 +24,7 @@
<server name="IS_DOCTRINE_ORM_SUPPORTED" value="true" />
<server name="ESCAPE_JSON" value="true" />
<server name="KERNEL_CLASS" value="App\Kernel" />
</php>
</phpunit>

View file

@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\DataPersister;
use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use Sylius\Bundle\ApiBundle\Exception\ChannelCannotBeRemoved;
use Sylius\Component\Channel\Checker\ChannelDeletionCheckerInterface;
use Sylius\Component\Core\Model\ChannelInterface;
final class ChannelDataPersister implements ContextAwareDataPersisterInterface
{
public function __construct (
private ContextAwareDataPersisterInterface $decoratedDataPersister,
private ChannelDeletionCheckerInterface $channelDeletionChecker,
) {
}
/**
* @param array<string, mixed> $context
*/
public function supports($data, array $context = []): bool
{
return $data instanceof ChannelInterface;
}
/**
* @param array<string, mixed> $context
*/
public function persist($data, array $context = [])
{
return $this->decoratedDataPersister->persist($data, $context);
}
/**
* @param array<string, mixed> $context
*/
public function remove($data, array $context = []): void
{
if (!$this->channelDeletionChecker->isDeletable($data)) {
throw new ChannelCannotBeRemoved('The channel cannot be deleted. At least one enabled channel is required.');
}
$this->decoratedDataPersister->remove($data, $context);
}
}

View file

@ -1,47 +0,0 @@
<?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\EventListener;
use Sylius\Bundle\ApiBundle\Exception\ChannelCannotBeRemoved;
use Sylius\Component\Channel\Model\ChannelInterface;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ViewEvent;
/** @experimental */
final class ChannelDeletionEventListener
{
/**
* @param ChannelRepositoryInterface<ChannelInterface> $channelRepository
*/
public function __construct(private ChannelRepositoryInterface $channelRepository)
{
}
public function protectFromRemovalTheOnlyChannelInStore(ViewEvent $event): void
{
$channel = $event->getControllerResult();
$method = $event->getRequest()->getMethod();
if (!$channel instanceof ChannelInterface || $method !== Request::METHOD_DELETE) {
return;
}
$results = $this->channelRepository->findBy(['enabled' => true]);
if (!$results || (count($results) === 1 && current($results) === $channel)) {
throw new ChannelCannotBeRemoved('The channel cannot be deleted. At least one enabled channel is required.');
}
}
}

View file

@ -177,11 +177,6 @@
<tag name="kernel.event_listener" event="lexik_jwt_authentication.on_authentication_success" method="onAuthenticationSuccessResponse" />
</service>
<service id="Sylius\Bundle\ApiBundle\EventListener\ChannelDeletionEventListener">
<argument type="service" id="sylius.repository.channel"/>
<tag name="kernel.event_listener" event="kernel.view" method="protectFromRemovalTheOnlyChannelInStore" priority="33"/>
</service>
<service id="Sylius\Bundle\ApiBundle\Validator\ResourceInputDataPropertiesValidatorInterface" class="Sylius\Bundle\ApiBundle\Validator\ResourceApiInputDataPropertiesValidator">
<argument type="service" id="validator" />
</service>

View file

@ -31,6 +31,12 @@
<tag name="api_platform.data_persister" />
</service>
<service id="Sylius\Bundle\ApiBundle\DataPersister\ChannelDataPersister">
<argument type="service" id="api_platform.doctrine.orm.data_persister" />
<argument type="service" id="sylius.checker.channel_deletion" />
<tag name="api_platform.data_persister" />
</service>
<service id="Sylius\Bundle\ApiBundle\DataPersister\MessengerDataPersister">
<argument type="service" id="api_platform.messenger.data_persister" />
<tag name="api_platform.data_persister" />

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\ApiBundle\DataPersister;
use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Exception\ChannelCannotBeRemoved;
use Sylius\Component\Channel\Checker\ChannelDeletionCheckerInterface;
use Sylius\Component\Core\Model\ChannelInterface;
final class ChannelDataPersisterSpec extends ObjectBehavior
{
function let(
ContextAwareDataPersisterInterface $decoratedDataPersister,
ChannelDeletionCheckerInterface $channelDeletionChecker,
): void {
$this->beConstructedWith($decoratedDataPersister, $channelDeletionChecker);
}
function it_supports_only_channel_entity(ChannelInterface $channel, \stdClass $object): void
{
$this->supports($channel)->shouldReturn(true);
$this->supports($object)->shouldReturn(false);
}
function it_persists_channel(
ContextAwareDataPersisterInterface $decoratedDataPersister,
ChannelInterface $channel
): void {
$decoratedDataPersister->persist($channel, [])->willReturn($channel);
$this->persist($channel, [])->shouldReturn($channel);
}
function it_throws_an_exception_if_channel_is_not_deletable(
ChannelDeletionCheckerInterface $channelDeletionChecker,
ChannelInterface $channel
): void {
$channelDeletionChecker->isDeletable($channel)->willReturn(false);
$this
->shouldThrow(ChannelCannotBeRemoved::class)
->during('remove', [$channel])
;
}
function it_removes_channel(
ContextAwareDataPersisterInterface $decoratedDataPersister,
ChannelDeletionCheckerInterface $channelDeletionChecker,
ChannelInterface $channel
): void {
$channelDeletionChecker->isDeletable($channel)->willReturn(true);
$decoratedDataPersister->remove($channel, [])->willReturn(null);
$this->remove($channel, [])->shouldReturn(null);
}
}

View file

@ -1,112 +0,0 @@
<?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\EventListener;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Exception\ChannelCannotBeRemoved;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class ChannelDeletionEventListenerSpec extends ObjectBehavior
{
function let(ChannelRepositoryInterface $channelRepository)
{
$this->beConstructedWith($channelRepository);
}
function it_throws_exception_when_trying_to_delete_the_only_enabled_channel(
Request $request,
ChannelInterface $channel,
ChannelRepositoryInterface $channelRepository,
HttpKernelInterface $kernel,
): void {
$request->getMethod()->willReturn(Request::METHOD_DELETE);
$event = new ViewEvent(
$kernel->getWrappedObject(),
$request->getWrappedObject(),
HttpKernelInterface::MAIN_REQUEST,
$channel->getWrappedObject(),
);
$channelRepository->findBy(['enabled' => true])->willReturn([$channel]);
$this->shouldThrow(ChannelCannotBeRemoved::class)->during('protectFromRemovalTheOnlyChannelInStore', [$event]);
}
function it_does_nothing_when_there_are_more_than_one_enabled_channels(
Request $request,
ChannelInterface $channel,
ChannelInterface $channel2,
ChannelRepositoryInterface $channelRepository,
HttpKernelInterface $kernel,
): void {
$request->getMethod()->willReturn(Request::METHOD_DELETE);
$event = new ViewEvent(
$kernel->getWrappedObject(),
$request->getWrappedObject(),
HttpKernelInterface::MAIN_REQUEST,
$channel->getWrappedObject(),
);
$channelRepository->findBy(['enabled' => true])->willReturn([$channel, $channel2]);
$this->shouldNotThrow(ChannelCannotBeRemoved::class)->during('protectFromRemovalTheOnlyChannelInStore', [$event]);
}
function it_does_nothing_when_request_method_is_not_delete(
Request $request,
ChannelInterface $channel,
ChannelRepositoryInterface $channelRepository,
HttpKernelInterface $kernel,
): void {
$request->getMethod()->willReturn(Request::METHOD_GET);
$event = new ViewEvent(
$kernel->getWrappedObject(),
$request->getWrappedObject(),
HttpKernelInterface::MAIN_REQUEST,
$channel->getWrappedObject(),
);
$channelRepository->findBy(['enabled' => true])->willReturn([$channel]);
$this->shouldNotThrow(ChannelCannotBeRemoved::class)->during('protectFromRemovalTheOnlyChannelInStore', [$event]);
}
function it_does_nothing_when_event_controller_result_is_not_channel(
Request $request,
ChannelRepositoryInterface $channelRepository,
HttpKernelInterface $kernel,
OrderInterface $order,
): void {
$request->getMethod()->willReturn(Request::METHOD_DELETE);
$event = new ViewEvent(
$kernel->getWrappedObject(),
$request->getWrappedObject(),
HttpKernelInterface::MAIN_REQUEST,
$order->getWrappedObject(),
);
$channelRepository->findBy(['enabled' => true])->willReturn([]);
$this->shouldNotThrow(ChannelCannotBeRemoved::class)->during('protectFromRemovalTheOnlyChannelInStore', [$event]);
}
}

View file

@ -56,6 +56,17 @@ class ChannelRepository extends EntityRepository implements ChannelRepositoryInt
;
}
/**
* @return ChannelInterface[]
*/
public function findEnabled(): iterable
{
/** @var ChannelInterface[] $enabledChannels */
$enabledChannels = $this->findBy(['enabled' => true]);
return $enabledChannels;
}
public function countAll(): int
{
return $this->count([]);

View file

@ -69,5 +69,10 @@
<argument>false</argument>
<tag name="data_collector" template="@SyliusChannel/Collector/channel.html.twig" id="sylius.channel_collector" />
</service>
<service id="sylius.checker.channel_deletion" class="Sylius\Component\Channel\Checker\ChannelDeletionChecker">
<argument type="service" id="sylius.repository.channel" />
</service>
<service id="Sylius\Component\Channel\Checker\ChannelDeletionCheckerInterface" alias="sylius.checker.channel_deletion" />
</services>
</container>

View file

@ -14,13 +14,13 @@ declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\EventListener;
use Sylius\Bundle\ResourceBundle\Event\ResourceControllerEvent;
use Sylius\Component\Channel\Checker\ChannelDeletionCheckerInterface;
use Sylius\Component\Channel\Model\ChannelInterface;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
use Sylius\Component\Resource\Exception\UnexpectedTypeException;
final class ChannelDeletionListener
{
public function __construct(private ChannelRepositoryInterface $channelRepository)
public function __construct(private ChannelDeletionCheckerInterface $channelDeletionChecker)
{
}
@ -38,9 +38,7 @@ final class ChannelDeletionListener
);
}
$results = $this->channelRepository->findBy(['enabled' => true]);
if (!$results || (count($results) === 1 && current($results) === $channel)) {
if (!$this->channelDeletionChecker->isDeletable($channel)) {
$event->stop('sylius.channel.delete_error');
}
}

View file

@ -20,7 +20,7 @@
<defaults public="true" />
<service id="sylius.listener.channel" class="Sylius\Bundle\CoreBundle\EventListener\ChannelDeletionListener">
<argument type="service" id="sylius.repository.channel" />
<argument type="service" id="sylius.checker.channel_deletion" />
<tag name="kernel.event_listener" event="sylius.channel.pre_delete" method="onChannelPreDelete" />
</service>

View file

@ -0,0 +1,38 @@
<?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\Component\Channel\Checker;
use Sylius\Component\Channel\Model\ChannelInterface;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
final class ChannelDeletionChecker implements ChannelDeletionCheckerInterface
{
/**
* @param ChannelRepositoryInterface<ChannelInterface> $channelRepository
*/
public function __construct(private ChannelRepositoryInterface $channelRepository)
{
}
public function isDeletable(ChannelInterface $channel): bool
{
if (!$channel->isEnabled()) {
return true;
}
$enabledChannels = $this->channelRepository->findEnabled();
return count($enabledChannels) > 1;
}
}

View file

@ -0,0 +1,21 @@
<?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\Component\Channel\Checker;
use Sylius\Component\Channel\Model\ChannelInterface;
interface ChannelDeletionCheckerInterface
{
public function isDeletable(ChannelInterface $channel): bool;
}

View file

@ -35,5 +35,10 @@ interface ChannelRepositoryInterface extends RepositoryInterface
public function findAllWithBasicData(): iterable;
/**
* @return ChannelInterface[]
*/
public function findEnabled(): iterable;
public function countAll(): int;
}

View file

@ -0,0 +1,64 @@
<?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\Component\Channel\Checker;
use PhpSpec\ObjectBehavior;
use Sylius\Component\Channel\Model\ChannelInterface;
use Sylius\Component\Channel\Repository\ChannelRepositoryInterface;
final class ChannelDeletionCheckerSpec extends ObjectBehavior
{
function let(ChannelRepositoryInterface $channelRepository): void
{
$this->beConstructedWith($channelRepository);
}
function it_returns_a_channel_can_be_deleted_when_is_disabled(ChannelInterface $channel): void
{
$channel->isEnabled()->willReturn(false);
$this->isDeletable($channel)->shouldReturn(true);
}
function it_returns_a_channel_can_be_deleted_when_at_least_two_channels_are_enabled(
ChannelRepositoryInterface $channelRepository,
ChannelInterface $channel,
ChannelInterface $anotherChannel
): void {
$channel->isEnabled()->willReturn(true);
$channelRepository->findEnabled()->willReturn([$channel, $anotherChannel]);
$this->isDeletable($channel)->shouldReturn(true);
}
function it_returns_a_channel_cannot_be_deleted_when_only_one_channel_is_enabled(
ChannelRepositoryInterface $channelRepository,
ChannelInterface $channel
): void {
$channel->isEnabled()->willReturn(true);
$channelRepository->findEnabled()->willReturn([$channel]);
$this->isDeletable($channel)->shouldReturn(false);
}
function it_returns_a_channel_cannot_be_deleted_when_no_channel_is_enabled(
ChannelRepositoryInterface $channelRepository,
ChannelInterface $channel
): void {
$channel->isEnabled()->willReturn(true);
$channelRepository->findEnabled()->willReturn([]);
$this->isDeletable($channel)->shouldReturn(false);
}
}

View file

@ -14,7 +14,6 @@ declare(strict_types=1);
namespace Sylius\Tests\Api\Admin;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\ShopBillingData;
use Sylius\Tests\Api\JsonApiTestCase;
use Sylius\Tests\Api\Utils\AdminUserLoginTrait;
use Symfony\Component\HttpFoundation\Response;
@ -76,6 +75,30 @@ final class ChannelsTest extends JsonApiTestCase
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND);
}
/** @test */
public function it_prevents_deleting_the_only_channel(): void
{
$this->loadFixturesFromFiles(['authentication/api_administrator.yaml', 'channel.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
$this->client->request(
method: 'DELETE',
uri: '/api/v2/admin/channels/MOBILE',
server: $header,
);
$this->client->request(
method: 'DELETE',
uri: '/api/v2/admin/channels/WEB',
server: $header,
);
$this->assertResponse(
$this->client->getResponse(),
'admin/channel/delete_channel_that_cannot_be_deleted',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
/** @test */
public function it_gets_channels(): void
{

View file

@ -0,0 +1,6 @@
{
"@context": "\/api\/v2\/contexts\/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "The channel cannot be deleted. At least one enabled channel is required."
}