diff --git a/features/user/managing_users/changing_shop_user_password.feature b/features/user/managing_users/changing_shop_user_password.feature index 4b1aaffcef1..b05422d1499 100644 --- a/features/user/managing_users/changing_shop_user_password.feature +++ b/features/user/managing_users/changing_shop_user_password.feature @@ -9,7 +9,7 @@ Feature: Changing shop user's password And there is a user "kibsoon@example.com" identified by "goodGuy" And I am logged in as an administrator - @ui + @api @ui Scenario: Changing a password of a shop user When I change the password of user "kibsoon@example.com" to "veryGoodGuy" Then I should be notified that it has been successfully edited diff --git a/features/user/managing_users/deleting_account.feature b/features/user/managing_users/deleting_account.feature index 1c075b00ba9..d2fda525ff6 100644 --- a/features/user/managing_users/deleting_account.feature +++ b/features/user/managing_users/deleting_account.feature @@ -9,13 +9,13 @@ Feature: Deleting the customer account And there is a user "theodore@example.com" identified by "pswd" And I am logged in as an administrator - @ui + @api @ui Scenario: Deleting account should not delete customer details When I delete the account of "theodore@example.com" user Then the user account should be deleted But the customer with this email should still exist - @ui + @api @ui Scenario: A customer with no user cannot be deleted Given the account of "theodore@example.com" was deleted Then I should not be able to delete it again diff --git a/src/Sylius/Behat/Context/Api/Admin/ManagingCustomersContext.php b/src/Sylius/Behat/Context/Api/Admin/ManagingCustomersContext.php index ef73478650d..7db28f995e9 100644 --- a/src/Sylius/Behat/Context/Api/Admin/ManagingCustomersContext.php +++ b/src/Sylius/Behat/Context/Api/Admin/ManagingCustomersContext.php @@ -18,8 +18,10 @@ use Sylius\Behat\Client\ApiClientInterface; use Sylius\Behat\Client\ResponseCheckerInterface; use Sylius\Behat\Context\Api\Resources; +use Sylius\Behat\Service\SharedStorageInterface; use Sylius\Component\Addressing\Model\CountryInterface; use Sylius\Component\Core\Model\CustomerInterface; +use Sylius\Component\Core\Model\ShopUserInterface; use Sylius\Component\Customer\Model\CustomerGroupInterface; use Webmozart\Assert\Assert; @@ -31,6 +33,7 @@ public function __construct( private ApiClientInterface $client, private ResponseCheckerInterface $responseChecker, private IriConverterInterface $iriConverter, + private SharedStorageInterface $sharedStorage, ) { } @@ -210,6 +213,25 @@ public function iSortThemBy(string $sortType = 'ascending'): void ]); } + /** + * @When I change the password of user :customer to :newPassword + */ + public function iChangeThePasswordOfUserTo(CustomerInterface $customer, string $newPassword): void + { + $this->iWantToEditThisCustomer($customer); + $this->iSpecifyItsPasswordAs($newPassword); + $this->client->update(); + } + + /** + * @When I delete the account of :shopUser user + */ + public function iDeleteAccount(ShopUserInterface $shopUser): void + { + $this->sharedStorage->set('customer', $shopUser->getCustomer()); + $this->client->delete(sprintf('customer/%s', $shopUser->getCustomer()->getId()), 'user'); + } + /** * @When I do not specify any information * @When I do not choose create account option @@ -561,6 +583,20 @@ public function thisCustomerShouldHaveAsTheirGroup(CustomerGroupInterface $custo ); } + /** + * @Then the customer with this email should still exist + */ + public function customerShouldStillExist(): void + { + /** @var CustomerInterface $customer */ + $customer = $this->sharedStorage->get('customer'); + + $this->client->show(Resources::CUSTOMERS, (string) $customer->getId()); + + Assert::same($this->client->getLastResponse()->getStatusCode(), 200); + Assert::same($this->responseChecker->getValue($this->client->getLastResponse(), 'email'), $customer->getEmail()); + } + /** * @Then I should not see create account option * @Then I should still be on the customer creation page @@ -572,4 +608,28 @@ public function thisCustomerShouldHaveAsTheirGroup(CustomerGroupInterface $custo public function intentionallyLeftBlank(): void { } + + /** + * @Then the user account should be deleted + */ + public function accountShouldBeDeleted(): void + { + /** @var CustomerInterface $customer */ + $customer = $this->sharedStorage->get('customer'); + + $response = $this->client->show(Resources::CUSTOMERS, (string) $customer->getId()); + + Assert::null($this->responseChecker->getValue($response, 'user')); + } + + /** + * @Then I should not be able to delete it again + */ + public function iShouldNotBeAbleToDeleteCustomerAgain(): void + { + $customer = $this->sharedStorage->get('customer'); + $this->client->delete(sprintf('customer/%s', $customer->getId()), 'user'); + + Assert::same($this->client->getLastResponse()->getStatusCode(), 404); + } } diff --git a/src/Sylius/Behat/Context/Transform/ShopUserContext.php b/src/Sylius/Behat/Context/Transform/ShopUserContext.php new file mode 100644 index 00000000000..57e8f98b1a8 --- /dev/null +++ b/src/Sylius/Behat/Context/Transform/ShopUserContext.php @@ -0,0 +1,38 @@ +shopUserRepository->findOneByEmail($email); + + Assert::notNull($shopUser, sprintf('Shop User with email "%s" does not exist', $email)); + + return $shopUser; + } +} 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 bc702557425..6726722186f 100644 --- a/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml +++ b/src/Sylius/Behat/Resources/config/services/contexts/api/admin.xml @@ -303,6 +303,7 @@ + diff --git a/src/Sylius/Behat/Resources/config/services/contexts/transform.xml b/src/Sylius/Behat/Resources/config/services/contexts/transform.xml index 9991189eb17..1fb1c7d1939 100644 --- a/src/Sylius/Behat/Resources/config/services/contexts/transform.xml +++ b/src/Sylius/Behat/Resources/config/services/contexts/transform.xml @@ -179,5 +179,9 @@ + + + + diff --git a/src/Sylius/Behat/Resources/config/suites.yml b/src/Sylius/Behat/Resources/config/suites.yml index 2173fa4722a..88c90f818bc 100644 --- a/src/Sylius/Behat/Resources/config/suites.yml +++ b/src/Sylius/Behat/Resources/config/suites.yml @@ -68,6 +68,7 @@ imports: - suites/api/user/managing_administrators.yml - suites/api/user/managing_customer_groups.yml - suites/api/user/managing_customers.yml + - suites/api/user/managing_users.yml - suites/cli/canceling_unpaid_orders.yml - suites/cli/installer.yml diff --git a/src/Sylius/Behat/Resources/config/suites/api/user/managing_users.yml b/src/Sylius/Behat/Resources/config/suites/api/user/managing_users.yml new file mode 100644 index 00000000000..913ff7c923e --- /dev/null +++ b/src/Sylius/Behat/Resources/config/suites/api/user/managing_users.yml @@ -0,0 +1,25 @@ +# This file is part of the Sylius package. +# (c) Sylius Sp. z o.o. + +default: + suites: + api_managing_users: + contexts: + - sylius.behat.context.hook.doctrine_orm + + - sylius.behat.context.transform.customer + - sylius.behat.context.transform.shared_storage + - sylius.behat.context.transform.shop_user + + - sylius.behat.context.setup.admin_api_security + - sylius.behat.context.setup.channel + - sylius.behat.context.setup.customer + - sylius.behat.context.setup.user + + - sylius.behat.context.api.admin.managing_customers + - sylius.behat.context.api.admin.response + + - sylius.behat.context.api.shop.login + + filters: + tags: "@managing_users&&@api" diff --git a/src/Sylius/Bundle/ApiBundle/Command/Customer/RemoveShopUser.php b/src/Sylius/Bundle/ApiBundle/Command/Customer/RemoveShopUser.php new file mode 100644 index 00000000000..ee292b31abb --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/Command/Customer/RemoveShopUser.php @@ -0,0 +1,34 @@ +shopUserId; + } + + public function setShopUserId(mixed $shopUserId): void + { + $this->shopUserId = $shopUserId; + } +} diff --git a/src/Sylius/Bundle/ApiBundle/CommandHandler/Customer/RemoveShopUserHandler.php b/src/Sylius/Bundle/ApiBundle/CommandHandler/Customer/RemoveShopUserHandler.php new file mode 100644 index 00000000000..cb7b580b649 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/CommandHandler/Customer/RemoveShopUserHandler.php @@ -0,0 +1,43 @@ + $shopUserRepository + */ + public function __construct( + private UserRepositoryInterface $shopUserRepository, + ){ + } + + public function __invoke(RemoveShopUser $removeShopUser): void + { + $shopUser = $this->shopUserRepository->find($removeShopUser->getShopUserId()); + + if (null === $shopUser) { + throw new UserNotFoundException(); + } + + $shopUser->setCustomer(null); + $this->shopUserRepository->remove($shopUser); + } +} diff --git a/src/Sylius/Bundle/ApiBundle/Controller/RemoveCustomerShopUserAction.php b/src/Sylius/Bundle/ApiBundle/Controller/RemoveCustomerShopUserAction.php new file mode 100644 index 00000000000..1a7cd38c728 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/Controller/RemoveCustomerShopUserAction.php @@ -0,0 +1,51 @@ + $shopUserRepository + */ + public function __construct( + private MessageBusInterface $commandBus, + private UserRepositoryInterface $shopUserRepository, + ) { + } + + public function __invoke(Request $request): Response + { + $customerId = $request->attributes->get('id', ''); + + $user = $this->shopUserRepository->findOneBy(['customer' => $customerId]); + if (null === $user) { + throw new UserNotFoundException(); + } + + $this->commandBus->dispatch(new RemoveShopUser($user->getId())); + + return new JsonResponse(status: Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Sylius/Bundle/ApiBundle/DataPersister/CustomerDataPersister.php b/src/Sylius/Bundle/ApiBundle/DataPersister/CustomerDataPersister.php new file mode 100644 index 00000000000..613791d283e --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/DataPersister/CustomerDataPersister.php @@ -0,0 +1,54 @@ + $context */ + public function supports($data, array $context = []): bool + { + return $data instanceof CustomerInterface; + } + + /** + * @param CustomerInterface $data + * @param array $context + */ + public function persist($data, array $context = []): void + { + $user = $data->getUser(); + if (null !== $user && null !== $user->getPlainPassword()) { + $this->passwordUpdater->updatePassword($user); + } + + $this->decoratedDataPersister->persist($data, $context); + } + + /** @param array $context */ + public function remove($data, array $context = []): void + { + $this->decoratedDataPersister->remove($data, $context); + } +} diff --git a/src/Sylius/Bundle/ApiBundle/Exception/UserNotFoundException.php b/src/Sylius/Bundle/ApiBundle/Exception/UserNotFoundException.php new file mode 100644 index 00000000000..70c30904985 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/Exception/UserNotFoundException.php @@ -0,0 +1,30 @@ + $headers */ + public function __construct( + string $message = 'User not found.', + \Throwable $previous = null, + int $code = 0, + array $headers = [], + ) { + parent::__construct($message, $previous, $code, $headers); + } +} diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Customer.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Customer.xml index 2b41ac3d254..13050ef69b0 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Customer.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/api_resources/Customer.xml @@ -95,6 +95,12 @@ + + DELETE + /admin/customer/{id}/user + Sylius\Bundle\ApiBundle\Controller\RemoveCustomerShopUserAction + + GET /shop/customers/{id} diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/command_handlers.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/command_handlers.xml index 0c922f02120..7590e7a7ac7 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/command_handlers.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/command_handlers.xml @@ -231,5 +231,11 @@ + + + + + + diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/controllers.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/controllers.xml index e5499f53f2f..ed839beaa4f 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/controllers.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/controllers.xml @@ -60,5 +60,10 @@ + + + + + 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 bc5248e1185..2cff1c5bd40 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/data_persisters.xml @@ -105,5 +105,11 @@ + + + + + + diff --git a/src/Sylius/Bundle/ApiBundle/spec/CommandHandler/Customer/RemoveShopUserHandlerSpec.php b/src/Sylius/Bundle/ApiBundle/spec/CommandHandler/Customer/RemoveShopUserHandlerSpec.php new file mode 100644 index 00000000000..2e5097df322 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/spec/CommandHandler/Customer/RemoveShopUserHandlerSpec.php @@ -0,0 +1,45 @@ +beConstructedWith($userRepository); + } + + public function it_throws_an_exception_if_user_has_not_been_found(UserRepositoryInterface $userRepository): void + { + $userRepository->find(42)->willReturn(null); + + $this->shouldThrow(UserNotFoundException::class)->during('__invoke', [new RemoveShopUser(42)]); + } + + public function it_should_remove_shop_user(UserRepositoryInterface $userRepository, ShopUserInterface $shopUser): void + { + $userRepository->find(42)->willReturn($shopUser); + $shopUser->setCustomer(null)->shouldBeCalled(); + $userRepository->remove($shopUser)->shouldBeCalled(); + + $this->__invoke(new RemoveShopUser(42)); + } +} diff --git a/src/Sylius/Bundle/ApiBundle/spec/DataPersister/CustomerDataPersisterSpec.php b/src/Sylius/Bundle/ApiBundle/spec/DataPersister/CustomerDataPersisterSpec.php new file mode 100644 index 00000000000..e15815950f9 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/spec/DataPersister/CustomerDataPersisterSpec.php @@ -0,0 +1,90 @@ +beConstructedWith($decoratedDataPersister, $passwordUpdater); + } + + function it_supports_only_customer_entity(CustomerInterface $customer, ProductInterface $product): void + { + $this->supports($customer)->shouldReturn(true); + $this->supports($product)->shouldReturn(false); + } + + function it_does_not_update_password_when_user_is_null( + ContextAwareDataPersisterInterface $decoratedDataPersister, + PasswordUpdaterInterface $passwordUpdater, + CustomerInterface $customer, + ): void { + $customer->getUser()->willReturn(null); + $passwordUpdater->updatePassword(Argument::any())->shouldNotBeCalled(); + + $decoratedDataPersister->persist($customer, [])->shouldBeCalled(); + + $this->persist($customer, []); + } + + function it_does_not_update_password_when_plain_password_is_null( + ContextAwareDataPersisterInterface $decoratedDataPersister, + PasswordUpdaterInterface $passwordUpdater, + CustomerInterface $customer, + ShopUserInterface $user, + ): void { + $user->getPlainPassword()->willReturn(null); + $customer->getUser()->willReturn($user); + $passwordUpdater->updatePassword($user)->shouldNotBeCalled(); + + $decoratedDataPersister->persist($customer, [])->shouldBeCalled(); + + $this->persist($customer, []); + } + + function it_updates_password_when_plain_password_is_set( + ContextAwareDataPersisterInterface $decoratedDataPersister, + PasswordUpdaterInterface $passwordUpdater, + CustomerInterface $customer, + ShopUserInterface $user, + ): void { + $user->getPlainPassword()->willReturn('password'); + $customer->getUser()->willReturn($user); + $passwordUpdater->updatePassword($user)->shouldBeCalled(); + + $decoratedDataPersister->persist($customer, [])->shouldBeCalled(); + + $this->persist($customer, []); + } + + function it_uses_decorated_data_persister_to_remove_customer( + ContextAwareDataPersisterInterface $decoratedDataPersister, + CustomerInterface $customer, + ): void { + $decoratedDataPersister->remove($customer, [])->shouldBeCalled(); + + $this->remove($customer, []); + } +}