diff --git a/app/code/Magento/Bundle/Model/Product/Type.php b/app/code/Magento/Bundle/Model/Product/Type.php index 2dc519dbf1540..fe120e9a179dd 100644 --- a/app/code/Magento/Bundle/Model/Product/Type.php +++ b/app/code/Magento/Bundle/Model/Product/Type.php @@ -6,6 +6,8 @@ namespace Magento\Bundle\Model\Product; +use Magento\Bundle\Model\Option; +use Magento\Bundle\Model\ResourceModel\Option\Collection; use Magento\Bundle\Model\ResourceModel\Selection\Collection as Selections; use Magento\Bundle\Model\ResourceModel\Selection\Collection\FilterApplier as SelectionCollectionFilterApplier; use Magento\Catalog\Api\ProductRepositoryInterface; @@ -414,16 +416,13 @@ public function beforeSave($product) if ($product->getCanSaveBundleSelections()) { $product->canAffectOptions(true); $selections = $product->getBundleSelectionsData(); - if ($selections && !empty($selections)) { - $options = $product->getBundleOptionsData(); - if ($options) { - foreach ($options as $option) { - if (empty($option['delete']) || 1 != (int)$option['delete']) { - $product->setTypeHasOptions(true); - if (1 == (int)$option['required']) { - $product->setTypeHasRequiredOptions(true); - break; - } + if (!empty($selections) && $options = $product->getBundleOptionsData()) { + foreach ($options as $option) { + if (empty($option['delete']) || 1 != (int)$option['delete']) { + $product->setTypeHasOptions(true); + if (1 == (int)$option['required']) { + $product->setTypeHasRequiredOptions(true); + break; } } } @@ -464,7 +463,7 @@ public function getOptionsIds($product) public function getOptionsCollection($product) { if (!$product->hasData($this->_keyOptionsCollection)) { - /** @var \Magento\Bundle\Model\ResourceModel\Option\Collection $optionsCollection */ + /** @var Collection $optionsCollection */ $optionsCollection = $this->_bundleOption->create() ->getResourceCollection(); $optionsCollection->setProductIdFilter($product->getEntityId()); @@ -530,10 +529,10 @@ public function getSelectionsCollection($optionIds, $product) * Example: the catalog inventory validation of decimal qty can change qty to int, * so need to change quote item qty option value too. * - * @param array $options - * @param \Magento\Framework\DataObject $option - * @param mixed $value - * @param \Magento\Catalog\Model\Product $product + * @param array $options + * @param \Magento\Framework\DataObject $option + * @param mixed $value + * @param \Magento\Catalog\Model\Product $product * @return $this */ public function updateQtyOption($options, \Magento\Framework\DataObject $option, $value, $product) @@ -682,6 +681,11 @@ protected function _prepareProduct(\Magento\Framework\DataObject $buyRequest, $p $options ); + $this->validateRadioAndSelectOptions( + $optionsCollection, + $options + ); + $selectionIds = array_values($this->arrayUtility->flatten($options)); // If product has not been configured yet then $selections array should be empty if (!empty($selectionIds)) { @@ -1184,9 +1188,11 @@ public function canConfigure($product) * @return void * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ + // @codingStandardsIgnoreStart public function deleteTypeSpecificData(\Magento\Catalog\Model\Product $product) { } + // @codingStandardsIgnoreEnd /** * Return array of specific to type product entities @@ -1196,18 +1202,19 @@ public function deleteTypeSpecificData(\Magento\Catalog\Model\Product $product) */ public function getIdentities(\Magento\Catalog\Model\Product $product) { - $identities = parent::getIdentities($product); + $identities = []; + $identities[] = parent::getIdentities($product); /** @var \Magento\Bundle\Model\Option $option */ foreach ($this->getOptions($product) as $option) { if ($option->getSelections()) { /** @var \Magento\Catalog\Model\Product $selection */ foreach ($option->getSelections() as $selection) { - $identities = array_merge($identities, $selection->getIdentities()); + $identities[] = $selection->getIdentities(); } } } - return $identities; + return array_merge([], ...$identities); } /** @@ -1272,6 +1279,53 @@ protected function checkIsAllRequiredOptions($product, $isStrictProcessMode, $op } } + /** + * Validate Options for Radio and Select input types + * + * @param Collection $optionsCollection + * @param int[] $options + * @return void + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function validateRadioAndSelectOptions($optionsCollection, $options): void + { + $errorTypes = []; + + if (is_array($optionsCollection->getItems())) { + foreach ($optionsCollection->getItems() as $option) { + if ($this->isSelectedOptionValid($option, $options)) { + $errorTypes[] = $option->getType(); + } + } + } + + if (!empty($errorTypes)) { + throw new \Magento\Framework\Exception\LocalizedException( + __( + 'Option type (%types) should have only one element.', + ['types' => implode(", ", $errorTypes)] + ) + ); + } + } + + /** + * Check if selected option is valid + * + * @param Option $option + * @param array $options + * @return bool + */ + private function isSelectedOptionValid($option, $options): bool + { + return ( + ($option->getType() == 'radio' || $option->getType() == 'select') && + isset($options[$option->getOptionId()]) && + is_array($options[$option->getOptionId()]) && + count($options[$option->getOptionId()]) > 1 + ); + } + /** * Check if selection is salable * @@ -1333,16 +1387,18 @@ protected function checkIsResult($_result) */ protected function mergeSelectionsWithOptions($options, $selections) { + $selections = []; + foreach ($options as $option) { $optionSelections = $option->getSelections(); if ($option->getRequired() && is_array($optionSelections) && count($optionSelections) == 1) { - $selections = array_merge($selections, $optionSelections); + $selections[] = $optionSelections; } else { $selections = []; break; } } - return $selections; + return array_merge([], ...$selections); } } diff --git a/app/code/Magento/BundleGraphQl/Model/Order/Shipment/BundleShipmentItemFormatter.php b/app/code/Magento/BundleGraphQl/Model/Order/Shipment/BundleShipmentItemFormatter.php new file mode 100644 index 0000000000000..8e678cdb12d24 --- /dev/null +++ b/app/code/Magento/BundleGraphQl/Model/Order/Shipment/BundleShipmentItemFormatter.php @@ -0,0 +1,51 @@ +itemFormatter = $itemFormatter; + } + + /** + * Format bundle product shipment item + * + * @param ShipmentInterface $shipment + * @param ShipmentItemInterface $item + * @return array|null + */ + public function formatShipmentItem(ShipmentInterface $shipment, ShipmentItemInterface $item): ?array + { + $orderItem = $item->getOrderItem(); + $shippingType = $orderItem->getProductOptions()['shipment_type'] ?? null; + if ($shippingType == AbstractType::SHIPMENT_SEPARATELY && !$orderItem->getParentItemId()) { + //When bundle items are shipped separately the children are treated as their own items + return null; + } + return $this->itemFormatter->formatShipmentItem($shipment, $item); + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/BundleOptions.php b/app/code/Magento/BundleGraphQl/Model/Resolver/Order/Item/BundleOptions.php similarity index 93% rename from app/code/Magento/SalesGraphQl/Model/Resolver/BundleOptions.php rename to app/code/Magento/BundleGraphQl/Model/Resolver/Order/Item/BundleOptions.php index 0d27197e255ca..a21bbbb84d735 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/BundleOptions.php +++ b/app/code/Magento/BundleGraphQl/Model/Resolver/Order/Item/BundleOptions.php @@ -5,7 +5,7 @@ */ declare(strict_types=1); -namespace Magento\SalesGraphQl\Model\Resolver; +namespace Magento\BundleGraphQl\Model\Resolver\Order\Item; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Config\Element\Field; @@ -15,6 +15,8 @@ use Magento\Framework\Serialize\Serializer\Json; use Magento\Sales\Api\Data\InvoiceItemInterface; use Magento\Sales\Api\Data\OrderItemInterface; +use Magento\Sales\Api\Data\ShipmentItemInterface; +use Magento\Sales\Api\Data\CreditmemoItemInterface; /** * Resolve bundle options items for order item @@ -55,12 +57,12 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value throw new LocalizedException(__('"model" value should be specified')); } if ($value['model'] instanceof OrderItemInterface) { - /** @var OrderItemInterface $item */ $item = $value['model']; return $this->getBundleOptions($item, $value); } - if ($value['model'] instanceof InvoiceItemInterface) { - /** @var InvoiceItemInterface $item */ + if ($value['model'] instanceof InvoiceItemInterface + || $value['model'] instanceof ShipmentItemInterface + || $value['model'] instanceof CreditmemoItemInterface) { $item = $value['model']; // Have to pass down order and item to map to avoid refetching all data return $this->getBundleOptions($item->getOrderItem(), $value); diff --git a/app/code/Magento/BundleGraphQl/composer.json b/app/code/Magento/BundleGraphQl/composer.json index cb49ab78588b3..e3c54719f4d0e 100644 --- a/app/code/Magento/BundleGraphQl/composer.json +++ b/app/code/Magento/BundleGraphQl/composer.json @@ -10,6 +10,8 @@ "magento/module-quote": "*", "magento/module-quote-graph-ql": "*", "magento/module-store": "*", + "magento/module-sales": "*", + "magento/module-sales-graph-ql": "*", "magento/framework": "*" }, "license": [ diff --git a/app/code/Magento/BundleGraphQl/etc/graphql/di.xml b/app/code/Magento/BundleGraphQl/etc/graphql/di.xml index b847a6672e046..863e152fbe177 100644 --- a/app/code/Magento/BundleGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/BundleGraphQl/etc/graphql/di.xml @@ -65,4 +65,39 @@ + + + + BundleOrderItem + + + + + + + BundleInvoiceItem + + + + + + + BundleShipmentItem + + + + + + + BundleCreditMemoItem + + + + + + + Magento\BundleGraphQl\Model\Order\Shipment\BundleShipmentItemFormatter\Proxy + + + diff --git a/app/code/Magento/BundleGraphQl/etc/schema.graphqls b/app/code/Magento/BundleGraphQl/etc/schema.graphqls index 5f5d48e1ae45c..a66fa397020a7 100644 --- a/app/code/Magento/BundleGraphQl/etc/schema.graphqls +++ b/app/code/Magento/BundleGraphQl/etc/schema.graphqls @@ -87,3 +87,33 @@ enum ShipBundleItemsEnum @doc(description: "This enumeration defines whether bun TOGETHER SEPARATELY } + +type BundleOrderItem implements OrderItemInterface { + bundle_options: [ItemSelectedBundleOption] @doc(description: "A list of bundle options that are assigned to the bundle product") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Order\\Item\\BundleOptions") +} + +type BundleInvoiceItem implements InvoiceItemInterface{ + bundle_options: [ItemSelectedBundleOption] @doc(description: "A list of bundle options that are assigned to the bundle product") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Order\\Item\\BundleOptions") +} + +type BundleShipmentItem implements ShipmentItemInterface { + bundle_options: [ItemSelectedBundleOption] @doc(description: "A list of bundle options that are assigned to the bundle product") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Order\\Item\\BundleOptions") +} + +type BundleCreditMemoItem implements CreditMemoItemInterface { + bundle_options: [ItemSelectedBundleOption] @doc(description: "A list of bundle options that are assigned to the bundle product") @resolver(class: "Magento\\BundleGraphQl\\Model\\Resolver\\Order\\Item\\BundleOptions") +} + +type ItemSelectedBundleOption @doc(description: "A list of options of the selected bundle product") { + id: ID! @doc(description: "The unique identifier of the option") + label: String! @doc(description: "The label of the option") + values: [ItemSelectedBundleOptionValue] @doc(description: "A list of products that represent the values of the parent option") +} + +type ItemSelectedBundleOptionValue @doc(description: "A list of values for the selected bundle product") { + id: ID! @doc(description: "The unique identifier of the value") + product_name: String! @doc(description: "The name of the child bundle product") + product_sku: String! @doc(description: "The SKU of the child bundle product") + quantity: Float! @doc(description: "Indicates how many of this bundle product were ordered") + price: Money! @doc(description: "The price of the child bundle product") +} diff --git a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/composite/configure.phtml b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/composite/configure.phtml index d04eb300746ac..5ca88689b9e5f 100644 --- a/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/composite/configure.phtml +++ b/app/code/Magento/Catalog/view/adminhtml/templates/catalog/product/composite/configure.phtml @@ -5,8 +5,10 @@ */ /** @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer */ +$blockId = $block->getId(); ?> -
+
renderEventListenerAsTag( 'onload', @@ -31,40 +33,45 @@ renderEventListenerAsTag( 'onsubmit', - "productConfigure.onConfirmBtn();event.preventDefault()", + 'productConfigure.onConfirmBtn();event.preventDefault()', '.product_composite_configure_form:last-of-type' ) ?>
+ integer boolean customer_nested_extension_attribute + integer boolean customer_nested_extension_attribute diff --git a/app/code/Magento/Customer/view/frontend/templates/form/edit.phtml b/app/code/Magento/Customer/view/frontend/templates/form/edit.phtml index 5b877500aa0c8..9821cff73a3dd 100644 --- a/app/code/Magento/Customer/view/frontend/templates/form/edit.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/form/edit.phtml @@ -47,6 +47,7 @@ use Magento\Customer\Block\Widget\Name; escapeHtml(__('Change Password')) ?>
+ getChildHtml('fieldset_edit_info_additional') ?>
diff --git a/app/code/Magento/Customer/view/frontend/templates/form/register.phtml b/app/code/Magento/Customer/view/frontend/templates/form/register.phtml index 50f78e93e3edf..99040706e50ac 100644 --- a/app/code/Magento/Customer/view/frontend/templates/form/register.phtml +++ b/app/code/Magento/Customer/view/frontend/templates/form/register.phtml @@ -67,6 +67,7 @@ $formData = $block->getFormData(); isEnabled()): ?> setGender($formData->getGender())->toHtml() ?> + getChildHtml('fieldset_create_info_additional') ?>
getShowAddressFields()): ?> getAttributeValidationClass('city'); ?> diff --git a/app/code/Magento/Eav/Model/Config.php b/app/code/Magento/Eav/Model/Config.php index ec099eb576425..8522700adbb6d 100644 --- a/app/code/Magento/Eav/Model/Config.php +++ b/app/code/Magento/Eav/Model/Config.php @@ -839,6 +839,7 @@ protected function _createAttribute($entityType, $attributeData) } /** @var AbstractAttribute $attribute */ $attribute = $this->createAttribute($model)->setData($attributeData); + $attribute->setOrigData('entity_type_id', $attribute->getEntityTypeId()); $this->_addAttributeReference( $attributeData['attribute_id'], $code, diff --git a/app/code/Magento/Eav/Test/Unit/Model/ConfigTest.php b/app/code/Magento/Eav/Test/Unit/Model/ConfigTest.php index e4a0e935b325d..83fb1253aba96 100644 --- a/app/code/Magento/Eav/Test/Unit/Model/ConfigTest.php +++ b/app/code/Magento/Eav/Test/Unit/Model/ConfigTest.php @@ -223,11 +223,13 @@ public function testGetAttributes($cacheEnabled) ->method('getData') ->willReturn([$attributeData]); $entityAttributeMock = $this->getMockBuilder(Attribute::class) - ->setMethods(['setData', 'load', 'toArray']) + ->setMethods(['setData', 'setOrigData', 'load', 'toArray']) ->disableOriginalConstructor() ->getMock(); $entityAttributeMock->method('setData') ->willReturnSelf(); + $entityAttributeMock->method('setOrigData') + ->willReturn($attributeData); $entityAttributeMock->method('load') ->willReturnSelf(); $entityAttributeMock->method('toArray') diff --git a/app/code/Magento/Elasticsearch/Elasticsearch5/Model/Client/Elasticsearch.php b/app/code/Magento/Elasticsearch/Elasticsearch5/Model/Client/Elasticsearch.php index 1ea2b6958734c..2560d7e26e7d9 100644 --- a/app/code/Magento/Elasticsearch/Elasticsearch5/Model/Client/Elasticsearch.php +++ b/app/code/Magento/Elasticsearch/Elasticsearch5/Model/Client/Elasticsearch.php @@ -166,6 +166,23 @@ public function createIndex($index, $settings) ); } + /** + * Add/update an Elasticsearch index settings. + * + * @param string $index + * @param array $settings + * @return void + */ + public function putIndexSettings(string $index, array $settings): void + { + $this->getClient()->indices()->putSettings( + [ + 'index' => $index, + 'body' => $settings, + ] + ); + } + /** * Delete an Elasticsearch index. * @@ -348,6 +365,17 @@ private function prepareFieldInfo($fieldInfo) return $fieldInfo; } + /** + * Get mapping from Elasticsearch index. + * + * @param array $params + * @return array + */ + public function getMapping(array $params): array + { + return $this->getClient()->indices()->getMapping($params); + } + /** * Delete mapping in Elasticsearch index * diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/Elasticsearch.php b/app/code/Magento/Elasticsearch/Model/Adapter/Elasticsearch.php index 5ab6669a34cc4..25e691972d81d 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/Elasticsearch.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/Elasticsearch.php @@ -6,6 +6,12 @@ namespace Magento\Elasticsearch\Model\Adapter; +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\StaticField; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Stdlib\ArrayManager; + /** * Elasticsearch adapter * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -69,6 +75,31 @@ class Elasticsearch */ private $batchDocumentDataMapper; + /** + * @var array + */ + private $mappedAttributes = []; + + /** + * @var string[] + */ + private $indexByCode = []; + + /** + * @var ProductAttributeRepositoryInterface + */ + private $productAttributeRepository; + + /** + * @var StaticField + */ + private $staticFieldProvider; + + /** + * @var ArrayManager + */ + private $arrayManager; + /** * @param \Magento\Elasticsearch\SearchAdapter\ConnectionManager $connectionManager * @param FieldMapperInterface $fieldMapper @@ -78,7 +109,11 @@ class Elasticsearch * @param Index\IndexNameResolver $indexNameResolver * @param BatchDataMapperInterface $batchDocumentDataMapper * @param array $options + * @param ProductAttributeRepositoryInterface|null $productAttributeRepository + * @param StaticField|null $staticFieldProvider + * @param ArrayManager|null $arrayManager * @throws \Magento\Framework\Exception\LocalizedException + * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( \Magento\Elasticsearch\SearchAdapter\ConnectionManager $connectionManager, @@ -88,7 +123,10 @@ public function __construct( \Psr\Log\LoggerInterface $logger, \Magento\Elasticsearch\Model\Adapter\Index\IndexNameResolver $indexNameResolver, BatchDataMapperInterface $batchDocumentDataMapper, - $options = [] + $options = [], + ProductAttributeRepositoryInterface $productAttributeRepository = null, + StaticField $staticFieldProvider = null, + ArrayManager $arrayManager = null ) { $this->connectionManager = $connectionManager; $this->fieldMapper = $fieldMapper; @@ -97,6 +135,12 @@ public function __construct( $this->logger = $logger; $this->indexNameResolver = $indexNameResolver; $this->batchDocumentDataMapper = $batchDocumentDataMapper; + $this->productAttributeRepository = $productAttributeRepository ?: + ObjectManager::getInstance()->get(ProductAttributeRepositoryInterface::class); + $this->staticFieldProvider = $staticFieldProvider ?: + ObjectManager::getInstance()->get(StaticField::class); + $this->arrayManager = $arrayManager ?: + ObjectManager::getInstance()->get(ArrayManager::class); try { $this->client = $this->connectionManager->getConnection($options); @@ -322,6 +366,93 @@ public function updateAlias($storeId, $mappedIndexerId) // remove obsolete index if ($oldIndex) { $this->client->deleteIndex($oldIndex); + unset($this->indexByCode[$mappedIndexerId . '_' . $storeId]); + } + + return $this; + } + + /** + * Update Elasticsearch mapping for index. + * + * @param int $storeId + * @param string $mappedIndexerId + * @param string $attributeCode + * @return $this + */ + public function updateIndexMapping(int $storeId, string $mappedIndexerId, string $attributeCode): self + { + $indexName = $this->getIndexFromAlias($storeId, $mappedIndexerId); + if (empty($indexName)) { + return $this; + } + + $attribute = $this->productAttributeRepository->get($attributeCode); + $newAttributeMapping = $this->staticFieldProvider->getField($attribute); + $mappedAttributes = $this->getMappedAttributes($indexName); + + $attrToUpdate = array_diff_key($newAttributeMapping, $mappedAttributes); + if (!empty($attrToUpdate)) { + $settings['index']['mapping']['total_fields']['limit'] = $this + ->getMappingTotalFieldsLimit(array_merge($mappedAttributes, $attrToUpdate)); + $this->client->putIndexSettings($indexName, ['settings' => $settings]); + + $this->client->addFieldsMapping( + $attrToUpdate, + $indexName, + $this->clientConfig->getEntityType() + ); + $this->setMappedAttributes($indexName, $attrToUpdate); + } + + return $this; + } + + /** + * Retrieve index definition from class. + * + * @param int $storeId + * @param string $mappedIndexerId + * @return string + */ + private function getIndexFromAlias(int $storeId, string $mappedIndexerId): string + { + $indexCode = $mappedIndexerId . '_' . $storeId; + if (!isset($this->indexByCode[$indexCode])) { + $this->indexByCode[$indexCode] = $this->indexNameResolver->getIndexFromAlias($storeId, $mappedIndexerId); + } + + return $this->indexByCode[$indexCode]; + } + + /** + * Retrieve mapped attributes from class. + * + * @param string $indexName + * @return array + */ + private function getMappedAttributes(string $indexName): array + { + if (empty($this->mappedAttributes[$indexName])) { + $mappedAttributes = $this->client->getMapping(['index' => $indexName]); + $pathField = $this->arrayManager->findPath('properties', $mappedAttributes); + $this->mappedAttributes[$indexName] = $this->arrayManager->get($pathField, $mappedAttributes, []); + } + + return $this->mappedAttributes[$indexName]; + } + + /** + * Set mapped attributes to class. + * + * @param string $indexName + * @param array $mappedAttributes + * @return $this + */ + private function setMappedAttributes(string $indexName, array $mappedAttributes): self + { + foreach ($mappedAttributes as $attributeCode => $attributeParams) { + $this->mappedAttributes[$indexName][$attributeCode] = $attributeParams; } return $this; diff --git a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php index f7dfcd29e5036..bc031fc988fb0 100644 --- a/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php +++ b/app/code/Magento/Elasticsearch/Model/Adapter/FieldMapper/Product/FieldProvider/StaticField.php @@ -7,8 +7,9 @@ namespace Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider; -use Magento\Eav\Model\Config; use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Eav\Model\Config; +use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\AttributeProvider; use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\FieldIndex\ConverterInterface as IndexTypeConverterInterface; @@ -109,67 +110,82 @@ public function getFields(array $context = []): array $allAttributes = []; foreach ($attributes as $attribute) { - if (in_array($attribute->getAttributeCode(), $this->excludedAttributes, true)) { - continue; - } - $attributeAdapter = $this->attributeAdapterProvider->getByAttributeCode($attribute->getAttributeCode()); - $fieldName = $this->fieldNameResolver->getFieldName($attributeAdapter); + $allAttributes += $this->getField($attribute); + } - $allAttributes[$fieldName] = [ - 'type' => $this->fieldTypeResolver->getFieldType($attributeAdapter), - ]; + $allAttributes['store_id'] = [ + 'type' => $this->fieldTypeConverter->convert(FieldTypeConverterInterface::INTERNAL_DATA_TYPE_STRING), + 'index' => $this->indexTypeConverter->convert(IndexTypeConverterInterface::INTERNAL_NO_INDEX_VALUE), + ]; - $index = $this->fieldIndexResolver->getFieldIndex($attributeAdapter); - if (null !== $index) { - $allAttributes[$fieldName]['index'] = $index; - } + return $allAttributes; + } - if ($attributeAdapter->isSortable()) { - $sortFieldName = $this->fieldNameResolver->getFieldName( - $attributeAdapter, - ['type' => FieldMapperInterface::TYPE_SORT] - ); - $allAttributes[$fieldName]['fields'][$sortFieldName] = [ - 'type' => $this->fieldTypeConverter->convert( - FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD - ), - 'index' => $this->indexTypeConverter->convert( - IndexTypeConverterInterface::INTERNAL_NO_ANALYZE_VALUE - ) - ]; - } + /** + * Get field mapping for specific attribute. + * + * @param AbstractAttribute $attribute + * @return array + */ + public function getField(AbstractAttribute $attribute): array + { + $fieldMapping = []; + if (in_array($attribute->getAttributeCode(), $this->excludedAttributes, true)) { + return $fieldMapping; + } + + $attributeAdapter = $this->attributeAdapterProvider->getByAttributeCode($attribute->getAttributeCode()); + $fieldName = $this->fieldNameResolver->getFieldName($attributeAdapter); - if ($attributeAdapter->isTextType()) { - $keywordFieldName = FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD; - $index = $this->indexTypeConverter->convert( + $fieldMapping[$fieldName] = [ + 'type' => $this->fieldTypeResolver->getFieldType($attributeAdapter), + ]; + + $index = $this->fieldIndexResolver->getFieldIndex($attributeAdapter); + if (null !== $index) { + $fieldMapping[$fieldName]['index'] = $index; + } + + if ($attributeAdapter->isSortable()) { + $sortFieldName = $this->fieldNameResolver->getFieldName( + $attributeAdapter, + ['type' => FieldMapperInterface::TYPE_SORT] + ); + $fieldMapping[$fieldName]['fields'][$sortFieldName] = [ + 'type' => $this->fieldTypeConverter->convert( + FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD + ), + 'index' => $this->indexTypeConverter->convert( IndexTypeConverterInterface::INTERNAL_NO_ANALYZE_VALUE - ); - $allAttributes[$fieldName]['fields'][$keywordFieldName] = [ - 'type' => $this->fieldTypeConverter->convert( - FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD - ) - ]; - if ($index) { - $allAttributes[$fieldName]['fields'][$keywordFieldName]['index'] = $index; - } - } + ) + ]; + } - if ($attributeAdapter->isComplexType()) { - $childFieldName = $this->fieldNameResolver->getFieldName( - $attributeAdapter, - ['type' => FieldMapperInterface::TYPE_QUERY] - ); - $allAttributes[$childFieldName] = [ - 'type' => $this->fieldTypeConverter->convert(FieldTypeConverterInterface::INTERNAL_DATA_TYPE_STRING) - ]; + if ($attributeAdapter->isTextType()) { + $keywordFieldName = FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD; + $index = $this->indexTypeConverter->convert( + IndexTypeConverterInterface::INTERNAL_NO_ANALYZE_VALUE + ); + $fieldMapping[$fieldName]['fields'][$keywordFieldName] = [ + 'type' => $this->fieldTypeConverter->convert( + FieldTypeConverterInterface::INTERNAL_DATA_TYPE_KEYWORD + ) + ]; + if ($index) { + $fieldMapping[$fieldName]['fields'][$keywordFieldName]['index'] = $index; } } - $allAttributes['store_id'] = [ - 'type' => $this->fieldTypeConverter->convert(FieldTypeConverterInterface::INTERNAL_DATA_TYPE_STRING), - 'index' => $this->indexTypeConverter->convert(IndexTypeConverterInterface::INTERNAL_NO_INDEX_VALUE), - ]; + if ($attributeAdapter->isComplexType()) { + $childFieldName = $this->fieldNameResolver->getFieldName( + $attributeAdapter, + ['type' => FieldMapperInterface::TYPE_QUERY] + ); + $fieldMapping[$childFieldName] = [ + 'type' => $this->fieldTypeConverter->convert(FieldTypeConverterInterface::INTERNAL_DATA_TYPE_STRING) + ]; + } - return $allAttributes; + return $fieldMapping; } } diff --git a/app/code/Magento/Elasticsearch/Model/DataProvider/Base/Suggestions.php b/app/code/Magento/Elasticsearch/Model/DataProvider/Base/Suggestions.php index 8364b6c116b7d..2d4f8abeb8ecd 100644 --- a/app/code/Magento/Elasticsearch/Model/DataProvider/Base/Suggestions.php +++ b/app/code/Magento/Elasticsearch/Model/DataProvider/Base/Suggestions.php @@ -5,19 +5,23 @@ */ namespace Magento\Elasticsearch\Model\DataProvider\Base; -use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProviderInterface; -use Magento\Store\Model\ScopeInterface; -use Magento\Search\Model\QueryInterface; +use Elasticsearch\Common\Exceptions\BadRequest400Exception; use Magento\AdvancedSearch\Model\SuggestedQueriesInterface; +use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProviderInterface; use Magento\Elasticsearch\Model\Config; use Magento\Elasticsearch\SearchAdapter\ConnectionManager; -use Magento\Search\Model\QueryResultFactory; -use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Elasticsearch\SearchAdapter\SearchIndexNameResolver; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Search\Model\QueryInterface; +use Magento\Search\Model\QueryResultFactory; +use Magento\Store\Model\ScopeInterface; use Magento\Store\Model\StoreManagerInterface as StoreManager; +use Psr\Log\LoggerInterface; /** * Default implementation to provide suggestions mechanism for Elasticsearch + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class Suggestions implements SuggestedQueriesInterface { @@ -56,6 +60,11 @@ class Suggestions implements SuggestedQueriesInterface */ private $fieldProvider; + /** + * @var LoggerInterface + */ + private $logger; + /** * Suggestions constructor. * @@ -66,6 +75,7 @@ class Suggestions implements SuggestedQueriesInterface * @param SearchIndexNameResolver $searchIndexNameResolver * @param StoreManager $storeManager * @param FieldProviderInterface $fieldProvider + * @param LoggerInterface|null $logger */ public function __construct( ScopeConfigInterface $scopeConfig, @@ -74,7 +84,8 @@ public function __construct( ConnectionManager $connectionManager, SearchIndexNameResolver $searchIndexNameResolver, StoreManager $storeManager, - FieldProviderInterface $fieldProvider + FieldProviderInterface $fieldProvider, + LoggerInterface $logger = null ) { $this->queryResultFactory = $queryResultFactory; $this->connectionManager = $connectionManager; @@ -83,6 +94,7 @@ public function __construct( $this->searchIndexNameResolver = $searchIndexNameResolver; $this->storeManager = $storeManager; $this->fieldProvider = $fieldProvider; + $this->logger = $logger ?: ObjectManager::getInstance()->get(LoggerInterface::class); } /** @@ -93,8 +105,14 @@ public function getItems(QueryInterface $query) $result = []; if ($this->isSuggestionsAllowed()) { $isResultsCountEnabled = $this->isResultsCountEnabled(); + try { + $suggestions = $this->getSuggestions($query); + } catch (BadRequest400Exception $e) { + $this->logger->critical($e); + $suggestions = []; + } - foreach ($this->getSuggestions($query) as $suggestion) { + foreach ($suggestions as $suggestion) { $count = null; if ($isResultsCountEnabled) { $count = isset($suggestion['freq']) ? $suggestion['freq'] : null; diff --git a/app/code/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/Attribute.php b/app/code/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/Attribute.php new file mode 100644 index 0000000000000..53f036a3b8e38 --- /dev/null +++ b/app/code/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/Attribute.php @@ -0,0 +1,119 @@ +config = $config; + $this->indexerProcessor = $indexerProcessor; + $this->dimensionProvider = $dimensionProvider; + $this->indexerHandlerFactory = $indexerHandlerFactory; + } + + /** + * Update catalog search indexer mapping if third party search engine is used. + * + * @param AttributeResourceModel $subject + * @param AttributeResourceModel $result + * @return AttributeResourceModel + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws LocalizedException + */ + public function afterSave( + AttributeResourceModel $subject, + AttributeResourceModel $result + ): AttributeResourceModel { + $indexer = $this->indexerProcessor->getIndexer(); + if ($this->isNewObject + && !$indexer->isScheduled() + && $this->config->isElasticsearchEnabled() + ) { + $indexerHandler = $this->indexerHandlerFactory->create(['data' => $indexer->getData()]); + if (!$indexerHandler instanceof ElasticsearchIndexerHandler) { + throw new LocalizedException( + __('Created indexer handler must be instance of %1.', ElasticsearchIndexerHandler::class) + ); + } + foreach ($this->dimensionProvider->getIterator() as $dimension) { + $indexerHandler->updateIndex($dimension, $this->attributeCode); + } + } + + return $result; + } + + /** + * Set class variables before saving attribute. + * + * @param AttributeResourceModel $subject + * @param AbstractModel $attribute + * @return void + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeSave( + AttributeResourceModel $subject, + AbstractModel $attribute + ): void { + $this->isNewObject = $attribute->isObjectNew(); + $this->attributeCode = $attribute->getAttributeCode(); + } +} diff --git a/app/code/Magento/Elasticsearch/Model/Indexer/IndexerHandler.php b/app/code/Magento/Elasticsearch/Model/Indexer/IndexerHandler.php index 847710eaa445a..90e21e9e3ea1e 100644 --- a/app/code/Magento/Elasticsearch/Model/Indexer/IndexerHandler.php +++ b/app/code/Magento/Elasticsearch/Model/Indexer/IndexerHandler.php @@ -5,12 +5,13 @@ */ namespace Magento\Elasticsearch\Model\Indexer; -use Magento\Framework\Indexer\SaveHandler\IndexerInterface; -use Magento\Framework\Indexer\SaveHandler\Batch; -use Magento\Framework\Indexer\IndexStructureInterface; use Magento\Elasticsearch\Model\Adapter\Elasticsearch as ElasticsearchAdapter; use Magento\Elasticsearch\Model\Adapter\Index\IndexNameResolver; use Magento\Framework\App\ScopeResolverInterface; +use Magento\Framework\Indexer\IndexStructureInterface; +use Magento\Framework\Indexer\SaveHandler\Batch; +use Magento\Framework\Indexer\SaveHandler\IndexerInterface; +use Magento\Framework\Search\Request\Dimension; /** * Indexer Handler for Elasticsearch engine. @@ -18,7 +19,7 @@ class IndexerHandler implements IndexerInterface { /** - * Default batch size + * Size of default batch */ const DEFAULT_BATCH_SIZE = 500; @@ -132,6 +133,22 @@ public function isAvailable($dimensions = []) return $this->adapter->ping(); } + /** + * Update mapping data for index. + * + * @param Dimension[] $dimensions + * @param string $attributeCode + * @return IndexerInterface + */ + public function updateIndex(array $dimensions, string $attributeCode): IndexerInterface + { + $dimension = current($dimensions); + $scopeId = (int)$this->scopeResolver->getScope($dimension->getValue())->getId(); + $this->adapter->updateIndexMapping($scopeId, $this->getIndexerId(), $attributeCode); + + return $this; + } + /** * Returns indexer id. * diff --git a/app/code/Magento/Elasticsearch/Test/Unit/Elasticsearch5/Model/Client/ElasticsearchTest.php b/app/code/Magento/Elasticsearch/Test/Unit/Elasticsearch5/Model/Client/ElasticsearchTest.php index fc146e801124a..398c79f056810 100644 --- a/app/code/Magento/Elasticsearch/Test/Unit/Elasticsearch5/Model/Client/ElasticsearchTest.php +++ b/app/code/Magento/Elasticsearch/Test/Unit/Elasticsearch5/Model/Client/ElasticsearchTest.php @@ -69,6 +69,7 @@ protected function setUp(): void 'create', 'delete', 'putMapping', + 'getMapping', 'deleteMapping', 'stats', 'updateAliases', @@ -533,6 +534,22 @@ public function testDeleteMappingFailure() ); } + /** + * Test get Elasticsearch mapping process. + * + * @return void + */ + public function testGetMapping(): void + { + $params = ['index' => 'indexName']; + $this->indicesMock->expects($this->once()) + ->method('getMapping') + ->with($params) + ->willReturn([]); + + $this->model->getMapping($params); + } + /** * Test query() method * @return void diff --git a/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/ElasticsearchTest.php b/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/ElasticsearchTest.php index dd4bffe8e7c33..5abe800884ced 100644 --- a/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/ElasticsearchTest.php +++ b/app/code/Magento/Elasticsearch/Test/Unit/Model/Adapter/ElasticsearchTest.php @@ -11,14 +11,18 @@ use Elasticsearch\Namespaces\IndicesNamespace; use Magento\AdvancedSearch\Model\Client\ClientInterface as ElasticsearchClient; use Magento\AdvancedSearch\Model\Client\ClientOptionsInterface; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Eav\Model\Entity\Attribute\AbstractAttribute; use Magento\Elasticsearch\Model\Adapter\BatchDataMapperInterface; use Magento\Elasticsearch\Model\Adapter\Elasticsearch as ElasticsearchAdapter; use Magento\Elasticsearch\Model\Adapter\FieldMapperInterface; +use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProvider\StaticField; use Magento\Elasticsearch\Model\Adapter\Index\BuilderInterface; use Magento\Elasticsearch\Model\Adapter\Index\IndexNameResolver; use Magento\Elasticsearch\Model\Config; use Magento\Elasticsearch\SearchAdapter\ConnectionManager; use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Stdlib\ArrayManager; use Magento\Framework\TestFramework\Unit\Helper\ObjectManager as ObjectManagerHelper; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -81,6 +85,21 @@ class ElasticsearchTest extends TestCase */ protected $indexNameResolver; + /** + * @var ProductAttributeRepositoryInterface|MockObject + */ + private $productAttributeRepository; + + /** + * @var StaticField|MockObject + */ + private $staticFieldProvider; + + /** + * @var ArrayManager|MockObject + */ + private $arrayManager; + /** * Setup * @@ -177,9 +196,17 @@ protected function setUp(): void ) ->disableOriginalConstructor() ->getMock(); - $this->batchDocumentDataMapper = $this->getMockBuilder( - BatchDataMapperInterface::class - )->disableOriginalConstructor() + $this->batchDocumentDataMapper = $this->getMockBuilder(BatchDataMapperInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->productAttributeRepository = $this->getMockBuilder(ProductAttributeRepositoryInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $this->staticFieldProvider = $this->getMockBuilder(StaticField::class) + ->disableOriginalConstructor() + ->getMock(); + $this->arrayManager = $this->getMockBuilder(ArrayManager::class) + ->disableOriginalConstructor() ->getMock(); $this->model = $this->objectManager->getObject( \Magento\Elasticsearch\Model\Adapter\Elasticsearch::class, @@ -192,6 +219,9 @@ protected function setUp(): void 'logger' => $this->logger, 'indexNameResolver' => $this->indexNameResolver, 'options' => [], + 'productAttributeRepository' => $this->productAttributeRepository, + 'staticFieldProvider' => $this->staticFieldProvider, + 'arrayManager' => $this->arrayManager, ] ); } @@ -459,6 +489,81 @@ public function testUpdateAliasWithoutOldIndex() $this->assertEquals($this->model, $this->model->updateAlias(1, 'product')); } + /** + * Test update Elasticsearch mapping for index without alias definition. + * + * @return void + */ + public function testUpdateIndexMappingWithoutAliasDefinition(): void + { + $storeId = 1; + $mappedIndexerId = 'product'; + + $this->indexNameResolver->expects($this->once()) + ->method('getIndexFromAlias') + ->with($storeId, $mappedIndexerId) + ->willReturn(''); + + $this->productAttributeRepository->expects($this->never()) + ->method('get'); + + $this->model->updateIndexMapping($storeId, $mappedIndexerId, 'attribute_code'); + } + + /** + * Test update Elasticsearch mapping for index with alias definition. + * + * @return void + */ + public function testUpdateIndexMappingWithAliasDefinition(): void + { + $storeId = 1; + $mappedIndexerId = 'product'; + $indexName = '_product_1_v1'; + $attributeCode = 'example_attribute_code'; + + $this->indexNameResolver->expects($this->once()) + ->method('getIndexFromAlias') + ->with($storeId, $mappedIndexerId) + ->willReturn($indexName); + + $attribute = $this->getMockBuilder(AbstractAttribute::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->productAttributeRepository->expects($this->once()) + ->method('get') + ->with($attributeCode) + ->willReturn($attribute); + + $this->staticFieldProvider->expects($this->once()) + ->method('getField') + ->with($attribute) + ->willReturn([$attributeCode => ['type' => 'text']]); + + $mappedAttributes = ['another_attribute_code' => 'attribute_mapping']; + $this->client->expects($this->once()) + ->method('getMapping') + ->with(['index' => $indexName]) + ->willReturn(['properties' => $mappedAttributes]); + + $this->arrayManager->expects($this->once()) + ->method('findPath') + ->with('properties', ['properties' => $mappedAttributes]) + ->willReturn('example/path/to/properties'); + + $this->arrayManager->expects($this->once()) + ->method('get') + ->with('example/path/to/properties', ['properties' => $mappedAttributes], []) + ->willReturn($mappedAttributes); + + $this->client->expects($this->once()) + ->method('addFieldsMapping') + ->with([$attributeCode => ['type' => 'text']], $indexName, 'product'); + + $this->model->updateIndexMapping($storeId, $mappedIndexerId, $attributeCode); + } + /** * Get elasticsearch client options * diff --git a/app/code/Magento/Elasticsearch/Test/Unit/Model/DataProvider/Base/SuggestionsTest.php b/app/code/Magento/Elasticsearch/Test/Unit/Model/DataProvider/Base/SuggestionsTest.php index 7151677db3405..9f1c5db60b3d8 100644 --- a/app/code/Magento/Elasticsearch/Test/Unit/Model/DataProvider/Base/SuggestionsTest.php +++ b/app/code/Magento/Elasticsearch/Test/Unit/Model/DataProvider/Base/SuggestionsTest.php @@ -7,7 +7,10 @@ namespace Magento\Elasticsearch\Test\Unit\Model\DataProvider\Base; +use Elasticsearch\Common\Exceptions\BadRequest400Exception; +use Magento\Elasticsearch\Model\Adapter\FieldMapper\Product\FieldProviderInterface; use Magento\Elasticsearch\Model\Config; +use Magento\Elasticsearch\Model\DataProvider\Base\Suggestions; use Magento\Elasticsearch\Model\DataProvider\Suggestions as SuggestionsDataProvider; use Magento\Elasticsearch\SearchAdapter\ConnectionManager; use Magento\Elasticsearch\SearchAdapter\SearchIndexNameResolver; @@ -21,6 +24,7 @@ use Magento\Store\Model\StoreManagerInterface as StoreManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -62,6 +66,21 @@ class SuggestionsTest extends TestCase */ private $storeManager; + /** + * @var FieldProviderInterface|MockObject + */ + private $fieldProvider; + + /** + * @var LoggerInterface|MockObject + */ + private $logger; + + /** + * @var Elasticsearch|MockObject + */ + private $client; + /** * @var QueryInterface|MockObject */ @@ -99,7 +118,19 @@ protected function setUp(): void ->setMethods(['getIndexName']) ->getMock(); - $this->storeManager = $this->getMockBuilder(\Magento\Store\Model\StoreManagerInterface::class) + $this->storeManager = $this->getMockBuilder(StoreManager::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->fieldProvider = $this->getMockBuilder(FieldProviderInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->logger = $this->getMockBuilder(LoggerInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $this->client = $this->getMockBuilder(Elasticsearch::class) ->disableOriginalConstructor() ->getMock(); @@ -110,81 +141,155 @@ protected function setUp(): void $objectManager = new ObjectManagerHelper($this); $this->model = $objectManager->getObject( - \Magento\Elasticsearch\Model\DataProvider\Base\Suggestions::class, + Suggestions::class, [ 'queryResultFactory' => $this->queryResultFactory, 'connectionManager' => $this->connectionManager, 'scopeConfig' => $this->scopeConfig, 'config' => $this->config, 'searchIndexNameResolver' => $this->searchIndexNameResolver, - 'storeManager' => $this->storeManager + 'storeManager' => $this->storeManager, + 'fieldProvider' => $this->fieldProvider, + 'logger' => $this->logger, ] ); } /** - * Test getItems() method + * Test get items process with search suggestions disabled. + * @return void */ - public function testGetItems() + public function testGetItemsWithDisabledSearchSuggestion(): void { - $this->scopeConfig->expects($this->any()) - ->method('getValue') - ->willReturn(1); - - $this->config->expects($this->any()) - ->method('isElasticsearchEnabled') - ->willReturn(1); - - $store = $this->getMockBuilder(StoreInterface::class) - ->disableOriginalConstructor() - ->getMockForAbstractClass(); + $this->scopeConfig->expects($this->once()) + ->method('isSetFlag') + ->willReturn(false); - $this->storeManager->expects($this->any()) - ->method('getStore') - ->willReturn($store); - - $store->expects($this->any()) - ->method('getId') - ->willReturn(1); + $this->scopeConfig->expects($this->never()) + ->method('getValue'); - $this->searchIndexNameResolver->expects($this->any()) - ->method('getIndexName') - ->willReturn('magento2_product_1'); + $this->config->expects($this->once()) + ->method('isElasticsearchEnabled') + ->willReturn(true); - $this->query->expects($this->any()) - ->method('getQueryText') - ->willReturn('query'); + $this->logger->expects($this->never()) + ->method('critical'); - $client = $this->getMockBuilder(Elasticsearch::class) - ->disableOriginalConstructor() - ->getMock(); + $this->queryResultFactory->expects($this->never()) + ->method('create'); - $this->connectionManager->expects($this->any()) - ->method('getConnection') - ->willReturn($client); + $this->assertEmpty($this->model->getItems($this->query)); + } - $client->expects($this->any()) + /** + * Test get items process with search suggestions enabled. + * @return void + */ + public function testGetItemsWithEnabledSearchSuggestion(): void + { + $this->prepareSearchQuery(); + $this->client->expects($this->once()) ->method('query') ->willReturn([ 'suggest' => [ 'phrase_field' => [ - 'options' => [ - 'text' => 'query', - 'score' => 1, - 'freq' => 1, + [ + 'options' => [ + 'suggestion' => [ + 'text' => 'query', + 'score' => 1, + 'freq' => 1, + ] + ] ] ], ], ]); + $this->logger->expects($this->never()) + ->method('critical'); + $query = $this->getMockBuilder(QueryResult::class) ->disableOriginalConstructor() ->getMock(); - $this->queryResultFactory->expects($this->any()) + $this->queryResultFactory->expects($this->once()) ->method('create') ->willReturn($query); - $this->assertIsArray($this->model->getItems($this->query)); + $this->assertEquals([$query], $this->model->getItems($this->query)); + } + + /** + * Test get items process when throwing an exception. + * @return void + */ + public function testGetItemsException(): void + { + $this->prepareSearchQuery(); + $exception = new BadRequest400Exception(); + + $this->client->expects($this->once()) + ->method('query') + ->willThrowException($exception); + + $this->logger->expects($this->once()) + ->method('critical') + ->with($exception); + + $this->queryResultFactory->expects($this->never()) + ->method('create'); + + $this->assertEmpty($this->model->getItems($this->query)); + } + + /** + * Prepare Mocks for default get items process. + * @return void + */ + private function prepareSearchQuery(): void + { + $storeId = 1; + + $this->scopeConfig->expects($this->exactly(2)) + ->method('isSetFlag') + ->willReturn(true); + + $this->scopeConfig->expects($this->once()) + ->method('getValue') + ->willReturn(1); + + $this->config->expects($this->once()) + ->method('isElasticsearchEnabled') + ->willReturn(true); + + $store = $this->getMockBuilder(StoreInterface::class) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $store->expects($this->once()) + ->method('getId') + ->willReturn($storeId); + + $this->storeManager->expects($this->once()) + ->method('getStore') + ->willReturn($store); + + $this->searchIndexNameResolver->expects($this->once()) + ->method('getIndexName') + ->with($storeId, Config::ELASTICSEARCH_TYPE_DEFAULT) + ->willReturn('magento2_product_1'); + + $this->query->expects($this->once()) + ->method('getQueryText') + ->willReturn('query'); + + $this->fieldProvider->expects($this->once()) + ->method('getFields') + ->willReturn([]); + + $this->connectionManager->expects($this->once()) + ->method('getConnection') + ->willReturn($this->client); } } diff --git a/app/code/Magento/Elasticsearch/Test/Unit/Model/Indexer/Fulltext/Plugin/Category/Product/AttributeTest.php b/app/code/Magento/Elasticsearch/Test/Unit/Model/Indexer/Fulltext/Plugin/Category/Product/AttributeTest.php new file mode 100644 index 0000000000000..801c7ca3be216 --- /dev/null +++ b/app/code/Magento/Elasticsearch/Test/Unit/Model/Indexer/Fulltext/Plugin/Category/Product/AttributeTest.php @@ -0,0 +1,173 @@ +configMock = $this->createMock(Config::class); + $this->indexerProcessorMock = $this->createMock(Processor::class); + $this->dimensionProviderMock = $this->getMockBuilder(DimensionProviderInterface::class) + ->getMockForAbstractClass(); + $this->indexerHandlerFactoryMock = $this->createMock(IndexerHandlerFactory::class); + + $this->attributePlugin = (new ObjectManager($this))->getObject( + AttributePlugin::class, + [ + 'config' => $this->configMock, + 'indexerProcessor' => $this->indexerProcessorMock, + 'dimensionProvider' => $this->dimensionProviderMock, + 'indexerHandlerFactory' => $this->indexerHandlerFactoryMock, + ] + ); + } + + /** + * Test update catalog search indexer process. + * + * @param bool $isNewObject + * @param bool $isElasticsearchEnabled + * @param array $dimensions + * @return void + * @dataProvider afterSaveDataProvider + * + */ + public function testAfterSave(bool $isNewObject, bool $isElasticsearchEnabled, array $dimensions): void + { + /** @var AttributeModel|MockObject $attributeMock */ + $attributeMock = $this->createMock(AttributeModel::class); + $attributeMock->expects($this->once()) + ->method('isObjectNew') + ->willReturn($isNewObject); + + $attributeMock->expects($this->once()) + ->method('getAttributeCode') + ->willReturn('example_attribute_code'); + + /** @var AttributeResourceModel|MockObject $subjectMock */ + $subjectMock = $this->createMock(AttributeResourceModel::class); + $this->attributePlugin->beforeSave($subjectMock, $attributeMock); + + $indexerData = ['indexer_example_data']; + + /** @var IndexerInterface|MockObject $indexerMock */ + $indexerMock = $this->getMockBuilder(IndexerInterface::class) + ->setMethods(['getData']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + + $indexerMock->expects($this->getExpectsCount($isNewObject, $isElasticsearchEnabled)) + ->method('getData') + ->willReturn($indexerData); + + $this->indexerProcessorMock->expects($this->once()) + ->method('getIndexer') + ->willReturn($indexerMock); + + $this->configMock->expects($isNewObject ? $this->once() : $this->never()) + ->method('isElasticsearchEnabled') + ->willReturn($isElasticsearchEnabled); + + /** @var IndexerHandler|MockObject $indexerHandlerMock */ + $indexerHandlerMock = $this->createMock(IndexerHandler::class); + + $indexerHandlerMock + ->expects(($isNewObject && $isElasticsearchEnabled) ? $this->exactly(count($dimensions)) : $this->never()) + ->method('updateIndex') + ->willReturnSelf(); + + $this->indexerHandlerFactoryMock->expects($this->getExpectsCount($isNewObject, $isElasticsearchEnabled)) + ->method('create') + ->with(['data' => $indexerData]) + ->willReturn($indexerHandlerMock); + + $this->dimensionProviderMock->expects($this->getExpectsCount($isNewObject, $isElasticsearchEnabled)) + ->method('getIterator') + ->willReturn(new ArrayIterator($dimensions)); + + $this->assertEquals($subjectMock, $this->attributePlugin->afterSave($subjectMock, $subjectMock)); + } + + /** + * DataProvider for testAfterSave(). + * + * @return array + */ + public function afterSaveDataProvider(): array + { + $dimensions = [['scope' => 1], ['scope' => 2]]; + + return [ + 'save_existing_object' => [false, false, $dimensions], + 'save_with_another_search_engine' => [true, false, $dimensions], + 'save_with_elasticsearch' => [true, true, []], + 'save_with_elasticsearch_and_dimensions' => [true, true, $dimensions], + ]; + } + + /** + * Retrieves how many times method is executed. + * + * @param bool $isNewObject + * @param bool $isElasticsearchEnabled + * @return InvokedCountMatcher + */ + private function getExpectsCount(bool $isNewObject, bool $isElasticsearchEnabled): InvokedCountMatcher + { + return ($isNewObject && $isElasticsearchEnabled) ? $this->once() : $this->never(); + } +} diff --git a/app/code/Magento/Elasticsearch/Test/Unit/Model/Indexer/IndexerHandlerTest.php b/app/code/Magento/Elasticsearch/Test/Unit/Model/Indexer/IndexerHandlerTest.php index a147ca1b42b3b..3a9aef68c328c 100644 --- a/app/code/Magento/Elasticsearch/Test/Unit/Model/Indexer/IndexerHandlerTest.php +++ b/app/code/Magento/Elasticsearch/Test/Unit/Model/Indexer/IndexerHandlerTest.php @@ -267,4 +267,45 @@ public function testCleanIndex() $this->assertEquals($model, $result); } + + /** + * Test mapping data is updated for index. + * + * @return void + */ + public function testUpdateIndex(): void + { + $dimensionValue = 'SomeDimension'; + $indexMapping = 'some_index_mapping'; + $attributeCode = 'example_attribute_code'; + + $dimension = $this->getMockBuilder(Dimension::class) + ->disableOriginalConstructor() + ->getMock(); + + $dimension->expects($this->once()) + ->method('getValue') + ->willReturn($dimensionValue); + + $this->scopeResolver->expects($this->once()) + ->method('getScope') + ->with($dimensionValue) + ->willReturn($this->scopeInterface); + + $this->scopeInterface->expects($this->once()) + ->method('getId') + ->willReturn(1); + + $this->indexNameResolver->expects($this->once()) + ->method('getIndexMapping') + ->with('catalogsearch_fulltext') + ->willReturn($indexMapping); + + $this->adapter->expects($this->once()) + ->method('updateIndexMapping') + ->with(1, $indexMapping, $attributeCode) + ->willReturnSelf(); + + $this->model->updateIndex([$dimension], $attributeCode); + } } diff --git a/app/code/Magento/Elasticsearch/etc/di.xml b/app/code/Magento/Elasticsearch/etc/di.xml index 6c1a771958081..edec07cb5d51e 100644 --- a/app/code/Magento/Elasticsearch/etc/di.xml +++ b/app/code/Magento/Elasticsearch/etc/di.xml @@ -475,6 +475,11 @@ Magento\Elasticsearch\Elasticsearch5\Model\Adapter\FieldMapper\Product\FieldProvider\FieldType\Converter + + + elasticsearch5StaticFieldProvider + + elasticsearch5FieldProvider @@ -550,4 +555,12 @@ + + + Magento\Store\Model\StoreDimensionProvider + + + + + diff --git a/app/code/Magento/Elasticsearch/i18n/en_US.csv b/app/code/Magento/Elasticsearch/i18n/en_US.csv index 3a0ec556dbf8d..e36b3e054accd 100644 --- a/app/code/Magento/Elasticsearch/i18n/en_US.csv +++ b/app/code/Magento/Elasticsearch/i18n/en_US.csv @@ -11,3 +11,4 @@ "Elasticsearch Server Timeout","Elasticsearch Server Timeout" "Test Connection","Test Connection" "Minimum Terms to Match","Minimum Terms to Match" +"Created indexer handler must be instance of %1.", "Created indexer handler must be instance of %1." diff --git a/app/code/Magento/Elasticsearch6/Model/Client/Elasticsearch.php b/app/code/Magento/Elasticsearch6/Model/Client/Elasticsearch.php index a6ca9ac690b33..2d787f4d4377d 100644 --- a/app/code/Magento/Elasticsearch6/Model/Client/Elasticsearch.php +++ b/app/code/Magento/Elasticsearch6/Model/Client/Elasticsearch.php @@ -179,6 +179,23 @@ public function createIndex($index, $settings) ); } + /** + * Add/update an Elasticsearch index settings. + * + * @param string $index + * @param array $settings + * @return void + */ + public function putIndexSettings(string $index, array $settings): void + { + $this->getClient()->indices()->putSettings( + [ + 'index' => $index, + 'body' => $settings, + ] + ); + } + /** * Delete an Elasticsearch index. * @@ -332,6 +349,17 @@ public function addFieldsMapping(array $fields, $index, $entityType) $this->getClient()->indices()->putMapping($params); } + /** + * Get mapping from Elasticsearch index. + * + * @param array $params + * @return array + */ + public function getMapping(array $params): array + { + return $this->getClient()->indices()->getMapping($params); + } + /** * Delete mapping in Elasticsearch index * diff --git a/app/code/Magento/Elasticsearch6/Test/Unit/Model/Client/ElasticsearchTest.php b/app/code/Magento/Elasticsearch6/Test/Unit/Model/Client/ElasticsearchTest.php index 427e630ac2099..f52e24d72e4d4 100644 --- a/app/code/Magento/Elasticsearch6/Test/Unit/Model/Client/ElasticsearchTest.php +++ b/app/code/Magento/Elasticsearch6/Test/Unit/Model/Client/ElasticsearchTest.php @@ -73,6 +73,7 @@ protected function setUp(): void 'delete', 'putMapping', 'deleteMapping', + 'getMapping', 'stats', 'updateAliases', 'existsAlias', @@ -608,6 +609,22 @@ public function testDeleteMappingFailure() ); } + /** + * Test get Elasticsearch mapping process. + * + * @return void + */ + public function testGetMapping(): void + { + $params = ['index' => 'indexName']; + $this->indicesMock->expects($this->once()) + ->method('getMapping') + ->with($params) + ->willReturn([]); + + $this->model->getMapping($params); + } + /** * Test query() method * @return void diff --git a/app/code/Magento/Elasticsearch7/Model/Client/Elasticsearch.php b/app/code/Magento/Elasticsearch7/Model/Client/Elasticsearch.php index a16a70b1cd702..d193c8aa108c8 100644 --- a/app/code/Magento/Elasticsearch7/Model/Client/Elasticsearch.php +++ b/app/code/Magento/Elasticsearch7/Model/Client/Elasticsearch.php @@ -179,6 +179,23 @@ public function createIndex(string $index, array $settings) ); } + /** + * Add/update an Elasticsearch index settings. + * + * @param string $index + * @param array $settings + * @return void + */ + public function putIndexSettings(string $index, array $settings): void + { + $this->getElasticsearchClient()->indices()->putSettings( + [ + 'index' => $index, + 'body' => $settings, + ] + ); + } + /** * Delete an Elasticsearch 7 index. * @@ -350,6 +367,17 @@ public function query(array $query): array return $this->getElasticsearchClient()->search($query); } + /** + * Get mapping from Elasticsearch index. + * + * @param array $params + * @return array + */ + public function getMapping(array $params): array + { + return $this->getElasticsearchClient()->indices()->getMapping($params); + } + /** * Delete mapping in Elasticsearch 7 index * diff --git a/app/code/Magento/Elasticsearch7/Test/Unit/Model/Client/ElasticsearchTest.php b/app/code/Magento/Elasticsearch7/Test/Unit/Model/Client/ElasticsearchTest.php index a31a1971a5acc..3b3cbcfbb15f8 100644 --- a/app/code/Magento/Elasticsearch7/Test/Unit/Model/Client/ElasticsearchTest.php +++ b/app/code/Magento/Elasticsearch7/Test/Unit/Model/Client/ElasticsearchTest.php @@ -73,6 +73,7 @@ protected function setUp(): void 'delete', 'putMapping', 'deleteMapping', + 'getMapping', 'stats', 'updateAliases', 'existsAlias', @@ -608,6 +609,22 @@ public function testDeleteMappingFailure() ); } + /** + * Test get Elasticsearch mapping process. + * + * @return void + */ + public function testGetMapping(): void + { + $params = ['index' => 'indexName']; + $this->indicesMock->expects($this->once()) + ->method('getMapping') + ->with($params) + ->willReturn([]); + + $this->model->getMapping($params); + } + /** * Test query() method * @return void 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/LoginAsCustomer/Model/Resolver/IsLoginAsCustomerEnabledResolver.php b/app/code/Magento/LoginAsCustomer/Model/Resolver/IsLoginAsCustomerEnabledResolver.php index de16a798983c0..89cb960e78bb8 100644 --- a/app/code/Magento/LoginAsCustomer/Model/Resolver/IsLoginAsCustomerEnabledResolver.php +++ b/app/code/Magento/LoginAsCustomer/Model/Resolver/IsLoginAsCustomerEnabledResolver.php @@ -7,7 +7,7 @@ namespace Magento\LoginAsCustomer\Model\Resolver; -use Magento\LoginAsCustomer\Model\IsLoginAsCustomerEnabledForCustomerResultFactory; +use Magento\LoginAsCustomerApi\Api\Data\IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory; use Magento\LoginAsCustomerApi\Api\ConfigInterface; use Magento\LoginAsCustomerApi\Api\Data\IsLoginAsCustomerEnabledForCustomerResultInterface; use Magento\LoginAsCustomerApi\Api\IsLoginAsCustomerEnabledForCustomerInterface; @@ -23,17 +23,17 @@ class IsLoginAsCustomerEnabledResolver implements IsLoginAsCustomerEnabledForCus private $config; /** - * @var IsLoginAsCustomerEnabledForCustomerResultFactory + * @var IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory */ private $resultFactory; /** * @param ConfigInterface $config - * @param IsLoginAsCustomerEnabledForCustomerResultFactory $resultFactory + * @param IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory $resultFactory */ public function __construct( ConfigInterface $config, - IsLoginAsCustomerEnabledForCustomerResultFactory $resultFactory + IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory $resultFactory ) { $this->config = $config; $this->resultFactory = $resultFactory; diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminCreateCustomerWithWebSiteAndGroupActionGroup.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminCreateCustomerWithWebSiteAndGroupActionGroup.xml new file mode 100644 index 0000000000000..e7f55e69b1cda --- /dev/null +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/ActionGroup/AdminCreateCustomerWithWebSiteAndGroupActionGroup.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Data/CustomerData.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Data/CustomerData.xml new file mode 100644 index 0000000000000..10cdc87be6430 --- /dev/null +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Data/CustomerData.xml @@ -0,0 +1,17 @@ + + + + + + AssistanceAllowed + + + AssistanceAllowed + + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Data/ExtensionAttributeAssistanceData.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Data/ExtensionAttributeAssistanceData.xml new file mode 100644 index 0000000000000..44582cfae5c36 --- /dev/null +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Data/ExtensionAttributeAssistanceData.xml @@ -0,0 +1,17 @@ + + + + + + 1 + + + 2 + + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Section/AdminCustomerAccountInformationSection.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Section/AdminCustomerAccountInformationSection.xml new file mode 100644 index 0000000000000..1a31afad3fbf0 --- /dev/null +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Section/AdminCustomerAccountInformationSection.xml @@ -0,0 +1,14 @@ + + + + +
+ +
+
diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminChangUserAccessToLoginAsCustomerButtonTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminChangUserAccessToLoginAsCustomerButtonTest.xml index 9ebeae23d47a8..8902877b38e54 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminChangUserAccessToLoginAsCustomerButtonTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminChangUserAccessToLoginAsCustomerButtonTest.xml @@ -27,7 +27,7 @@ - + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAddProductToWishlistTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAddProductToWishlistTest.xml index 8ab3fe8ce3bc3..c083383dd8861 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAddProductToWishlistTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAddProductToWishlistTest.xml @@ -28,7 +28,7 @@ - + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAutoDetectionTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAutoDetectionTest.xml index 42d53113e20f4..de9790894015c 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAutoDetectionTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerAutoDetectionTest.xml @@ -20,7 +20,7 @@ - + - + - + @@ -65,7 +65,7 @@ - + - + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerLoggingTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerLoggingTest.xml index bf2556b30967b..5b5e9e21113c8 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerLoggingTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerLoggingTest.xml @@ -26,8 +26,8 @@ stepKey="enableLoginAsCustomerAutoDetection"/> - - + + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseTest.xml index acae07d1cda11..da966fdcc1291 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerManualChooseTest.xml @@ -23,7 +23,7 @@ - + - + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerPlaceOrderTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerPlaceOrderTest.xml index a4994d5c041d2..8169b9df4c43d 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerPlaceOrderTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerPlaceOrderTest.xml @@ -28,7 +28,7 @@ - + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerReorderTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerReorderTest.xml index 16e0b94112562..11d622319af33 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerReorderTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerReorderTest.xml @@ -28,7 +28,7 @@ - + diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerSettingsAvailableForGlobalLevelTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerSettingsAvailableForGlobalLevelTest.xml deleted file mode 100644 index 807603bdcba0a..0000000000000 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerSettingsAvailableForGlobalLevelTest.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - <description value="'Login as Customer' settings are available for global level"/> - <severity value="MAJOR"/> - <group value="login_as_customer"/> - </annotations> - <before> - <actionGroup ref="AdminLoginActionGroup" stepKey="adminLogin"/> - </before> - <after> - <actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/> - </after> - - <amOnPage url="{{AdminLoginAsCustomerConfigPage.url}}" stepKey="navigateToLoginAsCustomerConfigSection"/> - <actionGroup ref="AdminAssertLoginAsCustomerConfigVisibleActionGroup" stepKey="seeLoginAsCustomerConfig"/> - - <actionGroup ref="SwitchToTheNewStoreViewActionGroup" stepKey="switchToDefaultStoreView"> - <argument name="storeViewName" value="'Default Store View'"/> - </actionGroup> - <actionGroup ref="AdminAssertLoginAsCustomerSectionLinkNotAvailableActionGroup" stepKey="dontSeeLoginAsCustomerSectionLinkOnStoreView"/> - - <actionGroup ref="SwitchToTheNewStoreViewActionGroup" stepKey="switchToDefaultWebsite"> - <argument name="storeViewName" value="'Main Website'"/> - </actionGroup> - <actionGroup ref="AdminAssertLoginAsCustomerSectionLinkNotAvailableActionGroup" stepKey="dontSeeLoginAsCustomerSectionLink"/> - </test> -</tests> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerSubscribeToNewsletterTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerSubscribeToNewsletterTest.xml index 44409471821db..bc4c4adc3ac5a 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerSubscribeToNewsletterTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerSubscribeToNewsletterTest.xml @@ -24,7 +24,7 @@ <magentoCLI command="config:set {{LoginAsCustomerStoreViewLogin.path}} 0" stepKey="enableLoginAsCustomerAutoDetection"/> <magentoCLI command="cache:flush config" stepKey="flushCacheBeforeTestRun"/> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="login"/> </before> <after> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerUserLogoutTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerUserLogoutTest.xml index 4f484e73d580b..e7b5de55a56cb 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerUserLogoutTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerUserLogoutTest.xml @@ -25,7 +25,7 @@ <magentoCLI command="config:set {{LoginAsCustomerStoreViewLogin.path}} 0" stepKey="enableLoginAsCustomerAutoDetection"/> <magentoCLI command="cache:flush config" stepKey="flushCacheBeforeTestRun"/> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsDefaultUser"/> </before> <after> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerUserSingleSessionTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerUserSingleSessionTest.xml index e4cd48d8e868e..5bbc218e0a948 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerUserSingleSessionTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminLoginAsCustomerUserSingleSessionTest.xml @@ -17,17 +17,14 @@ value="Verify Admin users can have only one 'Login as Customer' session at a time"/> <severity value="MAJOR"/> <group value="login_as_customer"/> - <skip> - <issueId value="https://github.com/magento/magento2-login-as-customer/pull/193"/> - </skip> </annotations> <before> <magentoCLI command="config:set {{LoginAsCustomerConfigDataEnabled.path}} 1" stepKey="enableLoginAsCustomer"/> <magentoCLI command="config:set {{LoginAsCustomerStoreViewLogin.path}} 0" stepKey="enableLoginAsCustomerAutoDetection"/> <magentoCLI command="cache:flush config" stepKey="flushCacheBeforeTestRun"/> - <createData entity="Simple_US_Customer" stepKey="createFirstCustomer"/> - <createData entity="Simple_US_CA_Customer" stepKey="createSecondCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createFirstCustomer"/> + <createData entity="Simple_US_CA_Customer_Assistance_Allowed" stepKey="createSecondCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsDefaultUser"/> </before> <after> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminNoAccessToLoginAsCustomerButtonTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminNoAccessToLoginAsCustomerButtonTest.xml index 52f5d7c51dcd1..50513797d06e9 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminNoAccessToLoginAsCustomerButtonTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminNoAccessToLoginAsCustomerButtonTest.xml @@ -29,7 +29,7 @@ <createData entity="SimpleProduct" stepKey="createSimpleProduct"> <requiredEntity createDataKey="createCategory"/> </createData> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsDefaultAdminUserBefore"/> <!--Create New Role--> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminNoAccessToLoginAsCustomerConfigurationTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminNoAccessToLoginAsCustomerConfigurationTest.xml index c7f42de741862..d48f167656301 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminNoAccessToLoginAsCustomerConfigurationTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminNoAccessToLoginAsCustomerConfigurationTest.xml @@ -31,7 +31,7 @@ <createData entity="SimpleProduct" stepKey="createSimpleProduct"> <requiredEntity createDataKey="createCategory"/> </createData> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="loginAsDefaultAdminUserBefore"/> <!--Create New Role--> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUINotShownIfLoginAsCustomerDisabledTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUINotShownIfLoginAsCustomerDisabledTest.xml index 19a84ecadc72b..e1ea363bdf6bc 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUINotShownIfLoginAsCustomerDisabledTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUINotShownIfLoginAsCustomerDisabledTest.xml @@ -24,7 +24,7 @@ <createData entity="SimpleProduct" stepKey="createSimpleProduct"> <requiredEntity createDataKey="createCategory"/> </createData> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="login"/> </before> <after> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUIShownIfLoginAsCustomerEnabledTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUIShownIfLoginAsCustomerEnabledTest.xml index 2bf364b29ba8d..ea06263901b9e 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUIShownIfLoginAsCustomerEnabledTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/AdminUIShownIfLoginAsCustomerEnabledTest.xml @@ -28,7 +28,7 @@ <createData entity="SimpleProduct" stepKey="createSimpleProduct"> <requiredEntity createDataKey="createCategory"/> </createData> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="login"/> </before> <after> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerBannerPresentOnAllPagesInSessionTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerBannerPresentOnAllPagesInSessionTest.xml index 6874fcb4fd220..4f85b9167fa54 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerBannerPresentOnAllPagesInSessionTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerBannerPresentOnAllPagesInSessionTest.xml @@ -27,7 +27,7 @@ <createData entity="SimpleProduct" stepKey="createSimpleProduct"> <requiredEntity createDataKey="createCategory"/> </createData> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="login"/> </before> <after> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerNotificationBannerTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerNotificationBannerTest.xml index d953c493562c6..351a3c569ce24 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerNotificationBannerTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerNotificationBannerTest.xml @@ -19,7 +19,7 @@ <group value="login_as_customer"/> </annotations> <before> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <magentoCLI command="config:set {{LoginAsCustomerConfigDataEnabled.path}} 1" stepKey="enableLoginAsCustomer"/> <magentoCLI command="config:set {{LoginAsCustomerStoreViewLogin.path}} 0" diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerSeeSpecialPriceOnCategoryTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerSeeSpecialPriceOnCategoryTest.xml index 775fcb122e181..3e70da8f8158d 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerSeeSpecialPriceOnCategoryTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerSeeSpecialPriceOnCategoryTest.xml @@ -31,7 +31,7 @@ <requiredEntity createDataKey="createCategory"/> <field key="price">10</field> </createData> - <createData entity="Simple_US_Customer" stepKey="createCustomer"> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"> <field key="group_id">3</field> </createData> <actionGroup ref="AdminLoginActionGroup" stepKey="login"/> diff --git a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerShoppingCartIsNotMergedWithGuestCartTest.xml b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerShoppingCartIsNotMergedWithGuestCartTest.xml index 09ec1b427f515..b2c7c6c35db18 100644 --- a/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerShoppingCartIsNotMergedWithGuestCartTest.xml +++ b/app/code/Magento/LoginAsCustomer/Test/Mftf/Test/StorefrontLoginAsCustomerShoppingCartIsNotMergedWithGuestCartTest.xml @@ -27,7 +27,7 @@ <createData entity="SimpleProduct" stepKey="createSimpleProduct"> <requiredEntity createDataKey="createCategory"/> </createData> - <createData entity="Simple_US_Customer" stepKey="createCustomer"/> + <createData entity="Simple_US_Customer_Assistance_Allowed" stepKey="createCustomer"/> <actionGroup ref="AdminLoginActionGroup" stepKey="login"/> </before> <after> diff --git a/app/code/Magento/LoginAsCustomer/etc/di.xml b/app/code/Magento/LoginAsCustomer/etc/di.xml index e9e4983957d31..9927237c51db6 100755 --- a/app/code/Magento/LoginAsCustomer/etc/di.xml +++ b/app/code/Magento/LoginAsCustomer/etc/di.xml @@ -22,10 +22,10 @@ <preference for="Magento\LoginAsCustomerApi\Api\GetLoggedAsCustomerAdminIdInterface" type="Magento\LoginAsCustomer\Model\GetLoggedAsCustomerAdminId"/> <preference for="Magento\LoginAsCustomerApi\Api\GetLoggedAsCustomerCustomerIdInterface" type="Magento\LoginAsCustomer\Model\GetLoggedAsCustomerCustomerId"/> <preference for="Magento\LoginAsCustomerApi\Api\SetLoggedAsCustomerAdminIdInterface" type="Magento\LoginAsCustomer\Model\SetLoggedAsCustomerAdminId"/> + <preference for="Magento\LoginAsCustomerApi\Api\Data\IsLoginAsCustomerEnabledForCustomerResultInterface" + type="Magento\LoginAsCustomer\Model\IsLoginAsCustomerEnabledForCustomerResult"/> <preference for="Magento\LoginAsCustomerApi\Api\SetLoggedAsCustomerCustomerIdInterface" type="Magento\LoginAsCustomer\Model\SetLoggedAsCustomerCustomerId"/> - <preference for="Magento\LoginAsCustomerApi\Api\IsLoginAsCustomerEnabledForCustomerInterface" - type="Magento\LoginAsCustomer\Model\IsLoginAsCustomerEnabledForCustomerChain"/> - <type name="Magento\LoginAsCustomer\Model\IsLoginAsCustomerEnabledForCustomerChain"> + <type name="Magento\LoginAsCustomerApi\Model\IsLoginAsCustomerEnabledForCustomerChain"> <arguments> <argument name="resolvers" xsi:type="array"> <item name="is_enabled" xsi:type="object"> diff --git a/app/code/Magento/LoginAsCustomer/Model/IsLoginAsCustomerEnabledForCustomerChain.php b/app/code/Magento/LoginAsCustomerApi/Model/IsLoginAsCustomerEnabledForCustomerChain.php similarity index 76% rename from app/code/Magento/LoginAsCustomer/Model/IsLoginAsCustomerEnabledForCustomerChain.php rename to app/code/Magento/LoginAsCustomerApi/Model/IsLoginAsCustomerEnabledForCustomerChain.php index 6937a11eb3b58..c852327743760 100644 --- a/app/code/Magento/LoginAsCustomer/Model/IsLoginAsCustomerEnabledForCustomerChain.php +++ b/app/code/Magento/LoginAsCustomerApi/Model/IsLoginAsCustomerEnabledForCustomerChain.php @@ -5,12 +5,12 @@ */ declare(strict_types=1); -namespace Magento\LoginAsCustomer\Model; +namespace Magento\LoginAsCustomerApi\Model; use Magento\LoginAsCustomerApi\Api\ConfigInterface; use Magento\LoginAsCustomerApi\Api\Data\IsLoginAsCustomerEnabledForCustomerResultInterface; +use Magento\LoginAsCustomerApi\Api\Data\IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory; use Magento\LoginAsCustomerApi\Api\IsLoginAsCustomerEnabledForCustomerInterface; -use Magento\LoginAsCustomerApi\Model\IsLoginAsCustomerEnabledForCustomerResolverInterface; /** * @inheritdoc @@ -23,7 +23,7 @@ class IsLoginAsCustomerEnabledForCustomerChain implements IsLoginAsCustomerEnabl private $config; /** - * @var IsLoginAsCustomerEnabledForCustomerResultFactory + * @var IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory */ private $resultFactory; @@ -34,12 +34,12 @@ class IsLoginAsCustomerEnabledForCustomerChain implements IsLoginAsCustomerEnabl /** * @param ConfigInterface $config - * @param IsLoginAsCustomerEnabledForCustomerResultFactory $resultFactory + * @param IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory $resultFactory * @param array $resolvers */ public function __construct( ConfigInterface $config, - IsLoginAsCustomerEnabledForCustomerResultFactory $resultFactory, + IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory $resultFactory, array $resolvers = [] ) { $this->config = $config; @@ -53,7 +53,7 @@ public function __construct( public function execute(int $customerId): IsLoginAsCustomerEnabledForCustomerResultInterface { $messages = [[]]; - /** @var IsLoginAsCustomerEnabledForCustomerResultInterface $resolver */ + /** @var IsLoginAsCustomerEnabledForCustomerInterface $resolver */ foreach ($this->resolvers as $resolver) { $resolverResult = $resolver->execute($customerId); if (!$resolverResult->isEnabled()) { diff --git a/app/code/Magento/LoginAsCustomerApi/etc/di.xml b/app/code/Magento/LoginAsCustomerApi/etc/di.xml new file mode 100644 index 0000000000000..18915f8f16267 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerApi/etc/di.xml @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <preference for="Magento\LoginAsCustomerApi\Api\IsLoginAsCustomerEnabledForCustomerInterface" + type="Magento\LoginAsCustomerApi\Model\IsLoginAsCustomerEnabledForCustomerChain"/> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/Api/ConfigInterface.php b/app/code/Magento/LoginAsCustomerAssistance/Api/ConfigInterface.php new file mode 100644 index 0000000000000..7cd54567d26d5 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Api/ConfigInterface.php @@ -0,0 +1,28 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Api; + +/** + * LoginAsCustomerAssistance config. + */ +interface ConfigInterface +{ + /** + * Get title for shopping assistance checkbox. + * + * @return string + */ + public function getShoppingAssistanceCheckboxTitle(): string; + + /** + * Get tooltip for shopping assistance checkbox. + * + * @return string + */ + public function getShoppingAssistanceCheckboxTooltip(): string; +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Api/IsAssistanceEnabledInterface.php b/app/code/Magento/LoginAsCustomerAssistance/Api/IsAssistanceEnabledInterface.php new file mode 100644 index 0000000000000..916d03477a5d3 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Api/IsAssistanceEnabledInterface.php @@ -0,0 +1,32 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Api; + +/** + * Get 'assistance_allowed' attribute from Customer. + */ +interface IsAssistanceEnabledInterface +{ + /** + * Merchant assistance denied by customer status code. + */ + public const DENIED = 1; + + /** + * Merchant assistance allowed by customer status code. + */ + public const ALLOWED = 2; + + /** + * Get 'assistance_allowed' attribute from Customer by id. + * + * @param int $customerId + * @return bool + */ + public function execute(int $customerId): bool; +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Api/SetAssistanceInterface.php b/app/code/Magento/LoginAsCustomerAssistance/Api/SetAssistanceInterface.php new file mode 100644 index 0000000000000..ce8d2020341be --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Api/SetAssistanceInterface.php @@ -0,0 +1,22 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Api; + +/** + * Set 'assistance_allowed' attribute to Customer. + */ +interface SetAssistanceInterface +{ + /** + * Set 'assistance_allowed' attribute to Customer by id. + * + * @param int $customerId + * @param bool $isEnabled + */ + public function execute(int $customerId, bool $isEnabled): void; +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Block/Adminhtml/NotAllowedPopup.php b/app/code/Magento/LoginAsCustomerAssistance/Block/Adminhtml/NotAllowedPopup.php new file mode 100644 index 0000000000000..f0f4eba026733 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Block/Adminhtml/NotAllowedPopup.php @@ -0,0 +1,76 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Block\Adminhtml; + +use Magento\Backend\Block\Template; +use Magento\Framework\Serialize\Serializer\Json; +use Magento\LoginAsCustomerApi\Api\ConfigInterface; + +/** + * Pop-up for Login as Customer button then Login as Customer is not allowed. + */ +class NotAllowedPopup extends Template +{ + /** + * Config + * + * @var ConfigInterface + */ + private $config; + + /** + * Json Serializer + * + * @var Json + */ + private $json; + + /** + * @param Template\Context $context + * @param ConfigInterface $config + * @param Json $json + * @param array $data + */ + public function __construct( + Template\Context $context, + ConfigInterface $config, + Json $json, + array $data = [] + ) { + parent::__construct($context, $data); + $this->config = $config; + $this->json = $json; + } + + /** + * @inheritdoc + */ + public function getJsLayout() + { + $layout = $this->json->unserialize(parent::getJsLayout()); + + $layout['components']['lac-not-allowed-popup']['title'] = __('Login as Customer not enabled'); + $layout['components']['lac-not-allowed-popup']['content'] = __( + 'The user has not enabled the "Allow remote shopping assistance" functionality. ' + . 'Contact the customer to discuss this user configuration.' + ); + + return $this->json->serialize($layout); + } + + /** + * @inheritdoc + */ + public function toHtml() + { + if (!$this->config->isEnabled()) { + return ''; + } + return parent::toHtml(); + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/LICENSE.txt b/app/code/Magento/LoginAsCustomerAssistance/LICENSE.txt new file mode 100644 index 0000000000000..49525fd99da9c --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/LICENSE.txt @@ -0,0 +1,48 @@ + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. \ No newline at end of file diff --git a/app/code/Magento/LoginAsCustomerAssistance/LICENSE_AFL.txt b/app/code/Magento/LoginAsCustomerAssistance/LICENSE_AFL.txt new file mode 100644 index 0000000000000..f39d641b18a19 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/LICENSE_AFL.txt @@ -0,0 +1,48 @@ + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under <insert your license name here>" or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/app/code/Magento/LoginAsCustomerAssistance/Model/Config.php b/app/code/Magento/LoginAsCustomerAssistance/Model/Config.php new file mode 100644 index 0000000000000..2fce39cd4e85e --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Model/Config.php @@ -0,0 +1,62 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Model; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\LoginAsCustomerAssistance\Api\ConfigInterface; +use Magento\Store\Model\ScopeInterface; + +/** + * @inheritdoc + */ +class Config implements ConfigInterface +{ + /** + * Extension config path + */ + private const XML_PATH_SHOPPING_ASSISTANCE_CHECKBOX_TITLE + = 'login_as_customer/general/shopping_assistance_checkbox_title'; + private const XML_PATH_SHOPPING_ASSISTANCE_CHECKBOX_TOOLTIP + = 'login_as_customer/general/shopping_assistance_checkbox_tooltip'; + + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @param ScopeConfigInterface $scopeConfig + */ + public function __construct( + ScopeConfigInterface $scopeConfig + ) { + $this->scopeConfig = $scopeConfig; + } + + /** + * @inheritdoc + */ + public function getShoppingAssistanceCheckboxTitle(): string + { + return (string)$this->scopeConfig->getValue( + self::XML_PATH_SHOPPING_ASSISTANCE_CHECKBOX_TITLE, + ScopeInterface::SCOPE_WEBSITE + ); + } + + /** + * @inheritdoc + */ + public function getShoppingAssistanceCheckboxTooltip(): string + { + return (string)$this->scopeConfig->getValue( + self::XML_PATH_SHOPPING_ASSISTANCE_CHECKBOX_TOOLTIP, + ScopeInterface::SCOPE_WEBSITE + ); + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Model/IsAssistanceEnabled.php b/app/code/Magento/LoginAsCustomerAssistance/Model/IsAssistanceEnabled.php new file mode 100644 index 0000000000000..da77c96164228 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Model/IsAssistanceEnabled.php @@ -0,0 +1,54 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Model; + +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerExtensionFactory; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\LoginAsCustomerAssistance\Api\IsAssistanceEnabledInterface; +use Magento\LoginAsCustomerAssistance\Model\ResourceModel\GetLoginAsCustomerAssistanceAllowed; + +/** + * Check if customer allows Login as Customer assistance. + */ +class IsAssistanceEnabled implements IsAssistanceEnabledInterface +{ + /** + * @var array + */ + private $registry = []; + + /** + * @var GetLoginAsCustomerAssistanceAllowed + */ + private $getLoginAsCustomerAssistanceAllowed; + + /** + * @param GetLoginAsCustomerAssistanceAllowed $getLoginAsCustomerAssistanceAllowed + */ + public function __construct( + GetLoginAsCustomerAssistanceAllowed $getLoginAsCustomerAssistanceAllowed + ) { + $this->getLoginAsCustomerAssistanceAllowed = $getLoginAsCustomerAssistanceAllowed; + } + + /** + * Check if customer allows Login as Customer assistance by Customer id. + * + * @param int $customerId + * @return bool + */ + public function execute(int $customerId): bool + { + if (!isset($this->registry[$customerId])) { + $this->registry[$customerId] = $this->getLoginAsCustomerAssistanceAllowed->execute($customerId); + } + + return $this->registry[$customerId]; + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Model/Processor/IsLoginAsCustomerAllowedResolver.php b/app/code/Magento/LoginAsCustomerAssistance/Model/Processor/IsLoginAsCustomerAllowedResolver.php new file mode 100644 index 0000000000000..966ea477b2394 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Model/Processor/IsLoginAsCustomerAllowedResolver.php @@ -0,0 +1,54 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Model\Processor; + +use Magento\LoginAsCustomerApi\Api\Data\IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory; +use Magento\LoginAsCustomerApi\Api\Data\IsLoginAsCustomerEnabledForCustomerResultInterface; +use Magento\LoginAsCustomerApi\Api\IsLoginAsCustomerEnabledForCustomerInterface; +use Magento\LoginAsCustomerAssistance\Api\IsAssistanceEnabledInterface; + +/** + * @inheritdoc + */ +class IsLoginAsCustomerAllowedResolver implements IsLoginAsCustomerEnabledForCustomerInterface +{ + /** + * @var IsAssistanceEnabledInterface + */ + private $isAssistanceEnabled; + + /** + * @var IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory + */ + private $resultFactory; + + /** + * @param IsAssistanceEnabledInterface $isAssistanceEnabled + * @param IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory $resultFactory + */ + public function __construct( + IsAssistanceEnabledInterface $isAssistanceEnabled, + IsLoginAsCustomerEnabledForCustomerResultInterfaceFactory $resultFactory + ) { + $this->isAssistanceEnabled = $isAssistanceEnabled; + $this->resultFactory = $resultFactory; + } + + /** + * @inheritdoc + */ + public function execute(int $customerId): IsLoginAsCustomerEnabledForCustomerResultInterface + { + $messages = []; + if (!$this->isAssistanceEnabled->execute($customerId)) { + $messages[] = __('Login as Customer assistance is disabled for this Customer.'); + } + + return $this->resultFactory->create(['messages' => $messages]); + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/DeleteLoginAsCustomerAssistanceAllowed.php b/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/DeleteLoginAsCustomerAssistanceAllowed.php new file mode 100644 index 0000000000000..360f2e2799282 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/DeleteLoginAsCustomerAssistanceAllowed.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Model\ResourceModel; + +use Magento\Framework\App\ResourceConnection; + +/** + * Delete Login as Customer assistance allowed record. + */ +class DeleteLoginAsCustomerAssistanceAllowed +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct( + ResourceConnection $resourceConnection + ) { + $this->resourceConnection = $resourceConnection; + } + + /** + * Delete Login as Customer assistance allowed record by Customer id. + * + * @param int $customerId + * @return void + */ + public function execute(int $customerId): void + { + $connection = $this->resourceConnection->getConnection(); + $tableName = $this->resourceConnection->getTableName('login_as_customer_assistance_allowed'); + + $connection->delete( + $tableName, + [ + 'customer_id = ?' => $customerId + ] + ); + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/GetLoginAsCustomerAssistanceAllowed.php b/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/GetLoginAsCustomerAssistanceAllowed.php new file mode 100644 index 0000000000000..412fd86351988 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/GetLoginAsCustomerAssistanceAllowed.php @@ -0,0 +1,50 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Model\ResourceModel; + +use Magento\Framework\App\ResourceConnection; + +/** + * Get Login as Customer assistance allowed record. + */ +class GetLoginAsCustomerAssistanceAllowed +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct( + ResourceConnection $resourceConnection + ) { + $this->resourceConnection = $resourceConnection; + } + + /** + * Get Login as Customer assistance allowed record by Customer id. + * + * @param int $customerId + * @return bool + */ + public function execute(int $customerId): bool + { + $connection = $this->resourceConnection->getConnection(); + $tableName = $this->resourceConnection->getTableName('login_as_customer_assistance_allowed'); + + $select = $connection->select() + ->from( + $tableName + ) + ->where('customer_id = ?', $customerId); + + return !!$connection->fetchOne($select); + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/SaveLoginAsCustomerAssistanceAllowed.php b/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/SaveLoginAsCustomerAssistanceAllowed.php new file mode 100644 index 0000000000000..c3b396bbe332d --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Model/ResourceModel/SaveLoginAsCustomerAssistanceAllowed.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Model\ResourceModel; + +use Magento\Framework\App\ResourceConnection; + +/** + * Save Login as Customer assistance allowed record. + */ +class SaveLoginAsCustomerAssistanceAllowed +{ + /** + * @var ResourceConnection + */ + private $resourceConnection; + + /** + * @param ResourceConnection $resourceConnection + */ + public function __construct( + ResourceConnection $resourceConnection + ) { + $this->resourceConnection = $resourceConnection; + } + + /** + * Save Login as Customer assistance allowed record by Customer id. + * + * @param int $customerId + * @return void + */ + public function execute(int $customerId): void + { + $connection = $this->resourceConnection->getConnection(); + $tableName = $this->resourceConnection->getTableName('login_as_customer_assistance_allowed'); + + $connection->insertOnDuplicate( + $tableName, + [ + 'customer_id' => $customerId + ] + ); + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Model/SetAssistance.php b/app/code/Magento/LoginAsCustomerAssistance/Model/SetAssistance.php new file mode 100644 index 0000000000000..9131599d9cba0 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Model/SetAssistance.php @@ -0,0 +1,101 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Model; + +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerExtensionFactory; +use Magento\LoginAsCustomerAssistance\Api\SetAssistanceInterface; +use Magento\LoginAsCustomerAssistance\Model\ResourceModel\DeleteLoginAsCustomerAssistanceAllowed; +use Magento\LoginAsCustomerAssistance\Model\ResourceModel\SaveLoginAsCustomerAssistanceAllowed; + +/** + * @inheritdoc + */ +class SetAssistance implements SetAssistanceInterface +{ + /** + * @var array + */ + private $registry = []; + + /** + * @var CustomerExtensionFactory + */ + private $customerExtensionFactory; + + /** + * @var CustomerRepositoryInterface + */ + private $customerRepository; + + /** + * @var DeleteLoginAsCustomerAssistanceAllowed + */ + private $deleteLoginAsCustomerAssistanceAllowed; + + /** + * @var SaveLoginAsCustomerAssistanceAllowed + */ + private $saveLoginAsCustomerAssistanceAllowed; + + /** + * @param CustomerExtensionFactory $customerExtensionFactory + * @param CustomerRepositoryInterface $customerRepository + * @param DeleteLoginAsCustomerAssistanceAllowed $deleteLoginAsCustomerAssistanceAllowed + * @param SaveLoginAsCustomerAssistanceAllowed $saveLoginAsCustomerAssistanceAllowed + */ + public function __construct( + CustomerExtensionFactory $customerExtensionFactory, + CustomerRepositoryInterface $customerRepository, + DeleteLoginAsCustomerAssistanceAllowed $deleteLoginAsCustomerAssistanceAllowed, + SaveLoginAsCustomerAssistanceAllowed $saveLoginAsCustomerAssistanceAllowed + ) { + $this->customerExtensionFactory = $customerExtensionFactory; + $this->customerRepository = $customerRepository; + $this->deleteLoginAsCustomerAssistanceAllowed = $deleteLoginAsCustomerAssistanceAllowed; + $this->saveLoginAsCustomerAssistanceAllowed = $saveLoginAsCustomerAssistanceAllowed; + } + + /** + * @inheritdoc + */ + public function execute(int $customerId, bool $isEnabled): void + { + if ($this->isUpdateRequired($customerId, $isEnabled)) { + if ($isEnabled) { + $this->saveLoginAsCustomerAssistanceAllowed->execute($customerId); + } else { + $this->deleteLoginAsCustomerAssistanceAllowed->execute($customerId); + } + $this->updateRegistry($customerId, $isEnabled); + } + } + + /** + * Check if 'assistance_allowed' cached value differs from actual. + * + * @param int $customerId + * @param bool $isEnabled + * @return bool + */ + private function isUpdateRequired(int $customerId, bool $isEnabled): bool + { + return !isset($this->registry[$customerId]) || $this->registry[$customerId] !== $isEnabled; + } + + /** + * Update 'assistance_allowed' cached value. + * + * @param int $customerId + * @param bool $isEnabled + */ + private function updateRegistry(int $customerId, bool $isEnabled): void + { + $this->registry[$customerId] = $isEnabled; + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerDataValidatePlugin.php b/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerDataValidatePlugin.php new file mode 100644 index 0000000000000..9da329b4a3991 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerDataValidatePlugin.php @@ -0,0 +1,133 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Plugin; + +use Magento\Customer\Model\Metadata\Form; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\AuthorizationInterface; +use Magento\Framework\Message\MessageInterface; +use Magento\LoginAsCustomerAssistance\Api\IsAssistanceEnabledInterface; +use Magento\LoginAsCustomerAssistance\Model\ResourceModel\GetLoginAsCustomerAssistanceAllowed; + +/** + * Check if User have permission to change Customers Opt-In preference. + */ +class CustomerDataValidatePlugin +{ + /** + * @var AuthorizationInterface + */ + private $authorization; + + /** + * @var GetLoginAsCustomerAssistanceAllowed + */ + private $getLoginAsCustomerAssistanceAllowed; + + /** + * @param AuthorizationInterface $authorization + * @param GetLoginAsCustomerAssistanceAllowed $getLoginAsCustomerAssistanceAllowed + */ + public function __construct( + AuthorizationInterface $authorization, + GetLoginAsCustomerAssistanceAllowed $getLoginAsCustomerAssistanceAllowed + ) { + $this->authorization = $authorization; + $this->getLoginAsCustomerAssistanceAllowed = $getLoginAsCustomerAssistanceAllowed; + } + + /** + * Check if User have permission to change Customers Opt-In preference. + * + * @param Form $subject + * @param RequestInterface $request + * @param null|string $scope + * @param bool $scopeOnly + * @throws \Magento\Framework\Validator\Exception + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function beforeExtractData( + Form $subject, + RequestInterface $request, + $scope = null, + $scopeOnly = true + ): void { + if ($this->isSetAssistanceAllowedParam($request) + && !$this->authorization->isAllowed('Magento_LoginAsCustomer::allow_shopping_assistance') + ) { + $customerId = $request->getParam('customer_id'); + $assistanceAllowedParam = + (int)$request->getParam('customer')['extension_attributes']['assistance_allowed']; + $assistanceAllowed = $this->getLoginAsCustomerAssistanceAllowed->execute((int)$customerId); + $assistanceAllowedStatus = $this->resolveStatus($assistanceAllowed); + if ($this->isAssistanceAllowedChangeImportant($assistanceAllowedStatus, $assistanceAllowedParam)) { + $errorMessages = [ + MessageInterface::TYPE_ERROR => [ + __( + 'You have no permission to change Opt-In preference.' + ), + ], + ]; + + throw new \Magento\Framework\Validator\Exception( + null, + null, + $errorMessages + ); + } + } + } + + /** + * Check if assistance_allowed param is set. + * + * @param RequestInterface $request + * @return bool + */ + private function isSetAssistanceAllowedParam(RequestInterface $request): bool + { + return is_array($request->getParam('customer')) + && isset($request->getParam('customer')['extension_attributes']) + && isset($request->getParam('customer')['extension_attributes']['assistance_allowed']); + } + + /** + * Check if change of assistance_allowed attribute is important. + * + * E. g. if assistance_allowed is going to be disabled while now it's enabled + * or if it's going to be enabled while now it is disabled or not set at all. + * + * @param int $assistanceAllowed + * @param int $assistanceAllowedParam + * @return bool + */ + private function isAssistanceAllowedChangeImportant(int $assistanceAllowed, int $assistanceAllowedParam): bool + { + $result = false; + if (($assistanceAllowedParam === IsAssistanceEnabledInterface::DENIED + && $assistanceAllowed === IsAssistanceEnabledInterface::ALLOWED) + || + ($assistanceAllowedParam === IsAssistanceEnabledInterface::ALLOWED + && $assistanceAllowed !== IsAssistanceEnabledInterface::ALLOWED)) { + $result = true; + } + + return $result; + } + + /** + * Get integer status value from boolean. + * + * @param bool $assistanceAllowed + * @return int + */ + private function resolveStatus(bool $assistanceAllowed): int + { + return $assistanceAllowed ? IsAssistanceEnabledInterface::ALLOWED : IsAssistanceEnabledInterface::DENIED; + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerExtractorPlugin.php b/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerExtractorPlugin.php new file mode 100644 index 0000000000000..619036da8bb22 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerExtractorPlugin.php @@ -0,0 +1,71 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Plugin; + +use Magento\Customer\Api\Data\CustomerExtensionFactory; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\Customer\Model\CustomerExtractor; +use Magento\Framework\App\RequestInterface; + +/** + * Plugin for Magento\Customer\Model\CustomerExtractor. + */ +class CustomerExtractorPlugin +{ + /** + * @var CustomerExtensionFactory + */ + private $customerExtensionFactory; + + /** + * @param CustomerExtensionFactory $customerExtensionFactory + */ + public function __construct( + CustomerExtensionFactory $customerExtensionFactory + ) { + $this->customerExtensionFactory = $customerExtensionFactory; + } + + /** + * Add assistance_allowed extension attribute value to Customer instance. + * + * @param CustomerExtractor $subject + * @param callable $proceed + * @param string $formCode + * @param RequestInterface $request + * @param array $attributeValues + * @return CustomerInterface + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function aroundExtract( + CustomerExtractor $subject, + callable $proceed, + string $formCode, + RequestInterface $request, + array $attributeValues = [] + ) { + /** @var CustomerInterface $customer */ + $customer = $proceed( + $formCode, + $request, + $attributeValues + ); + + $assistanceAllowedStatus = $request->getParam('assistance_allowed'); + if (!empty($assistanceAllowedStatus)) { + $extensionAttributes = $customer->getExtensionAttributes(); + if (null === $extensionAttributes) { + $extensionAttributes = $this->customerExtensionFactory->create(); + } + $extensionAttributes->setAssistanceAllowed((int)$assistanceAllowedStatus); + $customer->setExtensionAttributes($extensionAttributes); + } + + return $customer; + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerPlugin.php b/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerPlugin.php new file mode 100644 index 0000000000000..0bc22bf5d8869 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Plugin/CustomerPlugin.php @@ -0,0 +1,57 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Plugin; + +use Magento\Customer\Api\CustomerRepositoryInterface; +use Magento\Customer\Api\Data\CustomerInterface; +use Magento\LoginAsCustomerAssistance\Api\SetAssistanceInterface; +use Magento\LoginAsCustomerAssistance\Model\IsAssistanceEnabled; + +/** + * Plugin for Customer assistance_allowed extension attribute. + */ +class CustomerPlugin +{ + /** + * @var SetAssistanceInterface + */ + private $setAssistance; + + /** + * @param SetAssistanceInterface $setAssistance + */ + public function __construct( + SetAssistanceInterface $setAssistance + ) { + $this->setAssistance = $setAssistance; + } + + /** + * Save assistance_allowed extension attribute for Customer instance. + * + * @param CustomerRepositoryInterface $subject + * @param CustomerInterface $result + * @param CustomerInterface $customer + * @return CustomerInterface + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterSave( + CustomerRepositoryInterface $subject, + CustomerInterface $result, + CustomerInterface $customer + ): CustomerInterface { + $customerId = (int)$result->getId(); + $customerExtensionAttributes = $customer->getExtensionAttributes(); + if ($customerExtensionAttributes && $customerExtensionAttributes->getAssistanceAllowed()) { + $isEnabled = (int)$customerExtensionAttributes->getAssistanceAllowed() === IsAssistanceEnabled::ALLOWED; + $this->setAssistance->execute($customerId, $isEnabled); + } + + return $result; + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Plugin/DataProviderWithDefaultAddressesPlugin.php b/app/code/Magento/LoginAsCustomerAssistance/Plugin/DataProviderWithDefaultAddressesPlugin.php new file mode 100644 index 0000000000000..6653340285d32 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Plugin/DataProviderWithDefaultAddressesPlugin.php @@ -0,0 +1,128 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Plugin; + +use Magento\Customer\Model\Customer\DataProviderWithDefaultAddresses; +use Magento\Framework\AuthorizationInterface; +use Magento\LoginAsCustomerApi\Api\ConfigInterface; +use Magento\LoginAsCustomerAssistance\Api\IsAssistanceEnabledInterface; +use Magento\LoginAsCustomerAssistance\Model\ResourceModel\GetLoginAsCustomerAssistanceAllowed; + +/** + * Plugin for managing assistance_allowed extension attribute in Customer form Data Provider. + */ +class DataProviderWithDefaultAddressesPlugin +{ + /** + * @var AuthorizationInterface + */ + private $authorization; + + /** + * @var ConfigInterface + */ + private $config; + + /** + * @var GetLoginAsCustomerAssistanceAllowed + */ + private $getLoginAsCustomerAssistanceAllowed; + + /** + * @param AuthorizationInterface $authorization + * @param ConfigInterface $config + * @param GetLoginAsCustomerAssistanceAllowed $getLoginAsCustomerAssistanceAllowed + */ + public function __construct( + AuthorizationInterface $authorization, + ConfigInterface $config, + GetLoginAsCustomerAssistanceAllowed $getLoginAsCustomerAssistanceAllowed + ) { + $this->authorization = $authorization; + $this->config = $config; + $this->getLoginAsCustomerAssistanceAllowed = $getLoginAsCustomerAssistanceAllowed; + } + + /** + * Add assistance_allowed extension attribute data to Customer form Data Provider. + * + * @param DataProviderWithDefaultAddresses $subject + * @param array $result + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetData( + DataProviderWithDefaultAddresses $subject, + array $result + ): array { + $isAssistanceAllowed = []; + + foreach ($result as $id => $entityData) { + if ($id) { + $assistanceAllowedStatus = $this->resolveStatus( + $this->getLoginAsCustomerAssistanceAllowed->execute((int)$entityData['customer_id']) + ); + $isAssistanceAllowed[$id]['customer']['extension_attributes']['assistance_allowed'] = + (string)$assistanceAllowedStatus; + } + } + + return array_replace_recursive($result, $isAssistanceAllowed); + } + + /** + * Modify assistance_allowed extension attribute metadata for Customer form Data Provider. + * + * @param DataProviderWithDefaultAddresses $subject + * @param array $result + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetMeta( + DataProviderWithDefaultAddresses $subject, + array $result + ): array { + if (!$this->config->isEnabled()) { + $assistanceAllowedConfig = ['visible' => false]; + } elseif (!$this->authorization->isAllowed('Magento_LoginAsCustomer::allow_shopping_assistance')) { + $assistanceAllowedConfig = [ + 'disabled' => true, + 'notice' => __('You have no permission to change Opt-In preference.'), + ]; + } else { + $assistanceAllowedConfig = []; + } + + $config = [ + 'customer' => [ + 'children' => [ + 'extension_attributes.assistance_allowed' => [ + 'arguments' => [ + 'data' => [ + 'config' => $assistanceAllowedConfig, + ], + ], + ], + ], + ], + ]; + + return array_replace_recursive($result, $config); + } + + /** + * Get integer status value from boolean. + * + * @param bool $assistanceAllowed + * @return int + */ + private function resolveStatus(bool $assistanceAllowed): int + { + return $assistanceAllowed ? IsAssistanceEnabledInterface::ALLOWED : IsAssistanceEnabledInterface::DENIED; + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/Plugin/LoginAsCustomerButtonDataProviderPlugin.php b/app/code/Magento/LoginAsCustomerAssistance/Plugin/LoginAsCustomerButtonDataProviderPlugin.php new file mode 100644 index 0000000000000..45a3eb512e7f8 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/Plugin/LoginAsCustomerButtonDataProviderPlugin.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\Plugin; + +use Magento\LoginAsCustomerAdminUi\Ui\Customer\Component\Button\DataProvider; +use Magento\LoginAsCustomerAssistance\Model\IsAssistanceEnabled; + +/** + * Change Login as Customer button behavior if Customer has not granted permission. + */ +class LoginAsCustomerButtonDataProviderPlugin +{ + /** + * @var IsAssistanceEnabled + */ + private $isAssistanceEnabled; + + /** + * @param IsAssistanceEnabled $isAssistanceEnabled + */ + public function __construct( + IsAssistanceEnabled $isAssistanceEnabled + ) { + $this->isAssistanceEnabled = $isAssistanceEnabled; + } + + /** + * Change Login as Customer button behavior if Customer has not granted permission. + * + * @param DataProvider $subject + * @param array $result + * @param int $customerId + * @return array + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterGetData(DataProvider $subject, array $result, int $customerId): array + { + if (isset($result['on_click']) && !$this->isAssistanceEnabled->execute($customerId)) { + $result['on_click'] = 'window.lacNotAllowedPopup()'; + } + + return $result; + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/README.md b/app/code/Magento/LoginAsCustomerAssistance/README.md new file mode 100644 index 0000000000000..b43dd6c8db43a --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/README.md @@ -0,0 +1,3 @@ +# Magento_LoginAsCustomerAssistance module + +The Magento_LoginAsCustomerAssistance module provides possibility to enable/disable LoginAsCustomer functionality per Customer. diff --git a/app/code/Magento/LoginAsCustomerAssistance/ViewModel/ShoppingAssistanceViewModel.php b/app/code/Magento/LoginAsCustomerAssistance/ViewModel/ShoppingAssistanceViewModel.php new file mode 100644 index 0000000000000..f7e224efaa19a --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/ViewModel/ShoppingAssistanceViewModel.php @@ -0,0 +1,100 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\LoginAsCustomerAssistance\ViewModel; + +use Magento\Customer\Model\Session; +use Magento\Framework\View\Element\Block\ArgumentInterface; +use Magento\LoginAsCustomerApi\Api\ConfigInterface; +use Magento\LoginAsCustomerAssistance\Api\ConfigInterface as AssistanceConfigInterface; +use Magento\LoginAsCustomerAssistance\Model\IsAssistanceEnabled; + +/** + * View model for Login as Customer Shopping Assistance block. + */ +class ShoppingAssistanceViewModel implements ArgumentInterface +{ + /** + * @var AssistanceConfigInterface + */ + private $assistanceConfig; + + /** + * @var ConfigInterface + */ + private $config; + + /** + * @var IsAssistanceEnabled + */ + private $isAssistanceEnabled; + + /** + * @var Session + */ + private $session; + + /** + * @param AssistanceConfigInterface $assistanceConfig + * @param ConfigInterface $config + * @param IsAssistanceEnabled $isAssistanceEnabled + * @param Session $session + */ + public function __construct( + AssistanceConfigInterface $assistanceConfig, + ConfigInterface $config, + IsAssistanceEnabled $isAssistanceEnabled, + Session $session + ) { + $this->assistanceConfig = $assistanceConfig; + $this->config = $config; + $this->isAssistanceEnabled = $isAssistanceEnabled; + $this->session = $session; + } + + /** + * Is Login as Customer functionality enabled. + * + * @return bool + */ + public function isLoginAsCustomerEnabled(): bool + { + return $this->config->isEnabled(); + } + + /** + * Is merchant assistance allowed by Customer. + * + * @return bool + */ + public function isAssistanceAllowed(): bool + { + $customerId = $this->session->getId(); + + return $customerId && $this->isAssistanceEnabled->execute((int)$customerId); + } + + /** + * Get shopping assistance checkbox title from config. + * + * @return string + */ + public function getAssistanceCheckboxTitle() + { + return $this->assistanceConfig->getShoppingAssistanceCheckboxTitle(); + } + + /** + * Get shopping assistance checkbox tooltip text from config. + * + * @return string + */ + public function getAssistanceCheckboxTooltip() + { + return $this->assistanceConfig->getShoppingAssistanceCheckboxTooltip(); + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/composer.json b/app/code/Magento/LoginAsCustomerAssistance/composer.json new file mode 100644 index 0000000000000..a02852533b950 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/composer.json @@ -0,0 +1,27 @@ +{ + "name": "magento/module-login-as-customer-assistance", + "description": "", + "require": { + "php": "~7.3.0||~7.4.0", + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-customer": "*", + "magento/module-store": "*", + "magento/module-login-as-customer": "*", + "magento/module-login-as-customer-api": "*" + }, + "suggest": { + "magento/module-login-as-customer-admin-ui": "*" + }, + "type": "magento2-module", + "license": [ + "OSL-3.0", + "AFL-3.0" + ], + "autoload": { + "files": [ "registration.php" ], + "psr-4": { + "Magento\\LoginAsCustomerAssistance\\": "" + } + } +} diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/acl.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/acl.xml new file mode 100644 index 0000000000000..2c16b5a9125df --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/acl.xml @@ -0,0 +1,20 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd"> + <acl> + <resources> + <resource id="Magento_Backend::admin"> + <resource id="Magento_Customer::customer"> + <resource id="Magento_LoginAsCustomer::login"> + <resource id="Magento_LoginAsCustomer::allow_shopping_assistance" title="Allow remote shopping assistance" sortOrder="20" /> + </resource> + </resource> + </resource> + </resources> + </acl> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/adminhtml/di.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/adminhtml/di.xml new file mode 100644 index 0000000000000..3071e3038ffcc --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/adminhtml/di.xml @@ -0,0 +1,21 @@ +<?xml version="1.0" ?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\Customer\Model\Customer\DataProviderWithDefaultAddresses"> + <plugin name="login_as_customer_customer_data_provider_plugin" + type="Magento\LoginAsCustomerAssistance\Plugin\DataProviderWithDefaultAddressesPlugin"/> + </type> + <type name="Magento\Customer\Model\Metadata\Form"> + <plugin name="login_as_customer_customer_data_validate_plugin" + type="Magento\LoginAsCustomerAssistance\Plugin\CustomerDataValidatePlugin"/> + </type> + <type name="Magento\LoginAsCustomerAdminUi\Ui\Customer\Component\Button\DataProvider"> + <plugin name="login_as_customer_button_data_provider_plugin" + type="Magento\LoginAsCustomerAssistance\Plugin\LoginAsCustomerButtonDataProviderPlugin"/> + </type> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/adminhtml/system.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/adminhtml/system.xml new file mode 100644 index 0000000000000..bfdc5519937da --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/adminhtml/system.xml @@ -0,0 +1,21 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd"> + <system> + <section id="login_as_customer" showInWebsite="1"> + <group id="general" showInWebsite="1"> + <field id="shopping_assistance_checkbox_title" translate="label" type="text" sortOrder="50" showInDefault="1" showInWebsite="1" canRestore="1"> + <label>Title for Login as Customer opt-in checkbox</label> + </field> + <field id="shopping_assistance_checkbox_tooltip" translate="label" type="textarea" sortOrder="60" showInDefault="1" showInWebsite="1" canRestore="1"> + <label>Login as Customer checkbox tooltip</label> + </field> + </group> + </section> + </system> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/config.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/config.xml new file mode 100644 index 0000000000000..9b74e4734f00e --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/config.xml @@ -0,0 +1,18 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + --> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd"> + <default> + <login_as_customer> + <general> + <shopping_assistance_checkbox_title>Allow remote shopping assistance</shopping_assistance_checkbox_title> + <shopping_assistance_checkbox_tooltip>This allows merchants to "see what you see" and take actions on your behalf in order to provide better assistance.</shopping_assistance_checkbox_tooltip> + </general> + </login_as_customer> + </default> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/db_schema.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/db_schema.xml new file mode 100644 index 0000000000000..deaecc2bfb777 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/db_schema.xml @@ -0,0 +1,19 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd"> + <table name="login_as_customer_assistance_allowed" resource="default" engine="innodb" comment="Magento Login as Customer Assistance Allowed Table"> + <column xsi:type="int" name="customer_id" padding="10" unsigned="true" nullable="false" comment="Customer ID"/> + <constraint xsi:type="foreign" referenceId="LOGIN_AS_CUSTOMER_ASSISTANCE_ALLOWED_CUSTOMER_ID_CUSTOMER_ENTITY_ENTITY_ID" + table="login_as_customer_assistance_allowed" column="customer_id" referenceTable="customer_entity" + referenceColumn="entity_id" onDelete="CASCADE"/> + <constraint xsi:type="unique" referenceId="LOGIN_AS_CUSTOMER_ASSISTANCE_ALLOWED_CUSTOMER_ID"> + <column name="customer_id"/> + </constraint> + </table> +</schema> diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/db_schema_whitelist.json b/app/code/Magento/LoginAsCustomerAssistance/etc/db_schema_whitelist.json new file mode 100644 index 0000000000000..2c8aa79f3c7b1 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/db_schema_whitelist.json @@ -0,0 +1,11 @@ +{ + "login_as_customer_assistance_allowed": { + "column": { + "customer_id": true + }, + "constraint": { + "LOGIN_AS_CSTR_ASSISTANCE_ALLOWED_CSTR_ID_CSTR_ENTT_ENTT_ID": true, + "LOGIN_AS_CUSTOMER_ASSISTANCE_ALLOWED_CUSTOMER_ID": true + } + } +} \ No newline at end of file diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/di.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/di.xml new file mode 100755 index 0000000000000..0cbf3b4d10da6 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/di.xml @@ -0,0 +1,29 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <preference for="Magento\LoginAsCustomerAssistance\Api\ConfigInterface" + type="Magento\LoginAsCustomerAssistance\Model\Config"/> + <preference for="Magento\LoginAsCustomerAssistance\Api\IsAssistanceEnabledInterface" + type="Magento\LoginAsCustomerAssistance\Model\IsAssistanceEnabled"/> + <preference for="Magento\LoginAsCustomerAssistance\Api\SetAssistanceInterface" + type="Magento\LoginAsCustomerAssistance\Model\SetAssistance"/> + <type name="Magento\LoginAsCustomerApi\Model\IsLoginAsCustomerEnabledForCustomerChain"> + <arguments> + <argument name="resolvers" xsi:type="array"> + <item name="is_allowed" xsi:type="object"> + Magento\LoginAsCustomerAssistance\Model\Processor\IsLoginAsCustomerAllowedResolver + </item> + </argument> + </arguments> + </type> + <type name="Magento\Customer\Api\CustomerRepositoryInterface"> + <plugin name="login_as_customer_customer_repository_plugin" + type="Magento\LoginAsCustomerAssistance\Plugin\CustomerPlugin"/> + </type> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/extension_attributes.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/extension_attributes.xml new file mode 100644 index 0000000000000..ff47820faadaa --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/extension_attributes.xml @@ -0,0 +1,14 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> + +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd"> + <extension_attributes for="Magento\Customer\Api\Data\CustomerInterface"> + <attribute code="assistance_allowed" type="integer"/> + </extension_attributes> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/frontend/di.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/frontend/di.xml new file mode 100644 index 0000000000000..bdb2f82eddd60 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/frontend/di.xml @@ -0,0 +1,13 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\Customer\Model\CustomerExtractor"> + <plugin name="add_assistance_allowed_to_customer_data" + type="Magento\LoginAsCustomerAssistance\Plugin\CustomerExtractorPlugin"/> + </type> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/etc/module.xml b/app/code/Magento/LoginAsCustomerAssistance/etc/module.xml new file mode 100644 index 0000000000000..f443691bcf126 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/etc/module.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" ?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_LoginAsCustomerAssistance"/> +</config> diff --git a/app/code/Magento/LoginAsCustomerAssistance/registration.php b/app/code/Magento/LoginAsCustomerAssistance/registration.php new file mode 100644 index 0000000000000..c2be7af4bd396 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/registration.php @@ -0,0 +1,12 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +\Magento\Framework\Component\ComponentRegistrar::register( + \Magento\Framework\Component\ComponentRegistrar::MODULE, + 'Magento_LoginAsCustomerAssistance', + __DIR__ +); diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/layout/loginascustomer_confirmation_popup.xml b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/layout/loginascustomer_confirmation_popup.xml new file mode 100644 index 0000000000000..ef2e81cd37804 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/layout/loginascustomer_confirmation_popup.xml @@ -0,0 +1,22 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<layout xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/layout_generic.xsd"> + <referenceContainer name="content"> + <block class="Magento\LoginAsCustomerAssistance\Block\Adminhtml\NotAllowedPopup" name="lac.not.allowed.popup" template="Magento_LoginAsCustomerAssistance::not-allowed-popup.phtml"> + <arguments> + <argument name="jsLayout" xsi:type="array"> + <item name="components" xsi:type="array"> + <item name="lac-not-allowed-popup" xsi:type="array"> + <item name="component" xsi:type="string">Magento_LoginAsCustomerAssistance/js/not-allowed-popup</item> + </item> + </item> + </argument> + </arguments> + </block> + </referenceContainer> +</layout> diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/templates/not-allowed-popup.phtml b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/templates/not-allowed-popup.phtml new file mode 100644 index 0000000000000..42e19f9db4931 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/templates/not-allowed-popup.phtml @@ -0,0 +1,15 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +/** @var \Magento\LoginAsCustomerAssistance\Block\Adminhtml\NotAllowedPopup $block */ +?> + +<script type="text/x-magento-init"> + { + "*": { + "Magento_Ui/js/core/app": <?= /* @escapeNotVerified */ $block->getJsLayout();?> + } + } + </script> diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/ui_component/customer_form.xml b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/ui_component/customer_form.xml new file mode 100644 index 0000000000000..b677becd66064 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/ui_component/customer_form.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd"> + <fieldset name="customer"> + <field name="extension_attributes.assistance_allowed" sortOrder="85" formElement="checkbox"> + <argument name="data" xsi:type="array"> + <item name="config" xsi:type="array"> + <item name="source" xsi:type="string">customer</item> + </item> + </argument> + <settings> + <dataType>boolean</dataType> + <label translate="true">Allow remote shopping assistance</label> + </settings> + <formElements> + <checkbox> + <settings> + <valueMap> + <map name="false" xsi:type="number">1</map> + <map name="true" xsi:type="number">2</map> + </valueMap> + <prefer>toggle</prefer> + </settings> + </checkbox> + </formElements> + </field> + </fieldset> +</form> diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/web/js/not-allowed-popup.js b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/web/js/not-allowed-popup.js new file mode 100644 index 0000000000000..59d8dd4a7ed49 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/view/adminhtml/web/js/not-allowed-popup.js @@ -0,0 +1,55 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'uiComponent', + 'Magento_Ui/js/modal/confirm', + 'mage/translate' +], function (Component, confirm, $t) { + + 'use strict'; + + return Component.extend({ + /** + * Initialize Component + */ + initialize: function () { + var self = this, + content; + + this._super(); + + content = '<div class="message message-warning">' + self.content + '</div>'; + + /** + * Not Allowed popup + * + * @returns {Boolean} + */ + window.lacNotAllowedPopup = function () { + confirm({ + title: self.title, + content: content, + modalClass: 'confirm lac-confirm', + buttons: [ + { + text: $t('Cancel'), + class: 'action-secondary action-dismiss', + + /** + * Click handler. + */ + click: function (event) { + this.closeModal(event); + } + } + ] + }); + + return false; + }; + } + }); +}); diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/frontend/layout/customer_account_create.xml b/app/code/Magento/LoginAsCustomerAssistance/view/frontend/layout/customer_account_create.xml new file mode 100644 index 0000000000000..121d20395e295 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/view/frontend/layout/customer_account_create.xml @@ -0,0 +1,25 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="1column" + xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> + <body> + <referenceBlock name="customer_form_register"> + <container name="fieldset.create.info.additional" as="fieldset_create_info_additional"/> + </referenceBlock> + <referenceContainer name="fieldset.create.info.additional"> + <block name="login_as_customer_opt_in_create" + template="Magento_LoginAsCustomerAssistance::shopping-assistance.phtml"> + <arguments> + <argument name="view_model" xsi:type="object"> + Magento\LoginAsCustomerAssistance\ViewModel\ShoppingAssistanceViewModel + </argument> + </arguments> + </block> + </referenceContainer> + </body> +</page> diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/frontend/layout/customer_account_edit.xml b/app/code/Magento/LoginAsCustomerAssistance/view/frontend/layout/customer_account_edit.xml new file mode 100644 index 0000000000000..15b52cb6cf784 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/view/frontend/layout/customer_account_edit.xml @@ -0,0 +1,24 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> + <body> + <referenceBlock name="customer_edit"> + <container name="fieldset.edit.info.additional" as="fieldset_edit_info_additional"/> + </referenceBlock> + <referenceContainer name="fieldset.edit.info.additional"> + <block name="login_as_customer_opt_in_edit" + template="Magento_LoginAsCustomerAssistance::shopping-assistance.phtml"> + <arguments> + <argument name="view_model" xsi:type="object"> + Magento\LoginAsCustomerAssistance\ViewModel\ShoppingAssistanceViewModel + </argument> + </arguments> + </block> + </referenceContainer> + </body> +</page> diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/frontend/templates/shopping-assistance.phtml b/app/code/Magento/LoginAsCustomerAssistance/view/frontend/templates/shopping-assistance.phtml new file mode 100644 index 0000000000000..7765975863485 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/view/frontend/templates/shopping-assistance.phtml @@ -0,0 +1,54 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Framework\Escaper; +use Magento\LoginAsCustomerAssistance\Api\IsAssistanceEnabledInterface; +use Magento\LoginAsCustomerAssistance\ViewModel\ShoppingAssistanceViewModel; + +/** @var Escaper $escaper */ +/** @var ShoppingAssistanceViewModel $viewModel */ +$viewModel = $block->getViewModel(); +?> + +<script type="text/x-magento-init"> +{ + ".form-create-account, .form-edit-account": { + "Magento_LoginAsCustomerAssistance/js/opt-in": { + "allowAccess": "<?= /* @noEscape */ IsAssistanceEnabledInterface::ALLOWED ?>", + "denyAccess": "<?= /* @noEscape */ IsAssistanceEnabledInterface::DENIED ?>" + } + } +} +</script> + +<?php if ($viewModel->isLoginAsCustomerEnabled()): ?> + <div class="field choice"> + <input type="checkbox" + name="assistance_allowed_checkbox" + title="<?= $escaper->escapeHtmlAttr(__($viewModel->getAssistanceCheckboxTitle())) ?>" + value="1" + id="assistance_allowed_checkbox" + <?php if ($viewModel->isAssistanceAllowed()): ?>checked="checked"<?php endif; ?> + class="checkbox"> + <label for="assistance_allowed_checkbox" class="label"> + <span><?= $escaper->escapeHtmlAttr(__($viewModel->getAssistanceCheckboxTitle())) ?></span> + </label> + + <input type="hidden" name="assistance_allowed" value=""/> + + <div class="field-tooltip toggle"> + <span id="tooltip-label" class="label"><span>Tooltip</span></span> + <span id="tooltip" class="field-tooltip-action action-help" tabindex="0" data-toggle="dropdown" + data-bind="mageInit: {'dropdown':{'activeClass': '_active', 'parent': '.field-tooltip.toggle'}}" + aria-labelledby="tooltip-label" aria-haspopup="true" aria-expanded="false" role="button"> + </span> + <div class="field-tooltip-content" data-target="dropdown" + aria-hidden="true"> + <?= $escaper->escapeHtmlAttr(__($viewModel->getAssistanceCheckboxTooltip())) ?> + </div> + </div> + </div> +<?php endif ?> diff --git a/app/code/Magento/LoginAsCustomerAssistance/view/frontend/web/js/opt-in.js b/app/code/Magento/LoginAsCustomerAssistance/view/frontend/web/js/opt-in.js new file mode 100644 index 0000000000000..d225d298b7771 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerAssistance/view/frontend/web/js/opt-in.js @@ -0,0 +1,18 @@ +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +define([ + 'jquery' +], function ($) { + 'use strict'; + + return function (config, element) { + $(element).on('submit', function () { + this.elements['assistance_allowed'].value = + this.elements['assistance_allowed_checkbox'].checked ? + config.allowAccess : config.denyAccess; + }); + }; +}); diff --git a/app/code/Magento/Paypal/Model/AbstractConfig.php b/app/code/Magento/Paypal/Model/AbstractConfig.php index 6fc3ac7a7732d..c8ad56f242a63 100644 --- a/app/code/Magento/Paypal/Model/AbstractConfig.php +++ b/app/code/Magento/Paypal/Model/AbstractConfig.php @@ -58,7 +58,7 @@ abstract class AbstractConfig implements ConfigInterface /** * @var string */ - private static $bnCode = 'Magento_Cart_%s'; + private static $bnCode = 'Magento_2_%s'; /** * @var \Magento\Framework\App\Config\ScopeConfigInterface diff --git a/app/code/Magento/Paypal/Test/Unit/Model/AbstractConfigTest.php b/app/code/Magento/Paypal/Test/Unit/Model/AbstractConfigTest.php index 44151dc81a34b..e532e50a51209 100644 --- a/app/code/Magento/Paypal/Test/Unit/Model/AbstractConfigTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Model/AbstractConfigTest.php @@ -15,6 +15,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +/** + * Test for \Magento\Paypal\Model\AbstractConfig + */ class AbstractConfigTest extends TestCase { @@ -353,7 +356,7 @@ public function testGetBuildNotationCode() $productMetadata ); - self::assertEquals('Magento_Cart_SomeEdition', $this->config->getBuildNotationCode()); + self::assertEquals('Magento_2_SomeEdition', $this->config->getBuildNotationCode()); } /** diff --git a/app/code/Magento/PaypalGraphQl/Model/PayflowProCcVaultAdditionalDataProvider.php b/app/code/Magento/PaypalGraphQl/Model/PayflowProCcVaultAdditionalDataProvider.php new file mode 100644 index 0000000000000..781cd8d0a9095 --- /dev/null +++ b/app/code/Magento/PaypalGraphQl/Model/PayflowProCcVaultAdditionalDataProvider.php @@ -0,0 +1,47 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\PaypalGraphQl\Model; + +use Magento\Framework\Stdlib\ArrayManager; +use Magento\QuoteGraphQl\Model\Cart\Payment\AdditionalDataProviderInterface; + +/** + * Get payment additional data for Payflow pro cc vault payment + */ +class PayflowProCcVaultAdditionalDataProvider implements AdditionalDataProviderInterface +{ + const CC_VAULT_CODE = 'payflowpro_cc_vault'; + + /** + * @var ArrayManager + */ + private $arrayManager; + + /** + * @param ArrayManager $arrayManager + */ + public function __construct( + ArrayManager $arrayManager + ) { + $this->arrayManager = $arrayManager; + } + + /** + * Returns additional data + * + * @param array $args + * @return array + */ + public function getData(array $args): array + { + if (isset($args[self::CC_VAULT_CODE])) { + return $args[self::CC_VAULT_CODE]; + } + return []; + } +} diff --git a/app/code/Magento/PaypalGraphQl/Model/Plugin/Cart/PayflowProCcVault/SetPaymentMethodOnCart.php b/app/code/Magento/PaypalGraphQl/Model/Plugin/Cart/PayflowProCcVault/SetPaymentMethodOnCart.php new file mode 100644 index 0000000000000..46bad75f0ed19 --- /dev/null +++ b/app/code/Magento/PaypalGraphQl/Model/Plugin/Cart/PayflowProCcVault/SetPaymentMethodOnCart.php @@ -0,0 +1,95 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\PaypalGraphQl\Model\Plugin\Cart\PayflowProCcVault; + +use Magento\Quote\Model\Quote; +use Magento\QuoteGraphQl\Model\Cart\Payment\AdditionalDataProviderPool; +use Magento\Sales\Model\Order\Payment\Repository as PaymentRepository; +use Magento\Vault\Api\Data\PaymentTokenInterface; +use Magento\Vault\Api\PaymentTokenManagementInterface; + +/** + * Set additionalInformation on payment for PayflowPro vault method + */ +class SetPaymentMethodOnCart +{ + const CC_VAULT_CODE = 'payflowpro_cc_vault'; + + /** + * @var PaymentRepository + */ + private $paymentRepository; + + /** + * @var AdditionalDataProviderPool + */ + private $additionalDataProviderPool; + + /** + * PaymentTokenManagementInterface $paymentTokenManagement + */ + private $paymentTokenManagement; + + /** + * @param PaymentRepository $paymentRepository + * @param AdditionalDataProviderPool $additionalDataProviderPool + * @param PaymentTokenManagementInterface $paymentTokenManagement + */ + public function __construct( + PaymentRepository $paymentRepository, + AdditionalDataProviderPool $additionalDataProviderPool, + PaymentTokenManagementInterface $paymentTokenManagement + ) { + $this->paymentRepository = $paymentRepository; + $this->additionalDataProviderPool = $additionalDataProviderPool; + $this->paymentTokenManagement = $paymentTokenManagement; + } + + /** + * Set public hash and customer id on payment additionalInformation + * + * @param \Magento\QuoteGraphQl\Model\Cart\SetPaymentMethodOnCart $subject + * @param mixed $result + * @param Quote $cart + * @param array $additionalData + * @return void + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function afterExecute( + \Magento\QuoteGraphQl\Model\Cart\SetPaymentMethodOnCart $subject, + $result, + Quote $cart, + array $additionalData + ): void { + $additionalData = $this->additionalDataProviderPool->getData(self::CC_VAULT_CODE, $additionalData); + $customerId = (int) $cart->getCustomer()->getId(); + $payment = $cart->getPayment(); + if (!is_array($additionalData) + || !isset($additionalData[PaymentTokenInterface::PUBLIC_HASH]) + || $customerId === 0 + ) { + return; + } + $tokenPublicHash = $additionalData[PaymentTokenInterface::PUBLIC_HASH]; + if ($tokenPublicHash === null) { + return; + } + $paymentToken = $this->paymentTokenManagement->getByPublicHash($tokenPublicHash, $customerId); + if ($paymentToken === null) { + return; + } + $payment->setAdditionalInformation( + [ + PaymentTokenInterface::CUSTOMER_ID => $customerId, + PaymentTokenInterface::PUBLIC_HASH => $tokenPublicHash + ] + ); + $payment->save(); + } +} diff --git a/app/code/Magento/PaypalGraphQl/Observer/PayflowProSetCcData.php b/app/code/Magento/PaypalGraphQl/Observer/PayflowProSetCcData.php index 801ec91d063c5..55310b1744107 100644 --- a/app/code/Magento/PaypalGraphQl/Observer/PayflowProSetCcData.php +++ b/app/code/Magento/PaypalGraphQl/Observer/PayflowProSetCcData.php @@ -13,6 +13,7 @@ use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\Payment\Observer\AbstractDataAssignObserver; use Magento\Quote\Api\Data\PaymentInterface; +use Magento\Quote\Model\Quote\Payment; /** * Class PayflowProSetCcData set CcData to quote payment @@ -50,21 +51,23 @@ public function execute(Observer $observer) { $dataObject = $this->readDataArgument($observer); $additionalData = $dataObject->getData(PaymentInterface::KEY_ADDITIONAL_DATA); + /** + * @var Payment $paymentModel + */ $paymentModel = $this->readPaymentModelArgument($observer); + $customerId = (int)$paymentModel->getQuote()->getCustomer()->getId(); if (!isset($additionalData['cc_details'])) { return; } - if ($this->isPayflowProVaultEnable()) { - if (!isset($additionalData[self::IS_ACTIVE_PAYMENT_TOKEN_ENABLER])) { - $paymentModel->setData(self::IS_ACTIVE_PAYMENT_TOKEN_ENABLER, false); + if ($this->isPayflowProVaultEnable() && $customerId !== 0) { + if (isset($additionalData[self::IS_ACTIVE_PAYMENT_TOKEN_ENABLER])) { + $paymentModel->setData( + self::IS_ACTIVE_PAYMENT_TOKEN_ENABLER, + $additionalData[self::IS_ACTIVE_PAYMENT_TOKEN_ENABLER] + ); } - - $paymentModel->setData( - self::IS_ACTIVE_PAYMENT_TOKEN_ENABLER, - $additionalData[self::IS_ACTIVE_PAYMENT_TOKEN_ENABLER] - ); } else { $paymentModel->setData(self::IS_ACTIVE_PAYMENT_TOKEN_ENABLER, false); } diff --git a/app/code/Magento/PaypalGraphQl/composer.json b/app/code/Magento/PaypalGraphQl/composer.json index 13864e54fa1f5..285217da64d72 100644 --- a/app/code/Magento/PaypalGraphQl/composer.json +++ b/app/code/Magento/PaypalGraphQl/composer.json @@ -13,7 +13,8 @@ "magento/module-quote-graph-ql": "*", "magento/module-sales": "*", "magento/module-payment": "*", - "magento/module-store": "*" + "magento/module-store": "*", + "magento/module-vault": "*" }, "suggest": { "magento/module-graph-ql": "*", diff --git a/app/code/Magento/PaypalGraphQl/etc/graphql/di.xml b/app/code/Magento/PaypalGraphQl/etc/graphql/di.xml index f1b99295f5627..f5f22050fe50a 100644 --- a/app/code/Magento/PaypalGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/PaypalGraphQl/etc/graphql/di.xml @@ -12,6 +12,7 @@ <type name="Magento\QuoteGraphQl\Model\Cart\SetPaymentMethodOnCart"> <plugin name="hosted_pro_payment_method" type="Magento\PaypalGraphQl\Model\Plugin\Cart\HostedPro\SetPaymentMethodOnCart"/> <plugin name="payflowpro_payment_method" type="Magento\PaypalGraphQl\Model\Plugin\Cart\PayflowPro\SetPaymentMethodOnCart"/> + <plugin name="payflowpro_cc_vault_payment_method" type="Magento\PaypalGraphQl\Model\Plugin\Cart\PayflowProCcVault\SetPaymentMethodOnCart"/> </type> <type name="Magento\Paypal\Model\Payflowlink"> <plugin name="payflow_link_update_redirect_urls" type="Magento\PaypalGraphQl\Model\Plugin\Payflowlink"/> @@ -51,6 +52,7 @@ <item name="payflow_advanced" xsi:type="object">Magento\PaypalGraphQl\Model\PayflowLinkAdditionalDataProvider</item> <item name="payflowpro" xsi:type="object">\Magento\PaypalGraphQl\Model\PayflowProAdditionalDataProvider</item> <item name="hosted_pro" xsi:type="object">\Magento\PaypalGraphQl\Model\HostedProAdditionalDataProvider</item> + <item name="payflowpro_cc_vault" xsi:type="object">\Magento\PaypalGraphQl\Model\PayflowProCcVaultAdditionalDataProvider</item> </argument> </arguments> </type> diff --git a/app/code/Magento/PaypalGraphQl/etc/schema.graphqls b/app/code/Magento/PaypalGraphQl/etc/schema.graphqls index 52b0a6ec97518..cdc8ee6fda2f3 100644 --- a/app/code/Magento/PaypalGraphQl/etc/schema.graphqls +++ b/app/code/Magento/PaypalGraphQl/etc/schema.graphqls @@ -37,7 +37,7 @@ type PayflowLinkToken @doc(description:"Contains information used to generate Pa paypal_url: String @doc(description:"PayPal URL used for requesting Payflow form") } -type HostedProUrl @doc(desription:"Contains secure URL used for Payments Pro Hosted Solution payment method.") { +type HostedProUrl @doc(description:"Contains secure URL used for Payments Pro Hosted Solution payment method.") { secure_form_url: String @doc(description:"Secure Url generated by PayPal") } @@ -51,6 +51,7 @@ input PaymentMethodInput { payflow_link: PayflowLinkInput @doc(description:"Required input for PayPal Payflow Link and Payments Advanced payments") payflowpro: PayflowProInput @doc(description: "Required input type for PayPal Payflow Pro and Payment Pro payments") hosted_pro: HostedProInput @doc(description:"Required input for PayPal Hosted pro payments") + payflowpro_cc_vault: VaultTokenInput @doc(description:"Required input type for PayPal Payflow Pro vault payments") } input HostedProInput @doc(description:"A set of relative URLs that PayPal will use in response to various actions during the authorization process. Magento prepends the base URL to this value to create a full URL. For example, if the full URL is https://www.example.com/path/to/page.html, the relative URL is path/to/page.html. Use this input for Payments Pro Hosted Solution payment method.") { @@ -146,3 +147,7 @@ type PayflowProResponseOutput { type StoreConfig { payment_payflowpro_cc_vault_active: String @doc(description: "Payflow Pro vault status.") } + +input VaultTokenInput @doc(description:"Required input for payment methods with Vault support.") { + public_hash: String! @doc(description: "The public hash of the payment token") +} 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Api\Data\CartInterface; +use Magento\Quote\Model\Cart\BuyRequest\BuyRequestBuilder; +use Magento\Quote\Model\Cart\Data\AddProductsToCartOutput; +use Magento\Quote\Model\MaskedQuoteIdToQuoteIdInterface; +use Magento\Quote\Model\Quote; +use Magento\Framework\Message\MessageInterface; + +/** + * Unified approach to add products to the Shopping Cart. + * Client code must validate, that customer is eligible to call service with provided {cartId} and {cartItems} + */ +class AddProductsToCart +{ + /**#@+ + * Error message codes + */ + private const ERROR_PRODUCT_NOT_FOUND = 'PRODUCT_NOT_FOUND'; + private const ERROR_INSUFFICIENT_STOCK = 'INSUFFICIENT_STOCK'; + private const ERROR_NOT_SALABLE = 'NOT_SALABLE'; + private const ERROR_UNDEFINED = 'UNDEFINED'; + /**#@-*/ + + /** + * List of error messages and codes. + */ + private const MESSAGE_CODES = [ + 'Could not find a product with SKU' => 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\BuyRequest; + +use Magento\Framework\DataObject; +use Magento\Framework\DataObjectFactory; +use Magento\Quote\Model\Cart\Data\CartItem; + +/** + * Build buy request for adding products to cart + */ +class BuyRequestBuilder +{ + /** + * @var BuyRequestDataProviderInterface[] + */ + private $providers; + + /** + * @var DataObjectFactory + */ + private $dataObjectFactory; + + /** + * @param DataObjectFactory $dataObjectFactory + * @param array $providers + */ + public function __construct( + DataObjectFactory $dataObjectFactory, + array $providers = [] + ) { + $this->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\BuyRequest; + +use Magento\Quote\Model\Cart\Data\CartItem; + +/** + * Provides data for buy request for different types of products + */ +interface BuyRequestDataProviderInterface +{ + /** + * Provide buy request data from add to cart item request + * + * @param CartItem $cartItem + * @return array + */ + public function execute(CartItem $cartItem): array; +} diff --git a/app/code/Magento/Quote/Model/Cart/BuyRequest/CustomizableOptionDataProvider.php b/app/code/Magento/Quote/Model/Cart/BuyRequest/CustomizableOptionDataProvider.php new file mode 100644 index 0000000000000..90f2cbbc5f9e3 --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/BuyRequest/CustomizableOptionDataProvider.php @@ -0,0 +1,107 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\BuyRequest; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Quote\Model\Cart\Data\CartItem; + +/** + * Extract buy request elements require for custom options + */ +class CustomizableOptionDataProvider implements BuyRequestDataProviderInterface +{ + private const OPTION_TYPE = 'custom-option'; + + /** + * @inheritdoc + * + * @throws LocalizedException + */ + public function execute(CartItem $cartItem): array + { + $customizableOptionsData = []; + + foreach ($cartItem->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\Data; + +use Magento\Quote\Api\Data\CartInterface; + +/** + * DTO represents output for \Magento\Quote\Model\Cart\AddProductsToCart + */ +class AddProductsToCartOutput +{ + /** + * @var CartInterface + */ + private $cart; + + /** + * @var Error[] + */ + private $errors; + + /** + * @param CartInterface $cart + * @param Error[] $errors + */ + public function __construct(CartInterface $cart, array $errors) + { + $this->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\Data; + +/** + * DTO represents Cart Item data + */ +class CartItem +{ + /** + * @var string + */ + private $sku; + + /** + * @var float + */ + private $quantity; + + /** + * @var string + */ + private $parentSku; + + /** + * @var SelectedOption[] + */ + private $selectedOptions; + + /** + * @var EnteredOption[] + */ + private $enteredOptions; + + /** + * @param string $sku + * @param float $quantity + * @param string|null $parentSku + * @param array|null $selectedOptions + * @param array|null $enteredOptions + */ + public function __construct( + string $sku, + float $quantity, + string $parentSku = null, + array $selectedOptions = null, + array $enteredOptions = null + ) { + $this->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\Data; + +use Magento\Framework\Exception\InputException; + +/** + * Creates CartItem DTO + */ +class CartItemFactory +{ + /** + * Creates CartItem DTO + * + * @param array $data + * @return CartItem + * @throws InputException + */ + public function create(array $data): CartItem + { + if (!isset($data['sku'], $data['quantity'])) { + throw new InputException(__('Required fields are not present: sku, quantity')); + } + return new CartItem( + $data['sku'], + $data['quantity'], + $data['parent_sku'] ?? null, + isset($data['selected_options']) ? $this->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\Data; + +/** + * DTO for quote item entered option + */ +class EnteredOption +{ + /** + * @var string + */ + private $uid; + + /** + * @var string + */ + private $value; + + /** + * @param string $uid + * @param string $value + */ + public function __construct(string $uid, string $value) + { + $this->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\Data; + +/** + * DTO represents error item + */ +class Error +{ + /** + * @var string + */ + private $message; + + /** + * @var string + */ + private $code; + + /** + * @var int + */ + private $cartItemPosition; + + /** + * @param string $message + * @param string $code + * @param int $cartItemPosition + */ + public function __construct(string $message, string $code, int $cartItemPosition) + { + $this->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Quote\Model\Cart\Data; + +/** + * DTO for quote item selected option + */ +class SelectedOption +{ + /** + * @var string + */ + private $id; + + /** + * @param string $id + */ + public function __construct(string $id) + { + $this->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 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\Quote\Model\Cart\BuyRequest\BuyRequestBuilder"> + <arguments> + <argument name="providers" xsi:type="array"> + <item name="customizable_option" xsi:type="object">Magento\Quote\Model\Cart\BuyRequest\CustomizableOptionDataProvider</item> + </argument> + </arguments> + </type> +</config> 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteBundleOptions\Model\Cart\BuyRequest; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Quote\Model\Cart\BuyRequest\BuyRequestDataProviderInterface; +use Magento\Quote\Model\Cart\Data\CartItem; + +/** + * Data provider for bundle product buy requests + */ +class BundleDataProvider implements BuyRequestDataProviderInterface +{ + private const OPTION_TYPE = 'bundle'; + + /** + * @inheritdoc + * + * @throws LocalizedException + */ + public function execute(CartItem $cartItem): array + { + $bundleOptionsData = []; + + foreach ($cartItem->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 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\Quote\Model\Cart\BuyRequest\BuyRequestBuilder"> + <arguments> + <argument name="providers" xsi:type="array"> + <item name="bundle" xsi:type="object">Magento\QuoteBundleOptions\Model\Cart\BuyRequest\BundleDataProvider</item> + </argument> + </arguments> + </type> +</config> 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 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_QuoteBundleOptions"/> +</config> 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Component\ComponentRegistrar; + +ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magento_QuoteBundleOptions', __DIR__); diff --git a/app/code/Magento/QuoteConfigurableOptions/Model/Cart/BuyRequest/SuperAttributeDataProvider.php b/app/code/Magento/QuoteConfigurableOptions/Model/Cart/BuyRequest/SuperAttributeDataProvider.php new file mode 100644 index 0000000000000..d58b574352bd8 --- /dev/null +++ b/app/code/Magento/QuoteConfigurableOptions/Model/Cart/BuyRequest/SuperAttributeDataProvider.php @@ -0,0 +1,76 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteConfigurableOptions\Model\Cart\BuyRequest; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Quote\Model\Cart\BuyRequest\BuyRequestDataProviderInterface; +use Magento\Quote\Model\Cart\Data\CartItem; + +/** + * DataProvider for building super attribute options in buy requests + */ +class SuperAttributeDataProvider implements BuyRequestDataProviderInterface +{ + private const OPTION_TYPE = 'configurable'; + + /** + * @inheritdoc + * + * @throws LocalizedException + */ + public function execute(CartItem $cartItem): array + { + $configurableProductData = []; + foreach ($cartItem->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 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\Quote\Model\Cart\BuyRequest\BuyRequestBuilder"> + <arguments> + <argument name="providers" xsi:type="array"> + <item name="super_attribute" xsi:type="object">Magento\QuoteConfigurableOptions\Model\Cart\BuyRequest\SuperAttributeDataProvider</item> + </argument> + </arguments> + </type> +</config> 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 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_QuoteConfigurableOptions"/> +</config> 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Component\ComponentRegistrar; + +ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magento_QuoteConfigurableOptions', __DIR__); diff --git a/app/code/Magento/QuoteDownloadableLinks/Model/Cart/BuyRequest/DownloadableLinkDataProvider.php b/app/code/Magento/QuoteDownloadableLinks/Model/Cart/BuyRequest/DownloadableLinkDataProvider.php new file mode 100644 index 0000000000000..e412c7df573c7 --- /dev/null +++ b/app/code/Magento/QuoteDownloadableLinks/Model/Cart/BuyRequest/DownloadableLinkDataProvider.php @@ -0,0 +1,77 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteDownloadableLinks\Model\Cart\BuyRequest; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Quote\Model\Cart\BuyRequest\BuyRequestDataProviderInterface; +use Magento\Quote\Model\Cart\Data\CartItem; + +/** + * DataProvider for building downloadable product links in buy requests + */ +class DownloadableLinkDataProvider implements BuyRequestDataProviderInterface +{ + private const OPTION_TYPE = 'downloadable'; + + /** + * @inheritdoc + * + * @throws LocalizedException + */ + public function execute(CartItem $cartItem): array + { + $linksData = []; + + foreach ($cartItem->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 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + <type name="Magento\Quote\Model\Cart\BuyRequest\BuyRequestBuilder"> + <arguments> + <argument name="providers" xsi:type="array"> + <item name="downloadable" xsi:type="object">Magento\QuoteDownloadableLinks\Model\Cart\BuyRequest\DownloadableLinkDataProvider</item> + </argument> + </arguments> + </type> +</config> 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 @@ +<?xml version="1.0"?> +<!-- +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +--> +<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd"> + <module name="Magento_QuoteDownloadableLinks"/> +</config> 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +use Magento\Framework\Component\ComponentRegistrar; + +ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magento_QuoteDownloadableLinks', __DIR__); diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php new file mode 100644 index 0000000000000..d5e554f096ec1 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php @@ -0,0 +1,93 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\QuoteGraphQl\Model\Resolver; + +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Quote\Model\Cart\AddProductsToCart as AddProductsToCartService; +use Magento\Quote\Model\Cart\Data\AddProductsToCartOutput; +use Magento\Quote\Model\Cart\Data\CartItemFactory; +use Magento\QuoteGraphQl\Model\Cart\GetCartForUser; +use Magento\Quote\Model\Cart\Data\Error; + +/** + * Resolver for addProductsToCart mutation + * + * @inheritdoc + */ +class AddProductsToCart implements ResolverInterface +{ + /** + * @var GetCartForUser + */ + private $getCartForUser; + + /** + * @var AddProductsToCartService + */ + private $addProductsToCartService; + + /** + * @param GetCartForUser $getCartForUser + * @param AddProductsToCartService $addProductsToCart + */ + public function __construct( + GetCartForUser $getCartForUser, + AddProductsToCartService $addProductsToCart + ) { + $this->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/SalesGraphQl/Model/InvoiceItemInterfaceTypeResolverComposite.php b/app/code/Magento/SalesGraphQl/Model/InvoiceItemInterfaceTypeResolverComposite.php deleted file mode 100644 index 5b3c2aee1cecf..0000000000000 --- a/app/code/Magento/SalesGraphQl/Model/InvoiceItemInterfaceTypeResolverComposite.php +++ /dev/null @@ -1,58 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\SalesGraphQl\Model; - -use Magento\Framework\GraphQl\Exception\GraphQlInputException; -use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; - -/** - * Composite class to resolve invoice item type - */ -class InvoiceItemInterfaceTypeResolverComposite implements TypeResolverInterface -{ - /** - * @var TypeResolverInterface[] - */ - private $invoiceItemTypeResolvers = []; - - /** - * @param TypeResolverInterface[] $invoiceItemTypeResolvers - */ - public function __construct(array $invoiceItemTypeResolvers = []) - { - $this->invoiceItemTypeResolvers = $invoiceItemTypeResolvers; - } - - /** - * Resolve item type of an invoice through composite resolvers - * - * @param array $data - * @return string - * @throws GraphQlInputException - */ - public function resolveType(array $data): string - { - $resolvedType = null; - - foreach ($this->invoiceItemTypeResolvers as $invoiceItemTypeResolver) { - if (!isset($data['product_type'])) { - throw new GraphQlInputException( - __('Missing key %1 in sales item data', ['product_type']) - ); - } - $resolvedType = $invoiceItemTypeResolver->resolveType($data); - if (!empty($resolvedType)) { - return $resolvedType; - } - } - - throw new GraphQlInputException( - __('Concrete type for %1 not implemented', ['InvoiceItemInterface']) - ); - } -} diff --git a/app/code/Magento/SalesGraphQl/Model/InvoiceItemTypeResolver.php b/app/code/Magento/SalesGraphQl/Model/InvoiceItemTypeResolver.php deleted file mode 100644 index 4c2dcdf7f29ba..0000000000000 --- a/app/code/Magento/SalesGraphQl/Model/InvoiceItemTypeResolver.php +++ /dev/null @@ -1,29 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\SalesGraphQl\Model; - -use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; - -/** - * Leaf for composite class to resolve invoice item type - */ -class InvoiceItemTypeResolver implements TypeResolverInterface -{ - /** - * @inheritDoc - */ - public function resolveType(array $data): string - { - if (isset($data['product_type'])) { - if ($data['product_type'] == 'bundle') { - return 'BundleInvoiceItem'; - } - } - return 'InvoiceItem'; - } -} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem/DataProvider.php b/app/code/Magento/SalesGraphQl/Model/OrderItem/DataProvider.php similarity index 99% rename from app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem/DataProvider.php rename to app/code/Magento/SalesGraphQl/Model/OrderItem/DataProvider.php index 20cdd7313b8ad..a69d9bf58ee8d 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem/DataProvider.php +++ b/app/code/Magento/SalesGraphQl/Model/OrderItem/DataProvider.php @@ -5,7 +5,7 @@ */ declare(strict_types=1); -namespace Magento\SalesGraphQl\Model\Resolver\OrderItem; +namespace Magento\SalesGraphQl\Model\OrderItem; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Api\ProductRepositoryInterface; diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem/OptionsProcessor.php b/app/code/Magento/SalesGraphQl/Model/OrderItem/OptionsProcessor.php similarity index 97% rename from app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem/OptionsProcessor.php rename to app/code/Magento/SalesGraphQl/Model/OrderItem/OptionsProcessor.php index e168f185d39a4..83b7e0cc46d96 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem/OptionsProcessor.php +++ b/app/code/Magento/SalesGraphQl/Model/OrderItem/OptionsProcessor.php @@ -5,7 +5,7 @@ */ declare(strict_types=1); -namespace Magento\SalesGraphQl\Model\Resolver\OrderItem; +namespace Magento\SalesGraphQl\Model\OrderItem; use Magento\Sales\Api\Data\OrderItemInterface; diff --git a/app/code/Magento/SalesGraphQl/Model/OrderItemInterfaceTypeResolverComposite.php b/app/code/Magento/SalesGraphQl/Model/OrderItemInterfaceTypeResolverComposite.php deleted file mode 100644 index ed7b133ce1bb8..0000000000000 --- a/app/code/Magento/SalesGraphQl/Model/OrderItemInterfaceTypeResolverComposite.php +++ /dev/null @@ -1,58 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\SalesGraphQl\Model; - -use Magento\Framework\GraphQl\Exception\GraphQlInputException; -use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; - -/** - * Composite class to resolve order item type - */ -class OrderItemInterfaceTypeResolverComposite implements TypeResolverInterface -{ - /** - * TypeResolverInterface[] - */ - private $orderItemTypeResolvers = []; - - /** - * @param TypeResolverInterface[] $orderItemTypeResolvers - */ - public function __construct(array $orderItemTypeResolvers = []) - { - $this->orderItemTypeResolvers = $orderItemTypeResolvers; - } - - /** - * Resolve item type of an order through composite resolvers - * - * @param array $data - * @return string - * @throws GraphQlInputException - */ - public function resolveType(array $data) : string - { - $resolvedType = null; - - foreach ($this->orderItemTypeResolvers as $orderItemTypeResolver) { - if (!isset($data['product_type'])) { - throw new GraphQlInputException( - __('Missing key %1 in sales item data', ['product_type']) - ); - } - $resolvedType = $orderItemTypeResolver->resolveType($data); - if (!empty($resolvedType)) { - return $resolvedType; - } - } - - throw new GraphQlInputException( - __('Concrete type for %1 not implemented', ['OrderItemInterface']) - ); - } -} diff --git a/app/code/Magento/SalesGraphQl/Model/OrderItemTypeResolver.php b/app/code/Magento/SalesGraphQl/Model/OrderItemTypeResolver.php deleted file mode 100644 index 8e1b495406b54..0000000000000 --- a/app/code/Magento/SalesGraphQl/Model/OrderItemTypeResolver.php +++ /dev/null @@ -1,29 +0,0 @@ -<?php -/** - * Copyright © Magento, Inc. All rights reserved. - * See COPYING.txt for license details. - */ -declare(strict_types=1); - -namespace Magento\SalesGraphQl\Model; - -use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; - -/** - * Leaf for composite class to resolve order item type - */ -class OrderItemTypeResolver implements TypeResolverInterface -{ - /** - * @inheritDoc - */ - public function resolveType(array $data): string - { - if (isset($data['product_type'])) { - if ($data['product_type'] == 'bundle') { - return 'BundleOrderItem'; - } - } - return 'OrderItem'; - } -} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoComments.php b/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoComments.php new file mode 100644 index 0000000000000..2c9fedf61b502 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoComments.php @@ -0,0 +1,50 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Resolver\CreditMemo; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Api\Data\CreditmemoInterface; + +/** + * Resolve credit memo comments + */ +class CreditMemoComments implements ResolverInterface +{ + /** + * @inheritDoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (!(($value['model'] ?? null) instanceof CreditmemoInterface)) { + throw new LocalizedException(__('"model" value should be specified')); + } + + /** @var CreditmemoInterface $creditMemo */ + $creditMemo = $value['model']; + + $comments = []; + foreach ($creditMemo->getComments() as $comment) { + if ($comment->getIsVisibleOnFront()) { + $comments[] = [ + 'message' => $comment->getComment(), + 'timestamp' => $comment->getCreatedAt() + ]; + } + } + + return $comments; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoItems.php b/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoItems.php new file mode 100644 index 0000000000000..e1cee27e93f87 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoItems.php @@ -0,0 +1,158 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Resolver\CreditMemo; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Api\Data\CreditmemoInterface; +use Magento\Sales\Api\Data\CreditmemoItemInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\Data\OrderItemInterface; +use Magento\SalesGraphQl\Model\OrderItem\DataProvider as OrderItemProvider; + +/** + * Resolve credit memos items data + */ +class CreditMemoItems implements ResolverInterface +{ + /** + * @var ValueFactory + */ + private $valueFactory; + + /** + * @var OrderItemProvider + */ + private $orderItemProvider; + + /** + * @param ValueFactory $valueFactory + * @param OrderItemProvider $orderItemProvider + */ + public function __construct( + ValueFactory $valueFactory, + OrderItemProvider $orderItemProvider + ) { + $this->valueFactory = $valueFactory; + $this->orderItemProvider = $orderItemProvider; + } + + /** + * @inheritDoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (!(($value['model'] ?? null) instanceof CreditmemoInterface)) { + throw new LocalizedException(__('"model" value should be specified')); + } + + if (!(($value['order'] ?? null) instanceof OrderInterface)) { + throw new LocalizedException(__('"order" value should be specified')); + } + + /** @var CreditmemoInterface $creditMemoModel */ + $creditMemoModel = $value['model']; + /** @var OrderInterface $parentOrderModel */ + $parentOrderModel = $value['order']; + + return $this->valueFactory->create( + $this->getCreditMemoItems($parentOrderModel, $creditMemoModel->getItems()) + ); + } + + /** + * Get credit memo items data as a promise + * + * @param OrderInterface $order + * @param array $creditMemoItems + * @return \Closure + */ + private function getCreditMemoItems(OrderInterface $order, array $creditMemoItems): \Closure + { + $orderItems = []; + foreach ($creditMemoItems as $item) { + $this->orderItemProvider->addOrderItemId((int)$item->getOrderItemId()); + } + + return function () use ($order, $creditMemoItems, $orderItems): array { + foreach ($creditMemoItems as $creditMemoItem) { + $orderItem = $this->orderItemProvider->getOrderItemById((int)$creditMemoItem->getOrderItemId()); + /** @var OrderItemInterface $orderItemModel */ + $orderItemModel = $orderItem['model']; + if (!$orderItemModel->getParentItem()) { + $creditMemoItemData = $this->getCreditMemoItemData($order, $creditMemoItem); + if (!empty($creditMemoItemData)) { + $orderItems[$creditMemoItem->getOrderItemId()] = $creditMemoItemData; + } + } + } + return $orderItems; + }; + } + + /** + * Get credit memo item data + * + * @param OrderInterface $order + * @param CreditmemoItemInterface $creditMemoItem + * @return array + */ + private function getCreditMemoItemData(OrderInterface $order, CreditmemoItemInterface $creditMemoItem): array + { + $orderItem = $this->orderItemProvider->getOrderItemById((int)$creditMemoItem->getOrderItemId()); + return [ + 'id' => base64_encode($creditMemoItem->getEntityId()), + 'product_name' => $creditMemoItem->getName(), + 'product_sku' => $creditMemoItem->getSku(), + 'product_sale_price' => [ + 'value' => $creditMemoItem->getPrice(), + 'currency' => $order->getOrderCurrencyCode() + ], + 'quantity_refunded' => $creditMemoItem->getQty(), + 'model' => $creditMemoItem, + 'product_type' => $orderItem['product_type'], + 'discounts' => $this->formatDiscountDetails($order, $creditMemoItem) + ]; + } + + /** + * Returns formatted information about an applied discount + * + * @param OrderInterface $associatedOrder + * @param CreditmemoItemInterface $creditmemoItem + * @return array + */ + private function formatDiscountDetails( + OrderInterface $associatedOrder, + CreditmemoItemInterface $creditmemoItem + ): array { + if ($associatedOrder->getDiscountDescription() === null + && $creditmemoItem->getDiscountAmount() == 0 + && $associatedOrder->getDiscountAmount() == 0 + ) { + $discounts = []; + } else { + $discounts[] = [ + 'label' => $associatedOrder->getDiscountDescription() ?? _('Discount'), + 'amount' => [ + 'value' => abs($creditmemoItem->getDiscountAmount()) ?? 0, + 'currency' => $associatedOrder->getOrderCurrencyCode() + ] + ]; + } + return $discounts; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoTotal.php b/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoTotal.php new file mode 100644 index 0000000000000..5a8f4f7f17ce6 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemo/CreditMemoTotal.php @@ -0,0 +1,187 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Resolver\CreditMemo; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Api\Data\CreditmemoInterface; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\SalesGraphQl\Model\SalesItem\ShippingTaxCalculator; +use Magento\Tax\Api\OrderTaxManagementInterface; +use Magento\Tax\Helper\Data as TaxHelper; + +/** + * Resolve credit memo totals information + */ +class CreditMemoTotal implements ResolverInterface +{ + /** + * @var TaxHelper + */ + private $taxHelper; + + /** + * @var OrderTaxManagementInterface + */ + private $orderTaxManagement; + + /** + * @var ShippingTaxCalculator + */ + private $shippingTaxCalculator; + /** + * @param OrderTaxManagementInterface $orderTaxManagement + * @param TaxHelper $taxHelper + * @param ShippingTaxCalculator $shippingTaxCalculator + */ + public function __construct( + OrderTaxManagementInterface $orderTaxManagement, + TaxHelper $taxHelper, + ShippingTaxCalculator $shippingTaxCalculator + ) { + $this->taxHelper = $taxHelper; + $this->orderTaxManagement = $orderTaxManagement; + $this->shippingTaxCalculator = $shippingTaxCalculator; + } + + /** + * @inheritDoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (!(($value['model'] ?? null) instanceof CreditmemoInterface)) { + throw new LocalizedException(__('"model" value should be specified')); + } + + if (!(($value['order'] ?? null) instanceof OrderInterface)) { + throw new LocalizedException(__('"order" value should be specified')); + } + + /** @var OrderInterface $orderModel */ + $orderModel = $value['order']; + /** @var CreditmemoInterface $creditMemo */ + $creditMemo = $value['model']; + $currency = $orderModel->getOrderCurrencyCode(); + $baseCurrency = $orderModel->getBaseCurrencyCode(); + return [ + 'base_grand_total' => ['value' => $creditMemo->getBaseGrandTotal(), 'currency' => $baseCurrency], + 'grand_total' => ['value' => $creditMemo->getGrandTotal(), 'currency' => $currency], + 'subtotal' => ['value' => $creditMemo->getSubtotal(), 'currency' => $currency], + 'total_tax' => ['value' => $creditMemo->getTaxAmount(), 'currency' => $currency], + 'total_shipping' => ['value' => $creditMemo->getShippingAmount(), 'currency' => $currency], + 'discounts' => $this->getDiscountDetails($creditMemo), + 'taxes' => $this->formatTaxes( + $orderModel, + $this->taxHelper->getCalculatedTaxes($creditMemo), + ), + 'shipping_handling' => [ + 'amount_excluding_tax' => [ + 'value' => $creditMemo->getShippingAmount() ?? 0, + 'currency' => $currency + ], + 'amount_including_tax' => [ + 'value' => $creditMemo->getShippingInclTax() ?? 0, + 'currency' => $currency + ], + 'total_amount' => [ + 'value' => $creditMemo->getShippingAmount() ?? 0, + 'currency' => $currency + ], + 'discounts' => $this->getShippingDiscountDetails($creditMemo, $orderModel), + 'taxes' => $this->formatTaxes( + $orderModel, + $this->shippingTaxCalculator->calculateShippingTaxes($orderModel, $creditMemo), + ) + ], + 'adjustment' => [ + 'value' => abs($creditMemo->getAdjustment()), + 'currency' => $currency + ] + ]; + } + + /** + * Return information about an applied discount on shipping + * + * @param CreditmemoInterface $creditmemoModel + * @param OrderInterface $orderModel + * @return array + */ + private function getShippingDiscountDetails(CreditmemoInterface $creditmemoModel, $orderModel): array + { + $creditmemoShippingAmount = (float)$creditmemoModel->getShippingAmount(); + $orderShippingAmount = (float)$orderModel->getShippingAmount(); + $calculatedShippingRatio = (float)$creditmemoShippingAmount != 0 && $orderShippingAmount != 0 ? + ($creditmemoShippingAmount / $orderShippingAmount) : 0; + $orderShippingDiscount = (float)$orderModel->getShippingDiscountAmount(); + $calculatedCreditmemoShippingDiscount = $orderShippingDiscount * $calculatedShippingRatio; + + $shippingDiscounts = []; + if ($calculatedCreditmemoShippingDiscount != 0) { + $shippingDiscounts[] = [ + 'amount' => [ + 'value' => sprintf('%.2f', abs($calculatedCreditmemoShippingDiscount)), + 'currency' => $creditmemoModel->getOrderCurrencyCode() + ] + ]; + } + return $shippingDiscounts; + } + + /** + * Return information about an applied discount + * + * @param CreditmemoInterface $creditmemo + * @return array + */ + private function getDiscountDetails(CreditmemoInterface $creditmemo): array + { + $discounts = []; + if (!($creditmemo->getDiscountDescription() === null && $creditmemo->getDiscountAmount() == 0)) { + $discounts[] = [ + 'label' => $creditmemo->getDiscountDescription() ?? __('Discount'), + 'amount' => [ + 'value' => abs($creditmemo->getDiscountAmount()), + 'currency' => $creditmemo->getOrderCurrencyCode() + ] + ]; + } + return $discounts; + } + + /** + * Format applied taxes + * + * @param OrderInterface $order + * @param array $appliedTaxes + * @return array + */ + private function formatTaxes(OrderInterface $order, array $appliedTaxes): array + { + $taxes = []; + foreach ($appliedTaxes as $appliedTax) { + $appliedTaxesArray = [ + 'rate' => $appliedTax['percent'] ?? 0, + 'title' => $appliedTax['title'] ?? null, + 'amount' => [ + 'value' => $appliedTax['tax_amount'] ?? 0, + 'currency' => $order->getOrderCurrencyCode() + ] + ]; + $taxes[] = $appliedTaxesArray; + } + return $taxes; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemos.php b/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemos.php new file mode 100644 index 0000000000000..69dbca9d66599 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/CreditMemos.php @@ -0,0 +1,51 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Resolver; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Api\Data\CreditmemoInterface; +use Magento\Sales\Api\Data\OrderInterface; + +/** + * Resolve credit memos for order + */ +class CreditMemos implements ResolverInterface +{ + /** + * @inheritDoc + */ + public function resolve( + Field $field, + $context, + ResolveInfo $info, + array $value = null, + array $args = null + ) { + if (!(($value['model'] ?? null) instanceof OrderInterface)) { + throw new LocalizedException(__('"model" value should be specified')); + } + + /** @var OrderInterface $orderModel */ + $orderModel = $value['model']; + + $creditMemos = []; + /** @var CreditmemoInterface $creditMemo */ + foreach ($orderModel->getCreditmemosCollection() as $creditMemo) { + $creditMemos[] = [ + 'id' => base64_encode($creditMemo->getEntityId()), + 'number' => $creditMemo->getIncrementId(), + 'order' => $orderModel, + 'model' => $creditMemo + ]; + } + return $creditMemos; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/CustomerOrders/Carrier.php b/app/code/Magento/SalesGraphQl/Model/Resolver/CustomerOrders/Carrier.php new file mode 100644 index 0000000000000..8fae5c3d19d20 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/CustomerOrders/Carrier.php @@ -0,0 +1,76 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Resolver\CustomerOrders; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Model\Order; +use Magento\Shipping\Model\Config\Source\Allmethods; + +/** + * Resolve shipping carrier for order + */ +class Carrier implements ResolverInterface +{ + /** + * @var Allmethods + */ + private $carrierMethods; + + /** + * @param Allmethods $carrierMethods + */ + public function __construct(Allmethods $carrierMethods) + { + $this->carrierMethods = $carrierMethods; + } + + /** + * @inheritDoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!isset($value['model']) && !($value['model'] instanceof Order)) { + throw new LocalizedException(__('"model" value should be specified')); + } + /** @var Order $order */ + $order = $value['model']; + $methodCode = $order->getShippingMethod(); + if (null === $methodCode) { + return null; + } + + return $this->findCarrierByMethodCode($methodCode); + } + + /** + * Find carrier name by shipping method code + * + * @param string $methodCode + * @return string + */ + private function findCarrierByMethodCode(string $methodCode): ?string + { + $allCarrierMethods = $this->carrierMethods->toOptionArray(); + + foreach ($allCarrierMethods as $carrierMethods) { + $carrierLabel = $carrierMethods['label']; + $carrierMethodOptions = $carrierMethods['value']; + if (is_array($carrierMethodOptions)) { + foreach ($carrierMethodOptions as $option) { + if ($option['value'] === $methodCode) { + return $carrierLabel; + } + } + } + } + return null; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/InvoiceItems.php b/app/code/Magento/SalesGraphQl/Model/Resolver/Invoice/InvoiceItems.php similarity index 88% rename from app/code/Magento/SalesGraphQl/Model/Resolver/InvoiceItems.php rename to app/code/Magento/SalesGraphQl/Model/Resolver/Invoice/InvoiceItems.php index e9e43952641eb..1e9d282d80d94 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/InvoiceItems.php +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/Invoice/InvoiceItems.php @@ -5,7 +5,7 @@ */ declare(strict_types=1); -namespace Magento\SalesGraphQl\Model\Resolver; +namespace Magento\SalesGraphQl\Model\Resolver\Invoice; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Config\Element\Field; @@ -16,7 +16,7 @@ use Magento\Sales\Api\Data\InvoiceItemInterface; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\Data\OrderItemInterface; -use Magento\SalesGraphQl\Model\Resolver\OrderItem\DataProvider as OrderItemProvider; +use Magento\SalesGraphQl\Model\OrderItem\DataProvider as OrderItemProvider; /** * Resolver for Invoice Items @@ -93,7 +93,7 @@ public function getInvoiceItems(OrderInterface $order, array $invoiceItems): \Cl $orderItemModel = $orderItem['model']; if (!$orderItemModel->getParentItem()) { $invoiceItemData = $this->getInvoiceItemData($order, $invoiceItem); - if (isset($invoiceItemData)) { + if (!empty($invoiceItemData)) { $itemsList[$invoiceItem->getOrderItemId()] = $invoiceItemData; } } @@ -124,25 +124,26 @@ private function getInvoiceItemData(OrderInterface $order, InvoiceItemInterface 'model' => $invoiceItem, 'product_type' => $orderItem['product_type'], 'order_item' => $orderItem, - 'discounts' => $this->getDiscountDetails($order, $invoiceItem) + 'discounts' => $this->formatDiscountDetails($order, $invoiceItem) ]; } /** - * Returns information about an applied discount + * Returns formatted information about an applied discount * * @param OrderInterface $associatedOrder * @param InvoiceItemInterface $invoiceItem * @return array */ - private function getDiscountDetails(OrderInterface $associatedOrder, InvoiceItemInterface $invoiceItem) : array + private function formatDiscountDetails(OrderInterface $associatedOrder, InvoiceItemInterface $invoiceItem) : array { - if ($associatedOrder->getDiscountDescription() === null && $invoiceItem->getDiscountAmount() == 0 + if ($associatedOrder->getDiscountDescription() === null + && $invoiceItem->getDiscountAmount() == 0 && $associatedOrder->getDiscountAmount() == 0 ) { $discounts = []; } else { - $discounts [] = [ + $discounts[] = [ 'label' => $associatedOrder->getDiscountDescription() ?? _('Discount'), 'amount' => [ 'value' => abs($invoiceItem->getDiscountAmount()) ?? 0, diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/InvoiceTotal.php b/app/code/Magento/SalesGraphQl/Model/Resolver/Invoice/InvoiceTotal.php similarity index 83% rename from app/code/Magento/SalesGraphQl/Model/Resolver/InvoiceTotal.php rename to app/code/Magento/SalesGraphQl/Model/Resolver/Invoice/InvoiceTotal.php index a7be11d386fd3..b77fda9523843 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/InvoiceTotal.php +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/Invoice/InvoiceTotal.php @@ -5,7 +5,7 @@ */ declare(strict_types=1); -namespace Magento\SalesGraphQl\Model\Resolver; +namespace Magento\SalesGraphQl\Model\Resolver\Invoice; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Config\Element\Field; @@ -75,8 +75,9 @@ public function resolve( /** @var InvoiceInterface $invoiceModel */ $invoiceModel = $value['model']; $currency = $orderModel->getOrderCurrencyCode(); + $baseCurrency = $orderModel->getBaseCurrencyCode(); return [ - 'base_grand_total' => ['value' => $invoiceModel->getBaseGrandTotal(), 'currency' => $currency], + 'base_grand_total' => ['value' => $invoiceModel->getBaseGrandTotal(), 'currency' => $baseCurrency], 'grand_total' => ['value' => $invoiceModel->getGrandTotal(), 'currency' => $currency], 'subtotal' => ['value' => $invoiceModel->getSubtotal(), 'currency' => $currency], 'total_tax' => ['value' => $invoiceModel->getTaxAmount(), 'currency' => $currency], @@ -99,7 +100,7 @@ public function resolve( 'value' => $invoiceModel->getShippingAmount() ?? 0, 'currency' => $currency ], - 'discounts' => $this->getShippingDiscountDetails($invoiceModel), + 'discounts' => $this->getShippingDiscountDetails($invoiceModel, $orderModel), 'taxes' => $this->formatTaxes( $orderModel, $this->shippingTaxCalculator->calculateShippingTaxes($orderModel, $invoiceModel), @@ -111,20 +112,25 @@ public function resolve( /** * Return information about an applied discount on shipping * - * @param InvoiceInterface $invoice + * @param InvoiceInterface $invoiceModel + * @param OrderInterface $orderModel * @return array */ - private function getShippingDiscountDetails(InvoiceInterface $invoice) + private function getShippingDiscountDetails(InvoiceInterface $invoiceModel, OrderInterface $orderModel): array { + $invoiceShippingAmount = (float)$invoiceModel->getShippingAmount(); + $orderShippingAmount = (float)$orderModel->getShippingAmount(); + $calculatedShippingRatioFromOriginal = $invoiceShippingAmount != 0 && $orderShippingAmount != 0 ? + ($invoiceShippingAmount / $orderShippingAmount) : 0; + $orderShippingDiscount = (float)$orderModel->getShippingDiscountAmount(); + $calculatedInvoiceShippingDiscount = $orderShippingDiscount * $calculatedShippingRatioFromOriginal; $shippingDiscounts = []; - if (!($invoice->getDiscountDescription() === null - && $invoice->getShippingDiscountTaxCompensationAmount() == 0)) { + if ($calculatedInvoiceShippingDiscount != 0) { $shippingDiscounts[] = [ - 'label' => $invoice->getDiscountDescription() ?? __('Discount'), 'amount' => [ - 'value' => abs($invoice->getShippingDiscountTaxCompensationAmount()), - 'currency' => $invoice->getOrderCurrencyCode() + 'value' => sprintf('%.2f', abs($calculatedInvoiceShippingDiscount)), + 'currency' => $invoiceModel->getOrderCurrencyCode() ] ]; } @@ -137,7 +143,7 @@ private function getShippingDiscountDetails(InvoiceInterface $invoice) * @param InvoiceInterface $invoice * @return array */ - private function getDiscountDetails(InvoiceInterface $invoice) + private function getDiscountDetails(InvoiceInterface $invoice): array { $discounts = []; if (!($invoice->getDiscountDescription() === null && $invoice->getDiscountAmount() == 0)) { @@ -159,7 +165,7 @@ private function getDiscountDetails(InvoiceInterface $invoice) * @param array $appliedTaxes * @return array */ - private function formatTaxes(OrderInterface $order, array $appliedTaxes) + private function formatTaxes(OrderInterface $order, array $appliedTaxes): array { $taxes = []; foreach ($appliedTaxes as $appliedTax) { diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem.php b/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem.php index 116066f12bc28..d7819277e56db 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem.php +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItem.php @@ -12,7 +12,7 @@ use Magento\Framework\GraphQl\Query\Resolver\ValueFactory; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; -use Magento\SalesGraphQl\Model\Resolver\OrderItem\DataProvider as OrderItemProvider; +use Magento\SalesGraphQl\Model\OrderItem\DataProvider as OrderItemProvider; /** * Resolve a single order item diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItems.php b/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItems.php index 29e03afa9b59a..f0e768c513cd3 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItems.php +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/OrderItems.php @@ -15,7 +15,7 @@ use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\GraphQl\Model\Query\ContextInterface; use Magento\Sales\Api\Data\OrderInterface; -use Magento\SalesGraphQl\Model\Resolver\OrderItem\DataProvider as OrderItemProvider; +use Magento\SalesGraphQl\Model\OrderItem\DataProvider as OrderItemProvider; /** * Resolve order items for order diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderTotal.php b/app/code/Magento/SalesGraphQl/Model/Resolver/OrderTotal.php index 6f7b943bf6ca2..ab3ace45f336c 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderTotal.php +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/OrderTotal.php @@ -35,9 +35,10 @@ public function resolve( /** @var OrderInterface $order */ $order = $value['model']; $currency = $order->getOrderCurrencyCode(); + $baseCurrency = $order->getBaseCurrencyCode(); return [ - 'base_grand_total' => ['value' => $order->getBaseGrandTotal(), 'currency' => $currency], + 'base_grand_total' => ['value' => $order->getBaseGrandTotal(), 'currency' => $baseCurrency], 'grand_total' => ['value' => $order->getGrandTotal(), 'currency' => $currency], 'subtotal' => ['value' => $order->getSubtotal(), 'currency' => $currency], 'total_tax' => ['value' => $order->getTaxAmount(), 'currency' => $currency], diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/Shipment/ShipmentItems.php b/app/code/Magento/SalesGraphQl/Model/Resolver/Shipment/ShipmentItems.php new file mode 100644 index 0000000000000..dceb2848bda5b --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/Shipment/ShipmentItems.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Resolver\Shipment; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Api\Data\ShipmentInterface; +use Magento\SalesGraphQl\Model\Shipment\ItemProvider; + +/** + * Resolve items included in shipment + */ +class ShipmentItems implements ResolverInterface +{ + /** + * @var ItemProvider + */ + private $shipmentItemProvider; + + /** + * @param ItemProvider $shipmentItemProvider + */ + public function __construct(ItemProvider $shipmentItemProvider) + { + $this->shipmentItemProvider = $shipmentItemProvider; + } + + /** + * @inheritDoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!($value['model'] ?? null) instanceof ShipmentInterface) { + throw new LocalizedException(__('"model" value should be specified')); + } + + /** @var ShipmentInterface $shipment */ + $shipment = $value['model']; + + return $this->shipmentItemProvider->getItemData($shipment); + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/Shipment/ShipmentTracking.php b/app/code/Magento/SalesGraphQl/Model/Resolver/Shipment/ShipmentTracking.php new file mode 100644 index 0000000000000..e6ef0b8442852 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/Shipment/ShipmentTracking.php @@ -0,0 +1,45 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Resolver\Shipment; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Api\Data\ShipmentInterface; + +/** + * Resolve shipment tracking information + */ +class ShipmentTracking implements ResolverInterface +{ + /** + * @inheritDoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!isset($value['model']) && !($value['model'] instanceof ShipmentInterface)) { + throw new LocalizedException(__('"model" value should be specified')); + } + /** @var ShipmentInterface $shipment */ + $shipment = $value['model']; + $tracks = $shipment->getTracks(); + + $shipmentTracking = []; + foreach ($tracks as $tracking) { + $shipmentTracking[] = [ + 'title' => $tracking->getTitle(), + 'carrier' => $tracking->getCarrierCode(), + 'number' => $tracking->getTrackNumber(), + 'model' => $tracking + ]; + } + + return $shipmentTracking; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/Shipments.php b/app/code/Magento/SalesGraphQl/Model/Resolver/Shipments.php new file mode 100644 index 0000000000000..8b6aaad09c304 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/Shipments.php @@ -0,0 +1,72 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Resolver; + +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Query\ResolverInterface; +use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; +use Magento\Sales\Api\Data\ShipmentInterface; +use Magento\Sales\Model\Order; + +/** + * Resolve shipment information for order + */ +class Shipments implements ResolverInterface +{ + /** + * @inheritDoc + */ + public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null) + { + if (!isset($value['model']) && !($value['model'] instanceof Order)) { + throw new LocalizedException(__('"model" value should be specified')); + } + /** @var Order $order */ + $order = $value['model']; + $shipments = $order->getShipmentsCollection()->getItems(); + + if (empty($shipments)) { + //Order does not have any shipments + return []; + } + + $orderShipments = []; + foreach ($shipments as $shipment) { + $orderShipments[] = + [ + 'id' => base64_encode($shipment->getIncrementId()), + 'number' => $shipment->getIncrementId(), + 'comments' => $this->getShipmentComments($shipment), + 'model' => $shipment, + 'order' => $order + ]; + } + return $orderShipments; + } + + /** + * Get comments shipments in proper format + * + * @param ShipmentInterface $shipment + * @return array + */ + private function getShipmentComments(ShipmentInterface $shipment): array + { + $comments = []; + foreach ($shipment->getComments() as $comment) { + if ($comment->getIsVisibleOnFront()) { + $comments[] = [ + 'timestamp' => $comment->getCreatedAt(), + 'message' => $comment->getComment() + ]; + } + } + return $comments; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Shipment/Item/FormatterInterface.php b/app/code/Magento/SalesGraphQl/Model/Shipment/Item/FormatterInterface.php new file mode 100644 index 0000000000000..61ea89b5a81e6 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Shipment/Item/FormatterInterface.php @@ -0,0 +1,26 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Shipment\Item; + +use Magento\Sales\Api\Data\ShipmentInterface; +use Magento\Sales\Api\Data\ShipmentItemInterface; + +/** + * Format shipment items for GraphQl output + */ +interface FormatterInterface +{ + /** + * Format a shipment item for GraphQl + * + * @param ShipmentInterface $shipment + * @param ShipmentItemInterface $item + * @return array|null + */ + public function formatShipmentItem(ShipmentInterface $shipment, ShipmentItemInterface $item): ?array; +} diff --git a/app/code/Magento/SalesGraphQl/Model/Shipment/Item/ShipmentItemFormatter.php b/app/code/Magento/SalesGraphQl/Model/Shipment/Item/ShipmentItemFormatter.php new file mode 100644 index 0000000000000..e8ba6e5f784ec --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Shipment/Item/ShipmentItemFormatter.php @@ -0,0 +1,37 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Shipment\Item; + +use Magento\Sales\Api\Data\ShipmentInterface; +use Magento\Sales\Api\Data\ShipmentItemInterface; + +/** + * Format shipment item for GraphQl output + */ +class ShipmentItemFormatter implements FormatterInterface +{ + /** + * @inheritDoc + */ + public function formatShipmentItem(ShipmentInterface $shipment, ShipmentItemInterface $item): ?array + { + $order = $shipment->getOrder(); + return [ + 'id' => base64_encode($item->getEntityId()), + 'product_name' => $item->getName(), + 'product_sku' => $item->getSku(), + 'product_sale_price' => [ + 'value' => $item->getPrice(), + 'currency' => $order->getOrderCurrencyCode() + ], + 'product_type' => $item->getOrderItem()->getProductType(), + 'quantity_shipped' => $item->getQty(), + 'model' => $item, + ]; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/Shipment/ItemProvider.php b/app/code/Magento/SalesGraphQl/Model/Shipment/ItemProvider.php new file mode 100644 index 0000000000000..49f8e3b119da2 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Shipment/ItemProvider.php @@ -0,0 +1,65 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\Shipment; + +use Magento\Sales\Api\Data\ShipmentInterface; +use Magento\Sales\Api\Data\ShipmentItemInterface; +use Magento\SalesGraphQl\Model\Shipment\Item\FormatterInterface; + +/** + * Get shipment item data + */ +class ItemProvider +{ + /** + * @var FormatterInterface[] + */ + private $formatters; + + /** + * @param FormatterInterface[] $formatters + */ + public function __construct(array $formatters = []) + { + $this->formatters = $formatters; + } + + /** + * Get item data for shipment + * + * @param ShipmentInterface $shipment + * @return array + */ + public function getItemData(ShipmentInterface $shipment): array + { + $shipmentItems = []; + + foreach ($shipment->getItems() as $shipmentItem) { + $formattedItem = $this->formatItem($shipment, $shipmentItem); + if ($formattedItem) { + $shipmentItems[] = $formattedItem; + } + } + return $shipmentItems; + } + + /** + * Format individual shipment item + * + * @param ShipmentInterface $shipment + * @param ShipmentItemInterface $shipmentItem + * @return array|null + */ + private function formatItem(ShipmentInterface $shipment, ShipmentItemInterface $shipmentItem): ?array + { + $orderItem = $shipmentItem->getOrderItem(); + $formatter = $this->formatters[$orderItem->getProductType()] ?? $this->formatters['default']; + + return $formatter->formatShipmentItem($shipment, $shipmentItem); + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/TypeResolver/CreditMemoItem.php b/app/code/Magento/SalesGraphQl/Model/TypeResolver/CreditMemoItem.php new file mode 100644 index 0000000000000..8fab97153231b --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/TypeResolver/CreditMemoItem.php @@ -0,0 +1,44 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\TypeResolver; + +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; + +/** + * Resolve concrete type for CreditMemoItemInterface + */ +class CreditMemoItem implements TypeResolverInterface +{ + /** + * @var array + */ + private $productTypeMap; + + /** + * @param array $productTypeMap + */ + public function __construct($productTypeMap = []) + { + $this->productTypeMap = $productTypeMap; + } + + /** + * @inheritDoc + */ + public function resolveType(array $data): string + { + if (!isset($data['product_type'])) { + throw new GraphQlInputException(__('Missing key %1 in sales item data', ['product_type'])); + } + if (isset($this->productTypeMap[$data['product_type']])) { + return $this->productTypeMap[$data['product_type']]; + } + return $this->productTypeMap['default']; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/TypeResolver/InvoiceItem.php b/app/code/Magento/SalesGraphQl/Model/TypeResolver/InvoiceItem.php new file mode 100644 index 0000000000000..e4ceab02fbbe9 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/TypeResolver/InvoiceItem.php @@ -0,0 +1,44 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\TypeResolver; + +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; +use Magento\Framework\GraphQl\Exception\GraphQlInputException; + +/** + * Resolve concrete type for InvoiceItemInterface + */ +class InvoiceItem implements TypeResolverInterface +{ + /** + * @var array + */ + private $productTypeMap; + + /** + * @param array $productTypeMap + */ + public function __construct($productTypeMap = []) + { + $this->productTypeMap = $productTypeMap; + } + + /** + * @inheritDoc + */ + public function resolveType(array $data): string + { + if (!isset($data['product_type'])) { + throw new GraphQlInputException(__('Missing key %1 in sales item data', ['product_type'])); + } + if (isset($this->productTypeMap[$data['product_type']])) { + return $this->productTypeMap[$data['product_type']]; + } + return $this->productTypeMap['default']; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/TypeResolver/OrderItem.php b/app/code/Magento/SalesGraphQl/Model/TypeResolver/OrderItem.php new file mode 100644 index 0000000000000..851a0daf2f50d --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/TypeResolver/OrderItem.php @@ -0,0 +1,45 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\TypeResolver; + +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; + +/** + * Resolve concrete type for OrderItemInterface + */ +class OrderItem implements TypeResolverInterface +{ + /** + * @var array + */ + private $productTypeMap; + + /** + * @param array $productTypeMap + */ + public function __construct(array $productTypeMap = []) + { + $this->productTypeMap = $productTypeMap; + } + + /** + * @inheritDoc + */ + public function resolveType(array $data): string + { + if (!isset($data['product_type'])) { + throw new GraphQlInputException(__('Missing key %1 in sales item data', ['product_type'])); + } + if (isset($this->productTypeMap[$data['product_type']])) { + return $this->productTypeMap[$data['product_type']]; + } + + return $this->productTypeMap['default']; + } +} diff --git a/app/code/Magento/SalesGraphQl/Model/TypeResolver/ShipmentItem.php b/app/code/Magento/SalesGraphQl/Model/TypeResolver/ShipmentItem.php new file mode 100644 index 0000000000000..fd72a8729af27 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/TypeResolver/ShipmentItem.php @@ -0,0 +1,45 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\SalesGraphQl\Model\TypeResolver; + +use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\TypeResolverInterface; + +/** + * Resolve concrete type of ShipmentItemInterface + */ +class ShipmentItem implements TypeResolverInterface +{ + /** + * @var array + */ + private $productTypeMap; + + /** + * @param array $productTypeMap + */ + public function __construct(array $productTypeMap = []) + { + $this->productTypeMap = $productTypeMap; + } + + /** + * @inheritDoc + */ + public function resolveType(array $data): string + { + if (!isset($data['product_type'])) { + throw new GraphQlInputException(__('Missing key %1 in sales item data', ['product_type'])); + } + if (isset($this->productTypeMap[$data['product_type']])) { + return $this->productTypeMap[$data['product_type']]; + } + + return $this->productTypeMap['default']; + } +} diff --git a/app/code/Magento/SalesGraphQl/composer.json b/app/code/Magento/SalesGraphQl/composer.json index f820b239d727d..b85d8c0f852da 100644 --- a/app/code/Magento/SalesGraphQl/composer.json +++ b/app/code/Magento/SalesGraphQl/composer.json @@ -10,9 +10,7 @@ "magento/module-catalog": "*", "magento/module-tax": "*", "magento/module-quote": "*", - "magento/module-graph-ql": "*" - }, - "suggest": { + "magento/module-graph-ql": "*", "magento/module-shipping": "*" }, "license": [ diff --git a/app/code/Magento/SalesGraphQl/etc/graphql/di.xml b/app/code/Magento/SalesGraphQl/etc/graphql/di.xml index 5bba224ff2fad..b40d8e9331bbb 100644 --- a/app/code/Magento/SalesGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/SalesGraphQl/etc/graphql/di.xml @@ -6,17 +6,39 @@ */ --> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> - <type name="Magento\SalesGraphQl\Model\OrderItemInterfaceTypeResolverComposite"> + <type name="Magento\SalesGraphQl\Model\TypeResolver\OrderItem"> <arguments> - <argument name="orderItemTypeResolvers" xsi:type="array"> - <item name="order_catalog_item_type_resolver" xsi:type="object">Magento\SalesGraphQl\Model\OrderItemTypeResolver</item> + <argument name="productTypeMap" xsi:type="array"> + <item name="default" xsi:type="string">OrderItem</item> </argument> </arguments> </type> - <type name="Magento\SalesGraphQl\Model\InvoiceItemInterfaceTypeResolverComposite"> + <type name="Magento\SalesGraphQl\Model\TypeResolver\InvoiceItem"> <arguments> - <argument name="invoiceItemTypeResolvers" xsi:type="array"> - <item name="invoice_catalog_item_type_resolver" xsi:type="object">Magento\SalesGraphQl\Model\InvoiceItemTypeResolver</item> + <argument name="productTypeMap" xsi:type="array"> + <item name="default" xsi:type="string">InvoiceItem</item> + </argument> + </arguments> + </type> + <type name="Magento\SalesGraphQl\Model\TypeResolver\CreditMemoItem"> + <arguments> + <argument name="productTypeMap" xsi:type="array"> + <item name="default" xsi:type="string">CreditMemoItem</item> + </argument> + </arguments> + </type> + <type name="Magento\SalesGraphQl\Model\TypeResolver\ShipmentItem"> + <arguments> + <argument name="productTypeMap" xsi:type="array"> + <item name="default" xsi:type="string">ShipmentItem</item> + </argument> + </arguments> + </type> + <preference for="Magento\SalesGraphQl\Model\Shipment\Item\FormatterInterface" type="Magento\SalesGraphQl\Model\Shipment\Item\ShipmentItemFormatter"/> + <type name="Magento\SalesGraphQl\Model\Shipment\ItemProvider"> + <arguments> + <argument name="formatters" xsi:type="array"> + <item name="default" xsi:type="object">Magento\SalesGraphQl\Model\Shipment\Item\ShipmentItemFormatter\Proxy</item> </argument> </arguments> </type> diff --git a/app/code/Magento/SalesGraphQl/etc/schema.graphqls b/app/code/Magento/SalesGraphQl/etc/schema.graphqls index 218619e0ced34..8b9d58e48d4b1 100644 --- a/app/code/Magento/SalesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SalesGraphQl/etc/schema.graphqls @@ -46,11 +46,12 @@ type CustomerOrder @doc(description: "Contains details about each of the custome items: [OrderItemInterface] @doc(description: "An array containing the items purchased in this order") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\OrderItems") total: OrderTotal @doc(description: "Contains details about the calculated totals for this order") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\OrderTotal") invoices: [Invoice]! @doc(description: "A list of invoices for the order") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Invoices") - shipments: [OrderShipment] @doc(description: "A list of shipments for the order") + shipments: [OrderShipment] @doc(description: "A list of shipments for the order") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Shipments") + credit_memos: [CreditMemo] @doc(description: "A list of credit memos") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemos") payment_methods: [PaymentMethod] @doc(description: "Payment details for the order") shipping_address: OrderAddress @doc(description: "The shipping address for the order") billing_address: OrderAddress @doc(description: "The billing address for the order") - carrier: String @doc(description: "The shipping carrier for the order delivery") + carrier: String @doc(description: "The shipping carrier for the order delivery") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CustomerOrders\\Carrier") shipping_method: String @doc(description: "The delivery method for the order") comments: [CommentItem] @doc(description: "Comments about the order") increment_id: String @deprecated(reason: "Use the id attribute instead") @@ -77,12 +78,12 @@ type OrderAddress @doc(description: "OrderAddress contains detailed information vat_id: String @doc(description: "The customer's Value-added tax (VAT) number (for corporate customers)") } -interface OrderItemInterface @doc(description: "Order item details") @typeResolver(class: "Magento\\SalesGraphQl\\Model\\OrderItemTypeResolver") { +interface OrderItemInterface @doc(description: "Order item details") @typeResolver(class: "Magento\\SalesGraphQl\\Model\\TypeResolver\\OrderItem") { id: ID! @doc(description: "The unique identifier of the order item") product_name: String @doc(description: "The name of the base product") product_sku: String! @doc(description: "The SKU of the base product") product_url_key: String @doc(description: "URL key of the base product") - product_type: String @doc(description: "The type of product, such as simple, configurable, or bundle") + product_type: String @doc(description: "The type of product, such as simple, configurable, etc.") status: String @doc(description: "The status of the order item") product_sale_price: Money! @doc(description: "The sale price of the base product, including selected options") discounts: [Discount] @doc(description: "The final discount information for the product") @@ -99,24 +100,6 @@ interface OrderItemInterface @doc(description: "Order item details") @typeResolv type OrderItem implements OrderItemInterface { } -type BundleOrderItem implements OrderItemInterface { - bundle_options: [ItemSelectedBundleOption] @doc(description: "A list of bundle options that are assigned to the bundle product") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\BundleOptions") -} - -type ItemSelectedBundleOption @doc(description: "A list of options of the selected bundle product") { - id: ID! @doc(description: "The unique identifier of the option") - label: String! @doc(description: "The label of the option") - values: [ItemSelectedBundleOptionValue] @doc(description: "A list of products that represent the values of the parent option") -} - -type ItemSelectedBundleOptionValue @doc(description: "A list of values for the selected bundle product") { - id: ID! @doc(description: "The unique identifier of the value") - product_name: String! @doc(description: "The name of the child bundle product") - product_sku: String! @doc(description: "The SKU of the child bundle product") - quantity: Float! @doc(description: "Indicates how many of this bundle product were ordered") - price: Money! @doc(description: "The price of the child bundle product") -} - type OrderItemOption @doc(description: "Represents order item options like selected or entered") { id: String! @doc(description: "The name of the option") value: String! @doc(description: "The value of the option") @@ -142,12 +125,12 @@ type OrderTotal @doc(description: "Contains details about the sales total amount type Invoice @doc(description: "Invoice details") { id: ID! @doc(description: "The ID of the invoice, used for API purposes") number: String! @doc(description: "Sequential invoice number") - total: InvoiceTotal @doc(description: "Invoice total amount details") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\InvoiceTotal") - items: [InvoiceItemInterface] @doc(description: "Invoiced product details") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\InvoiceItems") + total: InvoiceTotal @doc(description: "Invoice total amount details") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Invoice\\InvoiceTotal") + items: [InvoiceItemInterface] @doc(description: "Invoiced product details") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Invoice\\InvoiceItems") comments: [CommentItem] @doc(description: "Comments on the invoice") } -interface InvoiceItemInterface @doc(description: "Invoice item details") @typeResolver(class: "Magento\\SalesGraphQl\\Model\\InvoiceItemTypeResolver") { +interface InvoiceItemInterface @doc(description: "Invoice item details") @typeResolver(class: "Magento\\SalesGraphQl\\Model\\TypeResolver\\InvoiceItem") { id: ID! @doc(description: "The unique ID of the invoice item") order_item: OrderItemInterface @doc(description: "Contains details about an individual order item") product_name: String @doc(description: "The name of the base product") @@ -160,15 +143,11 @@ interface InvoiceItemInterface @doc(description: "Invoice item details") @typeRe type InvoiceItem implements InvoiceItemInterface { } -type BundleInvoiceItem implements InvoiceItemInterface{ - bundle_options: [ItemSelectedBundleOption] @doc(description: "A list of bundle options that are assigned to the bundle product") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\BundleOptions") -} - type InvoiceTotal @doc(description: "Contains price details from an invoice"){ subtotal: Money! @doc(description: "The subtotal of the invoice, excluding shipping, discounts, and taxes") discounts: [Discount] @doc(description: "The applied discounts to the invoice") total_tax: Money! @doc(description: "The amount of tax applied to the invoice") - taxes: [TaxItem] @doc(description: "The order tax details") + taxes: [TaxItem] @doc(description: "The invoice tax details") grand_total: Money! @doc(description: "The final total amount, including shipping, discounts, and taxes") base_grand_total: Money! @doc(description: "The final base grand total amount in the base currency") total_shipping: Money! @doc(description: "The shipping amount for the invoice") @@ -180,14 +159,18 @@ type ShippingHandling @doc(description: "The Shipping handling details") { amount_including_tax: Money @doc(description: "The shipping amount, including tax") amount_excluding_tax: Money @doc(description: "The shipping amount, excluding tax") taxes: [TaxItem] @doc(description: "Contains details about taxes applied for shipping") - discounts: [Discount] @doc(description: "The applied discounts to the shipping") + discounts: [ShippingDiscount] @doc(description: "The applied discounts to the shipping") +} + +type ShippingDiscount @doc(description:"Defines an individual shipping discount. This discount can be applied to shipping.") { + amount: Money! @doc(description:"The amount of the discount") } type OrderShipment @doc(description: "Order shipment details") { id: ID! @doc(description: "The unique ID of the shipment") number: String! @doc(description: "The sequential credit shipment number") - tracking: [ShipmentTracking] @doc(description: "Contains shipment tracking details") - items: [ShipmentItem] @doc(description: "Contains items included in the shipment") + tracking: [ShipmentTracking] @doc(description: "Contains shipment tracking details") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Shipment\\ShipmentTracking") + items: [ShipmentItemInterface] @doc(description: "Contains items included in the shipment") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Shipment\\ShipmentItems") comments: [CommentItem] @doc(description: "Comments added to the shipment") } @@ -196,13 +179,16 @@ type CommentItem @doc(description: "Comment item details") { message: String! @doc(description: "The text of the message") } -type ShipmentItem @doc(description: "Order shipment item details") { - id: ID! @doc(description: "The unique ID of the shipment item") - order_item: OrderItemInterface @doc(description: "The shipped order item") - product_name: String @doc(description: "The name of the base product") - product_sku: String! @doc(description: "The SKU of the base product") - product_sale_price: Money! @doc(description: "The sale price for the base product") - quantity_shipped: Float! @doc(description: "The number of shipped items") +interface ShipmentItemInterface @doc(description: "Order shipment item details") @typeResolver(class: "Magento\\SalesGraphQl\\Model\\TypeResolver\\ShipmentItem"){ + id: ID! @doc(description: "Shipment item unique identifier") + order_item: OrderItemInterface @doc(description: "Associated order item") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\OrderItem") + product_name: String @doc(description: "Name of the base product") + product_sku: String! @doc(description: "SKU of the base product") + product_sale_price: Money! @doc(description: "Sale price for the base product") + quantity_shipped: Float! @doc(description: "Number of shipped items") +} + +type ShipmentItem implements ShipmentItemInterface { } type ShipmentTracking @doc(description: "Order shipment tracking details") { @@ -217,6 +203,39 @@ type PaymentMethod @doc(description: "Contains details about the payment method additional_data: [KeyValue] @doc(description: "Additional data per payment method type") } +type CreditMemo @doc(description: "Credit memo details") { + id: ID! @doc(description: "The unique ID of the credit memo, used for API purposes") + number: String! @doc(description: "The sequential credit memo number") + items: [CreditMemoItemInterface] @doc(description: "An array containing details about refunded items") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemo\\CreditMemoItems") + total: CreditMemoTotal @doc(description: "Contains details about the total refunded amount") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemo\\CreditMemoTotal") + comments: [CommentItem] @doc(description: "Comments on the credit memo") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\CreditMemo\\CreditMemoComments") +} + +interface CreditMemoItemInterface @doc(description: "Credit memo item details") @typeResolver(class: "Magento\\SalesGraphQl\\Model\\TypeResolver\\CreditMemoItem") { + id: ID! @doc(description: "The unique ID of the credit memo item, used for API purposes") + order_item: OrderItemInterface @doc(description: "The order item the credit memo is applied to") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\OrderItem") + product_name: String @doc(description: "The name of the base product") + product_sku: String! @doc(description: "SKU of the base product") + product_sale_price: Money! @doc(description: "The sale price for the base product, including selected options") + discounts: [Discount] @doc(description: "Contains information about the final discount amount for the base product, including discounts on options") + quantity_refunded: Float @doc(description: "The number of refunded items") +} + +type CreditMemoItem implements CreditMemoItemInterface { +} + +type CreditMemoTotal @doc(description: "Credit memo price details") { + subtotal: Money! @doc(description: "The subtotal of the invoice, excluding shipping, discounts, and taxes") + discounts: [Discount] @doc(description: "The applied discounts to the credit memo") + total_tax: Money! @doc(description: "The amount of tax applied to the credit memo") + taxes: [TaxItem] @doc(description: "The credit memo tax details") + grand_total: Money! @doc(description: "The final total amount, including shipping, discounts, and taxes") + base_grand_total: Money! @doc(description: "The final base grand total amount in the base currency") + total_shipping: Money! @doc(description: "The shipping amount for the credit memo") + shipping_handling: ShippingHandling @doc(description: "Contains details about the shipping and handling costs for the credit memo") + adjustment: Money! @doc(description: "An adjustment manually applied to the order") +} + type KeyValue @doc(description: "The key-value type") { name: String @doc(description: "The name part of the name/value pair") value: String @doc(description: "The value part of the name/value pair") 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..25be12b5bb72f 100644 --- a/composer.json +++ b/composer.json @@ -194,6 +194,7 @@ "magento/module-login-as-customer": "*", "magento/module-login-as-customer-admin-ui": "*", "magento/module-login-as-customer-api": "*", + "magento/module-login-as-customer-assistance": "*", "magento/module-login-as-customer-frontend-ui": "*", "magento/module-login-as-customer-log": "*", "magento/module-login-as-customer-quote": "*", @@ -242,6 +243,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/composer.lock b/composer.lock index 90131292fe956..c2eed9d87cc00 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5b074864c62821207d8994a4aca444fe", + "content-hash": "0b51badfd1978bb34febd90226af9e27", "packages": [ { "name": "colinmollenhour/cache-backend-file", @@ -206,16 +206,6 @@ "ssl", "tls" ], - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], "time": "2020-04-08T08:27:21+00:00" }, { diff --git a/dev/tests/api-functional/framework/Magento/TestFramework/TestCase/GraphQlAbstract.php b/dev/tests/api-functional/framework/Magento/TestFramework/TestCase/GraphQlAbstract.php index 94eb5ddec8604..3de18a932f2cd 100644 --- a/dev/tests/api-functional/framework/Magento/TestFramework/TestCase/GraphQlAbstract.php +++ b/dev/tests/api-functional/framework/Magento/TestFramework/TestCase/GraphQlAbstract.php @@ -171,6 +171,11 @@ protected function assertResponseFields($actualResponse, $assertionMap) $expectedValue, "Value of '{$responseField}' field must not be NULL" ); + self::assertArrayHasKey( + $responseField, + $actualResponse, + "Response array does not contain key '{$responseField}'" + ); self::assertEquals( $expectedValue, $actualResponse[$responseField], 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Bundle; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\QuoteIdToMaskedQuoteIdInterface; +use Magento\Quote\Model\ResourceModel\Quote as QuoteResource; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * Test adding bundled products to cart using the unified mutation mutation + */ +class AddBundleProductToCartSingleMutationTest extends GraphQlAbstract +{ + /** + * @var QuoteResource + */ + private $quoteResource; + + /** + * @var Quote + */ + private $quote; + + /** + * @var QuoteIdToMaskedQuoteIdInterface + */ + private $quoteIdToMaskedId; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $objectManager = Bootstrap::getObjectManager(); + $this->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 = <<<QUERY +mutation { + addProductsToCart( + cartId: "{$maskedQuoteId}", + cartItems: [ + { + sku: "{$sku}" + quantity: 1 + selected_options: [ + "{$bundleOptionIdV2}" + ] + } + ] + ) { + cart { + items { + id + quantity + product { + sku + } + ... on BundleCartItem { + bundle_options { + id + label + type + values { + id + label + price + quantity + } + } + } + } + } + } +} +QUERY; + + $response = $this->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 = <<<QUERY +mutation { + addProductsToCart( + cartId: "{$maskedQuoteId}", + cartItems: [ + { + sku: "bundle-product" + quantity: 1 + selected_options: [ + "{$bundleOptionIdV2}" + ] + } + ] + ) { + cart { + items { + id + quantity + product { + sku + } + ... on BundleCartItem { + bundle_options { + id + label + type + values { + id + label + price + quantity + } + } + } + } + } + user_errors { + message + } + } +} +QUERY; + + $response = $this->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 <<<QUERY +{ + products(search: "{$sku}") { + items { + sku + ... on BundleProduct { + items { + sku + option_id + required + type + title + options { + uid + label + product { + sku + } + can_change_quantity + id + price + + quantity + } + } + } + } + } +} +QUERY; + } + + private function getMutationsQuery( + string $maskedQuoteId, + string $optionUid0, + string $optionUid1, + string $sku + ): string { + return <<<QUERY +mutation { + addProductsToCart( + cartId: "{$maskedQuoteId}", + cartItems: [ + { + sku: "{$sku}" + quantity: 2 + selected_options: [ + "{$optionUid1}", "{$optionUid0}" + ], + entered_options: [{ + uid: "{$optionUid0}" + value: "5" + }, + { + uid: "{$optionUid1}" + value: "5" + }] + } + ] + ) { + cart { + items { + id + quantity + product { + sku + } + ... on BundleCartItem { + bundle_options { + id + label + type + values { + id + label + price + quantity + } + } + } + } + } + user_errors { + message + } + } +} +QUERY; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartTest.php index 0acd6bb333426..f705195050843 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/AddBundleProductToCartTest.php @@ -80,7 +80,7 @@ public function testAddBundleProductToCart() $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId()); $query = <<<QUERY -mutation { +mutation { addBundleProductsToCart(input:{ cart_id:"{$maskedQuoteId}" cart_items:[ @@ -223,7 +223,7 @@ public function testAddBundleToCartWithoutOptions() $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId()); $query = <<<QUERY -mutation { +mutation { addBundleProductsToCart(input:{ cart_id:"{$maskedQuoteId}" cart_items:[ @@ -268,6 +268,107 @@ public function testAddBundleToCartWithoutOptions() } } } +QUERY; + + $this->graphQlMutation($query); + } + + /** + * @magentoApiDataFixture Magento/Bundle/_files/product_with_multiple_options_radio_select.php + * @magentoApiDataFixture Magento/Checkout/_files/active_quote.php + */ + public function testAddBundleToCartWithRadioAndSelectErr() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Option type (select, radio) should have only one element.'); + + $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 */ + $options = $typeInstance->getOptionsCollection($product); + + $selectionIds = []; + $optionIds = []; + foreach ($options as $option) { + $type = $option->getType(); + + /** @var \Magento\Catalog\Model\Product $selection */ + $selections = $typeInstance->getSelectionsCollection([$option->getId()], $product); + $optionIds[$type] = $option->getId(); + + foreach ($selections->getItems() as $selection) { + $selectionIds[$type][] = $selection->getSelectionId(); + } + } + + $maskedQuoteId = $this->quoteIdToMaskedId->execute((int)$this->quote->getId()); + + $query = <<<QUERY +mutation { + addBundleProductsToCart(input:{ + cart_id:"{$maskedQuoteId}" + cart_items:[ + { + data:{ + sku:"{$sku}" + quantity:1 + } + bundle_options:[ + { + id:{$optionIds['select']} + quantity:1 + value:[ + "{$selectionIds['select'][0]}" + "{$selectionIds['select'][1]}" + ] + }, + { + id:{$optionIds['radio']} + quantity:1 + value:[ + "{$selectionIds['radio'][0]}" + "{$selectionIds['radio'][1]}" + ] + } + ] + } + ] + }) { + cart { + items { + id + quantity + product { + sku + } + ... on BundleCartItem { + bundle_options { + id + label + type + values { + id + label + price + quantity + } + } + } + } + } + } +} QUERY; $this->graphQlMutation($query); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php new file mode 100644 index 0000000000000..a2b7b54fb875a --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/ConfigurableProduct/AddConfigurableProductToCartSingleMutationTest.php @@ -0,0 +1,297 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\ConfigurableProduct; + +use Exception; +use Magento\GraphQl\Quote\GetMaskedQuoteIdByReservedOrderId; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * Add configurable product to cart testcases + */ +class AddConfigurableProductToCartSingleMutationTest extends GraphQlAbstract +{ + /** + * @var GetMaskedQuoteIdByReservedOrderId + */ + private $getMaskedQuoteIdByReservedOrderId; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $objectManager = Bootstrap::getObjectManager(); + $this->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 <<<QUERY +mutation { + addProductsToCart( + cartId:"{$maskedQuoteId}" + cartItems: [ + { + sku: "{$parentSku}" + quantity: $quantity + {$selectedOptionsQuery} + } + ] + ) { + cart { + items { + id + quantity + product { + sku + } + ... on ConfigurableCartItem { + configurable_options { + id + option_label + value_id + value_label + } + } + } + }, + user_errors { + message + } + } +} +QUERY; + } + + /** + * Returns information about testable configurable product retrieved from GraphQl query + * + * @return array + * @throws Exception + */ + private function getConfigurableProductInfo(): array + { + $searchResponse = $this->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 <<<QUERY +{ + products( + search:"{$term}" + pageSize:1 + ) { + items { + sku + ... on ConfigurableProduct { + configurable_options { + attribute_id + attribute_code + id + label + position + product_id + use_default + values { + default_label + label + store_label + use_default_value + value_index + } + } + } + } + } +} +QUERY; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddDownloadableProductToCartSingleMutationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddDownloadableProductToCartSingleMutationTest.php new file mode 100644 index 0000000000000..956316c1fa0fa --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddDownloadableProductToCartSingleMutationTest.php @@ -0,0 +1,196 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Quote; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\ObjectManager\ObjectManager; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * Test cases for adding downloadable product with custom options to cart using the single add to cart mutation. + */ +class AddDownloadableProductToCartSingleMutationTest extends GraphQlAbstract +{ + /** + * @var GetMaskedQuoteIdByReservedOrderId + */ + private $getMaskedQuoteIdByReservedOrderId; + + /** + * @var ObjectManager + */ + private $objectManager; + + /** + * @var GetCustomOptionsWithUIDForQueryBySku + */ + private $getCustomOptionsWithIDV2ForQueryBySku; + + /** + * @var GetCartItemOptionsFromUID + */ + private $getCartItemOptionsFromUID; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $this->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 <<<MUTATION +mutation { + addProductsToCart( + cartId: "{$maskedQuoteId}", + cartItems: [ + { + sku: "{$sku}" + quantity: {$qty} + {$customizableOptions} + } + ] + ) { + cart { + items { + quantity + ... on DownloadableCartItem { + links { + id + } + customizable_options { + label + id + values { + value + } + } + } + } + }, + user_errors { + message + } + } +} +MUTATION; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddSimpleProductToCartEndToEndTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddSimpleProductToCartEndToEndTest.php new file mode 100644 index 0000000000000..d8f7aedfdd583 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddSimpleProductToCartEndToEndTest.php @@ -0,0 +1,260 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Quote; + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * Get customizable options of simple product via the corresponding GraphQl query and add the product + * with customizable options to the shopping cart + */ +class AddSimpleProductToCartEndToEndTest extends GraphQlAbstract +{ + /** + * @var GetCustomOptionsWithUIDForQueryBySku + */ + private $getCustomOptionsWithIDV2ForQueryBySku; + + /** + * @var GetMaskedQuoteIdByReservedOrderId + */ + private $getMaskedQuoteIdByReservedOrderId; + + /** + * @var GetCartItemOptionsFromUID + */ + private $getCartItemOptionsFromUID; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $objectManager = Bootstrap::getObjectManager(); + $this->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 <<<QUERY +query { + products(search: "$sku") { + items { + sku + + ... on CustomizableProductInterface { + options { + option_id + title + + ... on CustomizableRadioOption { + option_id + selected_option: value { + option_type_id + uid + } + } + + ... on CustomizableDropDownOption { + option_id + selected_option: value { + option_type_id + uid + } + } + + ... on CustomizableMultipleOption { + option_id + selected_option: value { + option_type_id + uid + } + } + + ... on CustomizableCheckboxOption { + option_id + selected_option: value { + option_type_id + uid + } + } + + ... on CustomizableAreaOption { + option_id + entered_option: value { + uid + } + } + + ... on CustomizableFieldOption { + option_id + entered_option: value { + uid + } + } + + ... on CustomizableFileOption { + option_id + entered_option: value { + uid + } + } + + ... on CustomizableDateOption { + option_id + entered_option: value { + uid + } + } + } + } + } + } +} +QUERY; + } + + /** + * Returns GraphQl mutation for adding item to cart + * + * @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 <<<MUTATION +mutation { + addProductsToCart( + cartId: "{$maskedQuoteId}", + cartItems: [ + { + sku: "{$sku}" + quantity: {$qty} + {$customizableOptions} + } + ] + ) { + cart { + items { + quantity + ... on SimpleCartItem { + customizable_options { + label + id + values { + value + } + } + } + } + }, + user_errors { + message + } + } +} +MUTATION; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddSimpleProductToCartSingleMutationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddSimpleProductToCartSingleMutationTest.php new file mode 100644 index 0000000000000..4e50f6ff3a2ca --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddSimpleProductToCartSingleMutationTest.php @@ -0,0 +1,259 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Quote; + +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * Add simple product with custom options to cart using the unified mutation for adding different product types + */ +class AddSimpleProductToCartSingleMutationTest extends GraphQlAbstract +{ + /** + * @var GetCustomOptionsWithUIDForQueryBySku + */ + private $getCustomOptionsWithIDV2ForQueryBySku; + + /** + * @var GetMaskedQuoteIdByReservedOrderId + */ + private $getMaskedQuoteIdByReservedOrderId; + + /** + * @var GetCartItemOptionsFromUID + */ + private $getCartItemOptionsFromUID; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + $objectManager = Bootstrap::getObjectManager(); + $this->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 <<<MUTATION +mutation { + addProductsToCart( + cartId: "{$maskedQuoteId}", + cartItems: [ + { + sku: "{$sku}" + quantity: {$qty} + {$customizableOptions} + } + ] + ) { + cart { + total_quantity + items { + quantity + ... on SimpleCartItem { + customizable_options { + label + id + values { + value + } + } + } + } + }, + user_errors { + message + } + } +} +MUTATION; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/GetCartItemOptionsFromUID.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/GetCartItemOptionsFromUID.php new file mode 100644 index 0000000000000..44b44e0ccac05 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/GetCartItemOptionsFromUID.php @@ -0,0 +1,49 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Quote; + +/** + * Extracts cart item options from UID + */ +class GetCartItemOptionsFromUID +{ + /** + * Gets an array of encoded item options with UID, extracts and decodes the values + * + * @param array $encodedCustomOptions + * @return array + */ + public function execute(array $encodedCustomOptions): array + { + $customOptions = []; + + foreach ($encodedCustomOptions['selected_options'] as $selectedOption) { + [$optionType, $optionId, $optionValueId] = explode('/', base64_decode($selectedOption)); + if ($optionType == 'custom-option') { + if (isset($customOptions[$optionId])) { + $customOptions[$optionId] = [$customOptions[$optionId], $optionValueId]; + } else { + $customOptions[$optionId] = $optionValueId; + } + } + } + + foreach ($encodedCustomOptions['entered_options'] as $enteredOption) { + /* The date normalization is required since the attribute might value is formatted by the system */ + if ($enteredOption['type'] === 'date') { + $enteredOption['value'] = date('M d, Y', strtotime($enteredOption['value'])); + } + [$optionType, $optionId] = explode('/', base64_decode($enteredOption['uid'])); + if ($optionType == 'custom-option') { + $customOptions[$optionId] = $enteredOption['value']; + } + } + + return $customOptions; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/GetCustomOptionsWithUIDForQueryBySku.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/GetCustomOptionsWithUIDForQueryBySku.php new file mode 100644 index 0000000000000..870617555e8b2 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/GetCustomOptionsWithUIDForQueryBySku.php @@ -0,0 +1,107 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Quote; + +use Magento\Catalog\Api\ProductCustomOptionRepositoryInterface; + +/** + * Generate an array with test values for customizable options with UID + */ +class GetCustomOptionsWithUIDForQueryBySku +{ + /** + * @var ProductCustomOptionRepositoryInterface + */ + private $productCustomOptionRepository; + + /** + * @param ProductCustomOptionRepositoryInterface $productCustomOptionRepository + */ + public function __construct(ProductCustomOptionRepositoryInterface $productCustomOptionRepository) + { + $this->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/Sales/CreditmemoTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/CreditmemoTest.php new file mode 100644 index 0000000000000..cca2b5a66407c --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/CreditmemoTest.php @@ -0,0 +1,650 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Sales; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Framework\Exception\AuthenticationException; +use Magento\GraphQl\GetCustomerAuthenticationHeader; +use Magento\GraphQl\Sales\Fixtures\CustomerPlaceOrder; +use Magento\Sales\Api\CreditmemoRepositoryInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\CreditmemoFactory; +use Magento\Sales\Model\ResourceModel\Order\Collection as OrderCollection; +use Magento\Sales\Model\ResourceModel\Order\Creditmemo\Collection; +use Magento\Sales\Model\Service\CreditmemoService; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +/** + * Test for credit memo functionality + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class CreditmemoTest extends GraphQlAbstract +{ + /** + * @var GetCustomerAuthenticationHeader + */ + private $customerAuthenticationHeader; + + /** @var CreditmemoFactory */ + private $creditMemoFactory; + + /** @var Order */ + private $order; + + /** @var OrderCollection */ + private $orderCollection; + + /** @var CreditmemoService */ + private $creditMemoService; + + /** @var ProductRepositoryInterface */ + private $productRepository; + + /** @var OrderRepositoryInterface */ + private $orderRepository; + + /** @var SearchCriteriaBuilder */ + private $searchCriteriaBuilder; + + /** + * Set up + */ + protected function setUp(): void + { + $objectManager = Bootstrap::getObjectManager(); + $this->customerAuthenticationHeader = $objectManager->get( + GetCustomerAuthenticationHeader::class + ); + $this->productRepository = $objectManager->get(ProductRepositoryInterface::class); + $this->creditMemoFactory = $objectManager->get(CreditmemoFactory::class); + $this->order = $objectManager->create(Order::class); + $this->searchCriteriaBuilder = $objectManager->get(SearchCriteriaBuilder::class); + $this->orderCollection = $objectManager->get(OrderCollection::class); + $this->orderRepository = $objectManager->get(OrderRepositoryInterface::class); + $this->creditMemoService = $objectManager->get(CreditmemoService::class); + } + + protected function tearDown(): void + { + $this->cleanUpCreditMemos(); + $this->deleteOrder(); + } + + /** + * @magentoApiDataFixture Magento/Sales/_files/customer_creditmemo_with_two_items.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testCreditMemoForLoggedInCustomerQuery(): void + { + $response = $this->getCustomerOrderWithCreditMemoQuery(); + + $expectedCreditMemoData = [ + [ + 'comments' => [ + ['message' => 'some_comment'], + ['message' => 'some_other_comment'] + ], + 'items' => [ + [ + 'product_name' => 'Simple Related Product', + 'product_sku' => 'simple', + 'product_sale_price' => [ + 'value' => 10 + ], + 'discounts' => [], + 'quantity_refunded' => 1 + ], + [ + 'product_name' => 'Simple Product With Related Product', + 'product_sku' => 'simple_with_cross', + 'product_sale_price' => [ + 'value' => 10 + ], + 'discounts' => [], + 'quantity_refunded' => 1 + ] + ], + 'total' => [ + 'subtotal' => [ + 'value' => 20 + ], + 'grand_total' => [ + 'value' => 20, + 'currency' => 'USD' + ], + 'base_grand_total' => [ + 'value' => 10, + 'currency' => 'EUR' + ], + 'total_shipping' => [ + 'value' => 0 + ], + 'total_tax' => [ + 'value' => 0 + ], + 'shipping_handling' => [ + 'amount_including_tax' => [ + 'value' => 0 + ], + 'amount_excluding_tax' => [ + 'value' => 0 + ], + 'total_amount' => [ + 'value' => 0 + ], + 'taxes' => [], + 'discounts' => [], + ], + 'adjustment' => [ + 'value' => 1.23 + ] + ] + ] + ]; + + $firstOrderItem = current($response['customer']['orders']['items'] ?? []); + $this->assertArrayHasKey('credit_memos', $firstOrderItem); + $creditMemos = $firstOrderItem['credit_memos']; + $this->assertResponseFields($creditMemos, $expectedCreditMemoData); + } + + /** + * Test customer refund details from order for bundle product with a partial refund + * + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Bundle/_files/bundle_product_two_dropdown_options.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testCreditMemoForBundledProductsWithPartialRefund() + { + //Place order with bundled product + /** @var CustomerPlaceOrder $bundleProductOrderFixture */ + $bundleProductOrderFixture = Bootstrap::getObjectManager()->create(CustomerPlaceOrder::class); + $placeOrderResponse = $bundleProductOrderFixture->placeOrderWithBundleProduct( + ['email' => 'customer@example.com', 'password' => 'password'], + ['sku' => 'bundle-product-two-dropdown-options', 'quantity' => 2] + ); + $orderNumber = $placeOrderResponse['placeOrder']['order']['order_number']; + $this->prepareInvoice($orderNumber, 2); + + $order = $this->order->loadByIncrementId($orderNumber); + /** @var Order\Item $orderItem */ + $orderItem = current($order->getAllItems()); + $orderItem->setQtyRefunded(1); + $order->addItem($orderItem); + $order->save(); + // Create a credit memo + $creditMemo = $this->creditMemoFactory->createByOrder($order, $order->getData()); + $creditMemo->setOrder($order); + $creditMemo->setState(1); + $creditMemo->setSubtotal(15); + $creditMemo->setBaseSubTotal(15); + $creditMemo->setShippingAmount(10); + $creditMemo->setBaseGrandTotal(23); + $creditMemo->setGrandTotal(23); + $creditMemo->setAdjustment(-2.00); + $creditMemo->addComment("Test comment for partial refund", false, true); + $creditMemo->save(); + + $this->creditMemoService->refund($creditMemo, true); + $response = $this->getCustomerOrderWithCreditMemoQuery(); + $expectedInvoicesData = [ + [ + 'items' => [ + [ + 'product_name' => 'Bundle Product With Two dropdown options', + 'product_sku' => 'bundle-product-two-dropdown-options-simple1-simple2', + 'product_sale_price' => [ + 'value' => 15 + ], + 'discounts' => [], + 'bundle_options' => [ + [ + 'label' => 'Drop Down Option 1', + 'values' => [ + [ + 'product_name' => 'Simple Product1', + 'product_sku' => 'simple1', + 'quantity' => 1, + 'price' => ['value' => 1, 'currency' => 'USD'] + ] + ] + ], + [ + 'label' => 'Drop Down Option 2', + 'values' => [ + [ + 'product_name' => 'Simple Product2', + 'product_sku' => 'simple2', + 'quantity' => 2, + 'price' => ['value' => 2, 'currency' => 'USD'] + ] + ] + ] + ], + 'quantity_invoiced' => 2 + ], + + ] + ] + ]; + $expectedCreditMemoData = [ + [ + 'comments' => [ + ['message' => 'Test comment for partial refund'] + ], + 'items' => [ + [ + 'product_name' => 'Bundle Product With Two dropdown options', + 'product_sku' => 'bundle-product-two-dropdown-options-simple1-simple2', + 'product_sale_price' => [ + 'value' => 15 + ], + 'discounts' => [], + 'bundle_options' => [ + [ + 'label' => 'Drop Down Option 1', + 'values' => [ + [ + 'product_name' => 'Simple Product1', + 'product_sku' => 'simple1', + 'quantity' => 1, + 'price' => ['value' => 1, 'currency' => 'USD'] + ] + ] + ], + [ + 'label' => 'Drop Down Option 2', + 'values' => [ + [ + 'product_name' => 'Simple Product2', + 'product_sku' => 'simple2', + 'quantity' => 2, + 'price' => ['value' => 2, 'currency' => 'USD'] + ] + ] + ] + ], + 'quantity_refunded' => 1 + ], + + ], + 'total' => [ + 'subtotal' => [ + 'value' => 15 + ], + 'grand_total' => [ + 'value' => 23, + 'currency' => 'USD' + ], + 'base_grand_total' => [ + 'value' => 23, + 'currency' => 'USD' + ], + 'total_shipping' => [ + 'value' => 10 + ], + 'total_tax' => [ + 'value' => 0 + ], + 'shipping_handling' => [ + 'amount_including_tax' => [ + 'value' => 10 + ], + 'amount_excluding_tax' => [ + 'value' => 10 + ], + 'total_amount' => [ + 'value' => 10 + ], + 'taxes' => [], + 'discounts' => [], + ], + 'adjustment' => [ + 'value' => 2 + ] + ] + ] + ]; + $firstOrderItem = current($response['customer']['orders']['items'] ?? []); + + $this->assertArrayHasKey('invoices', $firstOrderItem); + $invoices = $firstOrderItem['invoices']; + $this->assertResponseFields($invoices, $expectedInvoicesData); + + $this->assertArrayHasKey('credit_memos', $firstOrderItem); + $creditMemos = $firstOrderItem['credit_memos']; + $this->assertResponseFields($creditMemos, $expectedCreditMemoData); + } + + /** + * Test customer order with credit memo details for bundle products with taxes and discounts + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Bundle/_files/bundle_product_two_dropdown_options.php + * @magentoApiDataFixture Magento/GraphQl/Tax/_files/tax_rule_for_region_1.php + * @magentoApiDataFixture Magento/SalesRule/_files/cart_rule_10_percent_off_with_discount_on_shipping.php + * @magentoApiDataFixture Magento/GraphQl/Tax/_files/tax_calculation_shipping_excludeTax_order_display_settings.php + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testCreditMemoForBundleProductWithTaxesAndDiscounts() + { + //Place order with bundled product + /** @var CustomerPlaceOrder $bundleProductOrderFixture */ + $bundleProductOrderFixture = Bootstrap::getObjectManager()->create(CustomerPlaceOrder::class); + $placeOrderResponse = $bundleProductOrderFixture->placeOrderWithBundleProduct( + ['email' => 'customer@example.com', 'password' => 'password'], + ['sku' => 'bundle-product-two-dropdown-options', 'quantity' => 2] + ); + $orderNumber = $placeOrderResponse['placeOrder']['order']['order_number']; + $this->prepareInvoice($orderNumber, 2); + $order = $this->order->loadByIncrementId($orderNumber); + /** @var Order\Item $orderItem */ + $orderItem = current($order->getAllItems()); + $orderItem->setQtyRefunded(1); + $order->addItem($orderItem); + $order->save(); + + $creditMemo = $this->creditMemoFactory->createByOrder($order, $order->getData()); + $creditMemo->setOrder($order); + $creditMemo->setState(1); + $creditMemo->setSubtotal(15); + $creditMemo->setBaseSubTotal(15); + $creditMemo->setShippingAmount(10); + $creditMemo->setTaxAmount(1.69); + $creditMemo->setBaseGrandTotal(24.19); + $creditMemo->setGrandTotal(24.19); + $creditMemo->setAdjustment(0.00); + $creditMemo->setDiscountAmount(-2.5); + $creditMemo->setDiscountDescription('Discount Label for 10% off'); + $creditMemo->addComment("Test comment for refund with taxes and discount", false, true); + $creditMemo->save(); + + $this->creditMemoService->refund($creditMemo, true); + $response = $this->getCustomerOrderWithCreditMemoQuery(); + $expectedCreditMemoData = [ + [ + 'comments' => [ + ['message' => 'Test comment for refund with taxes and discount'] + ], + 'items' => [ + [ + 'product_name' => 'Bundle Product With Two dropdown options', + 'product_sku' => 'bundle-product-two-dropdown-options-simple1-simple2', + 'product_sale_price' => [ + 'value' => 15 + ], + 'discounts' => [ + [ + 'amount' => [ + 'value' => 3, + 'currency' => "USD" + ], + 'label' => 'Discount Label for 10% off' + ] + ], + 'bundle_options' => [ + [ + 'label' => 'Drop Down Option 1', + 'values' => [ + [ + 'product_name' => 'Simple Product1', + 'product_sku' => 'simple1', + 'quantity' => 1, + 'price' => ['value' => 1, 'currency' => 'USD'] + ] + ] + ], + [ + 'label' => 'Drop Down Option 2', + 'values' => [ + [ + 'product_name' => 'Simple Product2', + 'product_sku' => 'simple2', + 'quantity' => 2, + 'price' => ['value' => 2, 'currency' => 'USD'] + ] + ] + ] + ], + 'quantity_refunded' => 1 + ], + + ], + 'total' => [ + 'subtotal' => [ + 'value' => 15 + ], + 'grand_total' => [ + 'value' => 24.19, + 'currency' => 'USD' + ], + 'base_grand_total' => [ + 'value' => 24.19, + 'currency' => 'USD' + ], + 'total_shipping' => [ + 'value' => 10 + ], + 'total_tax' => [ + 'value'=> 1.69 + ], + 'shipping_handling' => [ + 'amount_including_tax' => [ + 'value' => 10.75 + ], + 'amount_excluding_tax' => [ + 'value' => 10 + ], + 'total_amount' => [ + 'value' => 10 + ], + 'taxes'=> [ + 0 => [ + 'amount' => ['value' => 0.67], + 'title' => 'US-TEST-*-Rate-1', + 'rate' => 7.5 + ] + ], + 'discounts' => [ + [ + 'amount'=> ['value'=> 1] + ] + ], + ], + 'adjustment' => [ + 'value' => 0 + ] + ] + ] + ]; + $firstOrderItem = current($response['customer']['orders']['items'] ?? []); + $this->assertArrayHasKey('credit_memos', $firstOrderItem); + + $creditMemos = $firstOrderItem['credit_memos']; + $this->assertResponseFields($creditMemos, $expectedCreditMemoData); + } + + /** + * Prepare invoice for the order + * + * @param string $orderNumber + * @param int|null $qty + */ + private function prepareInvoice(string $orderNumber, int $qty = null) + { + /** @var \Magento\Sales\Model\Order $order */ + $order = Bootstrap::getObjectManager() + ->create(\Magento\Sales\Model\Order::class)->loadByIncrementId($orderNumber); + $orderItem = current($order->getItems()); + $orderService = Bootstrap::getObjectManager()->create( + \Magento\Sales\Api\InvoiceManagementInterface::class + ); + $invoice = $orderService->prepareInvoice($order, [$orderItem->getId() => $qty]); + $invoice->register(); + $order = $invoice->getOrder(); + $order->setIsInProcess(true); + $transactionSave = Bootstrap::getObjectManager() + ->create(\Magento\Framework\DB\Transaction::class); + $transactionSave->addObject($invoice)->addObject($order)->save(); + } + + /** + * @return void + */ + private function deleteOrder(): void + { + /** @var \Magento\Framework\Registry $registry */ + $registry = Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class); + $registry->unregister('isSecureArea'); + $registry->register('isSecureArea', true); + + /** @var $order \Magento\Sales\Model\Order */ + $orderCollection = Bootstrap::getObjectManager()->create(OrderCollection::class); + foreach ($orderCollection as $order) { + $this->orderRepository->delete($order); + } + $registry->unregister('isSecureArea'); + $registry->register('isSecureArea', false); + } + + /** + * @return void + */ + private function cleanUpCreditMemos(): void + { + /** @var \Magento\Framework\Registry $registry */ + $registry = Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class); + $registry->unregister('isSecureArea'); + $registry->register('isSecureArea', true); + $creditmemoRepository = Bootstrap::getObjectManager()->get(CreditmemoRepositoryInterface::class); + $creditmemoCollection = Bootstrap::getObjectManager()->create(Collection::class); + foreach ($creditmemoCollection as $creditmemo) { + $creditmemoRepository->delete($creditmemo); + } + $registry->unregister('isSecureArea'); + $registry->register('isSecureArea', false); + } + + /** + * Get CustomerOrder with credit memo details + * + * @return array + * @throws AuthenticationException + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + private function getCustomerOrderWithCreditMemoQuery(): array + { + $query = + <<<QUERY +query { + customer { + orders { + items { + invoices { + items { + product_name + product_sku + product_sale_price { + value + } + ... on BundleInvoiceItem { + bundle_options { + label + values { + product_sku + product_name + quantity + price { + value + currency + } + } + } + } + discounts { amount{value currency} label } + quantity_invoiced + discounts { amount{value currency} label } + } + } + credit_memos { + comments { + message + } + items { + product_name + product_sku + product_sale_price { + value + } + ... on BundleCreditMemoItem { + bundle_options { + label + values { + product_sku + product_name + quantity + price { + value + currency + } + } + } + } + discounts { amount{value currency} label } + quantity_refunded + } + total { + subtotal { + value + } + base_grand_total { + value + currency + } + grand_total { + value + currency + } + total_shipping { + value + } + total_tax { + value + } + shipping_handling { + amount_including_tax{value} + amount_excluding_tax{value} + total_amount{value} + taxes {amount{value} title rate} + discounts {amount{value}} + } + adjustment { + value + } + } + } + } + } + } +} +QUERY; + + $currentEmail = 'customer@example.com'; + $currentPassword = 'password'; + $response = $this->graphQlQuery( + $query, + [], + '', + $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) + ); + return $response; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/CustomerOrders/OrderShipmentsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/CustomerOrders/OrderShipmentsTest.php new file mode 100644 index 0000000000000..c9f507b1f94e8 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/CustomerOrders/OrderShipmentsTest.php @@ -0,0 +1,328 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Sales\CustomerOrders; + +use Magento\Framework\DB\Transaction; +use Magento\Framework\Registry; +use Magento\GraphQl\GetCustomerAuthenticationHeader; +use Magento\GraphQl\Sales\Fixtures\CustomerPlaceOrder; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Shipment; +use Magento\Sales\Model\Order\ShipmentFactory; +use Magento\Sales\Model\ResourceModel\Order\Collection as OrderCollection; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\TestCase\GraphQlAbstract; + +class OrderShipmentsTest extends GraphQlAbstract +{ + /** + * @var GetCustomerAuthenticationHeader + */ + private $getCustomerAuthHeader; + + /** + * @var OrderRepositoryInterface + */ + private $orderRepository; + + protected function setUp(): void + { + $this->getCustomerAuthHeader = Bootstrap::getObjectManager()->get(GetCustomerAuthenticationHeader::class); + $this->orderRepository = Bootstrap::getObjectManager()->get(OrderRepositoryInterface::class); + } + + protected function tearDown(): void + { + $this->cleanupOrders(); + } + + /** + * @magentoApiDataFixture Magento/GraphQl/Sales/_files/customer_order_with_simple_shipment.php + */ + public function testGetOrderShipment() + { + $query = $this->getQuery('100000555'); + $authHeader = $this->getCustomerAuthHeader->execute('customer_uk_address@test.com', 'password'); + $orderModel = $this->fetchOrderModel('100000555'); + + $result = $this->graphQlQuery($query, [], '', $authHeader); + $this->assertArrayNotHasKey('errors', $result); + $this->assertCount(1, $result['customer']['orders']['items']); + + $order = $result['customer']['orders']['items'][0]; + $this->assertEquals('Flat Rate', $order['carrier']); + $this->assertEquals('Flat Rate - Fixed', $order['shipping_method']); + $this->assertArrayHasKey('shipments', $order); + /** @var Shipment $orderShipmentModel */ + $orderShipmentModel = $orderModel->getShipmentsCollection()->getFirstItem(); + $shipment = $order['shipments'][0]; + $this->assertEquals(base64_encode($orderShipmentModel->getIncrementId()), $shipment['id']); + $this->assertEquals($orderShipmentModel->getIncrementId(), $shipment['number']); + //Check Tracking + $this->assertCount(1, $shipment['tracking']); + $tracking = $shipment['tracking'][0]; + $this->assertEquals('ups', $tracking['carrier']); + $this->assertEquals('United Parcel Service', $tracking['title']); + $this->assertEquals('1234567890', $tracking['number']); + //Check Items + $this->assertCount(2, $shipment['items']); + foreach ($orderShipmentModel->getItems() as $expectedItem) { + $sku = $expectedItem->getSku(); + $findItem = array_filter($shipment['items'], function ($item) use ($sku) { + return $item['product_sku'] === $sku; + }); + $this->assertCount(1, $findItem); + $actualItem = reset($findItem); + $expectedEncodedId = base64_encode($expectedItem->getEntityId()); + $this->assertEquals($expectedEncodedId, $actualItem['id']); + $this->assertEquals($expectedItem->getSku(), $actualItem['product_sku']); + $this->assertEquals($expectedItem->getName(), $actualItem['product_name']); + $this->assertEquals($expectedItem->getPrice(), $actualItem['product_sale_price']['value']); + $this->assertEquals('USD', $actualItem['product_sale_price']['currency']); + $this->assertEquals('1', $actualItem['quantity_shipped']); + //Check correct order_item + $this->assertNotEmpty($actualItem['order_item']); + $this->assertEquals($expectedItem->getSku(), $actualItem['order_item']['product_sku']); + } + //Check comments + $this->assertCount(1, $shipment['comments']); + $this->assertEquals('This comment is visible to the customer', $shipment['comments'][0]['message']); + $this->assertNotEmpty($shipment['comments'][0]['timestamp']); + } + + /** + * @magentoApiDataFixture Magento/GraphQl/Sales/_files/customer_order_with_multiple_shipments.php + */ + public function testGetOrderShipmentsMultiple() + { + $query = $this->getQuery('100000555'); + $authHeader = $this->getCustomerAuthHeader->execute('customer_uk_address@test.com', 'password'); + + $result = $this->graphQlQuery($query, [], '', $authHeader); + $this->assertArrayNotHasKey('errors', $result); + $order = $result['customer']['orders']['items'][0]; + $shipments = $order['shipments']; + $this->assertCount(2, $shipments); + $this->assertEquals('0000000098', $shipments[0]['number']); + $this->assertCount(1, $shipments[0]['items']); + $this->assertEquals('0000000099', $shipments[1]['number']); + $this->assertCount(1, $shipments[1]['items']); + } + + /** + * @magentoConfigFixture default_store carriers/ups/active 1 + * @magentoApiDataFixture Magento/GraphQl/Sales/_files/customer_order_with_ups_shipping.php + */ + public function testOrderShipmentWithUpsCarrier() + { + $query = $this->getQuery('100000001'); + $authHeader = $this->getCustomerAuthHeader->execute('customer@example.com', 'password'); + + $result = $this->graphQlQuery($query, [], '', $authHeader); + + $this->assertArrayNotHasKey('errors', $result); + $this->assertEquals('UPS Next Day Air', $result['customer']['orders']['items'][0]['shipping_method']); + $this->assertEquals('United Parcel Service', $result['customer']['orders']['items'][0]['carrier']); + $shipments = $result['customer']['orders']['items'][0]['shipments']; + $expectedTracking = [ + 'title' => 'United Parcel Service', + 'carrier' => 'ups', + 'number' => '987654321' + ]; + $this->assertEquals($expectedTracking, $shipments[0]['tracking'][0]); + } + + /** + * @magentoConfigFixture default_store carriers/ups/active 1 + * @magentoApiDataFixture Magento/Customer/_files/customer.php + * @magentoApiDataFixture Magento/Bundle/_files/bundle_product_two_dropdown_options.php + */ + public function testOrderShipmentWithBundleProduct() + { + //Place order with bundled product + /** @var CustomerPlaceOrder $bundleProductOrderFixture */ + $bundleProductOrderFixture = Bootstrap::getObjectManager()->create(CustomerPlaceOrder::class); + $placeOrderResponse = $bundleProductOrderFixture->placeOrderWithBundleProduct( + ['email' => 'customer@example.com', 'password' => 'password'], + ['sku' => 'bundle-product-two-dropdown-options'] + ); + $orderNumber = $placeOrderResponse['placeOrder']['order']['order_number']; + $this->shipOrder($orderNumber); + + $result = $this->graphQlQuery( + $this->getQuery(), + [], + '', + $this->getCustomerAuthHeader->execute('customer@example.com', 'password') + ); + $this->assertArrayNotHasKey('errors', $result); + + $shipments = $result['customer']['orders']['items'][0]['shipments']; + $shipmentBundleItem = $shipments[0]['items'][0]; + + $shipmentItemAssertionMap = [ + 'order_item' => [ + 'product_sku' => 'bundle-product-two-dropdown-options-simple1-simple2' + ], + 'product_name' => 'Bundle Product With Two dropdown options', + 'product_sku' => 'bundle-product-two-dropdown-options-simple1-simple2', + 'product_sale_price' => [ + 'value' => 15, + 'currency' => 'USD' + ], + 'bundle_options' => [ + [ + 'label' => 'Drop Down Option 1', + 'values' => [ + [ + 'product_name' => 'Simple Product1', + 'product_sku' => 'simple1', + 'quantity' => 1, + 'price' => ['value' => 1] + ] + ] + ], + [ + 'label' => 'Drop Down Option 2', + 'values' => [ + [ + 'product_name' => 'Simple Product2', + 'product_sku' => 'simple2', + 'quantity' => 2, + 'price' => ['value' => 2] + ] + ] + ] + ] + ]; + + $this->assertResponseFields($shipmentBundleItem, $shipmentItemAssertionMap); + } + + /** + * Get query that fetch orders and shipment information + * + * @param string|null $orderId + * @return string + */ + private function getQuery(string $orderId = null) + { + $filter = $orderId ? "(filter:{number:{eq:\"$orderId\"}})" : ""; + return <<<QUERY +{ + customer { + orders {$filter}{ + items { + number + status + items { + product_sku + } + carrier + shipping_method + shipments { + id + number + tracking { + title + carrier + number + } + items { + id + order_item { + product_sku + } + product_name + product_sku + product_sale_price { + value + currency + } + ... on BundleShipmentItem { + bundle_options { + label + values { + product_name + product_sku + quantity + price { + value + } + } + } + } + quantity_shipped + } + comments { + timestamp + message + } + } + } + } + } +} +QUERY; + } + + /** + * Get model instance for order by number + * + * @param string $orderNumber + * @return Order + */ + private function fetchOrderModel(string $orderNumber): Order + { + /** @var Order $order */ + $order = Bootstrap::getObjectManager()->get(Order::class); + $order->loadByIncrementId($orderNumber); + return $order; + } + + /** + * Create shipment for order + * + * @param string $orderNumber + */ + private function shipOrder(string $orderNumber): void + { + $order = $this->fetchOrderModel($orderNumber); + $order->setIsInProcess(true); + /** @var Transaction $transaction */ + $transaction = Bootstrap::getObjectManager()->create(Transaction::class); + + $items = []; + foreach ($order->getItems() as $orderItem) { + $items[$orderItem->getId()] = $orderItem->getQtyOrdered(); + } + + $shipment = Bootstrap::getObjectManager()->get(ShipmentFactory::class)->create($order, $items); + $shipment->register(); + $transaction->addObject($shipment)->addObject($order)->save(); + } + + /** + * Clean up orders + */ + private function cleanupOrders() + { + $registry = Bootstrap::getObjectManager()->get(Registry::class); + $registry->unregister('isSecureArea'); + $registry->register('isSecureArea', true); + + /** @var $order \Magento\Sales\Model\Order */ + $orderCollection = Bootstrap::getObjectManager()->create(OrderCollection::class); + foreach ($orderCollection as $order) { + $this->orderRepository->delete($order); + } + $registry->unregister('isSecureArea'); + $registry->register('isSecureArea', false); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrder.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrder.php new file mode 100644 index 0000000000000..0386d414b8682 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/Fixtures/CustomerPlaceOrder.php @@ -0,0 +1,367 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\GraphQl\Sales\Fixtures; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product; +use Magento\Integration\Api\CustomerTokenServiceInterface; +use Magento\TestFramework\TestCase\GraphQl\Client; + +class CustomerPlaceOrder +{ + /** + * @var Client + */ + private $gqlClient; + + /** + * @var CustomerTokenServiceInterface + */ + private $tokenService; + + /** + * @var ProductRepositoryInterface + */ + private $productRepository; + + /** + * @var string + */ + private $authHeader; + + /** + * @var string + */ + private $cartId; + + /** + * @var array + */ + private $customerLogin; + + /** + * @param Client $gqlClient + * @param CustomerTokenServiceInterface $tokenService + * @param ProductRepositoryInterface $productRepository + */ + public function __construct( + Client $gqlClient, + CustomerTokenServiceInterface $tokenService, + ProductRepositoryInterface $productRepository + ) { + $this->gqlClient = $gqlClient; + $this->tokenService = $tokenService; + $this->productRepository = $productRepository; + } + + /** + * Place order for a bundled product + * + * @param array $customerLogin + * @param array $productData + * @return array + */ + public function placeOrderWithBundleProduct(array $customerLogin, array $productData): array + { + $this->customerLogin = $customerLogin; + $this->createCustomerCart(); + $this->addBundleProduct($productData); + $this->setBillingAddress(); + $shippingMethod = $this->setShippingAddress(); + $paymentMethod = $this->setShippingMethod($shippingMethod); + $this->setPaymentMethod($paymentMethod); + return $this->doPlaceOrder(); + } + + /** + * Make GraphQl POST request + * + * @param string $query + * @param array $additionalHeaders + * @return array + */ + private function makeRequest(string $query, array $additionalHeaders = []): array + { + $headers = array_merge([$this->getAuthHeader()], $additionalHeaders); + return $this->gqlClient->post($query, [], '', $headers); + } + + /** + * Get header for authenticated requests + * + * @return string + * @throws \Magento\Framework\Exception\AuthenticationException + */ + private function getAuthHeader(): string + { + if (empty($this->authHeader)) { + $customerToken = $this->tokenService + ->createCustomerAccessToken($this->customerLogin['email'], $this->customerLogin['password']); + $this->authHeader = "Authorization: Bearer {$customerToken}"; + } + return $this->authHeader; + } + + /** + * Get cart id + * + * @return string + */ + private function getCartId(): string + { + if (empty($this->cartId)) { + $this->cartId = $this->createCustomerCart(); + } + return $this->cartId; + } + + /** + * Create empty cart for the customer + * + * @return array + */ + private function createCustomerCart(): string + { + //Create empty cart + $createEmptyCart = <<<QUERY +mutation { + createEmptyCart +} +QUERY; + $result = $this->makeRequest($createEmptyCart); + return $result['createEmptyCart']; + } + + /** + * Add a bundle product to the cart + * + * @param array $productData + * @return array + * @throws \Magento\Framework\Exception\NoSuchEntityException + */ + private function addBundleProduct(array $productData) + { + $productSku = $productData['sku']; + $qty = $productData['quantity'] ?? 1; + /** @var Product $bundleProduct */ + $bundleProduct = $this->productRepository->get($productSku); + /** @var \Magento\Bundle\Model\Product\Type $typeInstance */ + $typeInstance = $bundleProduct->getTypeInstance(); + $optionId1 = (int)$typeInstance->getOptionsCollection($bundleProduct)->getFirstItem()->getId(); + $optionId2 = (int)$typeInstance->getOptionsCollection($bundleProduct)->getLastItem()->getId(); + $selectionId1 = (int)$typeInstance->getSelectionsCollection([$optionId1], $bundleProduct) + ->getFirstItem() + ->getSelectionId(); + $selectionId2 = (int)$typeInstance->getSelectionsCollection([$optionId2], $bundleProduct) + ->getLastItem() + ->getSelectionId(); + + $addProduct = <<<QUERY +mutation { + addBundleProductsToCart(input:{ + cart_id:"{$this->getCartId()}" + cart_items:[ + { + data:{ + sku:"{$productSku}" + quantity:{$qty} + } + bundle_options:[ + { + id:{$optionId1} + quantity:1 + value:["{$selectionId1}"] + } + { + id:$optionId2 + quantity:2 + value:["{$selectionId2}"] + } + ] + } + ] + }) { + cart { + items {quantity product {sku}} + } + } +} +QUERY; + return $this->makeRequest($addProduct); + } + + /** + * Set the billing address on the cart + * + * @return array + */ + private function setBillingAddress(): array + { + $setBillingAddress = <<<QUERY +mutation { + setBillingAddressOnCart( + input: { + cart_id: "{$this->getCartId()}" + billing_address: { + address: { + firstname: "John" + lastname: "Smith" + company: "Test company" + street: ["test street 1", "test street 2"] + city: "Texas City" + postcode: "78717" + telephone: "5123456677" + region: "TX" + country_code: "US" + } + } + } + ) { + cart { + billing_address { + __typename + } + } + } +} +QUERY; + return $this->makeRequest($setBillingAddress); + } + + /** + * Set the shipping address on the cart and return an available shipping method + * + * @return array + */ + private function setShippingAddress(): array + { + $setShippingAddress = <<<QUERY +mutation { + setShippingAddressesOnCart( + input: { + cart_id: "{$this->getCartId()}" + shipping_addresses: [ + { + address: { + firstname: "test shipFirst" + lastname: "test shipLast" + company: "test company" + street: ["test street 1", "test street 2"] + city: "Montgomery" + region: "AL" + postcode: "36013" + country_code: "US" + telephone: "3347665522" + } + } + ] + } + ) { + cart { + shipping_addresses { + available_shipping_methods { + carrier_code + method_code + amount {value} + } + } + } + } +} +QUERY; + $result = $this->makeRequest($setShippingAddress); + $shippingMethod = $result['setShippingAddressesOnCart'] + ['cart']['shipping_addresses'][0]['available_shipping_methods'][0]; + return $shippingMethod; + } + + /** + * Set the shipping method on the cart and return an available payment method + * + * @param array $shippingMethod + * @return array + */ + private function setShippingMethod(array $shippingMethod): array + { + $setShippingMethod = <<<QUERY +mutation { + setShippingMethodsOnCart(input: { + cart_id: "{$this->getCartId()}", + shipping_methods: [ + { + carrier_code: "{$shippingMethod['carrier_code']}" + method_code: "{$shippingMethod['method_code']}" + } + ] + }) { + cart { + available_payment_methods { + code + title + } + } + } +} +QUERY; + $result = $this->makeRequest($setShippingMethod); + $paymentMethod = $result['setShippingMethodsOnCart']['cart']['available_payment_methods'][0]; + return $paymentMethod; + } + + /** + * Set the payment method on the cart + * + * @param array $paymentMethod + * @return array + */ + private function setPaymentMethod(array $paymentMethod): array + { + $setPaymentMethod = <<<QUERY +mutation { + setPaymentMethodOnCart( + input: { + cart_id: "{$this->getCartId()}" + payment_method: { + code: "{$paymentMethod['code']}" + } + } + ) { + cart { + selected_payment_method { + code + } + } + } +} +QUERY; + return $this->makeRequest($setPaymentMethod); + } + + /** + * Place the order + * + * @return array + */ + private function doPlaceOrder(): array + { + $placeOrder = <<<QUERY +mutation { + placeOrder( + input: { + cart_id: "{$this->getCartId()}" + } + ) { + order { + order_number + } + } +} +QUERY; + return $this->makeRequest($placeOrder); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/InvoiceTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/InvoiceTest.php index 10d47872b16f7..8b18d4bd07d1b 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/InvoiceTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/InvoiceTest.php @@ -100,7 +100,7 @@ public function testSingleInvoiceForLoggedInCustomerQuery() 'discounts' => [], 'base_grand_total' => [ 'value' => 100, - 'currency' => 'USD' + 'currency' => 'EUR' ], 'total_tax' => [ 'value' => 0, @@ -154,7 +154,7 @@ public function testMultipleInvoiceForLoggedInCustomerQuery() ], 'base_grand_total' => [ 'value' => 50, - 'currency' => 'USD' + 'currency' => 'EUR' ], 'total_tax' => [ 'value' => 0, @@ -204,7 +204,7 @@ public function testMultipleInvoiceForLoggedInCustomerQuery() ], 'base_grand_total' => [ 'value' => 0, - 'currency' => 'USD' + 'currency' => 'EUR' ], 'total_tax' => [ 'value' => 0, @@ -466,12 +466,9 @@ private function assertTotalsAndShippingWithTaxesAndDiscounts(array $customerOrd 'rate' => 7.5 ] ], - 'discounts'=> [ - 0 => [ - 'amount'=>['value' => 0.07, 'currency'=> 'USD'], - 'label' => 'Discount Label for 10% off' - ] - ], + 'discounts'=> [ + 0 => ['amount'=>['value' => 1, 'currency'=> 'USD']] + ], ] ]; $this->assertResponseFields($customerOrderItemTotal, $assertionMap); @@ -509,12 +506,8 @@ private function assertTotalsAndShippingWithTaxesAndDiscountsForOneQty(array $cu 'rate' => 7.5 ] ], - 'discounts'=> [ - 0 => [ - 'amount'=>['value' => 0.07, 'currency'=> 'USD'], - 'label' => 'Discount Label for 10% off' - ] - ], + 'discounts'=> [['amount'=>['value' => 1, 'currency'=> 'USD']] + ], ] ]; $this->assertResponseFields($customerOrderItemTotal, $assertionMap); @@ -827,7 +820,7 @@ private function getCustomerInvoicesBasedOnOrderNumber($orderNumber): array amount_excluding_tax{value currency} total_amount{value currency} taxes {amount{value} title rate} - discounts {amount{value currency} label} + discounts {amount{value currency}} } } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersByOrderNumberTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersByOrderNumberTest.php index d0caa90731887..299bccc5a1277 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersByOrderNumberTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersByOrderNumberTest.php @@ -186,9 +186,7 @@ private function assertTotalsWithTaxesAndDiscounts(array $customerOrderItemTotal 'amount_excluding_tax' => ['value' => 20], 'total_amount' => ['value' => 20, 'currency' =>'USD'], 'discounts' => [ - 0 => ['amount'=>['value'=> 2, 'currency' =>'USD'], - 'label' => 'Discount Label for 10% off' - ] + 0 => ['amount'=>['value'=> 2, 'currency' =>'USD']] ], 'taxes'=> [ 0 => [ @@ -271,9 +269,7 @@ private function assertTotalsWithTaxesAndDiscountsWithTwoRules(array $customerOr 'amount_excluding_tax' => ['value' => 20], 'total_amount' => ['value' => 20, 'currency' =>'USD'], 'discounts' => [ - 0 => ['amount'=>['value'=> 2, 'currency' =>'USD'], - 'label' => 'Discount Label for 10% off' - ] + 0 => ['amount'=>['value'=> 2, 'currency' =>'USD']] ], 'taxes'=> [ 0 => [ @@ -1245,7 +1241,7 @@ private function getCustomerOrderQuery($orderNumber): array amount_excluding_tax{value} total_amount{value currency} taxes {amount{value} title rate} - discounts {amount{value currency} label} + discounts {amount{value currency}} } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithBundleProductByOrderNumberTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithBundleProductByOrderNumberTest.php index f0a63b10b2a5b..b4c9bd4962cc2 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithBundleProductByOrderNumberTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/RetrieveOrdersWithBundleProductByOrderNumberTest.php @@ -7,12 +7,11 @@ namespace Magento\GraphQl\Sales; -use Magento\Bundle\Model\Selection; use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\Catalog\Model\Product; use Magento\Framework\Api\SearchCriteriaBuilder; use Magento\Framework\Exception\AuthenticationException; use Magento\GraphQl\GetCustomerAuthenticationHeader; +use Magento\GraphQl\Sales\Fixtures\CustomerPlaceOrder; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\ResourceModel\Order\Collection; use Magento\TestFramework\Helper\Bootstrap; @@ -45,6 +44,11 @@ protected function setUp():void $this->productRepository = $objectManager->get(ProductRepositoryInterface::class); } + protected function tearDown(): void + { + $this->deleteOrder(); + } + /** * Test customer order details with bundle product with child items * @@ -53,19 +57,19 @@ protected function setUp():void */ public function testGetCustomerOrderBundleProduct() { + //Place order with bundled product $qty = 1; $bundleSku = 'bundle-product-two-dropdown-options'; - $optionsAndSelectionData = $this->getBundleOptionAndSelectionData($bundleSku); + /** @var CustomerPlaceOrder $bundleProductOrderFixture */ + $bundleProductOrderFixture = Bootstrap::getObjectManager()->create(CustomerPlaceOrder::class); + $orderResponse = $bundleProductOrderFixture->placeOrderWithBundleProduct( + ['email' => 'customer@example.com', 'password' => 'password'], + ['sku' => $bundleSku, 'quantity' => $qty] + ); + $orderNumber = $orderResponse['placeOrder']['order']['order_number']; + //End place order with bundled product - $cartId = $this->createEmptyCart(); - $this->addBundleProductQuery($cartId, $qty, $bundleSku, $optionsAndSelectionData); - $this->setBillingAddress($cartId); - $shippingMethod = $this->setShippingAddress($cartId); - $paymentMethod = $this->setShippingMethod($cartId, $shippingMethod); - $this->setPaymentMethod($cartId, $paymentMethod); - $orderNumber = $this->placeOrder($cartId); $customerOrderResponse = $this->getCustomerOrderQueryBundleProduct($orderNumber); - $customerOrderItems = $customerOrderResponse[0]; $this->assertEquals("Pending", $customerOrderItems['status']); $bundledItemInTheOrder = $customerOrderItems['items'][0]; @@ -111,7 +115,6 @@ public function testGetCustomerOrderBundleProduct() ], ]; $this->assertEquals($expectedBundleOptions, $bundleOptionsFromResponse); - $this->deleteOrder(); } /** @@ -124,19 +127,19 @@ public function testGetCustomerOrderBundleProduct() */ public function testGetCustomerOrderBundleProductWithTaxesAndDiscounts() { + //Place order with bundled product $qty = 4; $bundleSku = 'bundle-product-two-dropdown-options'; - $optionsAndSelectionData = $this->getBundleOptionAndSelectionData($bundleSku); + /** @var CustomerPlaceOrder $bundleProductOrderFixture */ + $bundleProductOrderFixture = Bootstrap::getObjectManager()->create(CustomerPlaceOrder::class); + $orderResponse = $bundleProductOrderFixture->placeOrderWithBundleProduct( + ['email' => 'customer@example.com', 'password' => 'password'], + ['sku' => $bundleSku, 'quantity' => $qty] + ); + $orderNumber = $orderResponse['placeOrder']['order']['order_number']; + //End place order with bundled product - $cartId = $this->createEmptyCart(); - $this->addBundleProductQuery($cartId, $qty, $bundleSku, $optionsAndSelectionData); - $this->setBillingAddress($cartId); - $shippingMethod = $this->setShippingAddress($cartId); - $paymentMethod = $this->setShippingMethod($cartId, $shippingMethod); - $this->setPaymentMethod($cartId, $paymentMethod); - $orderNumber = $this->placeOrder($cartId); $customerOrderResponse = $this->getCustomerOrderQueryBundleProduct($orderNumber); - $customerOrderItems = $customerOrderResponse[0]; $this->assertEquals("Pending", $customerOrderItems['status']); @@ -160,7 +163,6 @@ public function testGetCustomerOrderBundleProductWithTaxesAndDiscounts() $this->assertEquals('simple1', $childItemsInTheOrder[0]['values'][0]['product_sku']); $this->assertEquals('simple2', $childItemsInTheOrder[1]['values'][0]['product_sku']); $this->assertTotalsOnBundleProductWithTaxesAndDiscounts($customerOrderItems['total']); - $this->deleteOrder(); } /** @@ -187,9 +189,7 @@ private function assertTotalsOnBundleProductWithTaxesAndDiscounts(array $custome 'amount_excluding_tax' => ['value' => 20], 'total_amount' => ['value' => 20], 'discounts' => [ - 0 => ['amount'=>['value'=> 2], - 'label' => 'Discount Label for 10% off' - ] + 0 => ['amount'=>['value'=> 2]] ], 'taxes'=> [ 0 => [ @@ -208,281 +208,6 @@ private function assertTotalsOnBundleProductWithTaxesAndDiscounts(array $custome $this->assertResponseFields($customerOrderItemTotal, $assertionMap); } - /** - * @return string - */ - private function createEmptyCart(): string - { - $query = <<<QUERY -mutation { - createEmptyCart -} -QUERY; - $currentEmail = 'customer@example.com'; - $currentPassword = 'password'; - $response = $this->graphQlMutation( - $query, - [], - '', - $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) - ); - return $response['createEmptyCart']; - } - - /** - * Add bundle product to cart with Graphql query - * - * @param string $cartId - * @param float $qty - * @param string $sku - * @param array $optionsAndSelectionData - * @throws AuthenticationException - */ - public function addBundleProductQuery( - string $cartId, - float $qty, - string $sku, - array $optionsAndSelectionData - ) { - $query = <<<QUERY -mutation { - addBundleProductsToCart(input:{ - cart_id:"{$cartId}" - cart_items:[ - { - data:{ - sku:"{$sku}" - quantity:$qty - } - bundle_options:[ - { - id:$optionsAndSelectionData[0] - quantity:1 - value:["{$optionsAndSelectionData[1]}"] - } - { - id:$optionsAndSelectionData[2] - quantity:2 - value:["{$optionsAndSelectionData[3]}"] - } - ] - } - ] - }) { - cart { - items {quantity product {sku}} - } - } -} -QUERY; - $currentEmail = 'customer@example.com'; - $currentPassword = 'password'; - $response = $this->graphQlMutation( - $query, - [], - '', - $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) - ); - $this->assertArrayHasKey('cart', $response['addBundleProductsToCart']); - } - /** - * @param string $cartId - * @param array $auth - * @return array - */ - private function setBillingAddress(string $cartId): void - { - $query = <<<QUERY -mutation { - setBillingAddressOnCart( - input: { - cart_id: "{$cartId}" - billing_address: { - address: { - firstname: "John" - lastname: "Smith" - company: "Test company" - street: ["test street 1", "test street 2"] - city: "Texas City" - postcode: "78717" - telephone: "5123456677" - region: "TX" - country_code: "US" - } - } - } - ) { - cart { - billing_address { - __typename - } - } - } -} -QUERY; - $currentEmail = 'customer@example.com'; - $currentPassword = 'password'; - $this->graphQlMutation( - $query, - [], - '', - $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) - ); - } - - /** - * @param string $cartId - * @return array - */ - private function setShippingAddress(string $cartId): array - { - $query = <<<QUERY -mutation { - setShippingAddressesOnCart( - input: { - cart_id: "$cartId" - shipping_addresses: [ - { - address: { - firstname: "test shipFirst" - lastname: "test shipLast" - company: "test company" - street: ["test street 1", "test street 2"] - city: "Montgomery" - region: "AL" - postcode: "36013" - country_code: "US" - telephone: "3347665522" - } - } - ] - } - ) { - cart { - shipping_addresses { - available_shipping_methods { - carrier_code - method_code - amount {value} - } - } - } - } -} -QUERY; - $currentEmail = 'customer@example.com'; - $currentPassword = 'password'; - $response = $this->graphQlMutation( - $query, - [], - '', - $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) - ); - $shippingAddress = current($response['setShippingAddressesOnCart']['cart']['shipping_addresses']); - $availableShippingMethod = current($shippingAddress['available_shipping_methods']); - return $availableShippingMethod; - } - /** - * @param string $cartId - * @param array $method - * @return array - */ - private function setShippingMethod(string $cartId, array $method): array - { - $query = <<<QUERY -mutation { - setShippingMethodsOnCart(input: { - cart_id: "{$cartId}", - shipping_methods: [ - { - carrier_code: "{$method['carrier_code']}" - method_code: "{$method['method_code']}" - } - ] - }) { - cart { - available_payment_methods { - code - title - } - } - } -} -QUERY; - $currentEmail = 'customer@example.com'; - $currentPassword = 'password'; - $response = $this->graphQlMutation( - $query, - [], - '', - $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) - ); - - $availablePaymentMethod = current($response['setShippingMethodsOnCart']['cart']['available_payment_methods']); - return $availablePaymentMethod; - } - - /** - * @param string $cartId - * @param array $method - * @return void - */ - private function setPaymentMethod(string $cartId, array $method): void - { - $query = <<<QUERY -mutation { - setPaymentMethodOnCart( - input: { - cart_id: "{$cartId}" - payment_method: { - code: "{$method['code']}" - } - } - ) { - cart {selected_payment_method {code}} - } -} -QUERY; - $currentEmail = 'customer@example.com'; - $currentPassword = 'password'; - $this->graphQlMutation( - $query, - [], - '', - $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) - ); - } - - /** - * @param string $cartId - * @return string - */ - private function placeOrder(string $cartId): string - { - $query = <<<QUERY -mutation { - placeOrder( - input: { - cart_id: "{$cartId}" - } - ) { - order { - order_number - } - } -} -QUERY; - $currentEmail = 'customer@example.com'; - $currentPassword = 'password'; - $response = $this->graphQlMutation( - $query, - [], - '', - $this->customerAuthenticationHeader->execute($currentEmail, $currentPassword) - ); - return $response['placeOrder']['order']['order_number']; - } - /** * Get customer order query for bundle order items * @@ -539,7 +264,7 @@ private function getCustomerOrderQueryBundleProduct($orderNumber) amount_including_tax{value} amount_excluding_tax{value} total_amount{value} - discounts{amount{value} label} + discounts{amount{value}} taxes {amount{value} title rate} } discounts {amount{value currency} label} @@ -576,37 +301,10 @@ private function deleteOrder(): void /** @var $order \Magento\Sales\Model\Order */ $orderCollection = Bootstrap::getObjectManager()->create(Collection::class); - //$orderCollection = $this->orderCollectionFactory->create(); foreach ($orderCollection as $order) { $this->orderRepository->delete($order); } $registry->unregister('isSecureArea'); $registry->register('isSecureArea', false); } - - /** - * @param string $bundleSku - * @return array - * @throws \Magento\Framework\Exception\NoSuchEntityException - */ - private function getBundleOptionAndSelectionData($bundleSku): array - { - /** @var Product $bundleProduct */ - $bundleProduct = $this->productRepository->get($bundleSku); - /** @var $typeInstance \Magento\Bundle\Model\Product\Type */ - $typeInstance = $bundleProduct->getTypeInstance(); - $optionsAndSelections = []; - /** @var $option \Magento\Bundle\Model\Option */ - $option1 = $typeInstance->getOptionsCollection($bundleProduct)->getFirstItem(); - $option2 = $typeInstance->getOptionsCollection($bundleProduct)->getLastItem(); - $optionId1 =(int) $option1->getId(); - $optionId2 =(int) $option2->getId(); - /** @var Selection $selection */ - $selection1 = $typeInstance->getSelectionsCollection([$option1->getId()], $bundleProduct)->getFirstItem(); - $selectionId1 = (int)$selection1->getSelectionId(); - $selection2 = $typeInstance->getSelectionsCollection([$option2->getId()], $bundleProduct)->getLastItem(); - $selectionId2 = (int)$selection2->getSelectionId(); - array_push($optionsAndSelections, $optionId1, $selectionId1, $optionId2, $selectionId2); - return $optionsAndSelections; - } } 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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->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 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; +use Magento\TestFramework\Helper\Bootstrap; + +Resolver::getInstance()->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); diff --git a/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_radio_select.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_radio_select.php new file mode 100644 index 0000000000000..74182d830dc6d --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_radio_select.php @@ -0,0 +1,177 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Catalog\Model\Product; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/multiple_products.php'); + +$objectManager = 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 Product */ +$product = $objectManager->create(Product::class); +$product->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_BUNDLE) + ->setId(3) + ->setAttributeSetId(4) + ->setWebsiteIds([1]) + ->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]) + ->setPriceType(1) + ->setPrice(10.0) + ->setShipmentType(0) + ->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' => 1, + 'delete' => '', + 'option_id' => 2 + ], + [ + 'product_id' => 11, + 'selection_qty' => 1, + 'selection_can_change_qty' => 1, + 'delete' => '', + 'option_id' => 2 + ] + ], + [ + [ + 'product_id' => 10, + 'selection_qty' => 1, + 'delete' => '', + 'option_id' => 3 + ], + [ + 'product_id' => 11, + 'selection_qty' => 1, + 'delete' => '', + 'option_id' => 3 + ] + ], + [ + [ + 'product_id' => 10, + 'selection_qty' => 1, + 'delete' => '', + 'option_id' => 4 + ], + [ + 'product_id' => 11, + 'selection_qty' => 1, + 'delete' => '', + 'option_id' => 4 + ] + ], + [ + [ + 'product_id' => 10, + 'selection_qty' => 1, + 'delete' => '', + 'option_id' => 5 + ], + [ + 'product_id' => 11, + 'selection_qty' => 1, + 'delete' => '', + 'option_id' => 5 + ] + ] + ] + ); +$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']); + $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_radio_select_rollback.php b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_radio_select_rollback.php new file mode 100644 index 0000000000000..57b4eb2e6cc91 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Bundle/_files/product_with_multiple_options_radio_select_rollback.php @@ -0,0 +1,28 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/multiple_products_rollback.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var \Magento\Framework\Registry $registry */ +$registry = $objectManager->get(\Magento\Framework\Registry::class); +/** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ +$productRepository = $objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::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); diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_attribute.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_attribute.php index 29b4a05c4dcbe..6b85b27929c2c 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_attribute.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_attribute.php @@ -4,9 +4,15 @@ * See COPYING.txt for license details. */ -/** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute */ -$attribute = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class); +/** @var Attribute $attribute */ + +use Magento\Catalog\Model\Category\AttributeFactory; +use Magento\Catalog\Model\Category\Attribute; +use Magento\TestFramework\Helper\Bootstrap; + +/** @var AttributeFactory $attributeFactory */ +$attributeFactory = Bootstrap::getObjectManager()->get(AttributeFactory::class); +$attribute = $attributeFactory->create(); $attribute->setAttributeCode('test_attribute_code_666') ->setEntityTypeId(3) ->setIsGlobal(1) diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_attribute_rollback.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_attribute_rollback.php index 34114703de344..2cae71c35b916 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/category_attribute_rollback.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/category_attribute_rollback.php @@ -4,15 +4,22 @@ * See COPYING.txt for license details. */ -/** @var \Magento\Framework\Registry $registry */ -$registry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->get(\Magento\Framework\Registry::class); +/** @var Registry $registry */ + +use Magento\Catalog\Model\Category\AttributeFactory; +use Magento\Framework\Registry; +use Magento\TestFramework\Helper\Bootstrap; + +$objectManager = Bootstrap::getObjectManager(); +/** @var Registry $registry */ +$registry = $objectManager->get(Registry::class); $registry->unregister('isSecureArea'); $registry->register('isSecureArea', true); -/** @var \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute */ -$attribute = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() - ->create(\Magento\Catalog\Model\ResourceModel\Eav\Attribute::class); +/** @var AttributeFactory $attributeFactory */ +$attributeFactory = $objectManager->get(AttributeFactory::class); +$attribute = $attributeFactory->create(); $attribute->loadByCode(3, 'test_attribute_code_666'); diff --git a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_with_uk_address.php b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_with_uk_address.php index a7ad0bb82719f..c024d18e40942 100644 --- a/dev/tests/integration/testsuite/Magento/Customer/_files/customer_with_uk_address.php +++ b/dev/tests/integration/testsuite/Magento/Customer/_files/customer_with_uk_address.php @@ -11,9 +11,10 @@ use Magento\Customer\Api\Data\AddressInterface; use Magento\Customer\Model\Address; use Magento\Customer\Model\AddressFactory; +use Magento\Customer\Model\AddressRegistry; use Magento\Customer\Model\CustomerFactory; use Magento\Customer\Model\CustomerRegistry; -use Magento\Customer\Model\AddressRegistry; +use Magento\Framework\Encryption\EncryptorInterface; use Magento\Store\Api\WebsiteRepositoryInterface; use Magento\Store\Model\Website; use Magento\Store\Model\WebsiteRepository; @@ -31,6 +32,9 @@ $websiteRepository = $objectManager->create(WebsiteRepositoryInterface::class); /** @var Website $mainWebsite */ $mainWebsite = $websiteRepository->get('base'); +/** @var EncryptorInterface $encryptor */ +$encryptor = $objectManager->get(EncryptorInterface::class); + $customer->setWebsiteId($mainWebsite->getId()) ->setEmail('customer_uk_address@test.com') ->setPassword('password') @@ -67,7 +71,7 @@ ); $customer->addAddress($customerAddress); $customer->isObjectNew(true); -$customerDataModel = $customerRepository->save($customer->getDataModel()); +$customerDataModel = $customerRepository->save($customer->getDataModel(), $encryptor->hash('password')); $addressId = $customerDataModel->getAddresses()[0]->getId(); $customerDataModel->setDefaultShipping($addressId); $customerDataModel->setDefaultBilling($addressId); diff --git a/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/AttributeTest.php b/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/AttributeTest.php new file mode 100644 index 0000000000000..c1fe6f11f6e6e --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Elasticsearch/Model/Indexer/Fulltext/Plugin/Category/Product/AttributeTest.php @@ -0,0 +1,195 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\Elasticsearch\Model\Indexer\Fulltext\Plugin\Category\Product; + +use Magento\AdvancedSearch\Model\Client\ClientInterface; +use Magento\Catalog\Api\Data\EavAttributeInterface; +use Magento\Catalog\Api\Data\ProductAttributeInterface; +use Magento\Catalog\Api\ProductAttributeRepositoryInterface; +use Magento\Catalog\Model\ResourceModel\Eav\AttributeFactory; +use Magento\Catalog\Setup\CategorySetup; +use Magento\CatalogSearch\Model\Indexer\Fulltext\Processor; +use Magento\Elasticsearch\Model\Adapter\Index\IndexNameResolver; +use Magento\Elasticsearch\SearchAdapter\ConnectionManager; +use Magento\Framework\Stdlib\ArrayManager; +use Magento\Store\Model\StoreManagerInterface; +use Magento\TestFramework\Helper\Bootstrap; +use PHPUnit\Framework\TestCase; + +/** + * Check Elasticsearch indexer mapping when working with attributes. + */ +class AttributeTest extends TestCase +{ + /** + * @var ClientInterface + */ + private $client; + + /** + * @var ArrayManager + */ + private $arrayManager; + + /** + * @var IndexNameResolver + */ + private $indexNameResolver; + + /** + * @var Processor + */ + private $indexerProcessor; + + /** + * @var StoreManagerInterface + */ + private $storeManager; + + /** + * @var CategorySetup + */ + private $installer; + + /** + * @var AttributeFactory + */ + private $attributeFactory; + + /** + * @var ProductAttributeRepositoryInterface + */ + private $attributeRepository; + + /** + * @inheritdoc + */ + protected function setUp(): void + { + parent::setUp(); + + $connectionManager = Bootstrap::getObjectManager()->get(ConnectionManager::class); + $this->client = $connectionManager->getConnection(); + $this->arrayManager = Bootstrap::getObjectManager()->get(ArrayManager::class); + $this->indexNameResolver = Bootstrap::getObjectManager()->get(IndexNameResolver::class); + $this->indexerProcessor = Bootstrap::getObjectManager()->get(Processor::class); + $this->storeManager = Bootstrap::getObjectManager()->get(StoreManagerInterface::class); + $this->installer = Bootstrap::getObjectManager()->get(CategorySetup::class); + $this->attributeFactory = Bootstrap::getObjectManager()->get(AttributeFactory::class); + $this->attributeRepository = Bootstrap::getObjectManager()->get(ProductAttributeRepositoryInterface::class); + } + + /** + * @inheritdoc + */ + protected function tearDown(): void + { + parent::tearDown(); + + /** @var ProductAttributeInterface $attribute */ + $attribute = $this->attributeRepository->get('dropdown_attribute'); + $this->attributeRepository->delete($attribute); + } + + /** + * Check Elasticsearch indexer mapping is updated after creating attribute. + * + * @return void + * @magentoConfigFixture default/catalog/search/engine elasticsearch7 + * @magentoDataFixture Magento/CatalogSearch/_files/full_reindex.php + */ + public function testCheckElasticsearchMappingAfterUpdateAttributeToSearchable(): void + { + $mappedAttributesBefore = $this->getMappingProperties(); + $expectedResult = [ + 'dropdown_attribute' => [ + 'type' => 'integer', + 'index' => false, + ], + 'dropdown_attribute_value' => [ + 'type' => 'text', + 'copy_to' => ['_search'], + ], + ]; + + /** @var ProductAttributeInterface $dropDownAttribute */ + $dropDownAttribute = $this->attributeFactory->create(); + $dropDownAttribute->setData($this->getAttributeData()); + $this->attributeRepository->save($dropDownAttribute); + $this->assertTrue($this->indexerProcessor->getIndexer()->isValid()); + + $mappedAttributesAfter = $this->getMappingProperties(); + $this->assertEquals($expectedResult, array_diff_key($mappedAttributesAfter, $mappedAttributesBefore)); + + $dropDownAttribute->setData(EavAttributeInterface::IS_SEARCHABLE, true); + $this->attributeRepository->save($dropDownAttribute); + $this->assertTrue($this->indexerProcessor->getIndexer()->isInvalid()); + + $this->assertEquals($mappedAttributesAfter, $this->getMappingProperties()); + } + + /** + * Retrieve Elasticsearch indexer mapping. + * + * @return array + */ + private function getMappingProperties(): array + { + $storeId = $this->storeManager->getStore()->getId(); + $mappedIndexerId = $this->indexNameResolver->getIndexMapping(Processor::INDEXER_ID); + $indexName = $this->indexNameResolver->getIndexFromAlias($storeId, $mappedIndexerId); + $mappedAttributes = $this->client->getMapping(['index' => $indexName]); + $pathField = $this->arrayManager->findPath('properties', $mappedAttributes); + + return $this->arrayManager->get($pathField, $mappedAttributes, []); + } + + /** + * Retrieve drop-down attribute data. + * + * @return array + */ + private function getAttributeData(): array + { + $entityTypeId = $this->installer->getEntityTypeId(ProductAttributeInterface::ENTITY_TYPE_CODE); + + return [ + 'attribute_code' => 'dropdown_attribute', + 'entity_type_id' => $entityTypeId, + 'is_global' => 0, + 'is_user_defined' => 1, + 'frontend_input' => 'select', + 'is_unique' => 0, + 'is_required' => 0, + 'is_searchable' => 0, + 'is_visible_in_advanced_search' => 0, + 'is_comparable' => 0, + 'is_filterable' => 0, + 'is_filterable_in_search' => 0, + 'is_used_for_promo_rules' => 0, + 'is_html_allowed_on_front' => 1, + 'is_visible_on_front' => 1, + 'used_in_product_listing' => 1, + 'used_for_sort_by' => 0, + 'frontend_label' => ['Drop-Down Attribute'], + 'backend_type' => 'varchar', + 'option' => [ + 'value' => [ + 'option_1' => ['Option 1'], + 'option_2' => ['Option 2'], + 'option_3' => ['Option 3'], + ], + 'order' => [ + 'option_1' => 1, + 'option_2' => 2, + 'option_3' => 3, + ], + ], + ]; + } +} diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_multiple_shipments.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_multiple_shipments.php new file mode 100644 index 0000000000000..def622b8f5025 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_multiple_shipments.php @@ -0,0 +1,35 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Shipment; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; +use Magento\Sales\Model\Order\ShipmentFactory; +use Magento\Framework\DB\Transaction; + +Resolver::getInstance()->requireDataFixture('Magento/Sales/_files/customer_order_with_two_items.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var Transaction $transaction */ +$transaction = $objectManager->create(Transaction::class); + +/** @var Order $order */ +$order = $objectManager->create(Order::class)->loadByIncrementId('100000555'); + +$items = []; +$shipmentIds = ['0000000098', '0000000099']; +$i = 0; +foreach ($order->getItems() as $orderItem) { + $items[$orderItem->getId()] = $orderItem->getQtyOrdered(); + /** @var Shipment $shipment */ + $shipment = $objectManager->get(ShipmentFactory::class)->create($order, $items); + $shipment->setIncrementId($shipmentIds[$i]); + $shipment->register(); + + $transaction->addObject($shipment)->addObject($order)->save(); + $i++; +} diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_multiple_shipments_rollback.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_multiple_shipments_rollback.php new file mode 100644 index 0000000000000..5fc01f2ecc073 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_multiple_shipments_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->requireDataFixture('Magento/Sales/_files/customer_order_with_two_items_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_simple_shipment.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_simple_shipment.php new file mode 100644 index 0000000000000..22eac03f9a6a8 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_simple_shipment.php @@ -0,0 +1,70 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Sales\Model\Order; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; +use Magento\Sales\Api\ShipmentCommentRepositoryInterface; +use Magento\Sales\Model\Order\Shipment\Comment; +use Magento\Sales\Model\Order\ShipmentFactory; +use Magento\Sales\Model\Order\Shipment\Track; +use Magento\Framework\DB\Transaction; +use Magento\Sales\Api\ShipmentTrackRepositoryInterface; + +Resolver::getInstance()->requireDataFixture('Magento/Sales/_files/customer_order_with_two_items.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var Transaction $transaction */ +$transaction = $objectManager->create(Transaction::class); + +/** @var Order $order */ +$order = $objectManager->create(Order::class)->loadByIncrementId('100000555'); + +$items = []; +foreach ($order->getItems() as $orderItem) { + $items[$orderItem->getId()] = $orderItem->getQtyOrdered(); +} +$shipment = $objectManager->get(ShipmentFactory::class)->create($order, $items); +$shipment->register(); + +$transaction->addObject($shipment)->addObject($order)->save(); + +//Add shipment comments +$shipmentCommentRepository = $objectManager->get(ShipmentCommentRepositoryInterface::class); +$comments = [ + [ + 'comment' => 'This comment is visible to the customer', + 'is_visible_on_front' => 1, + 'is_customer_notified' => 1, + ], + [ + 'comment' => 'This comment should not be visible to the customer', + 'is_visible_on_front' => 0, + 'is_customer_notified' => 0, + ], +]; + +foreach ($comments as $commentData) { + /** @var Comment $comment */ + $comment = $objectManager->create(Comment::class); + $comment->setParentId($shipment->getId()); + $comment->setComment($commentData['comment']); + $comment->setIsVisibleOnFront($commentData['is_visible_on_front']); + $comment->setIsCustomerNotified($commentData['is_customer_notified']); + $shipmentCommentRepository->save($comment); +} + +//Add tracking +/** @var ShipmentTrackRepositoryInterface $shipmentTrackRepository */ +$shipmentTrackRepository = $objectManager->get(ShipmentTrackRepositoryInterface::class); +/** @var Track $track */ +$track = $objectManager->create(Track::class); +$track->setOrderId($order->getId()); +$track->setParentId($shipment->getId()); +$track->setTitle('United Parcel Service'); +$track->setCarrierCode('ups'); +$track->setTrackNumber('1234567890'); +$shipmentTrackRepository->save($track); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_simple_shipment_rollback.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_simple_shipment_rollback.php new file mode 100644 index 0000000000000..5fc01f2ecc073 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_simple_shipment_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->requireDataFixture('Magento/Sales/_files/customer_order_with_two_items_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_ups_shipping.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_ups_shipping.php new file mode 100644 index 0000000000000..848ee1ff0174b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_ups_shipping.php @@ -0,0 +1,38 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\Sales\Model\Order; +use Magento\TestFramework\Helper\Bootstrap; +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; +use Magento\Framework\DB\Transaction; +use Magento\Sales\Model\Order\ShipmentFactory; + +Resolver::getInstance()->requireDataFixture('Magento/Sales/_files/order_with_customer.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var Transaction $transaction */ +$transaction = $objectManager->get(Transaction::class); +/** @var Order $order */ +$order = $objectManager->create(Order::class)->loadByIncrementId('100000001'); +//Set the shipping method +$order->setShippingDescription('UPS Next Day Air'); +$order->setShippingMethod('ups_01'); +$order->save(); + +//Create Shipment with UPS tracking and some items +$shipmentItems = []; +foreach ($order->getItems() as $orderItem) { + $shipmentItems[$orderItem->getId()] = $orderItem->getQtyOrdered(); +} +$tracking = [ + 'carrier_code' => 'ups', + 'title' => 'United Parcel Service', + 'number' => '987654321' +]; + +$shipment = $objectManager->get(ShipmentFactory::class)->create($order, $shipmentItems, [$tracking]); +$shipment->register(); +$transaction->addObject($shipment)->addObject($order)->save(); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_ups_shipping_rollback.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_ups_shipping_rollback.php new file mode 100644 index 0000000000000..bbb90e0326aec --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/customer_order_with_ups_shipping_rollback.php @@ -0,0 +1,9 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->requireDataFixture('Magento/Sales/_files/order_with_customer_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/order_with_totals.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/order_with_totals.php index 8e9161f1ec628..f8dd55c6fdbeb 100644 --- a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/order_with_totals.php +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/order_with_totals.php @@ -59,13 +59,14 @@ ->setState(Order::STATE_PROCESSING) ->setStatus($order->getConfig()->getStateDefaultStatus(Order::STATE_PROCESSING)) ->setSubtotal(110) - ->setOrderCurrencyCode("USD") ->setShippingAmount(10.0) ->setBaseShippingAmount(10.0) ->setTaxAmount(5.0) ->setGrandTotal(100) ->setBaseSubtotal(100) ->setBaseGrandTotal(100) + ->setOrderCurrencyCode("USD") + ->setBaseCurrencyCode('USD') ->setCustomerIsGuest(false) ->setCustomerId(1) ->setCustomerEmail('customer@example.com') diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/orders_with_customer.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/orders_with_customer.php index f1124ba135285..3caa1410c65e9 100644 --- a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/orders_with_customer.php +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/orders_with_customer.php @@ -30,6 +30,7 @@ 'state' => \Magento\Sales\Model\Order::STATE_NEW, 'status' => 'processing', 'order_currency_code' =>'USD', + 'base_currency_code' =>'USD', 'grand_total' => 120.00, 'subtotal' => 120.00, 'base_grand_total' => 120.00, @@ -40,6 +41,8 @@ 'increment_id' => '100000003', 'state' => \Magento\Sales\Model\Order::STATE_PROCESSING, 'status' => 'processing', + 'order_currency_code' =>'USD', + 'base_currency_code' =>'USD', 'grand_total' => 130.00, 'base_grand_total' => 130.00, 'subtotal' => 130.00, @@ -51,6 +54,8 @@ 'increment_id' => '100000004', 'state' => \Magento\Sales\Model\Order::STATE_PROCESSING, 'status' => 'closed', + 'order_currency_code' =>'USD', + 'base_currency_code' =>'USD', 'grand_total' => 140.00, 'base_grand_total' => 140.00, 'subtotal' => 140.00, @@ -61,6 +66,8 @@ 'increment_id' => '100000005', 'state' => \Magento\Sales\Model\Order::STATE_COMPLETE, 'status' => 'complete', + 'order_currency_code' =>'USD', + 'base_currency_code' =>'USD', 'grand_total' => 150.00, 'base_grand_total' => 150.00, 'subtotal' => 150.00, @@ -72,6 +79,8 @@ 'increment_id' => '100000006', 'state' => \Magento\Sales\Model\Order::STATE_PROCESSING, 'status' => 'Processing', + 'order_currency_code' =>'USD', + 'base_currency_code' =>'USD', 'grand_total' => 160.00, 'base_grand_total' => 160.00, 'subtotal' => 160.00, @@ -84,6 +93,7 @@ 'state' => \Magento\Sales\Model\Order::STATE_PROCESSING, 'status' => 'Processing', 'order_currency_code' =>'USD', + 'base_currency_code' =>'USD', 'grand_total' => 180.00, 'base_grand_total' => 180.00, 'subtotal' => 170.00, @@ -98,6 +108,7 @@ 'state' => \Magento\Sales\Model\Order::STATE_PROCESSING, 'status' => 'Processing', 'order_currency_code' =>'USD', + 'base_currency_code' =>'USD', 'grand_total' => 190.00, 'base_grand_total' => 190.00, 'subtotal' => 180.00, @@ -139,7 +150,6 @@ ->setName($product->getName()) ->setSku($product->getSku()); - $order->setData($orderData) ->addItem($orderItem) ->setCustomerIsGuest(false) diff --git a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/two_orders_with_order_items_two_storeviews.php b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/two_orders_with_order_items_two_storeviews.php index cb61f5e398630..c10ca26e640f1 100644 --- a/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/two_orders_with_order_items_two_storeviews.php +++ b/dev/tests/integration/testsuite/Magento/GraphQl/Sales/_files/two_orders_with_order_items_two_storeviews.php @@ -78,6 +78,7 @@ ->setCustomerId($customerIdFromFixture) ->setCustomerEmail('customer@null.com') ->setOrderCurrencyCode('USD') + ->setBaseCurrencyCode('USD') ->setBillingAddress($billingAddress) ->setShippingAddress($shippingAddress) ->setStoreId($objectManager->get(StoreManagerInterface::class)->getStore()->getId()) @@ -128,6 +129,7 @@ ->setCustomerId($customerIdFromFixture) ->setCustomerEmail('customer@null.com') ->setOrderCurrencyCode('USD') + ->setBaseCurrencyCode('USD') ->setBillingAddress($billingAddress) ->setShippingAddress($shippingAddress) ->setStoreId($secondStore->load('fixture_second_store')->getId()) diff --git a/dev/tests/integration/testsuite/Magento/Paypal/Model/Api/NvpTest.php b/dev/tests/integration/testsuite/Magento/Paypal/Model/Api/NvpTest.php index 9821a148589fd..05e572f5b64f0 100644 --- a/dev/tests/integration/testsuite/Magento/Paypal/Model/Api/NvpTest.php +++ b/dev/tests/integration/testsuite/Magento/Paypal/Model/Api/NvpTest.php @@ -95,7 +95,7 @@ public function testRequestTotalsAndLineItemsWithFPT() . '&SHIPPINGAMT=0.00&ITEMAMT=112.70&TAXAMT=0.00' . '&L_NAME0=Simple+Product+FPT&L_QTY0=1&L_AMT0=100.00' . '&L_NAME1=FPT&L_QTY1=1&L_AMT1=12.70' - . '&METHOD=SetExpressCheckout&VERSION=72.0&BUTTONSOURCE=Magento_Cart_'; + . '&METHOD=SetExpressCheckout&VERSION=72.0&BUTTONSOURCE=Magento_2_'; $this->httpClient->method('write') ->with( @@ -146,7 +146,7 @@ public function testCallRefundTransaction() $httpQuery = 'TRANSACTIONID=fooTransactionId&REFUNDTYPE=Partial' .'&CURRENCYCODE=USD&AMT=145.98&METHOD=RefundTransaction' - .'&VERSION=72.0&BUTTONSOURCE=Magento_Cart_'; + .'&VERSION=72.0&BUTTONSOURCE=Magento_2_'; $this->httpClient->expects($this->once())->method('write') ->with( diff --git a/dev/tests/integration/testsuite/Magento/Paypal/Model/Api/PayflowNvpTest.php b/dev/tests/integration/testsuite/Magento/Paypal/Model/Api/PayflowNvpTest.php index 274475b35ba6d..c10785624fc59 100644 --- a/dev/tests/integration/testsuite/Magento/Paypal/Model/Api/PayflowNvpTest.php +++ b/dev/tests/integration/testsuite/Magento/Paypal/Model/Api/PayflowNvpTest.php @@ -95,7 +95,7 @@ public function testRequestLineItems() . 'L_NAME1=Simple 2&L_QTY1=2&L_COST1=9.69&' . 'L_NAME2=Simple 3&L_QTY2=3&L_COST2=11.69&' . 'L_NAME3=Discount&L_QTY3=1&L_COST3=-10.00&' - . 'TRXTYPE=A&ACTION=S&BUTTONSOURCE=Magento_Cart_'; + . 'TRXTYPE=A&ACTION=S&BUTTONSOURCE=Magento_2_'; $this->httpClient->method('write') ->with( diff --git a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Customer/PlaceOrderWithPayflowLinkTest.php b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Customer/PlaceOrderWithPayflowLinkTest.php index a8a12650f9935..aebe8b4e3ef47 100644 --- a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Customer/PlaceOrderWithPayflowLinkTest.php +++ b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Customer/PlaceOrderWithPayflowLinkTest.php @@ -119,14 +119,14 @@ public function testResolvePlaceOrderWithPayflowLinkForCustomer(): void cart_id: "$cartId" payment_method: { code: "$paymentMethod" - payflow_link: + payflow_link: { cancel_url:"paypal/payflow/cancelPayment" return_url:"paypal/payflow/returnUrl" error_url:"paypal/payflow/errorUrl" } } - }) { + }) { cart { selected_payment_method { code @@ -142,7 +142,7 @@ public function testResolvePlaceOrderWithPayflowLinkForCustomer(): void QUERY; $productMetadata = ObjectManager::getInstance()->get(ProductMetadataInterface::class); - $button = 'Magento_Cart_' . $productMetadata->getEdition(); + $button = 'Magento_2_' . $productMetadata->getEdition(); $payflowLinkResponse = new DataObject( [ diff --git a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Customer/PlaceOrderWithPayflowProCCVaultTest.php b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Customer/PlaceOrderWithPayflowProCCVaultTest.php new file mode 100644 index 0000000000000..3ebfaf8890edb --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Customer/PlaceOrderWithPayflowProCCVaultTest.php @@ -0,0 +1,363 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ +declare(strict_types=1); + +namespace Magento\PaypalGraphQl\Model\Resolver\Customer; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Checkout\Api\Data\ShippingInformationInterface; +use Magento\Checkout\Api\Data\ShippingInformationInterfaceFactory; +use Magento\Checkout\Api\ShippingInformationManagementInterface; +use Magento\Framework\Api\DataObjectHelper; +use Magento\Framework\DataObject; +use Magento\Framework\Serialize\SerializerInterface; +use Magento\Integration\Model\Oauth\Token; +use Magento\PaypalGraphQl\PaypalPayflowProAbstractTest; +use Magento\Quote\Api\BillingAddressManagementInterface; +use Magento\Quote\Api\CartManagementInterface; +use Magento\Quote\Api\CartRepositoryInterface; +use Magento\Quote\Api\Data\AddressInterface; +use Magento\Quote\Api\Data\AddressInterfaceFactory; +use Magento\Quote\Model\QuoteFactory; +use Magento\Quote\Model\QuoteIdMask; +use Magento\Quote\Model\QuoteIdMaskFactory; +use Magento\Quote\Model\QuoteIdToMaskedQuoteId; +use Magento\Quote\Model\ResourceModel\Quote as QuoteResource; +use Magento\Quote\Model\ShippingAddressManagementInterface; +use Magento\Vault\Api\Data\PaymentTokenInterface; +use Magento\Vault\Model\PaymentTokenManagement; +use Magento\Vault\Model\PaymentTokenRepository; + +/** + * End to end place order test using payflowpro_cc_vault via graphql endpoint for customer + * + * @magentoAppArea graphql + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) + */ +class PlaceOrderWithPayflowProCCVaultTest extends PaypalPayflowProAbstractTest +{ + /** + * @var SerializerInterface + */ + private $json; + + /** + * @var QuoteIdToMaskedQuoteId + */ + private $quoteIdToMaskedId; + + protected function setUp(): void + { + parent::setUp(); + + $this->json = $this->objectManager->get(SerializerInterface::class); + $this->quoteIdToMaskedId = $this->objectManager->get(QuoteIdToMaskedQuoteId::class); + } + + /** + * Place order use payflowpro method and save cart data to future + * + * @magentoDataFixture Magento/Sales/_files/default_rollback.php + * @magentoDataFixture Magento/Customer/_files/customer.php + * @magentoDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoDataFixture Magento/GraphQl/Quote/_files/customer/create_empty_cart.php + * @magentoDataFixture Magento/GraphQl/Quote/_files/add_simple_product.php + * @magentoDataFixture Magento/GraphQl/Quote/_files/set_new_shipping_address.php + * @magentoDataFixture Magento/GraphQl/Quote/_files/set_new_billing_address.php + * @magentoDataFixture Magento/GraphQl/Quote/_files/set_flatrate_shipping_method.php + * + * @return void + * + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + public function testPlaceOrderWithCCVault(): void + { + $this->placeOrderPayflowPro('is_active_payment_token_enabler: true'); + $publicHash = $this->getVaultCartData()->getPublicHash(); + /** @var CartManagementInterface $cartManagement */ + $cartManagement = $this->objectManager->get(CartManagementInterface::class); + /** @var CartRepositoryInterface $cartRepository */ + $cartRepository = $this->objectManager->get(CartRepositoryInterface::class); + /** @var QuoteIdMaskFactory $quoteIdMaskFactory */ + $quoteIdMaskFactory = $this->objectManager->get(QuoteIdMaskFactory::class); + $cartId = $cartManagement->createEmptyCartForCustomer(1); + $cart = $cartRepository->get($cartId); + $cart->setReservedOrderId('test_quote_1'); + $cartRepository->save($cart); + /** @var QuoteIdMask $quoteIdMask */ + $quoteIdMask = $quoteIdMaskFactory->create(); + $quoteIdMask->setQuoteId($cartId) + ->save(); + + $reservedQuoteId = 'test_quote_1'; + $cart = $this->getQuoteByReservedOrderId($reservedQuoteId); + $cartId = $this->quoteIdToMaskedId->execute((int)$cart->getId()); + + /** @var ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->get(ProductRepositoryInterface::class); + /** @var QuoteFactory $quoteFactory */ + $quoteFactory = $this->objectManager->get(QuoteFactory::class); + /** @var QuoteResource $quoteResource */ + $quoteResource = $this->objectManager->get(QuoteResource::class); + $product = $productRepository->get('simple_product'); + $quote = $quoteFactory->create(); + $quoteResource->load($quote, 'test_quote_1', 'reserved_order_id'); + $quote->addProduct($product, 2); + $cartRepository->save($quote); + + /** @var AddressInterfaceFactory $quoteAddressFactory */ + $quoteAddressFactory = $this->objectManager->get(AddressInterfaceFactory::class); + /** @var DataObjectHelper $dataObjectHelper */ + $dataObjectHelper = $this->objectManager->get(DataObjectHelper::class); + /** @var ShippingAddressManagementInterface $shippingAddressManagement */ + $shippingAddressManagement = $this->objectManager->get(ShippingAddressManagementInterface::class); + + $quoteAddressData = [ + AddressInterface::KEY_TELEPHONE => 3468676, + AddressInterface::KEY_POSTCODE => '75477', + AddressInterface::KEY_COUNTRY_ID => 'US', + AddressInterface::KEY_CITY => 'CityM', + AddressInterface::KEY_COMPANY => 'CompanyName', + AddressInterface::KEY_STREET => 'Green str, 67', + AddressInterface::KEY_LASTNAME => 'Smith', + AddressInterface::KEY_FIRSTNAME => 'John', + AddressInterface::KEY_REGION_ID => 1, + ]; + $quoteAddress = $quoteAddressFactory->create(); + $dataObjectHelper->populateWithArray($quoteAddress, $quoteAddressData, AddressInterfaceFactory::class); + + $quote = $quoteFactory->create(); + $quoteResource->load($quote, 'test_quote_1', 'reserved_order_id'); + $shippingAddressManagement->assign($quote->getId(), $quoteAddress); + + /** @var BillingAddressManagementInterface $billingAddressManagement */ + $billingAddressManagement = $this->objectManager->get(BillingAddressManagementInterface::class); + $billingAddressManagement->assign($quote->getId(), $quoteAddress); + + /** @var ShippingInformationInterfaceFactory $shippingInformationFactory */ + $shippingInformationFactory = $this->objectManager->get(ShippingInformationInterfaceFactory::class); + /** @var ShippingInformationManagementInterface $shippingInformationManagement */ + $shippingInformationManagement = $this->objectManager->get(ShippingInformationManagementInterface::class); + $quoteAddress = $quote->getShippingAddress(); + + /** @var ShippingInformationInterface $shippingInformation */ + $shippingInformation = $shippingInformationFactory->create([ + 'data' => [ + ShippingInformationInterface::SHIPPING_ADDRESS => $quoteAddress, + ShippingInformationInterface::SHIPPING_CARRIER_CODE => 'flatrate', + ShippingInformationInterface::SHIPPING_METHOD_CODE => 'flatrate', + ], + ]); + $shippingInformationManagement->saveAddressInformation($quote->getId(), $shippingInformation); + + $secondQuery = <<<QUERY +mutation { +setPaymentMethodOnCart(input: { +payment_method: { + code: "payflowpro_cc_vault", + payflowpro_cc_vault: { + public_hash:"{$publicHash}" + } +}, +cart_id: "{$cartId}"}) +{ +cart { + selected_payment_method {code} + } +} +placeOrder(input: {cart_id: "{$cartId}"}) { +order {order_number} + } +} +QUERY; + /** @var Token $tokenModel */ + $tokenModel = $this->objectManager->create(Token::class); + $customerToken = $tokenModel->createCustomerToken(1)->getToken(); + + $requestHeaders = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $customerToken + ]; + $vaultResponse = $this->graphQlRequest->send($secondQuery, [], '', $requestHeaders); + + $responseData = $this->json->unserialize($vaultResponse->getContent()); + $this->assertArrayHasKey('data', $responseData); + $this->assertTrue( + isset($responseData['data']['setPaymentMethodOnCart']['cart']['selected_payment_method']['code']) + ); + $this->assertEquals( + 'payflowpro_cc_vault', + $responseData['data']['setPaymentMethodOnCart']['cart']['selected_payment_method']['code'] + ); + $this->assertTrue( + isset($responseData['data']['placeOrder']['order']['order_number']) + ); + $this->assertEquals( + 'test_quote_1', + $responseData['data']['placeOrder']['order']['order_number'] + ); + } + + /** + * @param $isActivePaymentTokenEnabler + * + * @return array + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + */ + private function placeOrderPayflowPro($isActivePaymentTokenEnabler) + { + $paymentMethod = 'payflowpro'; + $this->enablePaymentMethod($paymentMethod); + $this->enablePaymentMethod('payflowpro_cc_vault'); + $reservedQuoteId = 'test_quote'; + + $payload = 'BILLTOCITY=CityM&AMT=0.00&BILLTOSTREET=Green+str,+67&VISACARDLEVEL=12&SHIPTOCITY=CityM' + . '&NAMETOSHIP=John+Smith&ZIP=75477&BILLTOLASTNAME=Smith&BILLTOFIRSTNAME=John' + . '&RESPMSG=Verified&PROCCVV2=M&STATETOSHIP=AL&NAME=John+Smith&BILLTOZIP=75477&CVV2MATCH=Y' + . '&PNREF=B70CCC236815&ZIPTOSHIP=75477&SHIPTOCOUNTRY=US&SHIPTOSTREET=Green+str,+67&CITY=CityM' + . '&HOSTCODE=A&LASTNAME=Smith&STATE=AL&SECURETOKEN=MYSECURETOKEN&CITYTOSHIP=CityM&COUNTRYTOSHIP=US' + . '&AVSDATA=YNY&ACCT=1111&AUTHCODE=111PNI&FIRSTNAME=John&RESULT=0&IAVS=N&POSTFPSMSG=No+Rules+Triggered&' + . 'BILLTOSTATE=AL&BILLTOCOUNTRY=US&EXPDATE=0222&CARDTYPE=0&PREFPSMSG=No+Rules+Triggered&SHIPTOZIP=75477&' + . 'PROCAVS=A&COUNTRY=US&AVSZIP=N&ADDRESS=Green+str,+67&BILLTONAME=John+Smith&' + . 'ADDRESSTOSHIP=Green+str,+67&' + . 'AVSADDR=Y&SECURETOKENID=MYSECURETOKENID&SHIPTOSTATE=AL&TRANSTIME=2019-06-24+07%3A53%3A10'; + + $cart = $this->getQuoteByReservedOrderId($reservedQuoteId); + $cartId = $this->quoteIdToMaskedId->execute((int)$cart->getId()); + + $query = <<<QUERY +mutation { + setPaymentMethodOnCart(input: { + payment_method: { + code: "{$paymentMethod}", + payflowpro: { + {$isActivePaymentTokenEnabler} + cc_details: { + cc_exp_month: 12, + cc_exp_year: 2030, + cc_last_4: 1111, + cc_type: "IV", + } + } + }, + cart_id: "{$cartId}"}) + { + cart { + selected_payment_method { + code + } + } + } + createPayflowProToken( + input: { + cart_id:"{$cartId}", + urls: { + cancel_url: "paypal/transparent/cancel/" + error_url: "paypal/transparent/error/" + return_url: "paypal/transparent/response/" + } + } + ) { + response_message + result + result_code + secure_token + secure_token_id + } + handlePayflowProResponse(input: { + paypal_payload: "$payload", + cart_id: "{$cartId}" + }) + { + cart { + selected_payment_method { + code + } + } + } + placeOrder(input: {cart_id: "{$cartId}"}) { + order { + order_number + } + } +} +QUERY; + + /** @var Token $tokenModel */ + $tokenModel = $this->objectManager->create(Token::class); + $customerToken = $tokenModel->createCustomerToken(1)->getToken(); + + $requestHeaders = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $customerToken + ]; + $paypalResponse = new DataObject( + [ + 'result' => '0', + 'securetoken' => 'mysecuretoken', + 'securetokenid' => 'mysecuretokenid', + 'respmsg' => 'Approved', + 'result_code' => '0', + ] + ); + + $this->gatewayMock + ->method('postRequest') + ->willReturn($paypalResponse); + + $this->gatewayMock + ->method('postRequest') + ->willReturn( + new DataObject( + [ + 'result' => '0', + 'pnref' => 'A70AAC2378BA', + 'respmsg' => 'Approved', + 'authcode' => '647PNI', + 'avsaddr' => 'Y', + 'avszip' => 'N', + 'hostcode' => 'A', + 'procavs' => 'A', + 'visacardlevel' => '12', + 'transtime' => '2019-06-24 10:12:03', + 'firstname' => 'Cristian', + 'lastname' => 'Partica', + 'amt' => '14.99', + 'acct' => '1111', + 'expdate' => '0221', + 'cardtype' => '0', + 'iavs' => 'N', + 'result_code' => '0', + ] + ) + ); + + $response = $this->graphQlRequest->send($query, [], '', $requestHeaders); + + return $this->json->unserialize($response->getContent()); + } + + /** + * Get saved cart data + * + * @return PaymentTokenInterface + */ + private function getVaultCartData() + { + /** @var PaymentTokenManagement $tokenManagement */ + $tokenManagement = $this->objectManager->get(PaymentTokenManagement::class); + $token = $tokenManagement->getByGatewayToken( + 'B70CCC236815', + 'payflowpro', + 1 + ); + /** @var PaymentTokenRepository $tokenRepository */ + $tokenRepository = $this->objectManager->get(PaymentTokenRepository::class); + return $tokenRepository->getById($token->getEntityId()); + } +} diff --git a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Guest/PlaceOrderWithPayflowLinkTest.php b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Guest/PlaceOrderWithPayflowLinkTest.php index 797876cc2318f..a0776250cfc56 100644 --- a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Guest/PlaceOrderWithPayflowLinkTest.php +++ b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Guest/PlaceOrderWithPayflowLinkTest.php @@ -117,14 +117,14 @@ public function testResolvePlaceOrderWithPayflowLink(): void cart_id: "$cartId" payment_method: { code: "$paymentMethod" - payflow_link: + payflow_link: { cancel_url:"paypal/payflow/cancel" return_url:"paypal/payflow/return" error_url:"paypal/payflow/error" } } - }) { + }) { cart { selected_payment_method { code @@ -140,7 +140,7 @@ public function testResolvePlaceOrderWithPayflowLink(): void QUERY; $productMetadata = ObjectManager::getInstance()->get(ProductMetadataInterface::class); - $button = 'Magento_Cart_' . $productMetadata->getEdition(); + $button = 'Magento_2_' . $productMetadata->getEdition(); $payflowLinkResponse = new DataObject( [ @@ -219,14 +219,14 @@ public function testResolveWithPayflowLinkDeclined(): void cart_id: "$cartId" payment_method: { code: "$paymentMethod" - payflow_link: + payflow_link: { cancel_url:"paypal/payflow/cancelPayment" return_url:"paypal/payflow/returnUrl" error_url:"paypal/payflow/returnUrl" } } - }) { + }) { cart { selected_payment_method { code diff --git a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Guest/PlaceOrderWithPaymentsAdvancedTest.php b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Guest/PlaceOrderWithPaymentsAdvancedTest.php index 5de1ded43405a..468d9036992b9 100644 --- a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Guest/PlaceOrderWithPaymentsAdvancedTest.php +++ b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/Model/Resolver/Guest/PlaceOrderWithPaymentsAdvancedTest.php @@ -108,7 +108,7 @@ public function testResolvePlaceOrderWithPaymentsAdvanced(): void $cartId = $this->getMaskedQuoteIdByReservedOrderId->execute('test_quote'); $productMetadata = ObjectManager::getInstance()->get(ProductMetadataInterface::class); - $button = 'Magento_Cart_' . $productMetadata->getEdition(); + $button = 'Magento_2_' . $productMetadata->getEdition(); $payflowLinkResponse = new DataObject( [ @@ -256,7 +256,7 @@ private function setPaymentMethodAndPlaceOrder(string $cartId, string $paymentMe error_url:"paypal/payflowadvanced/customerror" } } - }) { + }) { cart { selected_payment_method { code @@ -300,7 +300,7 @@ private function setPaymentMethodWithInValidUrl(string $cartId, string $paymentM error_url:"paypal/payflowadvanced/error" } } - }) { + }) { cart { selected_payment_method { code diff --git a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/_files/paypal_place_order_request.php b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/_files/paypal_place_order_request.php index 7a622ab15814e..6c4f96121a96d 100644 --- a/dev/tests/integration/testsuite/Magento/PaypalGraphQl/_files/paypal_place_order_request.php +++ b/dev/tests/integration/testsuite/Magento/PaypalGraphQl/_files/paypal_place_order_request.php @@ -13,7 +13,7 @@ $notifyUrl = $url->getUrl('paypal/ipn/'); $productMetadata = ObjectManager::getInstance()->get(ProductMetadataInterface::class); -$button = 'Magento_Cart_' . $productMetadata->getEdition(); +$button = 'Magento_2_' . $productMetadata->getEdition(); return [ 'TOKEN' => $token, diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/customer_creditmemo_with_two_items.php b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_creditmemo_with_two_items.php new file mode 100644 index 0000000000000..478a10665cd7e --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_creditmemo_with_two_items.php @@ -0,0 +1,35 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; +use Magento\Sales\Api\Data\OrderInterfaceFactory; +use Magento\Sales\Model\Order\CreditmemoFactory; +use Magento\Sales\Model\Service\CreditmemoService; +use Magento\Sales\Model\Order; +use Magento\TestFramework\Helper\Bootstrap; + +Resolver::getInstance()->requireDataFixture( + 'Magento/Sales/_files/customer_invoice_with_two_products_and_custom_options.php' +); + +$objectManager = Bootstrap::getObjectManager(); + +/** @var CreditmemoFactory $creditMemoFactory */ +$creditMemoFactory = $objectManager->create(CreditmemoFactory::class); +/** @var CreditmemoService $creditMemoService */ +$creditMemoService = $objectManager->create(CreditmemoService::class); + +/** @var Order $order */ +$order = $objectManager->get(OrderInterfaceFactory::class)->create()->loadByIncrementId('100000001'); + +$creditMemo = $creditMemoFactory->createByOrder($order); +$creditMemo->setAdjustment(1.23); +$creditMemo->setBaseGrandTotal(10); +$creditMemo->addComment('some_comment', false, true); +$creditMemo->addComment('some_other_comment', false, true); +$creditMemo->addComment('not_visible', false, false); + +$creditMemoService->refund($creditMemo); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/customer_creditmemo_with_two_items_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_creditmemo_with_two_items_rollback.php new file mode 100644 index 0000000000000..b8a065f9383d2 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_creditmemo_with_two_items_rollback.php @@ -0,0 +1,11 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->requireDataFixture('Magento/Customer/_files/customer_rollback.php'); +Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/products_in_category_rollback.php'); +Resolver::getInstance()->requireDataFixture('Magento/Sales/_files/default_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/customer_invoice_with_two_products_and_custom_options.php b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_invoice_with_two_products_and_custom_options.php index 48fbdefb2cdf8..c14ff93b6393a 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/_files/customer_invoice_with_two_products_and_custom_options.php +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_invoice_with_two_products_and_custom_options.php @@ -5,6 +5,7 @@ */ use Magento\TestFramework\Workaround\Override\Fixture\Resolver; +use Magento\Sales\Api\InvoiceManagementInterface; Resolver::getInstance()->requireDataFixture('Magento/Customer/_files/customer.php'); Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/products_in_category.php'); @@ -97,12 +98,13 @@ $order->setBaseGrandTotal(100); $order->setGrandTotal(100); $order->setOrderCurrencyCode('USD'); +$order->setBaseCurrencyCode('EUR'); $order->setCustomerId(1) ->setCustomerIsGuest(false) ->save(); - +/** @var InvoiceManagementInterface $orderService */ $orderService = $objectManager->create( - \Magento\Sales\Api\InvoiceManagementInterface::class + InvoiceManagementInterface::class ); $invoice = $orderService->prepareInvoice($order); $invoice->register(); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/customer_multiple_invoices_with_two_products_and_custom_options.php b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_multiple_invoices_with_two_products_and_custom_options.php index 8507987240557..39ac3d1912b93 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/_files/customer_multiple_invoices_with_two_products_and_custom_options.php +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_multiple_invoices_with_two_products_and_custom_options.php @@ -97,6 +97,7 @@ $order->setBaseGrandTotal(60); $order->setGrandTotal(60); $order->setOrderCurrencyCode('USD'); +$order->setBaseCurrencyCode('EUR'); $order->setCustomerId(1) ->setCustomerIsGuest(false) ->save(); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/customer_order_with_two_items.php b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_order_with_two_items.php index 7cebee082e99f..cc71d59256c40 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/_files/customer_order_with_two_items.php +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/customer_order_with_two_items.php @@ -98,12 +98,15 @@ ->setBaseSubtotal(20) ->setBaseShippingAmount(10) ->setBaseGrandTotal(30) + ->setBaseCurrencyCode('USD') + ->setOrderCurrencyCode('USD') ->setCustomerIsGuest(false) ->setCustomerEmail($customerDataModel->getEmail()) ->setCustomerId($customerDataModel->getId()) ->setBillingAddress($billingAddress) ->setShippingAddress($shippingAddress) ->setShippingDescription('Flat Rate - Fixed') + ->setShippingMethod('flatrate_flatrate') ->setStoreId($mainWebsite->getDefaultStore()->getId()) ->addItem($firstOrderItem) ->addItem($secondOrderItem) diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order.php index 4140ce1c81f20..924562781e16b 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/_files/order.php +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order.php @@ -61,6 +61,8 @@ ->setGrandTotal(100) ->setBaseSubtotal(100) ->setBaseGrandTotal(100) + ->setOrderCurrencyCode('USD') + ->setBaseCurrencyCode('USD') ->setCustomerIsGuest(true) ->setCustomerEmail('customer@null.com') ->setBillingAddress($billingAddress) diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_customer_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_customer_rollback.php new file mode 100644 index 0000000000000..dffb191d142a1 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_customer_rollback.php @@ -0,0 +1,10 @@ +<?php +/** + * Copyright © Magento, Inc. All rights reserved. + * See COPYING.txt for license details. + */ + +use Magento\TestFramework\Workaround\Override\Fixture\Resolver; + +Resolver::getInstance()->requireDataFixture('Magento/Sales/_files/order_rollback.php'); +Resolver::getInstance()->requireDataFixture('Magento/Customer/_files/customer_rollback.php'); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_different_types_of_product.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_different_types_of_product.php index cefe464cbba09..7065eba4bbb92 100644 --- a/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_different_types_of_product.php +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_with_different_types_of_product.php @@ -87,6 +87,8 @@ /** @var \Magento\Sales\Model\Order\Item $orderItem */ $orderConfigurableItem = $objectManager->create(\Magento\Sales\Model\Order\Item::class); $orderConfigurableItem->setProductId($configurableProduct->getId())->setQtyOrdered($qtyOrdered); +$orderConfigurableItem->setSku($configurableProduct->getSku()); +$orderConfigurableItem->setName($configurableProduct->getName()); $orderConfigurableItem->setBasePrice($configurableProduct->getPrice()); $orderConfigurableItem->setPrice($configurableProduct->getPrice()); $orderConfigurableItem->setRowTotal($configurableProduct->getPrice()); @@ -184,6 +186,8 @@ /** @var \Magento\Sales\Model\Order\Item $orderItem */ $orderBundleItem = $objectManager->create(\Magento\Sales\Model\Order\Item::class); $orderBundleItem->setProductId($bundleProduct->getId()); +$orderBundleItem->setSku($bundleProduct->getSku()); +$orderBundleItem->setName($bundleProduct->getName()); $orderBundleItem->setQtyOrdered(1); $orderBundleItem->setBasePrice($bundleProduct->getPrice()); $orderBundleItem->setPrice($bundleProduct->getPrice()); diff --git a/dev/tests/static/framework/Magento/TestFramework/Dependency/PhpRule.php b/dev/tests/static/framework/Magento/TestFramework/Dependency/PhpRule.php index 2076daf811fb1..6fdeeb816f2cf 100644 --- a/dev/tests/static/framework/Magento/TestFramework/Dependency/PhpRule.php +++ b/dev/tests/static/framework/Magento/TestFramework/Dependency/PhpRule.php @@ -167,8 +167,9 @@ private function caseClassesAndIdentifiers($currentModule, $file, &$contents) '[_\\\\]|', Files::init()->getNamespaces() ) - . '[_\\\\])[a-zA-Z0-9]+)' - . '(?<class_inside_module>[a-zA-Z0-9_\\\\]*))\b(?:::(?<module_scoped_key>[a-z0-9_]+)[\'"])?~'; + . '(?<delimiter>[_\\\\]))[a-zA-Z0-9]{2,})' + . '(?<class_inside_module>\\4[a-zA-Z0-9_\\\\]{2,})?)\b' + . '(?:::(?<module_scoped_key>[A-Za-z0-9_/.]+)[\'"])?~'; if (!preg_match_all($pattern, $contents, $matches)) { return []; diff --git a/lib/internal/Magento/Framework/DB/Adapter/Pdo/Mysql.php b/lib/internal/Magento/Framework/DB/Adapter/Pdo/Mysql.php index 24ac8fe7f4b52..00c216d1f2100 100644 --- a/lib/internal/Magento/Framework/DB/Adapter/Pdo/Mysql.php +++ b/lib/internal/Magento/Framework/DB/Adapter/Pdo/Mysql.php @@ -2776,7 +2776,7 @@ public function addIndex( if (in_array(strtolower($indexType), ['primary', 'unique'])) { $match = []; // phpstan:ignore - if (preg_match('#SQLSTATE\[23000\]: [^:]+: 1062[^\']+\'([\d-\.]+)\'#', $e->getMessage(), $match)) { + if (preg_match('#SQLSTATE\[23000\]: [^:]+: 1062[^\']+\'([\d.-]+)\'#', $e->getMessage(), $match)) { $ids = explode('-', $match[1]); $this->_removeDuplicateEntry($tableName, $fields, $ids); continue; @@ -2788,6 +2788,7 @@ public function addIndex( $this->resetDdlCache($tableName, $schemaName); + // @phpstan-ignore-next-line return $result; } diff --git a/lib/internal/Magento/Framework/DB/Test/Unit/Adapter/Pdo/MysqlTest.php b/lib/internal/Magento/Framework/DB/Test/Unit/Adapter/Pdo/MysqlTest.php index e448095f0f066..cf29393820f8b 100644 --- a/lib/internal/Magento/Framework/DB/Test/Unit/Adapter/Pdo/MysqlTest.php +++ b/lib/internal/Magento/Framework/DB/Test/Unit/Adapter/Pdo/MysqlTest.php @@ -8,6 +8,7 @@ namespace Magento\Framework\DB\Test\Unit\Adapter\Pdo; use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Adapter\Pdo\Mysql as PdoMysqlAdapter; use Magento\Framework\DB\LoggerInterface; use Magento\Framework\DB\Select; use Magento\Framework\DB\Select\SelectRenderer; @@ -29,20 +30,6 @@ class MysqlTest extends TestCase { const CUSTOM_ERROR_HANDLER_MESSAGE = 'Custom error handler message'; - /** - * Adapter for test - * - * @var \Magento\Framework\DB\Adapter\Pdo\Mysql|MockObject - */ - protected $_adapter; - - /** - * Mock DB adapter for DDL query tests - * - * @var \Magento\Framework\DB\Adapter\Pdo\Mysql|MockObject - */ - protected $_mockAdapter; - /** * @var SelectFactory|MockObject */ @@ -58,89 +45,31 @@ class MysqlTest extends TestCase */ private $serializerMock; + /** + * @var MockObject|\Zend_Db_Profiler + */ + private $profiler; + + /** + * @var \PDO|MockObject + */ + private $connection; + /** * Setup */ protected function setUp(): void { - $string = $this->createMock(StringUtils::class); - $dateTime = $this->createMock(DateTime::class); - $logger = $this->getMockForAbstractClass(LoggerInterface::class); - $selectFactory = $this->getMockBuilder(SelectFactory::class) - ->disableOriginalConstructor() - ->getMock(); $this->serializerMock = $this->getMockBuilder(SerializerInterface::class) ->disableOriginalConstructor() ->getMockForAbstractClass(); $this->schemaListenerMock = $this->getMockBuilder(SchemaListener::class) ->disableOriginalConstructor() ->getMock(); - $this->_mockAdapter = $this->getMockBuilder(\Magento\Framework\DB\Adapter\Pdo\Mysql::class) - ->setMethods(['beginTransaction', 'getTransactionLevel', 'getSchemaListener']) - ->setConstructorArgs( - [ - 'string' => $string, - 'dateTime' => $dateTime, - 'logger' => $logger, - 'selectFactory' => $selectFactory, - 'config' => [ - 'dbname' => 'dbname', - 'username' => 'user', - 'password' => 'password', - ], - 'serializer' => $this->serializerMock - ] - ) - ->getMock(); - - $this->_mockAdapter->expects($this->any()) - ->method('getTransactionLevel') - ->willReturn(1); - - $this->_adapter = $this->getMockBuilder(\Magento\Framework\DB\Adapter\Pdo\Mysql::class) - ->setMethods( - [ - 'getCreateTable', - '_connect', - '_beginTransaction', - '_commit', - '_rollBack', - 'query', - 'fetchRow', - 'getSchemaListener' - ] - )->setConstructorArgs( - [ - 'string' => $string, - 'dateTime' => $dateTime, - 'logger' => $logger, - 'selectFactory' => $selectFactory, - 'config' => [ - 'dbname' => 'not_exists', - 'username' => 'not_valid', - 'password' => 'not_valid', - ], - 'serializer' => $this->serializerMock, - ] - ) - ->getMock(); - $this->_mockAdapter->expects($this->any()) - ->method('getSchemaListener') - ->willReturn($this->schemaListenerMock); - $this->_adapter->expects($this->any()) - ->method('getSchemaListener') - ->willReturn($this->schemaListenerMock); - - $profiler = $this->createMock( + $this->profiler = $this->createMock( \Zend_Db_Profiler::class ); - - $resourceProperty = new \ReflectionProperty( - get_class($this->_adapter), - '_profiler' - ); - $resourceProperty->setAccessible(true); - $resourceProperty->setValue($this->_adapter, $profiler); + $this->connection = $this->createMock(\PDO::class); } /** @@ -148,7 +77,8 @@ protected function setUp(): void */ public function testPrepareColumnValueForBigint($value, $expectedResult) { - $result = $this->_adapter->prepareColumnValue( + $adapter = $this->getMysqlPdoAdapterMock([]); + $result = $adapter->prepareColumnValue( ['DATA_TYPE' => 'bigint'], $value ); @@ -189,8 +119,9 @@ public function bigintResultProvider() */ public function testCheckNotDdlTransaction($query) { + $mockAdapter = $this->getMysqlPdoAdapterMockForDdlQueryTest(); try { - $this->_mockAdapter->query($query); + $mockAdapter->query($query); } catch (\Exception $e) { $this->assertStringNotContainsString( $e->getMessage(), @@ -198,10 +129,10 @@ public function testCheckNotDdlTransaction($query) ); } - $select = new Select($this->_mockAdapter, new SelectRenderer([])); + $select = new Select($mockAdapter, new SelectRenderer([])); $select->from('user'); try { - $this->_mockAdapter->query($select); + $mockAdapter->query($select); } catch (\Exception $e) { $this->assertStringNotContainsString( $e->getMessage(), @@ -219,7 +150,7 @@ public function testCheckDdlTransaction($ddlQuery) { $this->expectException('Exception'); $this->expectExceptionMessage('DDL statements are not allowed in transactions'); - $this->_mockAdapter->query($ddlQuery); + $this->getMysqlPdoAdapterMockForDdlQueryTest()->query($ddlQuery); } public function testMultipleQueryException() @@ -229,7 +160,7 @@ public function testMultipleQueryException() $sql = "SELECT COUNT(*) AS _num FROM test; "; $sql .= "INSERT INTO test(id) VALUES (1); "; $sql .= "SELECT COUNT(*) AS _num FROM test; "; - $this->_mockAdapter->query($sql); + $this->getMysqlPdoAdapterMockForDdlQueryTest()->query($sql); } /** @@ -265,8 +196,9 @@ public static function sqlQueryProvider() */ public function testAsymmetricRollBackFailure() { + $adapter = $this->getMysqlPdoAdapterMock([]); $this->expectExceptionMessage(AdapterInterface::ERROR_ASYMMETRIC_ROLLBACK_MESSAGE); - $this->_adapter->rollBack(); + $adapter->rollBack(); } /** @@ -274,8 +206,9 @@ public function testAsymmetricRollBackFailure() */ public function testAsymmetricCommitFailure() { + $adapter = $this->getMysqlPdoAdapterMock([]); $this->expectExceptionMessage(AdapterInterface::ERROR_ASYMMETRIC_COMMIT_MESSAGE); - $this->_adapter->commit(); + $adapter->commit(); } /** @@ -283,11 +216,13 @@ public function testAsymmetricCommitFailure() */ public function testAsymmetricCommitSuccess() { - $this->assertEquals(0, $this->_adapter->getTransactionLevel()); - $this->_adapter->beginTransaction(); - $this->assertEquals(1, $this->_adapter->getTransactionLevel()); - $this->_adapter->commit(); - $this->assertEquals(0, $this->_adapter->getTransactionLevel()); + $adapter = $this->getMysqlPdoAdapterMock(['_connect']); + $this->addConnectionMock($adapter); + $this->assertEquals(0, $adapter->getTransactionLevel()); + $adapter->beginTransaction(); + $this->assertEquals(1, $adapter->getTransactionLevel()); + $adapter->commit(); + $this->assertEquals(0, $adapter->getTransactionLevel()); } /** @@ -295,11 +230,13 @@ public function testAsymmetricCommitSuccess() */ public function testAsymmetricRollBackSuccess() { - $this->assertEquals(0, $this->_adapter->getTransactionLevel()); - $this->_adapter->beginTransaction(); - $this->assertEquals(1, $this->_adapter->getTransactionLevel()); - $this->_adapter->rollBack(); - $this->assertEquals(0, $this->_adapter->getTransactionLevel()); + $adapter = $this->getMysqlPdoAdapterMock(['_connect']); + $this->addConnectionMock($adapter); + $this->assertEquals(0, $adapter->getTransactionLevel()); + $adapter->beginTransaction(); + $this->assertEquals(1, $adapter->getTransactionLevel()); + $adapter->rollBack(); + $this->assertEquals(0, $adapter->getTransactionLevel()); } /** @@ -307,21 +244,22 @@ public function testAsymmetricRollBackSuccess() */ public function testNestedTransactionCommitSuccess() { - $this->_adapter->expects($this->exactly(2)) + $adapter = $this->getMysqlPdoAdapterMock(['_connect', '_beginTransaction', '_commit']); + $adapter->expects($this->exactly(2)) ->method('_connect'); - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('_beginTransaction'); - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('_commit'); - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->assertEquals(3, $this->_adapter->getTransactionLevel()); - $this->_adapter->commit(); - $this->_adapter->commit(); - $this->_adapter->commit(); - $this->assertEquals(0, $this->_adapter->getTransactionLevel()); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $this->assertEquals(3, $adapter->getTransactionLevel()); + $adapter->commit(); + $adapter->commit(); + $adapter->commit(); + $this->assertEquals(0, $adapter->getTransactionLevel()); } /** @@ -329,21 +267,22 @@ public function testNestedTransactionCommitSuccess() */ public function testNestedTransactionRollBackSuccess() { - $this->_adapter->expects($this->exactly(2)) + $adapter = $this->getMysqlPdoAdapterMock(['_connect', '_beginTransaction', '_rollBack']); + $adapter->expects($this->exactly(2)) ->method('_connect'); - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('_beginTransaction'); - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('_rollBack'); - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->assertEquals(3, $this->_adapter->getTransactionLevel()); - $this->_adapter->rollBack(); - $this->_adapter->rollBack(); - $this->_adapter->rollBack(); - $this->assertEquals(0, $this->_adapter->getTransactionLevel()); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $this->assertEquals(3, $adapter->getTransactionLevel()); + $adapter->rollBack(); + $adapter->rollBack(); + $adapter->rollBack(); + $this->assertEquals(0, $adapter->getTransactionLevel()); } /** @@ -351,21 +290,22 @@ public function testNestedTransactionRollBackSuccess() */ public function testNestedTransactionLastRollBack() { - $this->_adapter->expects($this->exactly(2)) + $adapter = $this->getMysqlPdoAdapterMock(['_connect', '_beginTransaction', '_rollBack']); + $adapter->expects($this->exactly(2)) ->method('_connect'); - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('_beginTransaction'); - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('_rollBack'); - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->assertEquals(3, $this->_adapter->getTransactionLevel()); - $this->_adapter->commit(); - $this->_adapter->commit(); - $this->_adapter->rollBack(); - $this->assertEquals(0, $this->_adapter->getTransactionLevel()); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $this->assertEquals(3, $adapter->getTransactionLevel()); + $adapter->commit(); + $adapter->commit(); + $adapter->rollBack(); + $this->assertEquals(0, $adapter->getTransactionLevel()); } /** @@ -374,20 +314,21 @@ public function testNestedTransactionLastRollBack() */ public function testIncompleteRollBackFailureOnCommit() { - $this->_adapter->expects($this->exactly(2))->method('_connect'); + $adapter = $this->getMysqlPdoAdapterMock(['_connect']); + $this->addConnectionMock($adapter); try { - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->_adapter->rollBack(); - $this->_adapter->commit(); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $adapter->rollBack(); + $adapter->commit(); throw new \Exception('Test Failed!'); } catch (\Exception $e) { $this->assertEquals( AdapterInterface::ERROR_ROLLBACK_INCOMPLETE_MESSAGE, $e->getMessage() ); - $this->_adapter->rollBack(); + $adapter->rollBack(); } } @@ -397,20 +338,21 @@ public function testIncompleteRollBackFailureOnCommit() */ public function testIncompleteRollBackFailureOnBeginTransaction() { - $this->_adapter->expects($this->exactly(2))->method('_connect'); + $adapter = $this->getMysqlPdoAdapterMock(['_connect']); + $this->addConnectionMock($adapter); try { - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->_adapter->rollBack(); - $this->_adapter->beginTransaction(); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $adapter->rollBack(); + $adapter->beginTransaction(); throw new \Exception('Test Failed!'); } catch (\Exception $e) { $this->assertEquals( AdapterInterface::ERROR_ROLLBACK_INCOMPLETE_MESSAGE, $e->getMessage() ); - $this->_adapter->rollBack(); + $adapter->rollBack(); } } @@ -419,24 +361,27 @@ public function testIncompleteRollBackFailureOnBeginTransaction() */ public function testSequentialTransactionsSuccess() { - $this->_adapter->expects($this->exactly(4)) + $adapter = $this->getMysqlPdoAdapterMock(['_connect', '_beginTransaction', '_rollBack', '_commit']); + $this->addConnectionMock($adapter); + + $adapter->expects($this->exactly(4)) ->method('_connect'); - $this->_adapter->expects($this->exactly(2)) + $adapter->expects($this->exactly(2)) ->method('_beginTransaction'); - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('_rollBack'); - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('_commit'); - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->_adapter->beginTransaction(); - $this->_adapter->rollBack(); - $this->_adapter->rollBack(); - $this->_adapter->rollBack(); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $adapter->beginTransaction(); + $adapter->rollBack(); + $adapter->rollBack(); + $adapter->rollBack(); - $this->_adapter->beginTransaction(); - $this->_adapter->commit(); + $adapter->beginTransaction(); + $adapter->commit(); } /** @@ -444,6 +389,7 @@ public function testSequentialTransactionsSuccess() */ public function testInsertOnDuplicateWithQuotedColumnName() { + $adapter = $this->getMysqlPdoAdapterMock([]); $table = 'some_table'; $data = [ 'index' => 'indexValue', @@ -457,12 +403,12 @@ public function testInsertOnDuplicateWithQuotedColumnName() $stmtMock = $this->createMock(\Zend_Db_Statement_Pdo::class); $bind = ['indexValue', 'rowValue', 'selectValue', 'insertValue']; - $this->_adapter->expects($this->once()) + $adapter->expects($this->once()) ->method('query') ->with($sqlQuery, $bind) ->willReturn($stmtMock); - $this->_adapter->insertOnDuplicate($table, $data, $fields); + $adapter->insertOnDuplicate($table, $data, $fields); } /** @@ -475,15 +421,14 @@ public function testInsertOnDuplicateWithQuotedColumnName() */ public function testAddColumn($options, $expectedQuery) { - $connectionMock = $this->createPartialMock( - \Magento\Framework\DB\Adapter\Pdo\Mysql::class, + $adapter = $this->getMysqlPdoAdapterMock( ['tableColumnExists', '_getTableName', 'rawQuery', 'resetDdlCache', 'quote', 'getSchemaListener'] ); - $connectionMock->expects($this->any())->method('getSchemaListener')->willReturn($this->schemaListenerMock); - $connectionMock->expects($this->any())->method('_getTableName')->willReturnArgument(0); - $connectionMock->expects($this->any())->method('quote')->willReturnArgument(0); - $connectionMock->expects($this->once())->method('rawQuery')->with($expectedQuery); - $connectionMock->addColumn('tableName', 'columnName', $options); + $adapter->expects($this->any())->method('getSchemaListener')->willReturn($this->schemaListenerMock); + $adapter->expects($this->any())->method('_getTableName')->willReturnArgument(0); + $adapter->expects($this->any())->method('quote')->willReturnArgument(0); + $adapter->expects($this->once())->method('rawQuery')->with($expectedQuery); + $adapter->addColumn('tableName', 'columnName', $options); } /** @@ -514,7 +459,7 @@ public function addColumnDataProvider() */ public function testGetIndexName($name, $fields, $indexType, $expectedName) { - $resultIndexName = $this->_mockAdapter->getIndexName($name, $fields, $indexType); + $resultIndexName = $this->getMysqlPdoAdapterMockForDdlQueryTest()->getIndexName($name, $fields, $indexType); $this->assertStringStartsWith($expectedName, $resultIndexName); } @@ -556,4 +501,296 @@ public function testConfigValidationByPortWithException() ['config' => ['host' => 'localhost', 'port' => '33390']] ); } + + /** + * @param string $indexName + * @param string $indexType + * @param array $keyLists + * @param \Exception $exception + * @param string $query + * @throws \ReflectionException + * @throws \Zend_Db_Exception + * @dataProvider addIndexWithDuplicationsInDBDataProvider + */ + public function testAddIndexWithDuplicationsInDB( + string $indexName, + string $indexType, + array $keyLists, + string $query, + string $exceptionMessage, + array $ids + ) { + $tableName = 'core_table'; + $fields = ['sku', 'field2']; + $quotedFields = [$this->quoteIdentifier('sku'), $this->quoteIdentifier('field2')]; + + $exception = new \Exception( + sprintf( + $exceptionMessage, + $tableName, + implode(',', $quotedFields) + ) + ); + + $this->expectException(get_class($exception)); + $this->expectExceptionMessage($exception->getMessage()); + + $adapter = $this->getMysqlPdoAdapterMock([ + 'describeTable', + 'getIndexList', + 'quoteIdentifier', + '_getTableName', + 'rawQuery', + '_removeDuplicateEntry', + 'resetDdlCache', + ]); + $this->addConnectionMock($adapter); + $columns = ['sku' => [], 'field2' => [], 'comment' => [], 'timestamp' => []]; + $schemaName = null; + + $this->schemaListenerMock + ->expects($this->once()) + ->method('addIndex') + ->with($tableName, $indexName, $fields, $indexType); + + $adapter + ->expects($this->once()) + ->method('describeTable') + ->with($tableName, $schemaName) + ->willReturn($columns); + $adapter + ->expects($this->once()) + ->method('getIndexList') + ->with($tableName, $schemaName) + ->willReturn($keyLists); + $adapter + ->expects($this->once()) + ->method('_getTableName') + ->with($tableName, $schemaName) + ->willReturn($tableName); + $adapter + ->method('quoteIdentifier') + ->willReturnMap([ + [$tableName, false, $this->quoteIdentifier($tableName)], + [$indexName, false, $this->quoteIdentifier($indexName)], + [$fields[0], false, $quotedFields[0]], + [$fields[1], false, $quotedFields[1]], + ]); + $adapter + ->expects($this->once()) + ->method('rawQuery') + ->with( + sprintf( + $query, + $tableName, + implode(',', $quotedFields) + ) + ) + ->willThrowException($exception); + $adapter + ->expects($this->exactly((int)in_array(strtolower($indexType), ['primary', 'unique']))) + ->method('_removeDuplicateEntry') + ->with($tableName, $fields, $ids) + ->willThrowException($exception); + $adapter + ->expects($this->never()) + ->method('resetDdlCache'); + + $adapter->addIndex($tableName, $indexName, $fields, $indexType); + } + + /** + * @return array + */ + public function addIndexWithDuplicationsInDBDataProvider(): array + { + return [ + 'New unique index' => [ + 'indexName' => 'SOME_UNIQUE_INDEX', + 'indexType' => AdapterInterface::INDEX_TYPE_UNIQUE, + 'keyLists' => [ + 'PRIMARY' => [ + 'INDEX_TYPE' => [ + AdapterInterface::INDEX_TYPE_PRIMARY + ] + ], + ], + 'query' => 'ALTER TABLE `%s` ADD UNIQUE `SOME_UNIQUE_INDEX` (%s)', + 'exceptionMessage' => 'SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry \'1-1-1\' ' + . 'for key \'SOME_UNIQUE_INDEX\', query was: ' + . 'ALTER TABLE `%s` ADD UNIQUE `SOME_UNIQUE_INDEX` (%s)', + 'ids' => [1, 1, 1], + ], + 'Existing unique index' => [ + 'indexName' => 'SOME_UNIQUE_INDEX', + 'indexType' => AdapterInterface::INDEX_TYPE_UNIQUE, + 'keyLists' => [ + 'PRIMARY' => [ + 'INDEX_TYPE' => [ + AdapterInterface::INDEX_TYPE_PRIMARY + ] + ], + 'SOME_UNIQUE_INDEX' => [ + 'INDEX_TYPE' => [ + AdapterInterface::INDEX_TYPE_UNIQUE + ] + ], + ], + 'query' => 'ALTER TABLE `%s` DROP INDEX `SOME_UNIQUE_INDEX`, ADD UNIQUE `SOME_UNIQUE_INDEX` (%s)', + 'exceptionMessage' => 'SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry \'1-2-5\' ' + . 'for key \'SOME_UNIQUE_INDEX\', query was: ' + . 'ALTER TABLE `%s` DROP INDEX `SOME_UNIQUE_INDEX`, ADD UNIQUE `SOME_UNIQUE_INDEX` (%s)', + 'ids' => [1, 2, 5], + ], + 'New primary index' => [ + 'indexName' => 'PRIMARY', + 'indexType' => AdapterInterface::INDEX_TYPE_PRIMARY, + 'keyLists' => [ + 'SOME_UNIQUE_INDEX' => [ + 'INDEX_TYPE' => [ + AdapterInterface::INDEX_TYPE_UNIQUE + ] + ], + ], + 'query' => 'ALTER TABLE `%s` ADD PRIMARY KEY (%s)', + 'exceptionMessage' => 'SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry \'1-3-4\' ' + . 'for key \'PRIMARY\', query was: ' + . 'ALTER TABLE `%s` ADD PRIMARY KEY (%s)', + 'ids' => [1, 3, 4], + ], + ]; + } + + /** + * @param string $field + * @return string + */ + private function quoteIdentifier(string $field): string + { + if (strpos($field, '`') !== 0) { + $field = '`' . $field . '`'; + } + + return $field; + } + + public function testAddIndexForNonExitingField() + { + $tableName = 'core_table'; + $this->expectException(\Zend_Db_Exception::class); + $this->expectExceptionMessage(sprintf( + 'There is no field "%s" that you are trying to create an index on "%s"', + 'sku', + $tableName + )); + + $adapter = $this->getMysqlPdoAdapterMock(['describeTable', 'getIndexList', 'quoteIdentifier', '_getTableName']); + + $fields = ['sku', 'field2']; + $schemaName = null; + + $adapter + ->expects($this->once()) + ->method('describeTable') + ->with($tableName, $schemaName) + ->willReturn([]); + $adapter + ->expects($this->once()) + ->method('getIndexList') + ->with($tableName, $schemaName) + ->willReturn([]); + $adapter + ->expects($this->once()) + ->method('_getTableName') + ->with($tableName, $schemaName) + ->willReturn($tableName); + $adapter + ->method('quoteIdentifier') + ->willReturnMap([ + [$tableName, $tableName], + ]); + + $adapter->addIndex($tableName, 'SOME_INDEX', $fields); + } + + /** + * @return MockObject|PdoMysqlAdapter + * @throws \ReflectionException + */ + private function getMysqlPdoAdapterMockForDdlQueryTest(): MockObject + { + $mockAdapter = $this->getMysqlPdoAdapterMock(['beginTransaction', 'getTransactionLevel', 'getSchemaListener']); + $mockAdapter + ->method('getTransactionLevel') + ->willReturn(1); + + return $mockAdapter; + } + + /** + * @param array $methods + * @return MockObject|PdoMysqlAdapter + * @throws \ReflectionException + */ + private function getMysqlPdoAdapterMock(array $methods): MockObject + { + if (empty($methods)) { + $methods = array_merge($methods, ['query']); + } + $methods = array_unique(array_merge($methods, ['getSchemaListener'])); + + $string = $this->createMock(StringUtils::class); + $dateTime = $this->createMock(DateTime::class); + $logger = $this->getMockForAbstractClass(LoggerInterface::class); + $selectFactory = $this->getMockBuilder(SelectFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $adapterMock = $this->getMockBuilder(PdoMysqlAdapter::class) + ->setMethods( + $methods + )->setConstructorArgs( + [ + 'string' => $string, + 'dateTime' => $dateTime, + 'logger' => $logger, + 'selectFactory' => $selectFactory, + 'config' => [ + 'dbname' => 'not_exists', + 'username' => 'not_valid', + 'password' => 'not_valid', + ], + 'serializer' => $this->serializerMock, + ] + ) + ->getMock(); + + $adapterMock + ->method('getSchemaListener') + ->willReturn($this->schemaListenerMock); + + /** add profiler Mock */ + $resourceProperty = new \ReflectionProperty( + get_class($adapterMock), + '_profiler' + ); + $resourceProperty->setAccessible(true); + $resourceProperty->setValue($adapterMock, $this->profiler); + + return $adapterMock; + } + + /** + * @param MockObject $pdoAdapterMock + * @throws \ReflectionException + */ + private function addConnectionMock(MockObject $pdoAdapterMock): void + { + $resourceProperty = new \ReflectionProperty( + get_class($pdoAdapterMock), + '_connection' + ); + $resourceProperty->setAccessible(true); + $resourceProperty->setValue($pdoAdapterMock, $this->connection); + } }