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, []);
+ }
+}