diff --git a/app/code/Magento/QuoteGraphQl/Model/CartItem/Precursor/ConfigurableProductPrecursor.php b/app/code/Magento/QuoteGraphQl/Model/CartItem/Precursor/ConfigurableProductPrecursor.php new file mode 100644 index 0000000000000..d9eb72e34bc31 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/CartItem/Precursor/ConfigurableProductPrecursor.php @@ -0,0 +1,147 @@ +productRepository = $productRepository; + } + + /** + * Process cart item data to handle parent_sku for configurable products + * + * @param array $cartItemData + * @param ContextInterface $_context + * @return array + */ + public function process(array $cartItemData, ContextInterface $_context): array + { + $processedCartItemData = []; + + foreach ($cartItemData as $cartItemIndex => $cartItem) { + if (!isset($cartItem['parent_sku'])) { + $processedCartItemData[$cartItemIndex] = $cartItem; + continue; + } + + try { + $childProduct = $this->productRepository->get($cartItem['sku']); + $parentProduct = $this->productRepository->get($cartItem['parent_sku']); + + if ($parentProduct->getTypeId() !== Configurable::TYPE_CODE) { + $this->errors[] = [ + 'message' => sprintf('Product %s is not a configurable product', $cartItem['parent_sku']), + 'code' => 'UNDEFINED' + ]; + $processedCartItemData[$cartItemIndex] = $cartItem; + continue; + } + + $configurableOptions = $this->getConfigurableOptions($parentProduct, $childProduct); + + if (empty($configurableOptions)) { + $this->errors[] = [ + 'message' => sprintf('Could not match child product %s with parent %s', + $cartItem['sku'], $cartItem['parent_sku']), + 'code' => 'UNDEFINED' + ]; + $processedCartItemData[$cartItemIndex] = $cartItem; + continue; + } + + $parentCartItem = [ + 'sku' => $cartItem['parent_sku'], + 'quantity' => $cartItem['quantity'], + 'selected_options' => [...$configurableOptions, ...($cartItem['selected_options'] ?? [])], + 'entered_options' => $cartItem['entered_options'] ?? [], + 'parent_sku' => null + ]; + + $processedCartItemData[] = $parentCartItem; + unset($cartItemData[$cartItemIndex]); + + } catch (NoSuchEntityException $e) { + $this->errors[] = [ + 'message' => $e->getMessage(), + 'code' => 'UNDEFINED' + ]; + $processedCartItemData[$cartItemIndex] = $cartItem; + } + } + + return $processedCartItemData; + } + + /** + * Get configurable option IDs for the simple product + * + * @param ProductInterface $parentProduct + * @param ProductInterface $childProduct + * @return array + */ + private function getConfigurableOptions(ProductInterface $parentProduct, ProductInterface $childProduct): array + { + $selectedOptions = []; + + /** @var Configurable $configurableType */ + $configurableType = $parentProduct->getTypeInstance(); + $attributes = $configurableType->getConfigurableAttributes($parentProduct); + + $childProductData = $childProduct->getData(); + + foreach ($attributes as $attribute) { + $attributeId = $attribute->getProductAttribute()->getAttributeId(); + $attributeCode = $attribute->getProductAttribute()->getAttributeCode(); + + if (!isset($childProductData[$attributeCode])) { + continue; + } + + $optionId = $childProductData[$attributeCode]; + $selectedOptions[] = base64_encode("configurable/$attributeId/$optionId"); + } + + return $selectedOptions; + } + + /** + * Return collected errors + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/app/code/Magento/QuoteGraphQl/Test/Unit/Model/CartItem/Precursor/ConfigurableProductPrecursorTest.php b/app/code/Magento/QuoteGraphQl/Test/Unit/Model/CartItem/Precursor/ConfigurableProductPrecursorTest.php new file mode 100644 index 0000000000000..9322b4ed66db6 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Test/Unit/Model/CartItem/Precursor/ConfigurableProductPrecursorTest.php @@ -0,0 +1,265 @@ +productRepositoryMock = $this->createMock(ProductRepositoryInterface::class); + $this->contextMock = $this->createMock(ContextInterface::class); + $this->precursor = new ConfigurableProductPrecursor($this->productRepositoryMock); + } + + /** + * Test processing cart item without parent SKU + * + * @return void + */ + public function testProcessItemWithoutParentSku(): void + { + $cartItemData = [ + [ + 'sku' => 'simple-1', + 'quantity' => 1, + 'selected_options' => ['option1'], + ] + ]; + + $result = $this->precursor->process($cartItemData, $this->contextMock); + + $this->assertSame($cartItemData, $result); + $this->assertEmpty($this->precursor->getErrors()); + } + + /** + * Test processing valid configurable product + * + * @return void + * @throws Exception + */ + public function testProcessValidConfigurableProduct(): void + { + $childProduct = $this->getMockProduct('simple-1'); + $childProduct->method('getData') + ->willReturn(['color' => '1']); + + $productAttributeMock = $this->createMock(AbstractAttribute::class); + $productAttributeMock->method('getAttributeId')->willReturn('1'); + $productAttributeMock->method('getAttributeCode')->willReturn('color'); + + $attributeMock = $this->getMockBuilder(Attribute::class) + ->disableOriginalConstructor() + ->addMethods(['getProductAttribute']) + ->getMock(); + $attributeMock->method('getProductAttribute')->willReturn($productAttributeMock); + + $attributesCollection = [$attributeMock]; + + $configurableTypeMock = $this->createMock(Configurable::class); + $configurableTypeMock->method('getConfigurableAttributes')->willReturn($attributesCollection); + + $parentProduct = $this->getMockProduct('configurable-1', Configurable::TYPE_CODE); + $parentProduct->method('getTypeInstance')->willReturn($configurableTypeMock); + + $this->productRepositoryMock->expects($this->exactly(2)) + ->method('get') + ->willReturnMap([ + ['simple-1', false, null, false, $childProduct], + ['configurable-1', false, null, false, $parentProduct], + ]); + + $cartItemData = [ + [ + 'sku' => 'simple-1', + 'parent_sku' => 'configurable-1', + 'quantity' => 2, + 'selected_options' => ['existing-option'], + ] + ]; + + $expected = [ + [ + 'sku' => 'configurable-1', + 'quantity' => 2, + 'selected_options' => [base64_encode('configurable/1/1'), 'existing-option'], + 'entered_options' => [], + 'parent_sku' => null + ] + ]; + + $result = $this->precursor->process($cartItemData, $this->contextMock); + + $this->assertEquals($expected, $result); + $this->assertEmpty($this->precursor->getErrors()); + } + + /** + * Test processing with non-configurable parent product + * + * @return void + * @throws Exception + */ + public function testProcessNonConfigurableParent(): void + { + $childProduct = $this->getMockProduct('simple-1'); + $parentProduct = $this->getMockProduct('simple-parent', 'simple'); + + $this->productRepositoryMock->expects($this->exactly(2)) + ->method('get') + ->willReturnMap([ + ['simple-1', false, null, false, $childProduct], + ['simple-parent', false, null, false, $parentProduct], + ]); + + $cartItemData = [ + [ + 'sku' => 'simple-1', + 'parent_sku' => 'simple-parent', + 'quantity' => 1 + ] + ]; + + $result = $this->precursor->process($cartItemData, $this->contextMock); + + $this->assertEquals($cartItemData, $result); + $this->assertCount(1, $this->precursor->getErrors()); + $this->assertStringContainsString('not a configurable product', $this->precursor->getErrors()[0]['message']); + } + + /** + * Test processing with no matching attributes + * + * @return void + * @throws Exception + */ + public function testProcessNoMatchingAttributes(): void + { + $childProduct = $this->getMockProduct('simple-1'); + $childProduct->method('getData') + ->willReturn([]); // Empty array to represent no matching attributes + + $productAttributeMock = $this->createMock(AbstractAttribute::class); + $productAttributeMock->method('getAttributeId')->willReturn('1'); + $productAttributeMock->method('getAttributeCode')->willReturn('color'); + + $attributeMock = $this->getMockBuilder(Attribute::class) + ->disableOriginalConstructor() + ->addMethods(['getProductAttribute']) + ->getMock(); + $attributeMock->method('getProductAttribute')->willReturn($productAttributeMock); + + $attributesCollection = [$attributeMock]; + + $configurableTypeMock = $this->createMock(Configurable::class); + $configurableTypeMock->method('getConfigurableAttributes')->willReturn($attributesCollection); + + $parentProduct = $this->getMockProduct('configurable-1', Configurable::TYPE_CODE); + $parentProduct->method('getTypeInstance')->willReturn($configurableTypeMock); + + $this->productRepositoryMock->expects($this->exactly(2)) + ->method('get') + ->willReturnMap([ + ['simple-1', false, null, false, $childProduct], + ['configurable-1', false, null, false, $parentProduct], + ]); + + $cartItemData = [ + [ + 'sku' => 'simple-1', + 'parent_sku' => 'configurable-1', + 'quantity' => 1 + ] + ]; + + $result = $this->precursor->process($cartItemData, $this->contextMock); + + $this->assertEquals($cartItemData, $result); + $this->assertCount(1, $this->precursor->getErrors()); + $this->assertStringContainsString('Could not match child product', $this->precursor->getErrors()[0]['message']); + } + + /** + * Test processing with product not found exception + * + * @return void + */ + public function testProcessProductNotFound(): void + { + $this->productRepositoryMock->method('get') + ->willThrowException(new NoSuchEntityException(__('Product not found'))); + + $cartItemData = [ + [ + 'sku' => 'unknown', + 'parent_sku' => 'unknown-parent', + 'quantity' => 1 + ] + ]; + + $result = $this->precursor->process($cartItemData, $this->contextMock); + + $this->assertEquals($cartItemData, $result); + $this->assertCount(1, $this->precursor->getErrors()); + $this->assertStringContainsString('Product not found', $this->precursor->getErrors()[0]['message']); + } + + /** + * Create mock product + * + * @param string $sku Product SKU + * @param string $typeId Product type ID + * @return ProductInterface + * @throws Exception + */ + private function getMockProduct(string $sku, string $typeId = 'simple'): ProductInterface + { + $product = $this->getMockBuilder(ProductInterface::class) + ->addMethods(['getData', 'getTypeInstance']) + ->onlyMethods(['getSku', 'getTypeId', 'getId']) + ->disableOriginalConstructor() + ->getMockForAbstractClass(); + $product->method('getSku')->willReturn($sku); + $product->method('getTypeId')->willReturn($typeId); + $product->method('getId')->willReturn(rand(1, 100)); + return $product; + } +} diff --git a/app/code/Magento/QuoteGraphQl/etc/graphql/di.xml b/app/code/Magento/QuoteGraphQl/etc/graphql/di.xml index eca1b68354e75..86be288de25e8 100644 --- a/app/code/Magento/QuoteGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/QuoteGraphQl/etc/graphql/di.xml @@ -106,4 +106,11 @@ + + + + Magento\QuoteGraphQl\Model\CartItem\Precursor\ConfigurableProductPrecursor + + +