diff --git a/app/code/Magento/CatalogGraphQl/Model/PriceRangeDataProvider.php b/app/code/Magento/CatalogGraphQl/Model/PriceRangeDataProvider.php index 25f101907f6..642af66e6cf 100644 --- a/app/code/Magento/CatalogGraphQl/Model/PriceRangeDataProvider.php +++ b/app/code/Magento/CatalogGraphQl/Model/PriceRangeDataProvider.php @@ -1,19 +1,17 @@ getExtensionAttributes()->getStore(); + $product = $this->getProduct($value, $context, $store); + + $requestedFields = $info->getFieldSelection(10); + $returnArray = []; + + $returnArray['minimum_price'] = ($requestedFields['minimum_price'] ?? 0) ? ($this->canShowPrice($product) ? + $this->getMinimumProductPrice($product, $store) : $this->formatEmptyResult()) : $this->formatEmptyResult(); + $returnArray['maximum_price'] = ($requestedFields['maximum_price'] ?? 0) ? ($this->canShowPrice($product) ? + $this->getMaximumProductPrice($product, $store) : $this->formatEmptyResult()) : $this->formatEmptyResult(); + + if ($product->getTypeId() === self::TYPE_DOWNLOADABLE && + $product->getData('links_purchased_separately')) { + $downloadableLinkPrice = (float)$this->getDownloadableLinkPrice($product); + if ($downloadableLinkPrice > 0) { + $returnArray['maximum_price']['regular_price']['value'] += $downloadableLinkPrice; + $returnArray['maximum_price']['final_price']['value'] += $downloadableLinkPrice; + } + } + + return $returnArray; + } + + /** + * Validate and return product + * + * @param array $value + * @param ContextInterface $context + * @param StoreInterface $store + * @return Product + * @throws LocalizedException + */ + private function getProduct(array $value, ContextInterface $context, StoreInterface $store): Product { if (!isset($value['model'])) { throw new LocalizedException(__('"model" value should be specified')); } - /** @var StoreInterface $store */ - $store = $context->getExtensionAttributes()->getStore(); - - /** @var Product $product */ $product = $value['model']; $product->unsetData('minimal_price'); // add store filter for the product @@ -69,15 +99,28 @@ public function prepare(ContextInterface $context, ResolveInfo $info, array $val } } - $requestedFields = $info->getFieldSelection(10); - $returnArray = []; + return $product; + } - $returnArray['minimum_price'] = ($requestedFields['minimum_price'] ?? 0) ? ($this->canShowPrice($product) ? - $this->getMinimumProductPrice($product, $store) : $this->formatEmptyResult()) : $this->formatEmptyResult(); - $returnArray['maximum_price'] = ($requestedFields['maximum_price'] ?? 0) ? ($this->canShowPrice($product) ? - $this->getMaximumProductPrice($product, $store) : $this->formatEmptyResult()) : $this->formatEmptyResult(); + /** + * Get the downloadable link price + * + * @param Product $product + * @return float + */ + private function getDownloadableLinkPrice(Product $product): float + { + $downloadableLinks = $product->getTypeInstance()->getLinks($product); + if (empty($downloadableLinks)) { + return 0.0; + } - return $returnArray; + $price = 0.0; + foreach ($downloadableLinks as $link) { + $price += (float)$link->getPrice(); + } + + return $price; } /** diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductName.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductName.php new file mode 100644 index 00000000000..6f419884777 --- /dev/null +++ b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductName.php @@ -0,0 +1,51 @@ +escaper->escapeUrl($value['model']->getName())) + ); + } +} diff --git a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls index 3d3875bb5c5..e5dc1d6d389 100644 --- a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls +++ b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls @@ -1,5 +1,5 @@ -# Copyright © Magento, Inc. All rights reserved. -# See COPYING.txt for license details. +# Copyright 2025 Adobe +# All Rights Reserved. type Query { products ( @@ -93,7 +93,7 @@ interface ProductLinksInterface @typeResolver(class: "Magento\\CatalogGraphQl\\M interface ProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "Contains fields that are common to all types of products.") { id: Int @deprecated(reason: "Use the `uid` field instead.") @doc(description: "The ID number assigned to the product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\EntityIdToId") uid: ID! @doc(description: "The unique ID for a `ProductInterface` object.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\EntityIdToUid") - name: String @doc(description: "The product name. Customers use this name to identify the product.") + name: String @doc(description: "The product name. Customers use this name to identify the product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductName") sku: String @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer.") description: ComplexTextValue @doc(description: "Detailed information about the product. The value can include simple HTML tags.") @resolver(class: "\\Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductComplexTextAttribute") short_description: ComplexTextValue @doc(description: "A short description of the product. Its use depends on the theme.") @resolver(class: "\\Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductComplexTextAttribute") diff --git a/app/code/Magento/CustomerGraphQl/Model/Resolver/UpdateCustomerEmail.php b/app/code/Magento/CustomerGraphQl/Model/Resolver/UpdateCustomerEmail.php index b4b560a2016..07dd73dc3dd 100644 --- a/app/code/Magento/CustomerGraphQl/Model/Resolver/UpdateCustomerEmail.php +++ b/app/code/Magento/CustomerGraphQl/Model/Resolver/UpdateCustomerEmail.php @@ -1,7 +1,7 @@ getCustomer->execute($context); + $customer->setData('ignore_validation_flag', true); $this->updateCustomerAccount->execute( $customer, [ @@ -86,8 +87,6 @@ public function resolve( $context->getExtensionAttributes()->getStore() ); - $data = $this->extractCustomerData->execute($customer); - - return ['customer' => $data]; + return ['customer' => $this->extractCustomerData->execute($customer)]; } } diff --git a/app/code/Magento/Downloadable/Test/Fixture/DownloadableProduct.php b/app/code/Magento/Downloadable/Test/Fixture/DownloadableProduct.php new file mode 100644 index 00000000000..d40899656ca --- /dev/null +++ b/app/code/Magento/Downloadable/Test/Fixture/DownloadableProduct.php @@ -0,0 +1,158 @@ + Type::TYPE_DOWNLOADABLE, + 'name' => 'DownloadableProduct%uniqid%', + 'sku' => 'downloadable-product%uniqid%', + 'price' => 0.00, + 'links_purchased_separately' => 1, + 'links_title' => 'Downloadable Links%uniqid%', + 'links_exist' => 0, + 'extension_attributes' => [ + 'website_ids' => [1], + 'stock_item' => [ + 'use_config_manage_stock' => true, + 'qty' => 100, + 'is_qty_decimal' => false, + 'is_in_stock' => true, + ], + 'downloadable_product_links' => [], + 'downloadable_product_samples' => null + ], + ]; + + /** + * DownloadableProduct constructor + * + * @param ServiceFactory $serviceFactory + * @param ProcessorInterface $dataProcessor + * @param DataMerger $dataMerger + * @param ProductRepositoryInterface $productRepository + * @param DirectoryList $directoryList + * @param Link $link + * @param File $file + */ + public function __construct( + private readonly ServiceFactory $serviceFactory, + private readonly ProcessorInterface $dataProcessor, + private readonly DataMerger $dataMerger, + private readonly ProductRepositoryInterface $productRepository, + private readonly DirectoryList $directoryList, + private readonly Link $link, + private readonly File $file + ) { + parent::__construct($serviceFactory, $dataProcessor, $dataMerger, $productRepository); + } + + /** + * @inheritdoc + * + * @throws FileSystemException + * @throws LocalizedException + */ + public function apply(array $data = []): ?DataObject + { + return parent::apply($this->prepareData($data)); + } + + /** + * Prepare product data + * + * @param array $data + * @return array + * @throws FileSystemException + * @throws LocalizedException + */ + private function prepareData(array $data): array + { + $data = $this->dataMerger->merge(self::DEFAULT_DATA, $data); + + // Remove common properties not needed for downloadable products + unset($data['weight']); + + // Prepare downloadable links + $links = $this->prepareLinksData($data); + $data['extension_attributes']['downloadable_product_links'] = $links; + $data['links_exist'] = count($links); + + return $this->dataProcessor->process($this, $data); + } + + /** + * Prepare links data + * + * @param array $data + * @return array + * @throws FileSystemException + * @throws LocalizedException + */ + private function prepareLinksData(array $data): array + { + $links = []; + foreach ($data['extension_attributes']['downloadable_product_links'] as $link) { + $links[] = [ + 'id' => null, + 'title' => $link['title'] ?? 'Test Link%uniqid%', + 'price' => $link['price'] ?? 0, + 'link_type' => $link['link_type'] ?? 'file', + 'link_url' => null, + 'link_file' => $this->generateDownloadableLink($link['link_file'] ?? 'test-' . uniqid() . '.txt'), + 'is_shareable' => $link['is_shareable'] ?? 0, + 'number_of_downloads' => $link['number_of_downloads'] ?? 5, + 'sort_order' => $link['sort_order'] ?? 10, + ]; + } + + return $links; + } + + /** + * Generate downloadable link file + * + * @param string $fileName + * @return string + * @throws FileSystemException|LocalizedException + */ + public function generateDownloadableLink(string $fileName): string + { + try { + $subDir = sprintf('%s/%s', $fileName[0], $fileName[1]); + $mediaPath = sprintf( + '%s/%s/%s', + $this->directoryList->getPath(DirectoryList::MEDIA), + $this->link->getBasePath(), + $subDir + ); + $this->file->checkAndCreateFolder($mediaPath); + $this->file->write(sprintf('%s/%s', $mediaPath, $fileName), "This is a temporary text file."); + + return sprintf('/%s/%s', $subDir, $fileName); + } catch (FileSystemException $e) { + throw new FileSystemException(__($e->getMessage())); + } catch (LocalizedException $e) { + throw new LocalizedException(__($e->getMessage())); + } + } +} diff --git a/app/code/Magento/GraphQl/etc/di.xml b/app/code/Magento/GraphQl/etc/di.xml index dbcf9932630..0b18d3c4cfd 100644 --- a/app/code/Magento/GraphQl/etc/di.xml +++ b/app/code/Magento/GraphQl/etc/di.xml @@ -1,7 +1,7 @@ diff --git a/app/code/Magento/OrderCancellation/Model/CancelOrder.php b/app/code/Magento/OrderCancellation/Model/CancelOrder.php index 4ae5619d8e9..cd17687c34b 100644 --- a/app/code/Magento/OrderCancellation/Model/CancelOrder.php +++ b/app/code/Magento/OrderCancellation/Model/CancelOrder.php @@ -1,7 +1,7 @@ refundInvoice = $refundInvoice; - $this->refundOrder = $refundOrder; - $this->orderRepository = $orderRepository; - $this->escaper = $escaper; - $this->sender = $sender; } /** - * Cancels and refund an order, if applicable. + * To cancel an order and if applicable process a refund * * @param Order $order * @param string $reason @@ -87,42 +58,64 @@ public function execute( Order $order, string $reason ): Order { - /** @var OrderPaymentInterface $payment */ $payment = $order->getPayment(); - if ($payment->getAmountPaid() === null) { - $order->cancel(); + + if ($payment->getAmountPaid() !== null) { + $order = $payment->getMethodInstance()->isOffline() + ? $this->handleOfflinePayment($order) + : $this->handleOnlinePayment($order); } else { - if ($payment->getMethodInstance()->isOffline()) { - $this->refundOrder->execute($order->getEntityId()); - // for partially invoiced orders we need to cancel after doing the refund - // so not invoiced items are cancelled and the whole order is set to cancelled - $order = $this->orderRepository->get($order->getId()); - $order->cancel(); - } else { - /** @var Order\Invoice $invoice */ - foreach ($order->getInvoiceCollection() as $invoice) { - $this->refundInvoice->execute($invoice->getEntityId()); - } - // in this case order needs to be re-instantiated - $order = $this->orderRepository->get($order->getId()); - } + $order->cancel(); } - $result = $this->sender->send( - $order, - true, - __("Order %1 was cancelled", $order->getRealOrderId()) - ); - $order->addCommentToStatusHistory( - $result ? - __("%1", CancelOrder::EMAIL_NOTIFICATION_SUCCESS) : __("%1", CancelOrder::EMAIL_NOTIFICATION_ERROR) - ); + return $this->updateOrderComments($order, $reason); + } + + /** + * Update order comments + * + * @param OrderInterface $order + * @param string $reason + * @return OrderInterface + */ + public function updateOrderComments(OrderInterface $order, string $reason): OrderInterface + { + $result = $this->sender->send($order, true, __("Order %1 was cancelled", $order->getRealOrderId())); $order->addCommentToStatusHistory( - $this->escaper->escapeHtml($reason), - $order->getStatus() + __("%1", $result ? self::EMAIL_NOTIFICATION_SUCCESS : self::EMAIL_NOTIFICATION_ERROR), + $order->getStatus(), + true ); + $order->addCommentToStatusHistory($this->escaper->escapeHtml($reason), $order->getStatus(), true); + return $this->orderRepository->save($order); } + + /** + * Handle order with offline payment + * + * @param OrderInterface $order + * @return OrderInterface + */ + private function handleOfflinePayment(OrderInterface $order): OrderInterface + { + $this->refundOrder->execute($order->getEntityId()); + return $this->orderRepository->get($order->getEntityId())->cancel(); + } + + /** + * Handle order with online payment + * + * @param OrderInterface $order + * @return OrderInterface + */ + private function handleOnlinePayment(OrderInterface $order): OrderInterface + { + foreach ($order->getInvoiceCollection() as $invoice) { + $this->refundInvoice->execute($invoice->getEntityId()); + } + return $this->orderRepository->get($order->getEntityId()); + } } diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php b/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php index 70de1e5ec1c..56355d4b8b4 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php @@ -65,12 +65,12 @@ public function execute(Order $order, array $input): array */ private function sendConfirmationKeyEmail(Order $order, string $reason): void { - $confirmationKey = $this->confirmationKey->execute($order, $reason); - $this->confirmationKeySender->execute($order, $confirmationKey); + $this->confirmationKeySender->execute($order, $this->confirmationKey->execute($order, $reason)); // add comment in order about confirmation key send $order->addCommentToStatusHistory( 'Order cancellation confirmation key was sent via email.', + $order->getStatus(), true ); $this->orderRepository->save($order); diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/RequestGuestOrderCancel.php b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/RequestGuestOrderCancel.php index d9a73e4ce29..6fbac7946c9 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/RequestGuestOrderCancel.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/RequestGuestOrderCancel.php @@ -56,10 +56,10 @@ public function resolve( ?array $args = null ) { $this->validateRequest->validateInput($args['input'] ?? []); - list($number, $email, $postcode) = $this->getNumberEmailPostcode($args['input']['token']); + list($number, $email, $lastname) = $this->getNumberEmailLastname($args['input']['token']); $order = $this->getOrder($number); - $this->validateRequest->validateOrderDetails($order, $postcode, $email); + $this->validateRequest->validateOrderDetails($order, $lastname, $email); $errors = $this->validateOrder->execute($order); if ($errors) { @@ -93,13 +93,13 @@ private function getOrder(string $number): OrderInterface } /** - * Retrieve number, email and postcode from token + * Retrieve number, email and lastname from token * * @param string $token * @return array * @throws GraphQlNoSuchEntityException */ - private function getNumberEmailPostcode(string $token): array + private function getNumberEmailLastname(string $token): array { $data = $this->token->decrypt($token); if (count($data) !== 3) { diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateGuestRequest.php b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateGuestRequest.php index 036d366a4c2..b01b5b554ab 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateGuestRequest.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateGuestRequest.php @@ -59,17 +59,17 @@ public function validateInput(mixed $input): void * Ensure the order matches the provided criteria * * @param OrderInterface $order - * @param string $postcode + * @param string $lastname * @param string $email * @return void * @throws GraphQlAuthorizationException * @throws GraphQlNoSuchEntityException */ - public function validateOrderDetails(OrderInterface $order, string $postcode, string $email): void + public function validateOrderDetails(OrderInterface $order, string $lastname, string $email): void { $billingAddress = $order->getBillingAddress(); - if ($billingAddress->getPostcode() !== $postcode || $billingAddress->getEmail() !== $email) { + if ($billingAddress->getLastname() !== $lastname || $billingAddress->getEmail() !== $email) { $this->cannotLocateOrder(); } diff --git a/app/code/Magento/QuoteGraphQl/Model/Cart/ExtractQuoteAddressData.php b/app/code/Magento/QuoteGraphQl/Model/Cart/ExtractQuoteAddressData.php index c785b632c00..da210976d63 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Cart/ExtractQuoteAddressData.php +++ b/app/code/Magento/QuoteGraphQl/Model/Cart/ExtractQuoteAddressData.php @@ -1,7 +1,7 @@ $address->getRegionId() ], 'uid' => $this->uidEncoder->encode((string)$address->getAddressId()) , + 'id' => $address->getCustomerAddressId(), 'street' => $address->getStreet(), 'items_weight' => $address->getWeight(), 'customer_notes' => $address->getCustomerNotes(), diff --git a/app/code/Magento/QuoteGraphQl/Model/Cart/SetBillingAddressOnCart.php b/app/code/Magento/QuoteGraphQl/Model/Cart/SetBillingAddressOnCart.php index a7d44fa104c..b29547e534a 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Cart/SetBillingAddressOnCart.php +++ b/app/code/Magento/QuoteGraphQl/Model/Cart/SetBillingAddressOnCart.php @@ -1,7 +1,7 @@ validateBillingAddress($billingAddress); $this->assignBillingAddressToCart->execute($cart, $billingAddress, $useForShipping); + if ($sameAsShipping) { + $cart->getShippingAddress()->setSameAsBilling(1)->save(); + } } /** @@ -146,7 +149,7 @@ private function validateCanUseBillingForShipping(CartInterface $quote) if (count($shippingAddresses) > 1) { throw new GraphQlInputException( - __('Could not use the "use_for_shipping" option, because multiple shipping addresses have already been set.') + __('Could not use "use_for_shipping" option, as multiple shipping addresses have already been set.') ); } } diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php index 0a30887e4db..abbd81475e7 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartItemPrices.php @@ -1,7 +1,7 @@ getDiscountAmount(); } - /** - * Calculate the actual price of the product with all discounts applied - */ - $originalItemPrice = $cartItem->getTotalDiscountAmount() > 0 - ? $this->priceCurrency->round( - $cartItem->getCalculationPrice() - ($cartItem->getTotalDiscountAmount() / max($cartItem->getQty(), 1)) - ) - : $cartItem->getCalculationPrice(); - return [ 'model' => $cartItem, 'price' => [ @@ -118,7 +110,7 @@ public function resolve(Field $field, $context, ResolveInfo $info, ?array $value ), 'original_item_price' => [ 'currency' => $currencyCode, - 'value' => $originalItemPrice + 'value' => $this->getOriginalItemPrice($cartItem), ], 'original_row_total' => [ 'currency' => $currencyCode, @@ -128,16 +120,34 @@ public function resolve(Field $field, $context, ResolveInfo $info, ?array $value } /** - * Calculate the original price row total + * Calculate the original item price, with no discounts or taxes applied + * + * @param Item $cartItem + * @return float + */ + private function getOriginalItemPrice(Item $cartItem): float + { + $originalItemPrice = $cartItem->getOriginalPrice() + $this->getCustomOptionPrice($cartItem); + + // To add downloadable product link price to the original item price + if ($cartItem->getProductType() === Type::TYPE_DOWNLOADABLE && + $cartItem->getProduct()->getData('links_purchased_separately')) { + $originalItemPrice += (float)$this->getDownloadableLinkPrice($cartItem); + } + + return $originalItemPrice; + } + + /** + * Calculate the original row total price * * @param Item $cartItem * @return float */ private function getOriginalRowTotal(Item $cartItem): float { - $qty = $cartItem->getTotalQty(); // Round unit price before multiplying to prevent losing 1 cent on subtotal - return $this->priceCurrency->round($cartItem->getOriginalPrice() + $this->getOptionsPrice($cartItem)) * $qty; + return $this->priceCurrency->round($this->getOriginalItemPrice($cartItem)) * $cartItem->getTotalQty(); } /** @@ -146,14 +156,13 @@ private function getOriginalRowTotal(Item $cartItem): float * @param Item $cartItem * @return float */ - private function getOptionsPrice(Item $cartItem): float + private function getCustomOptionPrice(Item $cartItem): float { $price = 0.0; $optionIds = $cartItem->getProduct()->getCustomOption('option_ids'); if (!$optionIds) { return $price; } - foreach (explode(',', $optionIds->getValue() ?? '') as $optionId) { $option = $cartItem->getProduct()->getOptionById($optionId); $optionValueIds = $cartItem->getOptionByCode('option_' . $optionId); @@ -170,4 +179,28 @@ private function getOptionsPrice(Item $cartItem): float return $price; } + + /** + * Get the downloadable link price + * + * @param Item $cartItem + * @return float + */ + private function getDownloadableLinkPrice(Item $cartItem): float + { + $linksOption = $cartItem->getProduct()->getCustomOption('downloadable_link_ids'); + if (!$linksOption || !$linksOption->getValue()) { + return 0.0; + } + + $selectedLinks = array_flip(explode(',', $linksOption->getValue())); + $downloadableLinks = $cartItem->getProduct()->getTypeInstance()->getLinks($cartItem->getProduct()); + + return array_reduce( + $downloadableLinks, + fn(float $total, $link) => isset($selectedLinks[$link->getId()]) ? + $total + (float) $link->getPrice() : $total, + 0.0 + ); + } } diff --git a/app/code/Magento/QuoteGraphQl/composer.json b/app/code/Magento/QuoteGraphQl/composer.json index 23e52495250..6691ca753fc 100644 --- a/app/code/Magento/QuoteGraphQl/composer.json +++ b/app/code/Magento/QuoteGraphQl/composer.json @@ -17,7 +17,8 @@ "magento/module-graph-ql": "*", "magento/module-gift-message": "*", "magento/module-catalog-inventory": "*", - "magento/module-eav-graph-ql": "*" + "magento/module-eav-graph-ql": "*", + "magento/module-downloadable": "*" }, "suggest": { "magento/module-graph-ql-cache": "*", diff --git a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls index 68ad5935af1..061f36df3a3 100644 --- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls @@ -294,6 +294,7 @@ type CartItems { interface CartAddressInterface @typeResolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\CartAddressTypeResolver") { uid: String! @doc(description: "The unique id of the customer address.") + id: Int @doc(description: "Id of the customer address.") firstname: String! @doc(description: "The first name of the customer or guest.") lastname: String! @doc(description: "The last name of the customer or guest.") company: String @doc(description: "The company specified for the billing or shipping address.") @@ -317,6 +318,7 @@ type ShippingCartAddress implements CartAddressInterface @doc(description: "Cont items_weight: Float @deprecated(reason: "This information should not be exposed on the frontend.") cart_items: [CartItemQuantity] @deprecated(reason: "Use `cart_items_v2` instead.") cart_items_v2: [CartItemInterface] @doc(description: "An array that lists the items in the cart.") + same_as_billing: Boolean! @doc(description: "Indicates whether the shipping address is same as billing address.") } type BillingCartAddress implements CartAddressInterface @doc(description: "Contains details about the billing address.") { diff --git a/app/code/Magento/SalesGraphQl/Model/Formatter/Order.php b/app/code/Magento/SalesGraphQl/Model/Formatter/Order.php index 86a82be4fc7..06afec2ac2e 100644 --- a/app/code/Magento/SalesGraphQl/Model/Formatter/Order.php +++ b/app/code/Magento/SalesGraphQl/Model/Formatter/Order.php @@ -59,6 +59,27 @@ public function format(OrderInterface $orderModel): array 'payment_methods' => $this->orderPayments->getOrderPaymentMethod($orderModel), 'applied_coupons' => $orderModel->getCouponCode() ? ['code' => $orderModel->getCouponCode()] : [], 'model' => $orderModel, + 'comments' => $this->getOrderComments($orderModel) ]; } + + /** + * Get order comments + * + * @param OrderInterface $order + * @return array + */ + public function getOrderComments(OrderInterface $order):array + { + $comments = []; + foreach ($order->getStatusHistories() as $comment) { + if ($comment->getIsVisibleOnFront()) { + $comments[] = [ + 'message' => $comment->getComment(), + 'timestamp' => $comment->getCreatedAt() + ]; + } + } + return $comments; + } } diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/GuestOrder.php b/app/code/Magento/SalesGraphQl/Model/Resolver/GuestOrder.php index ed5f002905b..6d8fbef4479 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/GuestOrder.php +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/GuestOrder.php @@ -20,7 +20,7 @@ use Magento\Store\Model\StoreManagerInterface; /** - * Retrieve guest order based + * Retrieve guest order details */ class GuestOrder implements ResolverInterface { @@ -50,9 +50,9 @@ public function resolve( ?array $value = null, ?array $args = null ) { - list($number, $email, $postcode) = $this->getNumberEmailPostcode($args['input'] ?? []); + list($number, $email, $lastname) = $this->getNumberEmailLastname($args['input'] ?? []); $order = $this->getOrder($number); - $this->validateOrder($order, $postcode, $email); + $this->validateOrder($order, $lastname, $email); return $this->orderFormatter->format($order); } @@ -82,35 +82,32 @@ private function getOrder(string $number): OrderInterface * Ensure the order matches the provided criteria * * @param OrderInterface $order - * @param string $postcode + * @param string $lastname * @param string $email * @return void * @throws GraphQlAuthorizationException * @throws GraphQlNoSuchEntityException */ - private function validateOrder(OrderInterface $order, string $postcode, string $email): void + private function validateOrder(OrderInterface $order, string $lastname, string $email): void { - if ($order->getBillingAddress()->getPostcode() !== $postcode) { - $this->cannotLocateOrder(); - } - - if ($order->getBillingAddress()->getEmail() !== $email) { + $billingAddress = $order->getBillingAddress(); + if ($billingAddress->getLastname() !== $lastname || $billingAddress->getEmail() !== $email) { $this->cannotLocateOrder(); } if ($order->getCustomerId()) { - $this->customerHasToLogin(); + throw new GraphQlAuthorizationException(__('Please login to view the order.')); } } /** - * Retrieve number, email and postcode from input + * Retrieve order number, email, and lastname from input * * @param array $input * @return array * @throws GraphQlNoSuchEntityException */ - private function getNumberEmailPostcode(array $input): array + private function getNumberEmailLastname(array $input): array { if (isset($input['token'])) { $data = $this->token->decrypt($input['token']); @@ -119,10 +116,10 @@ private function getNumberEmailPostcode(array $input): array } return $data; } - if (!isset($input['number']) || !isset($input['email']) || !isset($input['postcode'])) { + if (!isset($input['number']) || !isset($input['email']) || !isset($input['lastname'])) { $this->cannotLocateOrder(); } - return [$input['number'], $input['email'], $input['postcode']]; + return [$input['number'], $input['email'], $input['lastname']]; } /** @@ -135,15 +132,4 @@ private function cannotLocateOrder(): void { throw new GraphQlNoSuchEntityException(__('We couldn\'t locate an order with the information provided.')); } - - /** - * Throw exception when the guest checkout is not enabled or order is customer order - * - * @return void - * @throws GraphQlAuthorizationException - */ - private function customerHasToLogin(): void - { - throw new GraphQlAuthorizationException(__('Please login to view the order.')); - } } diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/Token.php b/app/code/Magento/SalesGraphQl/Model/Resolver/Token.php index 09022120dc9..51f8393a5c8 100644 --- a/app/code/Magento/SalesGraphQl/Model/Resolver/Token.php +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/Token.php @@ -12,6 +12,7 @@ use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\Sales\Api\Data\OrderInterface; +use Magento\SalesGraphQl\Model\Order\Token as OrderToken; /** * Retrieve order token @@ -22,7 +23,7 @@ class Token implements ResolverInterface * @param Token $token */ public function __construct( - private readonly \Magento\SalesGraphQl\Model\Order\Token $token + private readonly OrderToken $token ) { } @@ -44,7 +45,7 @@ public function resolve( return $this->token->encrypt( $order->getIncrementId(), $order->getBillingAddress()->getEmail(), - $order->getBillingAddress()->getPostcode() + $order->getBillingAddress()->getLastname() ); } } diff --git a/app/code/Magento/SalesGraphQl/etc/schema.graphqls b/app/code/Magento/SalesGraphQl/etc/schema.graphqls index 4e291a0a262..7a2b517f5b4 100644 --- a/app/code/Magento/SalesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SalesGraphQl/etc/schema.graphqls @@ -3,7 +3,7 @@ type Query { customerOrders: CustomerOrders @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Orders") @deprecated(reason: "Use the `customer` query instead.") @cache(cacheable: false) - guestOrder(input: OrderInformationInput!): CustomerOrder! @doc(description:"Retrieve guest order details based on number, email and postcode.") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\GuestOrder") @cache(cacheable: false) + guestOrder(input: OrderInformationInput!): CustomerOrder! @doc(description:"Retrieve guest order details based on number, email and billing last name.") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\GuestOrder") @cache(cacheable: false) guestOrderByToken(input: OrderTokenInput!): CustomerOrder! @doc(description:"Retrieve guest order details based on token.") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\GuestOrder") @cache(cacheable: false) } @@ -310,7 +310,7 @@ input OrderTokenInput @doc(description: "Input to retrieve an order based on tok input OrderInformationInput @doc(description: "Input to retrieve an order based on details.") { number: String! @doc(description: "Order number.") email: String! @doc(description: "Order billing address email.") - postcode: String! @doc(description: "Order billing address postcode.") + lastname: String! @doc(description: "Order billing address lastname.") } enum OrderActionType @doc(description: "The list of available order actions.") { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductCartPricesTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductCartPricesTest.php index a183d94cca0..21c00cf3d83 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductCartPricesTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Bundle/BundleProductCartPricesTest.php @@ -102,11 +102,7 @@ public function testBundleProductFixedPriceWithOptionsWithoutPrices() $query = $this->getCartQuery($maskedQuoteId); $response = $this->graphQlQuery($query); - // price is the bundle product price as in this case the options don't have prices - // specialPrice is the bundle product price * bundle product special price % - // originalItemPriceProduct1 is the bundle product price - // originalItemPriceProduct1 is with 10% discount as the special price - $expectedResponse = $this->getExpectedResponse(15, 30, 30, 13.5, 27, 15, 13.5); + $expectedResponse = $this->getExpectedResponse(15, 30, 30, 13.5, 27, 15, 15); $this->assertEquals($expectedResponse, $response); } @@ -174,11 +170,7 @@ public function testBundleProductFixedPriceWithOneOptionFixedPrice() $query = $this->getCartQuery($maskedQuoteId); $response = $this->graphQlQuery($query); - // price is the bundle product price + option fixed price - // specialPrice is the bundle product price + option fixed price * bundle product special price % - // originalItemPriceProduct1 is the bundle product price - // originalItemPriceProduct1 is with 10% discount as the special price - $expectedResponse = $this->getExpectedResponse(25, 50, 50, 22.5, 45, 25, 22.5); + $expectedResponse = $this->getExpectedResponse(25, 50, 50, 22.5, 45, 25, 25); $this->assertEquals($expectedResponse, $response); } @@ -254,11 +246,7 @@ public function testBundleProductFixedPriceWithBothOptionsFixedPrice() $query = $this->getCartQuery($maskedQuoteId); $response = $this->graphQlQuery($query); - // price is the bundle product price + options fixed prices - // specialPrice is the bundle product price + options fixed prices * bundle product special price % - // originalItemPriceProduct1 is the bundle product price - // originalItemPriceProduct1 is with 10% discount as the special price - $expectedResponse = $this->getExpectedResponse(45, 90, 90, 40.50, 81, 45, 40.5); + $expectedResponse = $this->getExpectedResponse(45, 90, 90, 40.50, 81, 45, 45); $this->assertEquals($expectedResponse, $response); } @@ -326,12 +314,7 @@ public function testBundleProductFixedPriceWithOneOptionPercentPrice() $query = $this->getCartQuery($maskedQuoteId); $response = $this->graphQlQuery($query); - // price is the (bundle product price * option percent price) + bundle product price - // specialPrice is the (bundle product price * option percent price) + - // bundle product price * bundle product special price % - // originalItemPriceProduct1 is the bundle product price - // originalItemPriceProduct1 is with 10% discount as the special price - $expectedResponse = $this->getExpectedResponse(18, 36, 36, 16.20, 32.40, 18, 16.2); + $expectedResponse = $this->getExpectedResponse(18, 36, 36, 16.20, 32.40, 18, 18); $this->assertEquals($expectedResponse, $response); } @@ -407,12 +390,7 @@ public function testBundleProductFixedPriceWithBothOptionsPercentPrices() $query = $this->getCartQuery($maskedQuoteId); $response = $this->graphQlQuery($query); - // price is the (bundle product price * options percent price) + bundle product price - // specialPrice is the (bundle product price * options percent price) + - // bundle product price * bundle product special price % - // originalItemPriceProduct1 is the bundle product price - // originalItemPriceProduct1 is with 10% discount as the special price - $expectedResponse = $this->getExpectedResponse(19.5, 39, 39, 17.55, 35.10, 19.5, 17.55); + $expectedResponse = $this->getExpectedResponse(19.5, 39, 39, 17.55, 35.10, 19.5, 19.5); $this->assertEquals($expectedResponse, $response); } @@ -488,12 +466,7 @@ public function testBundleProductFixedPriceWithOneOptionFixedAndOnePercentPrice( $query = $this->getCartQuery($maskedQuoteId); $response = $this->graphQlQuery($query); - // price is the (bundle product price * option percent price) + bundle product price + option fixed price - // specialPrice is the (bundle product price * option percent price) + bundle product price + - // option fixed price * bundle product special price % - // originalItemPriceProduct1 is the bundle product price - // originalItemPriceProduct1 is with 10% discount as the special price - $expectedResponse = $this->getExpectedResponse(28, 56, 56, 25.20, 50.40, 28, 25.2); + $expectedResponse = $this->getExpectedResponse(28, 56, 56, 25.20, 50.40, 28, 28); $this->assertEquals($expectedResponse, $response); } @@ -612,7 +585,7 @@ public function testBundleProductDynamicPriceWithSpecialPrice() "currency" => "USD" ], "original_item_price" => [ - "value" => 25, // product 1 special_price(15) + product 2 price (10) + "value" => 30, // product 1 price(20) + product 2 price (10) "currency" => "USD" ] ] diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php new file mode 100644 index 00000000000..e03746d6b52 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php @@ -0,0 +1,147 @@ +quoteIdToMaskedQuoteId = Bootstrap::getObjectManager()->get(QuoteIdToMaskedQuoteIdInterface::class); + $this->fixtures = Bootstrap::getObjectManager()->get(DataFixtureStorageManager::class)->getStorage(); + } + + /** + * Test product name with special characters + * + * @param string $sku + * @param string $expectedName + * @throws NoSuchEntityException + * @dataProvider productNameProvider + */ + #[ + DataFixture(ProductFixture::class, [ + 'sku' => 'test-product-1', + 'name' => 'Test Product© 1' + ]), + DataFixture(ProductFixture::class, [ + 'sku' => 'test-product-2', + 'name' => 'Test Product™ 2' + ]), + DataFixture(ProductFixture::class, [ + 'sku' => 'test-product-3', + 'name' => 'Sample Product© 3' + ]), + DataFixture(ProductFixture::class, [ + 'sku' => 'test-product-4', + 'name' => 'Sample Product™ 4' + ]), + DataFixture(ProductFixture::class, [ + 'sku' => 'test-product-5', + 'name' => 'Test Product 5' + ]), + DataFixture(GuestCartFixture::class, as: 'cart') + ] + public function testProductName(string $sku, string $expectedName): void + { + $maskedQuoteId = $this->quoteIdToMaskedQuoteId->execute( + (int)$this->fixtures->get('cart')->getId() + ); + + $response = $this->graphQlMutation($this->getAddToCartMutation($maskedQuoteId, $sku)); + + self::assertEquals( + [ + 'cart' => [ + 'items' => [ + [ + 'quantity' => 1, + 'product' => [ + 'sku' => $sku, + 'name' => $expectedName + ] + ] + ] + ] + ], + $response['addProductsToCart'] + ); + } + + /** + * Data provider for product name test cases + * + * @return array[] + */ + public static function productNameProvider(): array + { + return [ + ['test-product-1', 'Test Product© 1'], + ['test-product-2', 'Test Product™ 2'], + ['test-product-3', 'Sample Product© 3'], + ['test-product-4', 'Sample Product™ 4'], + ['test-product-5', 'Test Product 5'] + ]; + } + + /** + * Returns Add to cart mutation + * + * @param string $maskedQuoteId + * @param string $sku + * @return string + */ + private function getAddToCartMutation(string $maskedQuoteId, string $sku): string + { + return << 'customer@example.com'], as: 'customer'), + DataFixture( + Attribute::class, + [ + 'entity_type_id' => CustomerMetadataInterface::ATTRIBUTE_SET_ID_CUSTOMER, + 'is_required' => true, + ], + 'customer_attribute' + ) +] +class CustomerEmailUpdateTest extends GraphQlAbstract +{ + /** + * Test customer email update + * + * @return void + * @throws Exception + */ + public function testUpdateCustomerEmail(): void + { + $response = $this->graphQlMutation( + $this->getQuery('newcustomer@example.com', 'password'), + [], + '', + $this->getCustomerAuthHeaders('customer@example.com', 'password') + ); + + $this->assertEquals('newcustomer@example.com', $response['updateCustomerEmail']['customer']['email']); + } + + /** + * Test customer email update with empty fields + * + * @param string $email + * @param string $password + * @param string $message + * @dataProvider customerInputFieldDataProvider + * @return void + * @throws AuthenticationException + */ + public function testUpdateCustomerEmailWithEmptyFields( + string $email, + string $password, + string $message + ): void { + $this->expectException(Exception::class); + $this->expectExceptionMessage($message); + $this->graphQlMutation( + $this->getQuery($email, $password), + [], + '', + $this->getCustomerAuthHeaders('customer@example.com', 'password') + ); + } + + /** + * Data provider for testUpdateCustomerEmailWithEmptyFields + * + * @return array + */ + public static function customerInputFieldDataProvider(): array + { + return [ + [ + 'email' => 'newcustomer@example.com', + 'password' => '', + 'message' => 'Provide the current "password" to change "email".', + ], + [ + 'email' => '', + 'password' => 'password', + 'message' => '"" is not a valid email address.', + ] + ]; + } + + /** + * Get customer email update mutation + * + * @param string $newEmail + * @param string $currentPassword + * @return string + */ + private function getQuery(string $newEmail, string $currentPassword): string + { + return <<get(CustomerTokenServiceInterface::class) + ->createCustomerAccessToken($email, $password); + + return ['Authorization' => 'Bearer ' . $customerToken]; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/CancelGuestOrderTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/CancelGuestOrderTest.php index 3cf48bbd378..0c7bcef8b09 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/CancelGuestOrderTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/CancelGuestOrderTest.php @@ -467,14 +467,14 @@ private function getOrderToken(OrderInterface $order): string return Bootstrap::getObjectManager()->create(Token::class)->encrypt( $order->getIncrementId(), $order->getBillingAddress()->getEmail(), - $order->getBillingAddress()->getPostcode() + $order->getBillingAddress()->getLastname() ); } /** * @return array[] */ - public function orderStatusProvider(): array + public static function orderStatusProvider(): array { return [ 'On Hold status' => [ diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddressBookIdTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddressBookIdTest.php new file mode 100644 index 00000000000..98b79101a1a --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/AddressBookIdTest.php @@ -0,0 +1,187 @@ +fixtures = Bootstrap::getObjectManager()->get(DataFixtureStorageManager::class)->getStorage(); + $this->customerTokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class); + } + + /** + * @throws Exception + */ + #[ + DataFixture(Customer::class, ['addresses' => [['postcode' => '12345']]], as: 'customer'), + DataFixture(ProductFixture::class, as: 'product'), + DataFixture(CustomerCart::class, ['customer_id' => '$customer.id$'], as: 'cart'), + DataFixture(AddProductToCartFixture::class, ['cart_id' => '$cart.id$', 'product_id' => '$product.id$']), + DataFixture(QuoteIdMask::class, ['cart_id' => '$cart.id$'], 'quoteIdMask'), + ] + public function testDefaultAddressBookId(): void + { + $customer = $this->fixtures->get('customer'); + $addresses = $customer->getAddresses(); + $customerAddress = array_shift($addresses); + + $this->graphQlMutation( + $this->getSetShippingAddressOnCartMutation( + $this->fixtures->get('quoteIdMask')->getMaskedId(), + (int)$customerAddress->getId() + ), + [], + '', + $this->getCustomerAuthHeaders($customer->getEmail()) + ); + + $this->assertEquals( + [ + "customerCart" => [ + "shipping_addresses" => [ + ["id" => (int)$customerAddress->getId()] + ], + "billing_address" => ["id" => (int)$customerAddress->getId()] + ] + ], + $this->graphQlMutation( + $this->getCustomerCartQuery(), + [], + '', + $this->getCustomerAuthHeaders($customer->getEmail()) + ) + ); + } + + /** + * @throws Exception + */ + #[ + DataFixture(Customer::class, ['addresses' => [['postcode' => '12345']]], as: 'customer'), + DataFixture(ProductFixture::class, as: 'product'), + DataFixture(CustomerCart::class, ['customer_id' => '$customer.id$'], as: 'cart'), + DataFixture(AddProductToCartFixture::class, ['cart_id' => '$cart.id$', 'product_id' => '$product.id$']), + DataFixture(SetBillingAddressFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetShippingAddressFixture::class, ['cart_id' => '$cart.id$']), + ] + public function testAddressBookIdForNewQuoteAddress(): void + { + $this->assertEquals( + [ + "customerCart" => [ + "shipping_addresses" => [ + ["id" => ""] + ], + "billing_address" => ["id" => ""] + ] + ], + $this->graphQlMutation( + $this->getCustomerCartQuery(), + [], + '', + $this->getCustomerAuthHeaders($this->fixtures->get('customer')->getEmail()) + ) + ); + } + + /** + * Get setShippingAddressOnCart mutation + * + * @param string $cartId + * @param int $customerAddressId + * @return string + */ + private function getSetShippingAddressOnCartMutation(string $cartId, int $customerAddressId): string + { + return << 'Bearer ' . $this->customerTokenService->createCustomerAccessToken( + $customerEmail, + 'password' + ) + ]; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CartItemPriceTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CartItemPriceTest.php index f77ffdeda4d..82d3a5902cb 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CartItemPriceTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/CartItemPriceTest.php @@ -99,7 +99,7 @@ public function testGetCartItemPricesWithDiscount() 0 => [ 'prices' => [ 'original_item_price' => [ - 'value' => 8.5, + 'value' => 10, 'currency' => 'USD' ], 'original_row_total' => [ diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/DownloadableProductCartItemPriceTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/DownloadableProductCartItemPriceTest.php new file mode 100644 index 00000000000..c351a2b48f7 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/DownloadableProductCartItemPriceTest.php @@ -0,0 +1,281 @@ +fixtures = Bootstrap::getObjectManager()->get(DataFixtureStorageManager::class)->getStorage(); + } + + /** + * Test cart item price for a downloadable product without separate link selection + * + * @return void + * @throws LocalizedException + * @throws Exception + */ + #[ + DataFixture(DownloadableProductFixture::class, [ + 'price' => 100, + 'type_id' => 'downloadable', + 'links_purchased_separately' => 0, + 'downloadable_product_links' => [ + [ + 'title' => 'Example 1', + 'price' => 0.00, + 'link_type' => 'file' + ], + [ + 'title' => 'Example 2', + 'price' => 0.00, + 'link_type' => 'file' + ], + ] + ], as: 'product'), + DataFixture(CustomerFixture::class, ['email' => 'customer@example.com'], as: 'customer'), + DataFixture(CustomerCartFixture::class, ['customer_id' => '$customer.id$'], as: 'cart'), + DataFixture(AddProductToCartFixture::class, [ + 'cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 1 + ]), + DataFixture(QuoteIdMask::class, ['cart_id' => '$cart.id$'], 'quoteIdMask') + ] + public function testGetCartItemPricesForDownloadableProductWithoutSeparateLinks() + { + $query = <<fixtures->get('quoteIdMask')->getMaskedId()}") { + items { + ... on DownloadableCartItem { + prices { + original_item_price { + value + currency + } + original_row_total { + value + currency + } + } + product { + price_range { + maximum_price { + regular_price { + value + } + final_price { + value + } + } + } + } + } + } + } + } + QUERY; + self::assertEquals( + [ + 'cart' => [ + 'items' => [ + 0 => [ + 'prices' => [ + 'original_item_price' => [ + 'value' => 100, + 'currency' => 'USD' + ], + 'original_row_total' => [ + 'value' => 100, + 'currency' => 'USD' + ] + ], + 'product' => [ + 'price_range' => [ + 'maximum_price' => [ + 'regular_price' => [ + 'value' => 100 + ], + 'final_price' => [ + 'value' => 100 + ] + ] + ] + ] + ] + ] + ] + ], + $this->graphQlQuery($query, [], '', $this->getCustomerAuthHeaders()) + ); + } + + /** + * Test cart item price for a downloadable product with separate link selection + * + * @return void + * @throws AuthenticationException + * @throws LocalizedException + * @throws Exception + */ + #[ + DataFixture(DownloadableProductFixture::class, [ + 'price' => 0, + 'type_id' => 'downloadable', + 'links_purchased_separately' => 1, + 'downloadable_product_links' => [ + [ + 'title' => 'Example 1', + 'price' => 10, + 'link_type' => 'file' + ], + [ + 'title' => 'Example 2', + 'price' => 10, + 'link_type' => 'file' + ], + ] + ], as: 'product'), + DataFixture(CustomerFixture::class, ['email' => 'customer@example.com'], as: 'customer'), + DataFixture(CustomerCartFixture::class, ['customer_id' => '$customer.id$'], as: 'cart'), + DataFixture(QuoteIdMask::class, ['cart_id' => '$cart.id$'], 'quoteIdMask') + ] + public function testGetCartItemPricesForDownloadableProductWithSeparateLinks() + { + $product = DataFixtureStorageManager::getStorage()->get('product'); + $linkId = key($product->getDownloadableLinks()); + + $query = <<fixtures->get('quoteIdMask')->getMaskedId()}", + cart_items: [ + { + data: { + quantity: 1, + sku: "{$product->getSku()}" + }, + downloadable_product_links: [ + { + link_id: {$linkId} + } + ] + } + ] + } + ) { + cart { + items { + ... on DownloadableCartItem { + prices { + original_item_price { + value + currency + } + original_row_total { + value + currency + } + } + product { + price_range { + maximum_price { + regular_price { + value + } + final_price { + value + } + } + } + } + } + } + } + } + } + MUTATION; + + self::assertEquals( + [ + 'addDownloadableProductsToCart' => [ + 'cart' => [ + 'items' => [ + 0 => [ + 'prices' => [ + 'original_item_price' => [ + 'value' => 10, + 'currency' => 'USD' + ], + 'original_row_total' => [ + 'value' => 10, + 'currency' => 'USD' + ] + ], + 'product' => [ + 'price_range' => [ + 'maximum_price' => [ + 'regular_price' => [ + 'value' => 20 + ], + 'final_price' => [ + 'value' => 20 + ] + ] + ] + ] + ] + ] + ] + ] + ], + $this->graphQlMutation($query, [], '', $this->getCustomerAuthHeaders()) + ); + } + + /** + * Get Customer Auth Headers + * + * @return array + * @throws AuthenticationException + */ + private function getCustomerAuthHeaders(): array + { + $customerTokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class); + $customerToken = $customerTokenService->createCustomerAccessToken('customer@example.com', 'password'); + + return ['Authorization' => 'Bearer ' . $customerToken]; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/ProductsWithCustomOptionsCartPricesTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/ProductsWithCustomOptionsCartPricesTest.php index 3123151f8a3..a4558532bbf 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/ProductsWithCustomOptionsCartPricesTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/ProductsWithCustomOptionsCartPricesTest.php @@ -123,7 +123,7 @@ public function testProductsWithOneCustomOptionEnteredWithFixedPrice() "currency" => "USD" ], "original_item_price" => [ - "value" => 25, + "value" => 40, "currency" => "USD" ] ] @@ -208,7 +208,7 @@ public function testProductsWithOneCustomOptionEnteredWithPercentPrice() "currency" => "USD" ], "original_item_price" => [ - "value" => 16.5, + "value" => 33, "currency" => "USD" ] ] @@ -293,7 +293,7 @@ public function testProductsWithOneCustomOptionEnteredWithPercentPriceAndOneWith "currency" => "USD" ], "original_item_price" => [ - "value" => 66.5, + "value" => 83, "currency" => "USD" ] ] @@ -438,7 +438,7 @@ public function testCartWithMultipleCustomProductOption() "currency" => "USD" ], "original_item_price" => [ - "value" => 22, + "value" => 46, "currency" => "USD" ] ] diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/ShippingAddressSameAsBillingTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/ShippingAddressSameAsBillingTest.php new file mode 100644 index 00000000000..4b0505d9385 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/ShippingAddressSameAsBillingTest.php @@ -0,0 +1,265 @@ +fixtures = Bootstrap::getObjectManager()->get(DataFixtureStorageManager::class)->getStorage(); + $this->customerTokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class); + } + + /** + * @throws Exception + */ + #[ + DataFixture(ProductFixture::class, as: 'product'), + DataFixture(CustomerFixture::class, as: 'customer'), + DataFixture(CustomerCartFixture::class, ['customer_id' => '$customer.id$'], as: 'quote'), + DataFixture(AddProductToCartFixture::class, ['cart_id' => '$quote.id$', 'product_id' => '$product.id$']), + DataFixture(SetShippingAddressFixture::class, ['cart_id' => '$quote.id$']), + DataFixture(QuoteIdMask::class, ['cart_id' => '$quote.id$'], 'quoteIdMask'), + ] + public function testSetSameAsShipping(): void + { + $maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId(); + $headerMap = $this->getCustomerAuthHeaders($this->fixtures->get('customer')->getEmail()); + + $this->graphQlMutation( + $this->getBillingAddressMutationSameAsShipping($maskedQuoteId), + [], + '', + $headerMap + ); + + $this->assertSameAsBillingField( + $this->graphQlQuery( + $this->getQuery($maskedQuoteId), + [], + '', + $headerMap + ), + true + ); + } + + /** + * @throws Exception + */ + #[ + DataFixture(ProductFixture::class, as: 'product'), + DataFixture(CustomerFixture::class, as: 'customer'), + DataFixture(CustomerCartFixture::class, ['customer_id' => '$customer.id$'], as: 'quote'), + DataFixture(AddProductToCartFixture::class, ['cart_id' => '$quote.id$', 'product_id' => '$product.id$']), + DataFixture(QuoteIdMask::class, ['cart_id' => '$quote.id$'], 'quoteIdMask'), + ] + public function testSetUseForShipping(): void + { + $maskedQuoteId = $this->fixtures->get('quoteIdMask')->getMaskedId(); + $headerMap = $this->getCustomerAuthHeaders( + $this->fixtures->get('customer')->getEmail() + ); + + $this->graphQlMutation( + $this->getBillingAddressMutationUseForShipping($maskedQuoteId), + [], + '', + $headerMap + ); + + $this->assertSameAsBillingField( + $this->graphQlQuery( + $this->getQuery($maskedQuoteId), + [], + '', + $headerMap + ), + true + ); + } + + /** + * @throws Exception + */ + #[ + DataFixture(ProductFixture::class, as: 'product'), + DataFixture(CustomerFixture::class, as: 'customer'), + DataFixture(CustomerCartFixture::class, ['customer_id' => '$customer.id$'], as: 'quote'), + DataFixture(AddProductToCartFixture::class, ['cart_id' => '$quote.id$', 'product_id' => '$product.id$']), + DataFixture(QuoteIdMask::class, ['cart_id' => '$quote.id$'], 'quoteIdMask'), + DataFixture(SetBillingAddressFixture::class, ['cart_id' => '$quote.id$']), + DataFixture(SetShippingAddressFixture::class, ['cart_id' => '$quote.id$']), + ] + public function testShippingAndBillingAddressIsDifferent(): void + { + $this->assertSameAsBillingField( + $this->graphQlQuery( + $this->getQuery($this->fixtures->get('quoteIdMask')->getMaskedId()), + [], + '', + $this->getCustomerAuthHeaders($this->fixtures->get('customer')->getEmail()) + ), + false + ); + } + + /** + * Asserts the same_as_billing field in cart.shipping_addresses + * + * @param array $response + * @param bool $sameAsBilling + * @return void + */ + private function assertSameAsBillingField(array $response, bool $sameAsBilling): void + { + self::assertEquals( + [ + 'cart' => [ + 'shipping_addresses' => [ + 0 => [ + 'same_as_billing' => $sameAsBilling + ] + ] + ] + ], + $response + ); + } + + /** + * Returns GraphQl mutation for (setBillingAddressOnCart) with same_as_shipping: true + * + * @param string $maskedQuoteId + * @return string + */ + private function getBillingAddressMutationSameAsShipping(string $maskedQuoteId): string + { + return << 'Bearer ' . + $this->customerTokenService->createCustomerAccessToken($customerEmail, 'password') + ]; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/GuestOrderByTokenTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/GuestOrderByTokenTest.php index 7a9fd10d150..d8ddb42e1e9 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/GuestOrderByTokenTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/GuestOrderByTokenTest.php @@ -137,7 +137,7 @@ public function testCustomerOrder(): void '%token' => Bootstrap::getObjectManager()->get(Token::class)->encrypt( $order->getIncrementId(), $order->getBillingAddress()->getEmail(), - $order->getBillingAddress()->getPostcode() + $order->getBillingAddress()->getLastname() ) ] ); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/GuestOrderTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/GuestOrderTest.php index fa21f95675e..baf9c82428f 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/GuestOrderTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/GuestOrderTest.php @@ -33,7 +33,7 @@ class GuestOrderTest extends GraphQlAbstract guestOrder(input: { number: "%number", email: "%email", - postcode: "%postcode" + lastname: "%lastname" }) { number email @@ -65,7 +65,7 @@ public function testGuestOrder(): void [ '%number' => $order->getIncrementId(), '%email' => $order->getBillingAddress()->getEmail(), - '%postcode' => $order->getBillingAddress()->getPostcode(), + '%lastname' => $order->getBillingAddress()->getLastname(), ] ); $response = $this->graphQlQuery($query); @@ -106,7 +106,7 @@ public function testCustomerOrder(): void [ '%number' => $order->getIncrementId(), '%email' => $order->getBillingAddress()->getEmail(), - '%postcode' => $order->getBillingAddress()->getPostcode(), + '%lastname' => $order->getBillingAddress()->getLastname(), ] ); $this->graphQlQuery($query); @@ -133,7 +133,7 @@ public function testGuestOrderIncorrectEmail(): void [ '%number' => $order->getIncrementId(), '%email' => 'incorrect' . $order->getBillingAddress()->getEmail(), - '%postcode' => $order->getBillingAddress()->getPostcode(), + '%lastname' => $order->getBillingAddress()->getLastname(), ] ); $this->graphQlQuery($query); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderDetailsWithCommentsTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderDetailsWithCommentsTest.php new file mode 100644 index 00000000000..fff3219e1fe --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderDetailsWithCommentsTest.php @@ -0,0 +1,180 @@ +customerTokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class); + $this->fixtures = Bootstrap::getObjectManager()->get(DataFixtureStorageManager::class)->getStorage(); + } + + /** + * Test customerOrders query with order comments + * + * @throws AuthenticationException + * @throws Exception + */ + #[ + DataFixture(ProductFixture::class, as: 'product'), + DataFixture(CustomerFixture::class, as: 'customer'), + DataFixture(CustomerCartFixture::class, ['customer_id' => '$customer.id$'], as: 'quote'), + DataFixture(AddProductToCartFixture::class, ['cart_id' => '$quote.id$', 'product_id' => '$product.id$']), + DataFixture(SetBillingAddressFixture::class, ['cart_id' => '$quote.id$']), + DataFixture(SetShippingAddressFixture::class, ['cart_id' => '$quote.id$']), + DataFixture(SetDeliveryMethodFixture::class, ['cart_id' => '$quote.id$']), + DataFixture(SetPaymentMethodFixture::class, ['cart_id' => '$quote.id$']), + DataFixture(PlaceOrderFixture::class, ['cart_id' => '$quote.id$'], as: 'order'), + Config('sales/cancellation/enabled', 1) + ] + public function testOrderCustomerComments(): void + { + $customerAuthHeaders = $this->getCustomerAuthHeaders($this->fixtures->get('customer')->getEmail()); + + // cancel order + $this->graphQlMutation( + $this->getCancelOrderMutation($this->fixtures->get('order')->getEntityId()), + [], + '', + $customerAuthHeaders + ); + + // fetch order comments + $response = $this->graphQlQuery( + $this->getCustomerOrdersQuery(), + [], + '', + $customerAuthHeaders + ); + + // validate order comments + $this->assertEquals( + $this->getOrderComments(), + $response['customer']['orders']['items'][0]['comments'] + ); + } + + /** + * To get order comments + * + * @return array + * @throws LocalizedException + */ + private function getOrderComments(): array + { + $comments = []; + foreach ($this->fixtures->get('order')->getStatusHistories() as $comment) { + if ($comment->getIsVisibleOnFront()) { + $comments[] = [ + 'message' => $comment->getComment(), + 'timestamp' => $comment->getCreatedAt() + ]; + } + } + return $comments; + } + + /** + * Get cancel order mutation + * + * @param string $orderId + * @return string + */ + private function getCancelOrderMutation(string $orderId): string + { + return <<customerTokenService->createCustomerAccessToken($email, 'password'); + return ['Authorization' => 'Bearer ' . $customerToken]; + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderItemPricesTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderItemPricesTest.php index 1c45ad725e0..beb2e5e5ceb 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderItemPricesTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderItemPricesTest.php @@ -112,7 +112,7 @@ public function testOrderItemPricesWithSpecialPriceAndTax(): void $this->getQuery( $order->getIncrementId(), $order->getBillingAddress()->getEmail(), - $order->getBillingAddress()->getPostcode() + $order->getBillingAddress()->getLastname() ) ); @@ -170,7 +170,7 @@ public function testOrderItemPricesWithoutSpecialPriceAndTax(): void $this->getQuery( $order->getIncrementId(), $order->getBillingAddress()->getEmail(), - $order->getBillingAddress()->getPostcode() + $order->getBillingAddress()->getLastname() ) ); @@ -192,17 +192,17 @@ public function testOrderItemPricesWithoutSpecialPriceAndTax(): void * * @param string $number * @param string $email - * @param string $postcode + * @param string $lastname * @return string */ - private function getQuery(string $number, string $email, string $postcode): string + private function getQuery(string $number, string $email, string $lastname): string { return <<graphQlMutation($this->getQuery( $order->getIncrementId(), $order->getBillingAddress()->getEmail(), - $order->getBillingAddress()->getPostcode() + $order->getBillingAddress()->getLastname() )); self::assertEquals( self::STATUS_MAPPER[$status], @@ -100,17 +100,17 @@ private function assertOrderStatusChangeDate(OrderInterface $order, string $stat * * @param string $number * @param string $email - * @param string $postcode + * @param string $lastname * @return string */ - private function getQuery(string $number, string $email, string $postcode): string + private function getQuery(string $number, string $email, string $lastname): string { return <<getQuery( $order['placeOrder']['order']['order_number'], self::EMAIL, - self::POSTCODE + self::LASTNAME ) ); self::assertEquals( @@ -369,7 +369,7 @@ public function testOrderItemPricesWithOutTax(): void $this->getQuery( $order['placeOrder']['order']['order_number'], self::EMAIL, - self::POSTCODE + self::LASTNAME ) ); self::assertEquals( @@ -390,17 +390,17 @@ public function testOrderItemPricesWithOutTax(): void * * @param string $number * @param string $email - * @param string $postcode + * @param string $lastname * @return string */ - private function getQuery(string $number, string $email, string $postcode): string + private function getQuery(string $number, string $email, string $lastname): string { return <<getQuery( $order->getIncrementId(), $order->getBillingAddress()->getEmail(), - $order->getBillingAddress()->getPostcode() + $order->getBillingAddress()->getLastname() ) ); @@ -247,17 +247,17 @@ public static function orderItemFixedProductTaxDataProvider(): array * * @param string $number * @param string $email - * @param string $postcode + * @param string $lastname * @return string */ - private function getQuery(string $number, string $email, string $postcode): string + private function getQuery(string $number, string $email, string $lastname): string { return << [ 'store' => 'CREATE TABLE `store` ( - `store_owner` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT \'Store Owner Name\' + `store_owner` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT \'Store Owner Name\' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci' ] ]; diff --git a/lib/internal/Magento/Framework/Setup/Declaration/Schema/Db/MySQL/DbSchemaWriter.php b/lib/internal/Magento/Framework/Setup/Declaration/Schema/Db/MySQL/DbSchemaWriter.php index ead0de2d667..58cea08d6a0 100644 --- a/lib/internal/Magento/Framework/Setup/Declaration/Schema/Db/MySQL/DbSchemaWriter.php +++ b/lib/internal/Magento/Framework/Setup/Declaration/Schema/Db/MySQL/DbSchemaWriter.php @@ -77,7 +77,7 @@ class DbSchemaWriter implements DbSchemaWriterInterface */ private DtoFactoriesTable $columnConfig; - private const COLUMN_TYPE = ['varchar', 'char', 'text', 'mediumtext', 'longtext']; + private const TEXTUAL_COLUMN_TYPES = ['varchar', 'char', 'text', 'mediumtext', 'longtext']; /** * @param ResourceConnection $resourceConnection @@ -110,7 +110,7 @@ public function createTable($tableName, $resource, array $definition, array $opt { if (count($definition)) { foreach ($definition as $index => $value) { - if ($this->isColumnExists($value, self::COLUMN_TYPE)) { + if ($this->isColumnTypes($value, self::TEXTUAL_COLUMN_TYPES)) { if (str_contains($index, 'column')) { $definition[$index] = $this->setDefaultCharsetAndCollation($value); } @@ -192,6 +192,11 @@ private function getDropElementSQL($type, $name) public function addElement($elementName, $resource, $tableName, $elementDefinition, $elementType) { $addElementSyntax = $elementType === Column::TYPE ? 'ADD COLUMN %s' : 'ADD %s'; + if ($elementType === Column::TYPE) { + if ($this->isColumnTypes($elementDefinition, self::TEXTUAL_COLUMN_TYPES)) { + $elementDefinition = $this->setDefaultCharsetAndCollation($elementDefinition); + } + } $sql = sprintf( $addElementSyntax, $elementDefinition @@ -237,7 +242,7 @@ public function modifyTableOption($tableName, $resource, $optionName, $optionVal */ public function modifyColumn($columnName, $resource, $tableName, $columnDefinition) { - if ($this->isColumnExists($columnDefinition, self::COLUMN_TYPE)) { + if ($this->isColumnTypes($columnDefinition, self::TEXTUAL_COLUMN_TYPES)) { $columnDefinition = $this->setDefaultCharsetAndCollation($columnDefinition); } @@ -499,16 +504,16 @@ private function setDefaultCharsetAndCollation(string $columnDefinition): string } /** - * Checks if any column of type varchar,char or text (mediumtext/longtext) + * Checks if any column is of the types passed in $columnTypes * * @param string $definition - * @param array $columntypes + * @param array $columnTypes * @return bool */ - private function isColumnExists(string $definition, array $columntypes): bool + private function isColumnTypes(string $definition, array $columnTypes): bool { $type = explode(' ', $definition); - $pattern = '/\b(' . implode('|', array_map('preg_quote', $columntypes)) . ')\b/i'; + $pattern = '/\b(' . implode('|', array_map('preg_quote', $columnTypes)) . ')\b/i'; return preg_match($pattern, $type[1]) === 1; } }