diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableEnteredOptionValueUid.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableEnteredOptionValueUid.php
index 69fafc49c9137..2d6975bb8a4e2 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableEnteredOptionValueUid.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableEnteredOptionValueUid.php
@@ -45,6 +45,9 @@ public function resolve(
array $value = null,
array $args = null
) {
+ if (isset($value['uid'])) {
+ return $value['uid'];
+ }
if (!isset($value['option_id']) || empty($value['option_id'])) {
throw new GraphQlInputException(__('"option_id" value should be specified.'));
}
diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableSelectedOptionValueUid.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableSelectedOptionValueUid.php
index 5fbd8a56bb570..795782d6e3718 100644
--- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableSelectedOptionValueUid.php
+++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/CustomizableSelectedOptionValueUid.php
@@ -45,6 +45,9 @@ public function resolve(
array $value = null,
array $args = null
) {
+ if (isset($value['uid'])) {
+ return $value['uid'];
+ }
if (!isset($value['option_id']) || empty($value['option_id'])) {
throw new GraphQlInputException(__('"option_id" value should be specified.'));
}
diff --git a/app/code/Magento/GraphQl/etc/schema.graphqls b/app/code/Magento/GraphQl/etc/schema.graphqls
index 0212d32db0f2f..2595ad09c072a 100644
--- a/app/code/Magento/GraphQl/etc/schema.graphqls
+++ b/app/code/Magento/GraphQl/etc/schema.graphqls
@@ -277,3 +277,8 @@ enum CurrencyEnum @doc(description: "The list of available currency codes") {
TRL
XPF
}
+
+input EnteredOptionInput @doc(description: "Defines a customer-entered option") {
+ uid: ID! @doc(description: "An encoded ID")
+ value: String! @doc(description: "Text the customer entered")
+}
diff --git a/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php b/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php
new file mode 100644
index 0000000000000..2c5c3536d6682
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php
@@ -0,0 +1,225 @@
+ self::ERROR_PRODUCT_NOT_FOUND,
+ 'The required options you selected are not available' => self::ERROR_NOT_SALABLE,
+ 'Product that you are trying to add is not available.' => self::ERROR_NOT_SALABLE,
+ 'This product is out of stock' => self::ERROR_INSUFFICIENT_STOCK,
+ 'There are no source items' => self::ERROR_NOT_SALABLE,
+ 'The fewest you may purchase is' => self::ERROR_INSUFFICIENT_STOCK,
+ 'The most you may purchase is' => self::ERROR_INSUFFICIENT_STOCK,
+ 'The requested qty is not available' => self::ERROR_INSUFFICIENT_STOCK,
+ ];
+
+ /**
+ * @var ProductRepositoryInterface
+ */
+ private $productRepository;
+
+ /**
+ * @var array
+ */
+ private $errors = [];
+
+ /**
+ * @var CartRepositoryInterface
+ */
+ private $cartRepository;
+
+ /**
+ * @var MaskedQuoteIdToQuoteIdInterface
+ */
+ private $maskedQuoteIdToQuoteId;
+
+ /**
+ * @var BuyRequestBuilder
+ */
+ private $requestBuilder;
+
+ /**
+ * @param ProductRepositoryInterface $productRepository
+ * @param CartRepositoryInterface $cartRepository
+ * @param MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId
+ * @param BuyRequestBuilder $requestBuilder
+ */
+ public function __construct(
+ ProductRepositoryInterface $productRepository,
+ CartRepositoryInterface $cartRepository,
+ MaskedQuoteIdToQuoteIdInterface $maskedQuoteIdToQuoteId,
+ BuyRequestBuilder $requestBuilder
+ ) {
+ $this->productRepository = $productRepository;
+ $this->cartRepository = $cartRepository;
+ $this->maskedQuoteIdToQuoteId = $maskedQuoteIdToQuoteId;
+ $this->requestBuilder = $requestBuilder;
+ }
+
+ /**
+ * Add cart items to the cart
+ *
+ * @param string $maskedCartId
+ * @param Data\CartItem[] $cartItems
+ * @return AddProductsToCartOutput
+ * @throws NoSuchEntityException Could not find a Cart with provided $maskedCartId
+ */
+ public function execute(string $maskedCartId, array $cartItems): AddProductsToCartOutput
+ {
+ $cartId = $this->maskedQuoteIdToQuoteId->execute($maskedCartId);
+ $cart = $this->cartRepository->get($cartId);
+
+ foreach ($cartItems as $cartItemPosition => $cartItem) {
+ $this->addItemToCart($cart, $cartItem, $cartItemPosition);
+ }
+
+ if ($cart->getData('has_error')) {
+ $errors = $cart->getErrors();
+
+ /** @var MessageInterface $error */
+ foreach ($errors as $error) {
+ $this->addError($error->getText());
+ }
+ }
+
+ if (count($this->errors) !== 0) {
+ /* Revert changes introduced by add to cart processes in case of an error */
+ $cart->getItemsCollection()->clear();
+ }
+
+ return $this->prepareErrorOutput($cart);
+ }
+
+ /**
+ * Adds a particular item to the shopping cart
+ *
+ * @param CartInterface|Quote $cart
+ * @param Data\CartItem $cartItem
+ * @param int $cartItemPosition
+ */
+ private function addItemToCart(CartInterface $cart, Data\CartItem $cartItem, int $cartItemPosition): void
+ {
+ $sku = $cartItem->getSku();
+
+ if ($cartItem->getQuantity() <= 0) {
+ $this->addError(__('The product quantity should be greater than 0')->render());
+
+ return;
+ }
+
+ try {
+ $product = $this->productRepository->get($sku, false, null, true);
+ } catch (NoSuchEntityException $e) {
+ $this->addError(
+ __('Could not find a product with SKU "%sku"', ['sku' => $sku])->render(),
+ $cartItemPosition
+ );
+
+ return;
+ }
+
+ try {
+ $result = $cart->addProduct($product, $this->requestBuilder->build($cartItem));
+ $this->cartRepository->save($cart);
+ } catch (\Throwable $e) {
+ $this->addError(
+ __($e->getMessage())->render(),
+ $cartItemPosition
+ );
+ $cart->setHasError(false);
+
+ return;
+ }
+
+ if (is_string($result)) {
+ $errors = array_unique(explode("\n", $result));
+ foreach ($errors as $error) {
+ $this->addError(__($error)->render(), $cartItemPosition);
+ }
+ }
+ }
+
+ /**
+ * Add order line item error
+ *
+ * @param string $message
+ * @param int $cartItemPosition
+ * @return void
+ */
+ private function addError(string $message, int $cartItemPosition = 0): void
+ {
+ $this->errors[] = new Data\Error(
+ $message,
+ $this->getErrorCode($message),
+ $cartItemPosition
+ );
+ }
+
+ /**
+ * Get message error code.
+ *
+ * TODO: introduce a separate class for getting error code from a message
+ *
+ * @param string $message
+ * @return string
+ */
+ private function getErrorCode(string $message): string
+ {
+ foreach (self::MESSAGE_CODES as $codeMessage => $code) {
+ if (false !== stripos($message, $codeMessage)) {
+ return $code;
+ }
+ }
+
+ /* If no code was matched, return the default one */
+ return self::ERROR_UNDEFINED;
+ }
+
+ /**
+ * Creates a new output from existing errors
+ *
+ * @param CartInterface $cart
+ * @return AddProductsToCartOutput
+ */
+ private function prepareErrorOutput(CartInterface $cart): AddProductsToCartOutput
+ {
+ $output = new AddProductsToCartOutput($cart, $this->errors);
+ $this->errors = [];
+ $cart->setHasError(false);
+
+ return $output;
+ }
+}
diff --git a/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php
new file mode 100644
index 0000000000000..13b19e4f79c9a
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestBuilder.php
@@ -0,0 +1,61 @@
+dataObjectFactory = $dataObjectFactory;
+ $this->providers = $providers;
+ }
+
+ /**
+ * Build buy request for adding product to cart
+ *
+ * @see \Magento\Quote\Model\Quote::addProduct
+ * @param CartItem $cartItem
+ * @return DataObject
+ */
+ public function build(CartItem $cartItem): DataObject
+ {
+ $requestData = [
+ ['qty' => $cartItem->getQuantity()]
+ ];
+
+ /** @var BuyRequestDataProviderInterface $provider */
+ foreach ($this->providers as $provider) {
+ $requestData[] = $provider->execute($cartItem);
+ }
+
+ return $this->dataObjectFactory->create(['data' => array_merge(...$requestData)]);
+ }
+}
diff --git a/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestDataProviderInterface.php b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestDataProviderInterface.php
new file mode 100644
index 0000000000000..b9c41b18ee163
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/BuyRequest/BuyRequestDataProviderInterface.php
@@ -0,0 +1,24 @@
+getSelectedOptions() as $optionData) {
+ // phpcs:ignore Magento2.Functions.DiscouragedFunction
+ $optionData = \explode('/', base64_decode($optionData->getId()));
+
+ if ($this->isProviderApplicable($optionData) === false) {
+ continue;
+ }
+ $this->validateInput($optionData);
+
+ [$optionType, $optionId, $optionValue] = $optionData;
+ if ($optionType == self::OPTION_TYPE) {
+ $customizableOptionsData[$optionId][] = $optionValue;
+ }
+ }
+
+ foreach ($cartItem->getEnteredOptions() as $option) {
+ // phpcs:ignore Magento2.Functions.DiscouragedFunction
+ $optionData = \explode('/', base64_decode($option->getUid()));
+
+ if ($this->isProviderApplicable($optionData) === false) {
+ continue;
+ }
+
+ [$optionType, $optionId] = $optionData;
+ if ($optionType == self::OPTION_TYPE) {
+ $customizableOptionsData[$optionId][] = $option->getValue();
+ }
+ }
+
+ return ['options' => $this->flattenOptionValues($customizableOptionsData)];
+ }
+
+ /**
+ * Flatten option values for non-multiselect customizable options
+ *
+ * @param array $customizableOptionsData
+ * @return array
+ */
+ private function flattenOptionValues(array $customizableOptionsData): array
+ {
+ foreach ($customizableOptionsData as $optionId => $optionValue) {
+ if (count($optionValue) === 1) {
+ $customizableOptionsData[$optionId] = $optionValue[0];
+ }
+ }
+
+ return $customizableOptionsData;
+ }
+
+ /**
+ * Checks whether this provider is applicable for the current option
+ *
+ * @param array $optionData
+ * @return bool
+ */
+ private function isProviderApplicable(array $optionData): bool
+ {
+ if ($optionData[0] !== self::OPTION_TYPE) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validates the provided options structure
+ *
+ * @param array $optionData
+ * @throws LocalizedException
+ */
+ private function validateInput(array $optionData): void
+ {
+ if (count($optionData) !== 3) {
+ throw new LocalizedException(
+ __('Wrong format of the entered option data')
+ );
+ }
+ }
+}
diff --git a/app/code/Magento/Quote/Model/Cart/Data/AddProductsToCartOutput.php b/app/code/Magento/Quote/Model/Cart/Data/AddProductsToCartOutput.php
new file mode 100644
index 0000000000000..c12c02c0449f6
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/Data/AddProductsToCartOutput.php
@@ -0,0 +1,56 @@
+cart = $cart;
+ $this->errors = $errors;
+ }
+
+ /**
+ * Get Shopping Cart
+ *
+ * @return CartInterface
+ */
+ public function getCart(): CartInterface
+ {
+ return $this->cart;
+ }
+
+ /**
+ * Get errors happened during adding item to the cart
+ *
+ * @return Error[]
+ */
+ public function getErrors(): array
+ {
+ return $this->errors;
+ }
+}
diff --git a/app/code/Magento/Quote/Model/Cart/Data/CartItem.php b/app/code/Magento/Quote/Model/Cart/Data/CartItem.php
new file mode 100644
index 0000000000000..9836247c56694
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/Data/CartItem.php
@@ -0,0 +1,110 @@
+sku = $sku;
+ $this->quantity = $quantity;
+ $this->parentSku = $parentSku;
+ $this->selectedOptions = $selectedOptions;
+ $this->enteredOptions = $enteredOptions;
+ }
+
+ /**
+ * Returns cart item SKU
+ *
+ * @return string
+ */
+ public function getSku(): string
+ {
+ return $this->sku;
+ }
+
+ /**
+ * Returns cart item quantity
+ *
+ * @return float
+ */
+ public function getQuantity(): float
+ {
+ return $this->quantity;
+ }
+
+ /**
+ * Returns parent SKU
+ *
+ * @return string|null
+ */
+ public function getParentSku(): ?string
+ {
+ return $this->parentSku;
+ }
+
+ /**
+ * Returns selected options
+ *
+ * @return SelectedOption[]|null
+ */
+ public function getSelectedOptions(): ?array
+ {
+ return $this->selectedOptions;
+ }
+
+ /**
+ * Returns entered options
+ *
+ * @return EnteredOption[]|null
+ */
+ public function getEnteredOptions(): ?array
+ {
+ return $this->enteredOptions;
+ }
+}
diff --git a/app/code/Magento/Quote/Model/Cart/Data/CartItemFactory.php b/app/code/Magento/Quote/Model/Cart/Data/CartItemFactory.php
new file mode 100644
index 0000000000000..823f03b28229c
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/Data/CartItemFactory.php
@@ -0,0 +1,74 @@
+createSelectedOptions($data['selected_options']) : [],
+ isset($data['entered_options']) ? $this->createEnteredOptions($data['entered_options']) : []
+ );
+ }
+
+ /**
+ * Creates array of Entered Options
+ *
+ * @param array $options
+ * @return EnteredOption[]
+ */
+ private function createEnteredOptions(array $options): array
+ {
+ return \array_map(
+ function (array $option) {
+ if (!isset($option['uid'], $option['value'])) {
+ throw new InputException(
+ __('Required fields are not present EnteredOption.uid, EnteredOption.value')
+ );
+ }
+ return new EnteredOption($option['uid'], $option['value']);
+ },
+ $options
+ );
+ }
+
+ /**
+ * Creates array of Selected Options
+ *
+ * @param string[] $options
+ * @return SelectedOption[]
+ */
+ private function createSelectedOptions(array $options): array
+ {
+ return \array_map(
+ function ($option) {
+ return new SelectedOption($option);
+ },
+ $options
+ );
+ }
+}
diff --git a/app/code/Magento/Quote/Model/Cart/Data/EnteredOption.php b/app/code/Magento/Quote/Model/Cart/Data/EnteredOption.php
new file mode 100644
index 0000000000000..ba55051d33805
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/Data/EnteredOption.php
@@ -0,0 +1,54 @@
+uid = $uid;
+ $this->value = $value;
+ }
+
+ /**
+ * Returns entered option ID
+ *
+ * @return string
+ */
+ public function getUid(): string
+ {
+ return $this->uid;
+ }
+
+ /**
+ * Returns entered option value
+ *
+ * @return string
+ */
+ public function getValue(): string
+ {
+ return $this->value;
+ }
+}
diff --git a/app/code/Magento/Quote/Model/Cart/Data/Error.php b/app/code/Magento/Quote/Model/Cart/Data/Error.php
new file mode 100644
index 0000000000000..42b14b06d94aa
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/Data/Error.php
@@ -0,0 +1,71 @@
+message = $message;
+ $this->code = $code;
+ $this->cartItemPosition = $cartItemPosition;
+ }
+
+ /**
+ * Get error message
+ *
+ * @return string
+ */
+ public function getMessage(): string
+ {
+ return $this->message;
+ }
+
+ /**
+ * Get error code
+ *
+ * @return string
+ */
+ public function getCode(): string
+ {
+ return $this->code;
+ }
+
+ /**
+ * Get cart item position
+ *
+ * @return int
+ */
+ public function getCartItemPosition(): int
+ {
+ return $this->cartItemPosition;
+ }
+}
diff --git a/app/code/Magento/Quote/Model/Cart/Data/SelectedOption.php b/app/code/Magento/Quote/Model/Cart/Data/SelectedOption.php
new file mode 100644
index 0000000000000..70edd93cd8ef8
--- /dev/null
+++ b/app/code/Magento/Quote/Model/Cart/Data/SelectedOption.php
@@ -0,0 +1,37 @@
+id = $id;
+ }
+
+ /**
+ * Get selected option ID
+ *
+ * @return string
+ */
+ public function getId(): string
+ {
+ return $this->id;
+ }
+}
diff --git a/app/code/Magento/Quote/etc/graphql/di.xml b/app/code/Magento/Quote/etc/graphql/di.xml
new file mode 100644
index 0000000000000..0e688d42ecb32
--- /dev/null
+++ b/app/code/Magento/Quote/etc/graphql/di.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ - Magento\Quote\Model\Cart\BuyRequest\CustomizableOptionDataProvider
+
+
+
+
diff --git a/app/code/Magento/QuoteBundleOptions/Model/Cart/BuyRequest/BundleDataProvider.php b/app/code/Magento/QuoteBundleOptions/Model/Cart/BuyRequest/BundleDataProvider.php
new file mode 100644
index 0000000000000..575784c86ace1
--- /dev/null
+++ b/app/code/Magento/QuoteBundleOptions/Model/Cart/BuyRequest/BundleDataProvider.php
@@ -0,0 +1,94 @@
+getSelectedOptions() as $optionData) {
+ // phpcs:ignore Magento2.Functions.DiscouragedFunction
+ $optionData = \explode('/', base64_decode($optionData->getId()));
+
+ if ($this->isProviderApplicable($optionData) === false) {
+ continue;
+ }
+ $this->validateInput($optionData);
+
+ [$optionType, $optionId, $optionValueId, $optionQuantity] = $optionData;
+ if ($optionType == self::OPTION_TYPE) {
+ $bundleOptionsData['bundle_option'][$optionId] = $optionValueId;
+ $bundleOptionsData['bundle_option_qty'][$optionId] = $optionQuantity;
+ }
+ }
+ //for bundle options with custom quantity
+ foreach ($cartItem->getEnteredOptions() as $option) {
+ // phpcs:ignore Magento2.Functions.DiscouragedFunction
+ $optionData = \explode('/', base64_decode($option->getUid()));
+
+ if ($this->isProviderApplicable($optionData) === false) {
+ continue;
+ }
+ $this->validateInput($optionData);
+
+ [$optionType, $optionId, $optionValueId] = $optionData;
+ if ($optionType == self::OPTION_TYPE) {
+ $optionQuantity = $option->getValue();
+ $bundleOptionsData['bundle_option'][$optionId] = $optionValueId;
+ $bundleOptionsData['bundle_option_qty'][$optionId] = $optionQuantity;
+ }
+ }
+
+ return $bundleOptionsData;
+ }
+
+ /**
+ * Checks whether this provider is applicable for the current option
+ *
+ * @param array $optionData
+ * @return bool
+ */
+ private function isProviderApplicable(array $optionData): bool
+ {
+ if ($optionData[0] !== self::OPTION_TYPE) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validates the provided options structure
+ *
+ * @param array $optionData
+ * @throws LocalizedException
+ */
+ private function validateInput(array $optionData): void
+ {
+ if (count($optionData) !== 4) {
+ $errorMessage = __('Wrong format of the entered option data');
+ throw new LocalizedException($errorMessage);
+ }
+ }
+}
diff --git a/app/code/Magento/QuoteBundleOptions/README.md b/app/code/Magento/QuoteBundleOptions/README.md
new file mode 100644
index 0000000000000..3207eeaf2b683
--- /dev/null
+++ b/app/code/Magento/QuoteBundleOptions/README.md
@@ -0,0 +1,3 @@
+# QuoteBundleOptions
+
+**QuoteBundleOptions** provides data provider for creating buy request for bundle products.
diff --git a/app/code/Magento/QuoteBundleOptions/composer.json b/app/code/Magento/QuoteBundleOptions/composer.json
new file mode 100644
index 0000000000000..a2651272018a8
--- /dev/null
+++ b/app/code/Magento/QuoteBundleOptions/composer.json
@@ -0,0 +1,22 @@
+{
+ "name": "magento/module-quote-bundle-options",
+ "description": "Magento module provides data provider for creating buy request for bundle products",
+ "require": {
+ "php": "~7.3.0||~7.4.0",
+ "magento/framework": "*",
+ "magento/module-quote": "*"
+ },
+ "type": "magento2-module",
+ "license": [
+ "OSL-3.0",
+ "AFL-3.0"
+ ],
+ "autoload": {
+ "files": [
+ "registration.php"
+ ],
+ "psr-4": {
+ "Magento\\QuoteBundleOptions\\": ""
+ }
+ }
+}
diff --git a/app/code/Magento/QuoteBundleOptions/etc/graphql/di.xml b/app/code/Magento/QuoteBundleOptions/etc/graphql/di.xml
new file mode 100644
index 0000000000000..e15493e092e3b
--- /dev/null
+++ b/app/code/Magento/QuoteBundleOptions/etc/graphql/di.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ - Magento\QuoteBundleOptions\Model\Cart\BuyRequest\BundleDataProvider
+
+
+
+
diff --git a/app/code/Magento/QuoteBundleOptions/etc/module.xml b/app/code/Magento/QuoteBundleOptions/etc/module.xml
new file mode 100644
index 0000000000000..4dc531b561115
--- /dev/null
+++ b/app/code/Magento/QuoteBundleOptions/etc/module.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/code/Magento/QuoteBundleOptions/registration.php b/app/code/Magento/QuoteBundleOptions/registration.php
new file mode 100644
index 0000000000000..cf4c92fd929d9
--- /dev/null
+++ b/app/code/Magento/QuoteBundleOptions/registration.php
@@ -0,0 +1,10 @@
+getSelectedOptions() as $optionData) {
+ // phpcs:ignore Magento2.Functions.DiscouragedFunction
+ $optionData = \explode('/', base64_decode($optionData->getId()));
+
+ if ($this->isProviderApplicable($optionData) === false) {
+ continue;
+ }
+ $this->validateInput($optionData);
+
+ [$optionType, $attributeId, $valueIndex] = $optionData;
+ if ($optionType == self::OPTION_TYPE) {
+ $configurableProductData[$attributeId] = $valueIndex;
+ }
+ }
+
+ return ['super_attribute' => $configurableProductData];
+ }
+
+ /**
+ * Checks whether this provider is applicable for the current option
+ *
+ * @param array $optionData
+ * @return bool
+ */
+ private function isProviderApplicable(array $optionData): bool
+ {
+ if ($optionData[0] !== self::OPTION_TYPE) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validates the provided options structure
+ *
+ * @param array $optionData
+ * @throws LocalizedException
+ */
+ private function validateInput(array $optionData): void
+ {
+ if (count($optionData) !== 3) {
+ throw new LocalizedException(
+ __('Wrong format of the entered option data')
+ );
+ }
+ }
+}
diff --git a/app/code/Magento/QuoteConfigurableOptions/README.md b/app/code/Magento/QuoteConfigurableOptions/README.md
new file mode 100644
index 0000000000000..db47e2c37c3ff
--- /dev/null
+++ b/app/code/Magento/QuoteConfigurableOptions/README.md
@@ -0,0 +1,3 @@
+# QuoteConfigurableOptions
+
+**QuoteConfigurableOptions** provides data provider for creating buy request for configurable products.
diff --git a/app/code/Magento/QuoteConfigurableOptions/composer.json b/app/code/Magento/QuoteConfigurableOptions/composer.json
new file mode 100644
index 0000000000000..51d6933d5c6d6
--- /dev/null
+++ b/app/code/Magento/QuoteConfigurableOptions/composer.json
@@ -0,0 +1,22 @@
+{
+ "name": "magento/module-quote-configurable-options",
+ "description": "Magento module provides data provider for creating buy request for configurable products",
+ "require": {
+ "php": "~7.3.0||~7.4.0",
+ "magento/framework": "*",
+ "magento/module-quote": "*"
+ },
+ "type": "magento2-module",
+ "license": [
+ "OSL-3.0",
+ "AFL-3.0"
+ ],
+ "autoload": {
+ "files": [
+ "registration.php"
+ ],
+ "psr-4": {
+ "Magento\\QuoteConfigurableOptions\\": ""
+ }
+ }
+}
diff --git a/app/code/Magento/QuoteConfigurableOptions/etc/graphql/di.xml b/app/code/Magento/QuoteConfigurableOptions/etc/graphql/di.xml
new file mode 100644
index 0000000000000..c4fe6357a5689
--- /dev/null
+++ b/app/code/Magento/QuoteConfigurableOptions/etc/graphql/di.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ - Magento\QuoteConfigurableOptions\Model\Cart\BuyRequest\SuperAttributeDataProvider
+
+
+
+
diff --git a/app/code/Magento/QuoteConfigurableOptions/etc/module.xml b/app/code/Magento/QuoteConfigurableOptions/etc/module.xml
new file mode 100644
index 0000000000000..e32489c1b2109
--- /dev/null
+++ b/app/code/Magento/QuoteConfigurableOptions/etc/module.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/code/Magento/QuoteConfigurableOptions/registration.php b/app/code/Magento/QuoteConfigurableOptions/registration.php
new file mode 100644
index 0000000000000..0b55a18a81fce
--- /dev/null
+++ b/app/code/Magento/QuoteConfigurableOptions/registration.php
@@ -0,0 +1,10 @@
+getSelectedOptions() as $optionData) {
+ // phpcs:ignore Magento2.Functions.DiscouragedFunction
+ $optionData = \explode('/', base64_decode($optionData->getId()));
+
+ if ($this->isProviderApplicable($optionData) === false) {
+ continue;
+ }
+ $this->validateInput($optionData);
+
+ [$optionType, $linkId] = $optionData;
+ if ($optionType == self::OPTION_TYPE) {
+ $linksData[] = $linkId;
+ }
+ }
+
+ return ['links' => $linksData];
+ }
+
+ /**
+ * Checks whether this provider is applicable for the current option
+ *
+ * @param array $optionData
+ * @return bool
+ */
+ private function isProviderApplicable(array $optionData): bool
+ {
+ if ($optionData[0] !== self::OPTION_TYPE) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validates the provided options structure
+ *
+ * @param array $optionData
+ * @throws LocalizedException
+ */
+ private function validateInput(array $optionData): void
+ {
+ if (count($optionData) !== 2) {
+ throw new LocalizedException(
+ __('Wrong format of the entered option data')
+ );
+ }
+ }
+}
diff --git a/app/code/Magento/QuoteDownloadableLinks/README.md b/app/code/Magento/QuoteDownloadableLinks/README.md
new file mode 100644
index 0000000000000..68efffcea6fb8
--- /dev/null
+++ b/app/code/Magento/QuoteDownloadableLinks/README.md
@@ -0,0 +1,3 @@
+# QuoteDownloadableLinks
+
+**QuoteDownloadableLinks** provides data provider for creating buy request for links of downloadable products.
diff --git a/app/code/Magento/QuoteDownloadableLinks/composer.json b/app/code/Magento/QuoteDownloadableLinks/composer.json
new file mode 100644
index 0000000000000..ad120dea96263
--- /dev/null
+++ b/app/code/Magento/QuoteDownloadableLinks/composer.json
@@ -0,0 +1,22 @@
+{
+ "name": "magento/module-quote-downloadable-links",
+ "description": "Magento module provides data provider for creating buy request for links of downloadable products",
+ "require": {
+ "php": "~7.3.0||~7.4.0",
+ "magento/framework": "*",
+ "magento/module-quote": "*"
+ },
+ "type": "magento2-module",
+ "license": [
+ "OSL-3.0",
+ "AFL-3.0"
+ ],
+ "autoload": {
+ "files": [
+ "registration.php"
+ ],
+ "psr-4": {
+ "Magento\\QuoteDownloadableLinks\\": ""
+ }
+ }
+}
diff --git a/app/code/Magento/QuoteDownloadableLinks/etc/graphql/di.xml b/app/code/Magento/QuoteDownloadableLinks/etc/graphql/di.xml
new file mode 100644
index 0000000000000..a932d199983a3
--- /dev/null
+++ b/app/code/Magento/QuoteDownloadableLinks/etc/graphql/di.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ - Magento\QuoteDownloadableLinks\Model\Cart\BuyRequest\DownloadableLinkDataProvider
+
+
+
+
diff --git a/app/code/Magento/QuoteDownloadableLinks/etc/module.xml b/app/code/Magento/QuoteDownloadableLinks/etc/module.xml
new file mode 100644
index 0000000000000..a0cc652ab9188
--- /dev/null
+++ b/app/code/Magento/QuoteDownloadableLinks/etc/module.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/app/code/Magento/QuoteDownloadableLinks/registration.php b/app/code/Magento/QuoteDownloadableLinks/registration.php
new file mode 100644
index 0000000000000..8b766e7fde06c
--- /dev/null
+++ b/app/code/Magento/QuoteDownloadableLinks/registration.php
@@ -0,0 +1,10 @@
+getCartForUser = $getCartForUser;
+ $this->addProductsToCartService = $addProductsToCart;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null)
+ {
+ if (empty($args['cartId'])) {
+ throw new GraphQlInputException(__('Required parameter "cartId" is missing'));
+ }
+ if (empty($args['cartItems']) || !is_array($args['cartItems'])
+ ) {
+ throw new GraphQlInputException(__('Required parameter "cartItems" is missing'));
+ }
+
+ $maskedCartId = $args['cartId'];
+ $cartItemsData = $args['cartItems'];
+ $storeId = (int)$context->getExtensionAttributes()->getStore()->getId();
+
+ // Shopping Cart validation
+ $this->getCartForUser->execute($maskedCartId, $context->getUserId(), $storeId);
+
+ $cartItems = [];
+ foreach ($cartItemsData as $cartItemData) {
+ $cartItems[] = (new CartItemFactory())->create($cartItemData);
+ }
+
+ /** @var AddProductsToCartOutput $addProductsToCartOutput */
+ $addProductsToCartOutput = $this->addProductsToCartService->execute($maskedCartId, $cartItems);
+
+ return [
+ 'cart' => [
+ 'model' => $addProductsToCartOutput->getCart(),
+ ],
+ 'user_errors' => array_map(
+ function (Error $error) {
+ return [
+ 'code' => $error->getCode(),
+ 'message' => $error->getMessage(),
+ 'path' => [$error->getCartItemPosition()]
+ ];
+ },
+ $addProductsToCartOutput->getErrors()
+ )
+ ];
+ }
+}
diff --git a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls
index 955ee1cc2429a..4e0e7ce5732be 100644
--- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls
+++ b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls
@@ -22,6 +22,7 @@ type Mutation {
setPaymentMethodAndPlaceOrder(input: SetPaymentMethodAndPlaceOrderInput): PlaceOrderOutput @deprecated(reason: "Should use setPaymentMethodOnCart and placeOrder mutations in single request.") @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\SetPaymentAndPlaceOrder")
mergeCarts(source_cart_id: String!, destination_cart_id: String!): Cart! @doc(description:"Merges the source cart into the destination cart") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\MergeCarts")
placeOrder(input: PlaceOrderInput): PlaceOrderOutput @resolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\PlaceOrder")
+ addProductsToCart(cartId: String!, cartItems: [CartItemInput!]!): AddProductsToCartOutput @doc(description:"Add any type of product to the cart") @resolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\AddProductsToCart")
}
input createEmptyCartInput {
@@ -51,6 +52,9 @@ input VirtualProductCartItemInput {
input CartItemInput {
sku: String!
quantity: Float!
+ parent_sku: String @doc(description: "For child products, the SKU of its parent product")
+ selected_options: [ID!] @doc(description: "The selected options for the base product, such as color or size")
+ entered_options: [EnteredOptionInput!] @doc(description: "An array of entered options for the base product, such as personalization text")
}
input CustomizableOptionInput {
@@ -368,3 +372,21 @@ type Order {
order_number: String!
order_id: String @deprecated(reason: "The order_id field is deprecated, use order_number instead.")
}
+
+type CartUserInputError @doc(description:"An error encountered while adding an item to the the cart.") {
+ message: String! @doc(description: "A localized error message")
+ code: CartUserInputErrorType! @doc(description: "Cart-specific error code")
+}
+
+type AddProductsToCartOutput {
+ cart: Cart! @doc(description: "The cart after products have been added")
+ user_errors: [CartUserInputError!]! @doc(description: "An error encountered while adding an item to the cart.")
+}
+
+enum CartUserInputErrorType {
+ PRODUCT_NOT_FOUND
+ NOT_SALABLE
+ INSUFFICIENT_STOCK
+ UNDEFINED
+}
+
diff --git a/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/CustomizableOptionDataProvider.php b/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/CustomizableOptionDataProvider.php
index e8f5bf0654f64..8bf12206336a8 100644
--- a/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/CustomizableOptionDataProvider.php
+++ b/app/code/Magento/Wishlist/Model/Wishlist/BuyRequest/CustomizableOptionDataProvider.php
@@ -31,21 +31,25 @@ public function execute(WishlistItem $wishlistItemData, ?int $productId): array
continue;
}
- [, $optionId, $optionValue] = $optionData;
+ [$optionType, $optionId, $optionValue] = $optionData;
- $customizableOptionsData[$optionId][] = $optionValue;
+ if ($optionType == self::PROVIDER_OPTION_TYPE) {
+ $customizableOptionsData[$optionId][] = $optionValue;
+ }
}
foreach ($wishlistItemData->getEnteredOptions() as $option) {
- $optionData = \explode('/', base64_decode($option->getId()));
+ $optionData = \explode('/', base64_decode($option->getUid()));
if ($this->isProviderApplicable($optionData) === false) {
continue;
}
- [, $optionId] = $optionData;
+ [$optionType, $optionId] = $optionData;
- $customizableOptionsData[$optionId][] = $option->getValue();
+ if ($optionType == self::PROVIDER_OPTION_TYPE) {
+ $customizableOptionsData[$optionId][] = $option->getValue();
+ }
}
if (empty($customizableOptionsData)) {
diff --git a/app/code/Magento/Wishlist/Model/Wishlist/Data/EnteredOption.php b/app/code/Magento/Wishlist/Model/Wishlist/Data/EnteredOption.php
index 0d6b2a2302540..edbf84781da38 100644
--- a/app/code/Magento/Wishlist/Model/Wishlist/Data/EnteredOption.php
+++ b/app/code/Magento/Wishlist/Model/Wishlist/Data/EnteredOption.php
@@ -15,7 +15,7 @@ class EnteredOption
/**
* @var string
*/
- private $id;
+ private $uid;
/**
* @var string
@@ -23,12 +23,12 @@ class EnteredOption
private $value;
/**
- * @param string $id
+ * @param string $uid
* @param string $value
*/
- public function __construct(string $id, string $value)
+ public function __construct(string $uid, string $value)
{
- $this->id = $id;
+ $this->uid = $uid;
$this->value = $value;
}
@@ -37,9 +37,9 @@ public function __construct(string $id, string $value)
*
* @return string
*/
- public function getId(): string
+ public function getUid(): string
{
- return $this->id;
+ return $this->uid;
}
/**
diff --git a/app/code/Magento/Wishlist/Model/Wishlist/Data/WishlistItemFactory.php b/app/code/Magento/Wishlist/Model/Wishlist/Data/WishlistItemFactory.php
index 153e8451bae31..aef3cbf571ff6 100644
--- a/app/code/Magento/Wishlist/Model/Wishlist/Data/WishlistItemFactory.php
+++ b/app/code/Magento/Wishlist/Model/Wishlist/Data/WishlistItemFactory.php
@@ -45,12 +45,12 @@ private function createEnteredOptions(array $options): array
{
return \array_map(
function (array $option) {
- if (!isset($option['id'], $option['value'])) {
+ if (!isset($option['uid'], $option['value'])) {
throw new InputException(
- __('Required fields are not present EnteredOption.id, EnteredOption.value')
+ __('Required fields are not present EnteredOption.uid, EnteredOption.value')
);
}
- return new EnteredOption($option['id'], $option['value']);
+ return new EnteredOption($option['uid'], $option['value']);
},
$options
);
diff --git a/app/code/Magento/WishlistGraphQl/Model/Resolver/AddProductsToWishlist.php b/app/code/Magento/WishlistGraphQl/Model/Resolver/AddProductsToWishlist.php
index 11c8446a72a9d..3489585cd17d7 100644
--- a/app/code/Magento/WishlistGraphQl/Model/Resolver/AddProductsToWishlist.php
+++ b/app/code/Magento/WishlistGraphQl/Model/Resolver/AddProductsToWishlist.php
@@ -105,7 +105,7 @@ public function resolve(
return [
'wishlist' => $this->wishlistDataMapper->map($wishlistOutput->getWishlist()),
- 'userInputErrors' => array_map(
+ 'user_errors' => array_map(
function (Error $error) {
return [
'code' => $error->getCode(),
diff --git a/app/code/Magento/WishlistGraphQl/Model/Resolver/RemoveProductsFromWishlist.php b/app/code/Magento/WishlistGraphQl/Model/Resolver/RemoveProductsFromWishlist.php
index 1c741361ea7b7..a59c5ccdb0f70 100644
--- a/app/code/Magento/WishlistGraphQl/Model/Resolver/RemoveProductsFromWishlist.php
+++ b/app/code/Magento/WishlistGraphQl/Model/Resolver/RemoveProductsFromWishlist.php
@@ -109,7 +109,7 @@ public function resolve(
return [
'wishlist' => $this->wishlistDataMapper->map($wishlistOutput->getWishlist()),
- 'userInputErrors' => \array_map(
+ 'user_errors' => \array_map(
function (Error $error) {
return [
'code' => $error->getCode(),
diff --git a/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php b/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php
index 50a56863596c0..c6ede66fc2b1b 100644
--- a/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php
+++ b/app/code/Magento/WishlistGraphQl/Model/Resolver/UpdateProductsInWishlist.php
@@ -110,7 +110,7 @@ public function resolve(
return [
'wishlist' => $this->wishlistDataMapper->map($wishlistOutput->getWishlist()),
- 'userInputErrors' => \array_map(
+ 'user_errors' => \array_map(
function (Error $error) {
return [
'code' => $error->getCode(),
diff --git a/app/code/Magento/WishlistGraphQl/etc/schema.graphqls b/app/code/Magento/WishlistGraphQl/etc/schema.graphqls
index 794e90ed9f9a9..430e77cc45e96 100644
--- a/app/code/Magento/WishlistGraphQl/etc/schema.graphqls
+++ b/app/code/Magento/WishlistGraphQl/etc/schema.graphqls
@@ -43,34 +43,39 @@ input WishlistItemInput @doc(description: "Defines the items to add to a wish li
sku: String @doc(description: "The SKU of the product to add. For complex product types, specify the child product SKU")
quantity: Float @doc(description: "The amount or number of items to add")
parent_sku: String @doc(description: "For complex product types, the SKU of the parent product")
- selected_options: [String!] @doc(description: "An array of strings corresponding to options the customer selected")
+ selected_options: [ID!] @doc(description: "An array of strings corresponding to options the customer selected")
entered_options: [EnteredOptionInput!] @doc(description: "An array of options that the customer entered")
}
type AddProductsToWishlistOutput @doc(description: "Contains the customer's wish list and any errors encountered") {
wishlist: Wishlist! @doc(description: "Contains the wish list with all items that were successfully added")
- userInputErrors:[CheckoutUserInputError]! @doc(description: "An array of errors encountered while adding products to a wish list")
-}
-
-input EnteredOptionInput @doc(description: "Defines a customer-entered option") {
- id: String! @doc(description: "A base64 encoded ID")
- value: String! @doc(description: "Text the customer entered")
+ user_errors:[WishListUserInputError!]! @doc(description: "An array of errors encountered while adding products to a wish list")
}
type RemoveProductsFromWishlistOutput @doc(description: "Contains the customer's wish list and any errors encountered") {
wishlist: Wishlist! @doc(description: "Contains the wish list with after items were successfully deleted")
- userInputErrors:[CheckoutUserInputError]! @doc(description:"An array of errors encountered while deleting products from a wish list")
+ user_errors:[WishListUserInputError!]! @doc(description:"An array of errors encountered while deleting products from a wish list")
}
input WishlistItemUpdateInput @doc(description: "Defines updates to items in a wish list") {
wishlist_item_id: ID @doc(description: "The ID of the wishlist item to update")
quantity: Float @doc(description: "The new amount or number of this item")
description: String @doc(description: "Describes the update")
- selected_options: [String!] @doc(description: "An array of strings corresponding to options the customer selected")
+ selected_options: [ID!] @doc(description: "An array of strings corresponding to options the customer selected")
entered_options: [EnteredOptionInput!] @doc(description: "An array of options that the customer entered")
}
type UpdateProductsInWishlistOutput @doc(description: "Contains the customer's wish list and any errors encountered") {
wishlist: Wishlist! @doc(description: "Contains the wish list with all items that were successfully updated")
- userInputErrors:[CheckoutUserInputError]! @doc(description:"An array of errors encountered while updating products in a wish list")
+ user_errors: [WishListUserInputError!]! @doc(description:"An array of errors encountered while updating products in a wish list")
+}
+
+type WishListUserInputError @doc(description:"An error encountered while performing operations with WishList.") {
+ message: String! @doc(description: "A localized error message")
+ code: WishListUserInputErrorType! @doc(description: "Wishlist-specific error code")
+}
+
+enum WishListUserInputErrorType {
+ PRODUCT_NOT_FOUND
+ UNDEFINED
}
diff --git a/composer.json b/composer.json
index 5b39c1b3f75ea..c270fa0017f7f 100644
--- a/composer.json
+++ b/composer.json
@@ -242,6 +242,9 @@
"magento/module-product-video": "*",
"magento/module-quote": "*",
"magento/module-quote-analytics": "*",
+ "magento/module-quote-bundle-options": "*",
+ "magento/module-quote-configurable-options": "*",
+ "magento/module-quote-downloadable-links": "*",
"magento/module-quote-graph-ql": "*",
"magento/module-related-product-graph-ql": "*",
"magento/module-release-notification": "*",
diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartSingleMutationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartSingleMutationTest.php
new file mode 100644
index 0000000000000..fc0fdcf71525f
--- /dev/null
+++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartSingleMutationTest.php
@@ -0,0 +1,351 @@
+quoteResource = $objectManager->get(QuoteResource::class);
+ $this->quote = $objectManager->create(Quote::class);
+ $this->quoteIdToMaskedId = $objectManager->get(QuoteIdToMaskedQuoteIdInterface::class);
+ $this->productRepository = $objectManager->get(ProductRepositoryInterface::class);
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/Bundle/_files/product_1.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddBundleProductToCart()
+ {
+ $sku = 'bundle-product';
+
+ $this->quoteResource->load(
+ $this->quote,
+ 'test_order_1',
+ 'reserved_order_id'
+ );
+
+ $product = $this->productRepository->get($sku);
+
+ /** @var $typeInstance \Magento\Bundle\Model\Product\Type */
+ $typeInstance = $product->getTypeInstance();
+ $typeInstance->setStoreFilter($product->getStoreId(), $product);
+ /** @var $option \Magento\Bundle\Model\Option */
+ $option = $typeInstance->getOptionsCollection($product)->getFirstItem();
+ /** @var \Magento\Catalog\Model\Product $selection */
+ $selection = $typeInstance->getSelectionsCollection([$option->getId()], $product)->getFirstItem();
+ $optionId = $option->getId();
+ $selectionId = $selection->getSelectionId();
+
+ $bundleOptionIdV2 = $this->generateBundleOptionIdV2((int) $optionId, (int) $selectionId, 1);
+ $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId());
+
+ $query = <<graphQlMutation($query);
+
+ self::assertArrayHasKey('addProductsToCart', $response);
+ self::assertArrayHasKey('cart', $response['addProductsToCart']);
+ $cart = $response['addProductsToCart']['cart'];
+ $bundleItem = current($cart['items']);
+ self::assertEquals($sku, $bundleItem['product']['sku']);
+ $bundleItemOption = current($bundleItem['bundle_options']);
+ self::assertEquals($optionId, $bundleItemOption['id']);
+ self::assertEquals($option->getTitle(), $bundleItemOption['label']);
+ self::assertEquals($option->getType(), $bundleItemOption['type']);
+ $value = current($bundleItemOption['values']);
+ self::assertEquals($selection->getSelectionId(), $value['id']);
+ self::assertEquals((float) $selection->getSelectionPriceValue(), $value['price']);
+ self::assertEquals(1, $value['quantity']);
+ }
+
+ /**
+ * @param int $optionId
+ * @param int $selectionId
+ * @param int $quantity
+ * @return string
+ */
+ private function generateBundleOptionIdV2(int $optionId, int $selectionId, int $quantity): string
+ {
+ return base64_encode("bundle/$optionId/$selectionId/$quantity");
+ }
+
+ public function dataProviderTestUpdateBundleItemQuantity(): array
+ {
+ return [
+ [2],
+ [0],
+ ];
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/Bundle/_files/product_1.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ * @expectedExceptionMessage Please select all required options
+ */
+ public function testAddBundleToCartWithWrongBundleOptions()
+ {
+ $this->quoteResource->load(
+ $this->quote,
+ 'test_order_1',
+ 'reserved_order_id'
+ );
+
+ $bundleOptionIdV2 = $this->generateBundleOptionIdV2((int) 1, (int) 1, 1);
+ $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId());
+
+ $query = <<graphQlMutation($query);
+
+ self::assertEquals(
+ "Please select all required options.",
+ $response['addProductsToCart']['user_errors'][0]['message']
+ );
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddBundleItemWithCustomOptionQuantity()
+ {
+
+ $this->quoteResource->load(
+ $this->quote,
+ 'test_order_1',
+ 'reserved_order_id'
+ );
+ $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId());
+ $response = $this->graphQlQuery($this->getProductQuery("bundle-product"));
+ $bundleItem = $response['products']['items'][0];
+ $sku = $bundleItem['sku'];
+ $bundleOptions = $bundleItem['items'];
+
+ $uId0 = $bundleOptions[0]['options'][0]['uid'];
+ $uId1 = $bundleOptions[1]['options'][0]['uid'];
+ $response = $this->graphQlMutation(
+ $this->getMutationsQuery($maskedQuoteId, $uId0, $uId1, $sku)
+ );
+ $bundleOptions = $response['addProductsToCart']['cart']['items'][0]['bundle_options'];
+ $this->assertEquals(5, $bundleOptions[0]['values'][0]['quantity']);
+ $this->assertEquals(1, $bundleOptions[1]['values'][0]['quantity']);
+ }
+
+ /**
+ * Returns GraphQL query for retrieving a product with customizable options
+ *
+ * @param string $sku
+ * @return string
+ */
+ private function getProductQuery(string $sku): string
+ {
+ return <<getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class);
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddConfigurableProductToCart()
+ {
+ $product = $this->getConfigurableProductInfo();
+ $quantity = 2;
+ $parentSku = $product['sku'];
+ $attributeId = (int) $product['configurable_options'][0]['attribute_id'];
+ $valueIndex = $product['configurable_options'][0]['values'][1]['value_index'];
+
+ $selectedConfigurableOptionsQuery = $this->generateSuperAttributesUIDQuery($attributeId, $valueIndex);
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+
+ $query = $this->getQuery(
+ $maskedQuoteId,
+ $product['sku'],
+ 2,
+ $selectedConfigurableOptionsQuery
+ );
+
+ $response = $this->graphQlMutation($query);
+
+ $cartItem = current($response['addProductsToCart']['cart']['items']);
+ self::assertEquals($quantity, $cartItem['quantity']);
+ self::assertEquals($parentSku, $cartItem['product']['sku']);
+ self::assertArrayHasKey('configurable_options', $cartItem);
+
+ $option = current($cartItem['configurable_options']);
+ self::assertEquals($attributeId, $option['id']);
+ self::assertEquals($valueIndex, $option['value_id']);
+ self::assertArrayHasKey('option_label', $option);
+ self::assertArrayHasKey('value_label', $option);
+ }
+
+ /**
+ * Generates UID for super configurable product super attributes
+ *
+ * @param int $attributeId
+ * @param int $valueIndex
+ * @return string
+ */
+ private function generateSuperAttributesUIDQuery(int $attributeId, int $valueIndex): string
+ {
+ return 'selected_options: ["' . base64_encode("configurable/$attributeId/$valueIndex") . '"]';
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/Catalog/_files/configurable_products_with_custom_attribute_layered_navigation.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddConfigurableProductWithWrongSuperAttributes()
+ {
+ $product = $this->getConfigurableProductInfo();
+ $quantity = 2;
+ $parentSku = $product['sku'];
+
+ $selectedConfigurableOptionsQuery = $this->generateSuperAttributesUIDQuery(0, 0);
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+
+ $query = $this->getQuery(
+ $maskedQuoteId,
+ $parentSku,
+ $quantity,
+ $selectedConfigurableOptionsQuery
+ );
+
+ $response = $this->graphQlMutation($query);
+
+ self::assertEquals(
+ 'You need to choose options for your item.',
+ $response['addProductsToCart']['user_errors'][0]['message']
+ );
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_sku.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddProductIfQuantityIsNotAvailable()
+ {
+ $product = $this->getConfigurableProductInfo();
+ $parentSku = $product['sku'];
+ $attributeId = (int) $product['configurable_options'][0]['attribute_id'];
+ $valueIndex = $product['configurable_options'][0]['values'][1]['value_index'];
+
+ $selectedConfigurableOptionsQuery = $this->generateSuperAttributesUIDQuery($attributeId, $valueIndex);
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+
+ $query = $this->getQuery(
+ $maskedQuoteId,
+ $parentSku,
+ 2000,
+ $selectedConfigurableOptionsQuery
+ );
+
+ $response = $this->graphQlMutation($query);
+
+ self::assertEquals(
+ 'The requested qty is not available',
+ $response['addProductsToCart']['user_errors'][0]['message']
+ );
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_sku.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddNonExistentConfigurableProductParentToCart()
+ {
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+ $parentSku = 'configurable_no_exist';
+
+ $query = $this->getQuery(
+ $maskedQuoteId,
+ $parentSku,
+ 1,
+ ''
+ );
+
+ $response = $this->graphQlMutation($query);
+
+ self::assertEquals(
+ 'Could not find a product with SKU "configurable_no_exist"',
+ $response['addProductsToCart']['user_errors'][0]['message']
+ );
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/ConfigurableProduct/_files/product_configurable_zero_qty_first_child.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testOutOfStockVariationToCart()
+ {
+ $product = $this->getConfigurableProductInfo();
+ $attributeId = (int) $product['configurable_options'][0]['attribute_id'];
+ $valueIndex = $product['configurable_options'][0]['values'][0]['value_index'];
+ $parentSku = $product['sku'];
+
+ $configurableOptionsQuery = $this->generateSuperAttributesUIDQuery($attributeId, $valueIndex);
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+
+ $query = $this->getQuery(
+ $maskedQuoteId,
+ $parentSku,
+ 1,
+ $configurableOptionsQuery
+ );
+
+ $response = $this->graphQlMutation($query);
+
+ $expectedErrorMessages = [
+ 'There are no source items with the in stock status',
+ 'This product is out of stock.'
+ ];
+ $this->assertContains(
+ $response['addProductsToCart']['user_errors'][0]['message'],
+ $expectedErrorMessages
+ );
+ }
+
+ /**
+ * @param string $maskedQuoteId
+ * @param string $parentSku
+ * @param int $quantity
+ * @param string $selectedOptionsQuery
+ * @return string
+ */
+ private function getQuery(
+ string $maskedQuoteId,
+ string $parentSku,
+ int $quantity,
+ string $selectedOptionsQuery
+ ): string {
+ return <<graphQlQuery($this->getFetchProductQuery('configurable'));
+ return current($searchResponse['products']['items']);
+ }
+
+ /**
+ * Returns GraphQl query for fetching configurable product information
+ *
+ * @param string $term
+ * @return string
+ */
+ private function getFetchProductQuery(string $term): string
+ {
+ return <<objectManager = Bootstrap::getObjectManager();
+ $this->getMaskedQuoteIdByReservedOrderId = $this->objectManager->get(GetMaskedQuoteIdByReservedOrderId::class);
+ $this->getCartItemOptionsFromUID = $this->objectManager->get(GetCartItemOptionsFromUID::class);
+ $this->getCustomOptionsWithIDV2ForQueryBySku =
+ $this->objectManager->get(GetCustomOptionsWithUIDForQueryBySku::class);
+ }
+
+ /**
+ * @magentoApiDataFixture Magento/Downloadable/_files/product_downloadable_with_custom_options.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddDownloadableProductWithOptions()
+ {
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+
+ $sku = 'downloadable-product-with-purchased-separately-links';
+ $qty = 1;
+ $links = $this->getProductsLinks($sku);
+ $linkId = key($links);
+
+ $itemOptions = $this->getCustomOptionsWithIDV2ForQueryBySku->execute($sku);
+ $decodedItemOptions = $this->getCartItemOptionsFromUID->execute($itemOptions);
+
+ /* The type field is only required for assertions, it should not be present in query */
+ foreach ($itemOptions['entered_options'] as &$enteredOption) {
+ if (isset($enteredOption['type'])) {
+ unset($enteredOption['type']);
+ }
+ }
+
+ /* Add downloadable product link data to the "selected_options" */
+ $itemOptions['selected_options'][] = $this->generateProductLinkSelectedOptions($linkId);
+
+ $productOptionsQuery = preg_replace(
+ '/"([^"]+)"\s*:\s*/',
+ '$1:',
+ json_encode($itemOptions)
+ );
+
+ $query = $this->getQuery($maskedQuoteId, $qty, $sku, trim($productOptionsQuery, '{}'));
+ $response = $this->graphQlMutation($query);
+
+ self::assertArrayHasKey('items', $response['addProductsToCart']['cart']);
+ self::assertCount($qty, $response['addProductsToCart']['cart']);
+ self::assertEquals($linkId, $response['addProductsToCart']['cart']['items'][0]['links'][0]['id']);
+
+ $customizableOptionsOutput =
+ $response['addProductsToCart']['cart']['items'][0]['customizable_options'];
+
+ foreach ($customizableOptionsOutput as $customizableOptionOutput) {
+ $customizableOptionOutputValues = [];
+ foreach ($customizableOptionOutput['values'] as $customizableOptionOutputValue) {
+ $customizableOptionOutputValues[] = $customizableOptionOutputValue['value'];
+ }
+ if (count($customizableOptionOutputValues) === 1) {
+ $customizableOptionOutputValues = $customizableOptionOutputValues[0];
+ }
+
+ self::assertEquals(
+ $decodedItemOptions[$customizableOptionOutput['id']],
+ $customizableOptionOutputValues
+ );
+ }
+ }
+
+ /**
+ * Function returns array of all product's links
+ *
+ * @param string $sku
+ * @return array
+ */
+ private function getProductsLinks(string $sku) : array
+ {
+ $result = [];
+ $productRepository = $this->objectManager->get(ProductRepositoryInterface::class);
+
+ $product = $productRepository->get($sku, false, null, true);
+
+ foreach ($product->getDownloadableLinks() as $linkObject) {
+ $result[$linkObject->getLinkId()] = [
+ 'title' => $linkObject->getTitle(),
+ 'link_type' => null, //deprecated field
+ 'price' => $linkObject->getPrice(),
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Generates UID for downloadable links
+ *
+ * @param int $linkId
+ * @return string
+ */
+ private function generateProductLinkSelectedOptions(int $linkId): string
+ {
+ return base64_encode("downloadable/$linkId");
+ }
+
+ /**
+ * Returns GraphQl query string
+ *
+ * @param string $maskedQuoteId
+ * @param int $qty
+ * @param string $sku
+ * @param string $customizableOptions
+ * @return string
+ */
+ private function getQuery(
+ string $maskedQuoteId,
+ int $qty,
+ string $sku,
+ string $customizableOptions
+ ): string {
+ return <<getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class);
+ $this->getCartItemOptionsFromUID = $objectManager->get(GetCartItemOptionsFromUID::class);
+ $this->getCustomOptionsWithIDV2ForQueryBySku = $objectManager->get(
+ GetCustomOptionsWithUIDForQueryBySku::class
+ );
+ }
+
+ /**
+ * Test adding a simple product to the shopping cart with all supported
+ * customizable options assigned
+ *
+ * @magentoApiDataFixture Magento/Catalog/_files/product_simple_with_options.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddSimpleProductWithOptions()
+ {
+ $sku = 'simple';
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+ $qty = 1;
+
+ $productOptionsData = $this->getProductOptionsViaQuery($sku);
+
+ $itemOptionsQuery = preg_replace(
+ '/"([^"]+)"\s*:\s*/',
+ '$1:',
+ json_encode($productOptionsData['received_options'])
+ );
+
+ $query = $this->getAddToCartMutation($maskedQuoteId, $qty, $sku, trim($itemOptionsQuery, '{}'));
+ $response = $this->graphQlMutation($query);
+
+ self::assertArrayHasKey('customizable_options', $response['addProductsToCart']['cart']['items'][0]);
+
+ foreach ($response['addProductsToCart']['cart']['items'][0]['customizable_options'] as $option) {
+ self::assertEquals($productOptionsData['expected_options'][$option['id']], $option['values'][0]['value']);
+ }
+ }
+
+ /**
+ * Get product data with customizable options using GraphQl query
+ *
+ * @param string $sku
+ * @return array
+ * @throws \Exception
+ */
+ private function getProductOptionsViaQuery(string $sku): array
+ {
+ $query = $this->getProductQuery($sku);
+ $response = $this->graphQlQuery($query);
+ self::assertArrayHasKey('options', $response['products']['items'][0]);
+
+ $expectedItemOptions = [];
+ $receivedItemOptions = [
+ 'entered_options' => [],
+ 'selected_options' => []
+ ];
+
+ foreach ($response['products']['items'][0]['options'] as $option) {
+ if (isset($option['entered_option'])) {
+ /* The date normalization is required since the attribute might value is formatted by the system */
+ if ($option['title'] === 'date option') {
+ $value = '2012-12-12 00:00:00';
+ $expectedItemOptions[$option['option_id']] = date('M d, Y', strtotime($value));
+ } else {
+ $value = 'test';
+ $expectedItemOptions[$option['option_id']] = $value;
+ }
+ $value = $option['title'] === 'date option' ? '2012-12-12 00:00:00' : 'test';
+
+ $receivedItemOptions['entered_options'][] = [
+ 'uid' => $option['entered_option']['uid'],
+ 'value' => $value
+ ];
+
+ } elseif (isset($option['selected_option'])) {
+ $receivedItemOptions['selected_options'][] = reset($option['selected_option'])['uid'];
+ $expectedItemOptions[$option['option_id']] = reset($option['selected_option'])['option_type_id'];
+ }
+ }
+
+ return [
+ 'expected_options' => $expectedItemOptions,
+ 'received_options' => $receivedItemOptions
+ ];
+ }
+
+ /**
+ * Returns GraphQL query for retrieving a product with customizable options
+ *
+ * @param string $sku
+ * @return string
+ */
+ private function getProductQuery(string $sku): string
+ {
+ return <<getMaskedQuoteIdByReservedOrderId = $objectManager->get(GetMaskedQuoteIdByReservedOrderId::class);
+ $this->getCartItemOptionsFromUID = $objectManager->get(GetCartItemOptionsFromUID::class);
+ $this->getCustomOptionsWithIDV2ForQueryBySku = $objectManager->get(
+ GetCustomOptionsWithUIDForQueryBySku::class
+ );
+ }
+
+ /**
+ * Test adding a simple product to the shopping cart with all supported
+ * customizable options assigned
+ *
+ * @magentoApiDataFixture Magento/Catalog/_files/product_simple_with_options.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddSimpleProductWithOptions()
+ {
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+
+ $sku = 'simple';
+ $qty = 1;
+
+ $itemOptions = $this->getCustomOptionsWithIDV2ForQueryBySku->execute($sku);
+ $decodedItemOptions = $this->getCartItemOptionsFromUID->execute($itemOptions);
+
+ /* The type field is only required for assertions, it should not be present in query */
+ foreach ($itemOptions['entered_options'] as &$enteredOption) {
+ if (isset($enteredOption['type'])) {
+ unset($enteredOption['type']);
+ }
+ }
+
+ $productOptionsQuery = preg_replace(
+ '/"([^"]+)"\s*:\s*/',
+ '$1:',
+ json_encode($itemOptions)
+ );
+
+ $query = $this->getAddToCartMutation($maskedQuoteId, $qty, $sku, trim($productOptionsQuery, '{}'));
+ $response = $this->graphQlMutation($query);
+
+ self::assertArrayHasKey('items', $response['addProductsToCart']['cart']);
+ self::assertCount($qty, $response['addProductsToCart']['cart']['items']);
+ $customizableOptionsOutput =
+ $response['addProductsToCart']['cart']['items'][0]['customizable_options'];
+
+ foreach ($customizableOptionsOutput as $customizableOptionOutput) {
+ $customizableOptionOutputValues = [];
+ foreach ($customizableOptionOutput['values'] as $customizableOptionOutputValue) {
+ $customizableOptionOutputValues[] = $customizableOptionOutputValue['value'];
+ }
+ if (count($customizableOptionOutputValues) === 1) {
+ $customizableOptionOutputValues = $customizableOptionOutputValues[0];
+ }
+
+ self::assertEquals(
+ $decodedItemOptions[$customizableOptionOutput['id']],
+ $customizableOptionOutputValues
+ );
+ }
+ }
+
+ /**
+ * @param string $sku
+ * @param string $message
+ *
+ * @dataProvider wrongSkuDataProvider
+ *
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddProductWithWrongSku(string $sku, string $message)
+ {
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+
+ $query = $this->getAddToCartMutation($maskedQuoteId, 1, $sku, '');
+ $response = $this->graphQlMutation($query);
+
+ self::assertArrayHasKey('user_errors', $response['addProductsToCart']);
+ self::assertCount(1, $response['addProductsToCart']['user_errors']);
+ self::assertEquals(
+ $message,
+ $response['addProductsToCart']['user_errors'][0]['message']
+ );
+ }
+
+ /**
+ * The test covers the case when upon adding available_qty + 1 to the shopping cart, the cart is being
+ * cleared
+ *
+ * @magentoApiDataFixture Magento/Catalog/_files/product_simple_without_custom_options.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddToCartWithQtyPlusOne()
+ {
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+ $sku = 'simple-2';
+
+ $query = $this->getAddToCartMutation($maskedQuoteId, 100, $sku, '');
+ $response = $this->graphQlMutation($query);
+
+ self::assertEquals(100, $response['addProductsToCart']['cart']['total_quantity']);
+
+ $query = $this->getAddToCartMutation($maskedQuoteId, 1, $sku, '');
+ $response = $this->graphQlMutation($query);
+
+ self::assertArrayHasKey('user_errors', $response['addProductsToCart']);
+ self::assertEquals(
+ 'The requested qty is not available',
+ $response['addProductsToCart']['user_errors'][0]['message']
+ );
+ self::assertEquals(100, $response['addProductsToCart']['cart']['total_quantity']);
+ }
+
+ /**
+ * @param int $quantity
+ * @param string $message
+ *
+ * @dataProvider wrongQuantityDataProvider
+ *
+ * @magentoApiDataFixture Magento/Catalog/_files/product_simple_without_custom_options.php
+ * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php
+ */
+ public function testAddProductWithWrongQuantity(int $quantity, string $message)
+ {
+ $maskedQuoteId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_order_1');
+ $sku = 'simple-2';
+
+ $query = $this->getAddToCartMutation($maskedQuoteId, $quantity, $sku, '');
+ $response = $this->graphQlMutation($query);
+ self::assertArrayHasKey('user_errors', $response['addProductsToCart']);
+ self::assertCount(1, $response['addProductsToCart']['user_errors']);
+
+ self::assertEquals(
+ $message,
+ $response['addProductsToCart']['user_errors'][0]['message']
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public function wrongSkuDataProvider(): array
+ {
+ return [
+ 'Non-existent SKU' => [
+ 'non-existent',
+ 'Could not find a product with SKU "non-existent"'
+ ],
+ 'Empty SKU' => [
+ '',
+ 'Could not find a product with SKU ""'
+ ]
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function wrongQuantityDataProvider(): array
+ {
+ return [
+ 'More quantity than in stock' => [
+ 101,
+ 'The requested qty is not available'
+ ],
+ 'Quantity equals zero' => [
+ 0,
+ 'The product quantity should be greater than 0'
+ ]
+ ];
+ }
+
+ /**
+ * Returns GraphQl query string
+ *
+ * @param string $maskedQuoteId
+ * @param int $qty
+ * @param string $sku
+ * @param string $customizableOptions
+ * @return string
+ */
+ private function getAddToCartMutation(
+ string $maskedQuoteId,
+ int $qty,
+ string $sku,
+ string $customizableOptions
+ ): string {
+ return <<productCustomOptionRepository = $productCustomOptionRepository;
+ }
+
+ /**
+ * Returns array of custom options for the product
+ *
+ * @param string $sku
+ * @return array
+ */
+ public function execute(string $sku): array
+ {
+ $customOptions = $this->productCustomOptionRepository->getList($sku);
+ $selectedOptions = [];
+ $enteredOptions = [];
+
+ foreach ($customOptions as $customOption) {
+ $optionType = $customOption->getType();
+
+ switch ($optionType) {
+ case 'field':
+ case 'area':
+ $enteredOptions[] = [
+ 'type' => 'field',
+ 'uid' => $this->encodeEnteredOption((int) $customOption->getOptionId()),
+ 'value' => 'test'
+ ];
+ break;
+ case 'date':
+ $enteredOptions[] = [
+ 'type' => 'date',
+ 'uid' => $this->encodeEnteredOption((int) $customOption->getOptionId()),
+ 'value' => '2012-12-12 00:00:00'
+ ];
+ break;
+ case 'drop_down':
+ $optionSelectValues = $customOption->getValues();
+ $selectedOptions[] = $this->encodeSelectedOption(
+ (int) $customOption->getOptionId(),
+ (int) reset($optionSelectValues)->getOptionTypeId()
+ );
+ break;
+ case 'multiple':
+ foreach ($customOption->getValues() as $optionValue) {
+ $selectedOptions[] = $this->encodeSelectedOption(
+ (int) $customOption->getOptionId(),
+ (int) $optionValue->getOptionTypeId()
+ );
+ }
+ break;
+ }
+ }
+
+ return [
+ 'selected_options' => $selectedOptions,
+ 'entered_options' => $enteredOptions
+ ];
+ }
+
+ /**
+ * Returns UID of the selected custom option
+ *
+ * @param int $optionId
+ * @param int $optionValueId
+ * @return string
+ */
+ private function encodeSelectedOption(int $optionId, int $optionValueId): string
+ {
+ return base64_encode("custom-option/$optionId/$optionValueId");
+ }
+
+ /**
+ * Returns UID of the entered custom option
+ *
+ * @param int $optionId
+ * @return string
+ */
+ private function encodeEnteredOption(int $optionId): string
+ {
+ return base64_encode("custom-option/$optionId");
+ }
+}
diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php
index c0199e8908d0e..a81ec701b22a8 100644
--- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php
+++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddBundleProductToWishlistTest.php
@@ -140,7 +140,7 @@ private function getQuery(
}
]
) {
- userInputErrors {
+ user_errors {
code
message
}
diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddConfigurableProductToWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddConfigurableProductToWishlistTest.php
index 386df99f0d211..d8d44541f899d 100644
--- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddConfigurableProductToWishlistTest.php
+++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddConfigurableProductToWishlistTest.php
@@ -126,7 +126,7 @@ private function getQuery(
}
]
) {
- userInputErrors {
+ user_errors {
code
message
}
diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddDownloadableProductToWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddDownloadableProductToWishlistTest.php
index 389f4eae4c574..489a960056f1b 100644
--- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddDownloadableProductToWishlistTest.php
+++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddDownloadableProductToWishlistTest.php
@@ -181,7 +181,7 @@ private function getQuery(
}
]
) {
- userInputErrors {
+ user_errors {
code
message
}
diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php
index 2e203e3ff4228..ebe99289b8934 100644
--- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php
+++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/DeleteProductsFromWishlistTest.php
@@ -90,7 +90,7 @@ private function getQuery(
wishlistId: {$wishlistId},
wishlistItemsIds: [{$wishlistItemId}]
) {
- userInputErrors {
+ user_errors {
code
message
}
diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/GetCustomOptionsWithIDV2ForQueryBySku.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/GetCustomOptionsWithIDV2ForQueryBySku.php
index 6d54d9f0b4444..fcba7458f317a 100644
--- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/GetCustomOptionsWithIDV2ForQueryBySku.php
+++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/GetCustomOptionsWithIDV2ForQueryBySku.php
@@ -45,7 +45,7 @@ public function execute(string $sku): array
if ($optionType === 'field' || $optionType === 'area' || $optionType === 'date') {
$enteredOptions[] = [
- 'id' => $this->encodeEnteredOption((int)$customOption->getOptionId()),
+ 'uid' => $this->encodeEnteredOption((int)$customOption->getOptionId()),
'value' => '2012-12-12'
];
} elseif ($optionType === 'drop_down') {
diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php
index 9e96bdc5d7079..9a9cd424e54ca 100644
--- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php
+++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/UpdateProductsFromWishlistTest.php
@@ -102,7 +102,7 @@ private function getQuery(
}
]
) {
- userInputErrors {
+ user_errors {
code
message
}
diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php
new file mode 100644
index 0000000000000..a623c583fb599
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity.php
@@ -0,0 +1,137 @@
+requireDataFixture('Magento/Catalog/_files/multiple_products.php');
+
+$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
+
+$productIds = range(10, 12, 1);
+foreach ($productIds as $productId) {
+ /** @var \Magento\CatalogInventory\Model\Stock\Item $stockItem */
+ $stockItem = $objectManager->create(\Magento\CatalogInventory\Model\Stock\Item::class);
+ $stockItem->load($productId, 'product_id');
+
+ if (!$stockItem->getProductId()) {
+ $stockItem->setProductId($productId);
+ }
+ $stockItem->setUseConfigManageStock(1);
+ $stockItem->setQty(1000);
+ $stockItem->setIsQtyDecimal(0);
+ $stockItem->setIsInStock(1);
+ $stockItem->save();
+}
+
+/** @var $product \Magento\Catalog\Model\Product */
+$product = $objectManager->create(\Magento\Catalog\Model\Product::class);
+$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_BUNDLE)
+ ->setId(3)
+ ->setAttributeSetId(4)
+ ->setName('Bundle Product')
+ ->setSku('bundle-product')
+ ->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH)
+ ->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED)
+ ->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1])
+ ->setWebsiteIds([1])
+ ->setPriceType(1)
+ ->setPrice(10.0)
+ ->setShipmentType(0)
+ ->setPriceView(1)
+ ->setBundleOptionsData(
+ [
+ // Required "Drop-down" option
+ [
+ 'title' => 'Option 1',
+ 'default_title' => 'Option 1',
+ 'type' => 'select',
+ 'required' => 1,
+ 'position' => 1,
+ 'delete' => '',
+ ],
+ // Required "Radio Buttons" option
+ [
+ 'title' => 'Option 2',
+ 'default_title' => 'Option 2',
+ 'type' => 'radio',
+ 'required' => 1,
+ 'position' => 2,
+ 'delete' => '',
+ ],
+ ]
+ )->setBundleSelectionsData(
+ [
+ [
+ [
+ 'product_id' => 10,
+ 'selection_qty' => 1,
+ 'selection_can_change_qty' => 1,
+ 'delete' => '',
+ 'option_id' => 1
+ ],
+ [
+ 'product_id' => 11,
+ 'selection_qty' => 1,
+ 'selection_can_change_qty' => 1,
+ 'delete' => '',
+ 'option_id' => 1
+ ]
+ ],
+ [
+ [
+ 'product_id' => 10,
+ 'selection_qty' => 1,
+ 'selection_can_change_qty' => 0,
+ 'delete' => '',
+ 'option_id' => 2
+ ],
+ [
+ 'product_id' => 11,
+ 'selection_qty' => 1,
+ 'selection_can_change_qty' => 0,
+ 'delete' => '',
+ 'option_id' => 2
+ ]
+ ]
+ ]
+ );
+$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class);
+
+if ($product->getBundleOptionsData()) {
+ $options = [];
+ foreach ($product->getBundleOptionsData() as $key => $optionData) {
+ if (!(bool)$optionData['delete']) {
+ $option = $objectManager->create(\Magento\Bundle\Api\Data\OptionInterfaceFactory::class)
+ ->create(['data' => $optionData]);
+ $option->setSku($product->getSku());
+ $option->setOptionId(null);
+
+ $links = [];
+ $bundleLinks = $product->getBundleSelectionsData();
+ if (!empty($bundleLinks[$key])) {
+ foreach ($bundleLinks[$key] as $linkData) {
+ if (!(bool)$linkData['delete']) {
+ $link = $objectManager->create(\Magento\Bundle\Api\Data\LinkInterfaceFactory::class)
+ ->create(['data' => $linkData]);
+ $linkProduct = $productRepository->getById($linkData['product_id']);
+ $link->setSku($linkProduct->getSku());
+ $link->setQty($linkData['selection_qty']);
+ if (isset($linkData['selection_can_change_qty'])) {
+ $link->setCanChangeQuantity($linkData['selection_can_change_qty']);
+ }
+ $links[] = $link;
+ }
+ }
+ $option->setProductLinks($links);
+ $options[] = $option;
+ }
+ }
+ }
+ $extension = $product->getExtensionAttributes();
+ $extension->setBundleProductOptions($options);
+ $product->setExtensionAttributes($extension);
+}
+$product->save();
diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity_rollback.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity_rollback.php
new file mode 100644
index 0000000000000..9d702b4506551
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_and_custom_quantity_rollback.php
@@ -0,0 +1,30 @@
+requireDataFixture('Magento/Catalog/_files/multiple_products_rollback.php');
+
+$objectManager = Bootstrap::getObjectManager();
+/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */
+$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class);
+/** @var \Magento\Framework\Registry $registry */
+$registry = Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class);
+
+
+$registry->unregister('isSecureArea');
+$registry->register('isSecureArea', true);
+
+try {
+ $product = $productRepository->get('bundle-product');
+ $productRepository->delete($product);
+} catch (\Magento\Framework\Exception\NoSuchEntityException $exception) {
+ //Product already removed
+}
+
+$registry->unregister('isSecureArea');
+$registry->register('isSecureArea', false);