From 894f9a31e822d57f3158e7fcd9c15b0fe64d9d12 Mon Sep 17 00:00:00 2001 From: Rafal Janicki Date: Thu, 17 Oct 2024 13:33:23 +0100 Subject: [PATCH 01/12] LYNX-581: Revert cookie-related properties from private to protected --- lib/internal/Magento/Framework/App/PageCache/Version.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/internal/Magento/Framework/App/PageCache/Version.php b/lib/internal/Magento/Framework/App/PageCache/Version.php index 47ada94e2bd..0cc40217f14 100644 --- a/lib/internal/Magento/Framework/App/PageCache/Version.php +++ b/lib/internal/Magento/Framework/App/PageCache/Version.php @@ -40,10 +40,10 @@ class Version * @param ScopeConfigInterface $scopeConfig */ public function __construct( - private readonly CookieManagerInterface $cookieManager, - private readonly CookieMetadataFactory $cookieMetadataFactory, - private readonly Http $request, - private readonly ScopeConfigInterface $scopeConfig + protected readonly CookieManagerInterface $cookieManager, + protected readonly CookieMetadataFactory $cookieMetadataFactory, + protected readonly Http $request, + protected readonly ScopeConfigInterface $scopeConfig ) { } From 0aefc4bf50d1d22e40786c41fbc5258be3cefc20 Mon Sep 17 00:00:00 2001 From: Abhishek Pathak <107833467+wip44850@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:12:41 +0530 Subject: [PATCH 02/12] LYNX-541: Introduce InsufficientStockError type --- app/code/Magento/Quote/Api/ErrorInterface.php | 46 +++++++ .../Quote/Model/Cart/AddProductsToCart.php | 115 ++++++++-------- .../Model/Cart/AddProductsToCartError.php | 43 +++--- .../Magento/Quote/Model/Cart/Data/Error.php | 45 +++---- .../Cart/Data/InsufficientStockError.php | 56 ++++++++ app/code/Magento/Quote/etc/di.xml | 17 +++ .../QuoteGraphQl/Model/AddProductsToCart.php | 100 ++++++++++++++ .../Cart/ValidateProductCartResolver.php | 41 ++++++ .../Model/Resolver/AddProductsToCart.php | 90 ++----------- .../Model/Resolver/CartErrorTypeResolver.php | 45 +++++++ .../Magento/QuoteGraphQl/etc/schema.graphqls | 11 +- .../Quote/InsufficientStockErrorTest.php | 123 ++++++++++++++++++ 12 files changed, 539 insertions(+), 193 deletions(-) create mode 100644 app/code/Magento/Quote/Api/ErrorInterface.php create mode 100644 app/code/Magento/Quote/Model/Cart/Data/InsufficientStockError.php create mode 100644 app/code/Magento/QuoteGraphQl/Model/AddProductsToCart.php create mode 100644 app/code/Magento/QuoteGraphQl/Model/Cart/ValidateProductCartResolver.php create mode 100644 app/code/Magento/QuoteGraphQl/Model/Resolver/CartErrorTypeResolver.php create mode 100644 dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/InsufficientStockErrorTest.php diff --git a/app/code/Magento/Quote/Api/ErrorInterface.php b/app/code/Magento/Quote/Api/ErrorInterface.php new file mode 100644 index 00000000000..3e93c2e7108 --- /dev/null +++ b/app/code/Magento/Quote/Api/ErrorInterface.php @@ -0,0 +1,46 @@ +cartRepository = $cartRepository; - $this->maskedQuoteIdToQuoteId = $maskedQuoteIdToQuoteId; - $this->requestBuilder = $requestBuilder; - $this->productReader = $productReader; - $this->error = $addProductsToCartError; } /** @@ -128,7 +101,16 @@ function ($item) { ); $this->productReader->loadProducts($skus, $cart->getStoreId()); foreach ($cartItems as $cartItemPosition => $cartItem) { - $errors = $this->addItemToCart($cart, $cartItem, $cartItemPosition); + $product = $this->productReader->getProductBySku($cartItem->getSku()); + $stockItemQuantity = 0.0; + if ($product) { + $stockItem = $this->stockRegistry->getStockItem( + $product->getId(), + $cart->getStore()->getWebsiteId() + ); + $stockItemQuantity = $stockItem->getQty() - $stockItem->getMinQty(); + } + $errors = $this->addItemToCart($cart, $cartItem, $cartItemPosition, $stockItemQuantity); if ($errors) { $failedCartItems[$cartItemPosition] = $errors; } @@ -143,10 +125,15 @@ function ($item) { * @param Quote $cart * @param Data\CartItem $cartItem * @param int $cartItemPosition + * @param float $stockItemQuantity * @return array */ - private function addItemToCart(Quote $cart, Data\CartItem $cartItem, int $cartItemPosition): array - { + private function addItemToCart( + Quote $cart, + Data\CartItem $cartItem, + int $cartItemPosition, + float $stockItemQuantity + ): array { $sku = $cartItem->getSku(); $errors = []; $result = null; @@ -154,31 +141,37 @@ private function addItemToCart(Quote $cart, Data\CartItem $cartItem, int $cartIt if ($cartItem->getQuantity() <= 0) { $errors[] = $this->error->create( __('The product quantity should be greater than 0')->render(), - $cartItemPosition + $cartItemPosition, + $stockItemQuantity ); - } else { - $productBySku = $this->productReader->getProductBySku($sku); - $product = isset($productBySku) ? clone $productBySku : null; - if (!$product || !$product->isSaleable() || !$product->isAvailable()) { - $errors[] = $this->error->create( + } + + $productBySku = $this->productReader->getProductBySku($sku); + $product = isset($productBySku) ? clone $productBySku : null; + + if (!$product || !$product->isSaleable() || !$product->isAvailable()) { + return [ + $this->error->create( __('Could not find a product with SKU "%sku"', ['sku' => $sku])->render(), - $cartItemPosition - ); - } else { - try { - $result = $cart->addProduct($product, $this->requestBuilder->build($cartItem)); - } catch (\Throwable $e) { - $errors[] = $this->error->create( - __($e->getMessage())->render(), - $cartItemPosition - ); - } - } + $cartItemPosition, + $stockItemQuantity + ) + ]; + } - if (is_string($result)) { - foreach (array_unique(explode("\n", $result)) as $error) { - $errors[] = $this->error->create(__($error)->render(), $cartItemPosition); - } + try { + $result = $cart->addProduct($product, $this->requestBuilder->build($cartItem)); + } catch (\Throwable $e) { + $errors[] = $this->error->create( + __($e->getMessage())->render(), + $cartItemPosition, + $stockItemQuantity + ); + } + + if (is_string($result)) { + foreach (array_unique(explode("\n", $result)) as $error) { + $errors[] = $this->error->create(__($error)->render(), $cartItemPosition, $stockItemQuantity); } } diff --git a/app/code/Magento/Quote/Model/Cart/AddProductsToCartError.php b/app/code/Magento/Quote/Model/Cart/AddProductsToCartError.php index 2b014b4366f..3bfb4bdc031 100644 --- a/app/code/Magento/Quote/Model/Cart/AddProductsToCartError.php +++ b/app/code/Magento/Quote/Model/Cart/AddProductsToCartError.php @@ -7,49 +7,42 @@ namespace Magento\Quote\Model\Cart; +use Magento\Quote\Api\ErrorInterface; + /** * Create instances of errors on adding products to cart. Identify error code based on the message */ class AddProductsToCartError { - /**#@+ - * 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. + * @param array $errorMessageCodesMapper */ - 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, - 'Not enough items for sale' => self::ERROR_INSUFFICIENT_STOCK, - 'Only %s of %s available' => self::ERROR_INSUFFICIENT_STOCK, - ]; + public function __construct( + private readonly array $errorMessageCodesMapper + ) { + } /** * Returns an error object * * @param string $message * @param int $cartItemPosition + * @param float $stockItemQuantity * @return Data\Error */ - public function create(string $message, int $cartItemPosition = 0): Data\Error - { - return new Data\Error( + public function create( + string $message, + int $cartItemPosition = 0, + float $stockItemQuantity = 0.0 + ): ErrorInterface { + + return new Data\InsufficientStockError( $message, $this->getErrorCode($message), - $cartItemPosition + $cartItemPosition, + $stockItemQuantity ); } @@ -62,7 +55,7 @@ public function create(string $message, int $cartItemPosition = 0): Data\Error private function getErrorCode(string $message): string { $message = preg_replace('/\d+/', '%s', $message); - foreach (self::MESSAGE_CODES as $codeMessage => $code) { + foreach ($this->errorMessageCodesMapper as $codeMessage => $code) { if (false !== stripos($message, $codeMessage)) { return $code; } diff --git a/app/code/Magento/Quote/Model/Cart/Data/Error.php b/app/code/Magento/Quote/Model/Cart/Data/Error.php index 42b14b06d94..bc5f3e1ef91 100644 --- a/app/code/Magento/Quote/Model/Cart/Data/Error.php +++ b/app/code/Magento/Quote/Model/Cart/Data/Error.php @@ -1,42 +1,39 @@ message = $message; - $this->code = $code; - $this->cartItemPosition = $cartItemPosition; + public function __construct( + private readonly string $message, + private readonly string $code, + private readonly int $cartItemPosition + ) { } /** diff --git a/app/code/Magento/Quote/Model/Cart/Data/InsufficientStockError.php b/app/code/Magento/Quote/Model/Cart/Data/InsufficientStockError.php new file mode 100644 index 00000000000..4f49795692b --- /dev/null +++ b/app/code/Magento/Quote/Model/Cart/Data/InsufficientStockError.php @@ -0,0 +1,56 @@ +quantity = $quantity; + parent::__construct($message, $code, $cartItemPosition); + } + + /** + * Get Stock quantity + * + * @return float + */ + public function getQuantity(): float + { + return $this->quantity; + } +} diff --git a/app/code/Magento/Quote/etc/di.xml b/app/code/Magento/Quote/etc/di.xml index 04be517537b..816500f6bc7 100644 --- a/app/code/Magento/Quote/etc/di.xml +++ b/app/code/Magento/Quote/etc/di.xml @@ -166,4 +166,21 @@ Magento\Quote\Model\QuoteIdMutex + + + + + PRODUCT_NOT_FOUND + NOT_SALABLE + NOT_SALABLE + INSUFFICIENT_STOCK + NOT_SALABLE + INSUFFICIENT_STOCK + INSUFFICIENT_STOCK + INSUFFICIENT_STOCK + INSUFFICIENT_STOCK + INSUFFICIENT_STOCK + + + diff --git a/app/code/Magento/QuoteGraphQl/Model/AddProductsToCart.php b/app/code/Magento/QuoteGraphQl/Model/AddProductsToCart.php new file mode 100644 index 00000000000..4add0607805 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/AddProductsToCart.php @@ -0,0 +1,100 @@ +getExtensionAttributes()->getStore()->getId(); + + // Shopping Cart validation + $this->getCartForUser->execute($maskedCartId, $context->getUserId(), $storeId); + $cartItemsData = $this->cartItemPrecursor->process($cartItemsData, $context); + $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 (ErrorInterface $error) { + return [ + 'code' => $error->getCode(), + 'message' => $error->getMessage(), + 'path' => [$error->getCartItemPosition()], + 'quantity' => $this->isStockItemMessageEnabled() ? $error->getQuantity() : null + ]; + }, + array_merge($addProductsToCartOutput->getErrors(), $this->cartItemPrecursor->getErrors()) + ) + ]; + } + + /** + * Check inventory option available message + * + * @return bool + */ + private function isStockItemMessageEnabled(): bool + { + return (int) $this->scopeConfig->getValue('cataloginventory/options/not_available_message') === 1; + } +} diff --git a/app/code/Magento/QuoteGraphQl/Model/Cart/ValidateProductCartResolver.php b/app/code/Magento/QuoteGraphQl/Model/Cart/ValidateProductCartResolver.php new file mode 100644 index 00000000000..de3ec7d2748 --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/Cart/ValidateProductCartResolver.php @@ -0,0 +1,41 @@ +getCartForUser = $getCartForUser; - $this->addProductsToCartService = $addProductsToCart; - $this->quoteMutex = $quoteMutex; - $this->cartItemPrecursor = $cartItemPrecursor ?: ObjectManager::getInstance()->get(PrecursorInterface::class); } /** @@ -76,14 +40,7 @@ public function __construct( */ 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')); - } - + $this->validateCartResolver->execute($args); return $this->quoteMutex->execute( [$args['cartId']], \Closure::fromCallable([$this, 'run']), @@ -102,35 +59,6 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value */ private function run($context, ?array $args): array { - $maskedCartId = $args['cartId']; - $cartItemsData = $args['cartItems']; - $storeId = (int)$context->getExtensionAttributes()->getStore()->getId(); - - // Shopping Cart validation - $this->getCartForUser->execute($maskedCartId, $context->getUserId(), $storeId); - $cartItemsData = $this->cartItemPrecursor->process($cartItemsData, $context); - $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()] - ]; - }, - array_merge($addProductsToCartOutput->getErrors(), $this->cartItemPrecursor->getErrors()) - ) - ]; + return $this->addProductsToCartService->execute($context, $args); } } diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/CartErrorTypeResolver.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartErrorTypeResolver.php new file mode 100644 index 00000000000..9e8cded85df --- /dev/null +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/CartErrorTypeResolver.php @@ -0,0 +1,45 @@ + 0) { + return "InsufficientStockError"; + } + + return $errorType; + } +} diff --git a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls index 8764b4c2c2d..b6e71541dbf 100644 --- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/QuoteGraphQl/etc/schema.graphqls @@ -487,14 +487,21 @@ type Order @doc(description: "Contains the order ID.") { order_id: String @deprecated(reason: "Use `order_number` instead.") } -type CartUserInputError @doc(description:"An error encountered while adding an item to the the cart.") { +interface Error @typeResolver(class: "\\Magento\\QuoteGraphQl\\Model\\Resolver\\CartErrorTypeResolver") @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: "A cart-specific error code.") } +type CartUserInputError implements Error { +} + +type InsufficientStockError implements Error { + quantity: Float @doc(description: "Amount of available stock") +} + type AddProductsToCartOutput @doc(description: "Contains details about the cart after adding products to it.") { cart: Cart! @doc(description: "The cart after products have been added.") - user_errors: [CartUserInputError!]! @doc(description: "Contains errors encountered while adding an item to the cart.") + user_errors: [Error!]! @doc(description: "Contains errors encountered while adding an item to the cart.") } enum CartUserInputErrorType { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/InsufficientStockErrorTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/InsufficientStockErrorTest.php new file mode 100644 index 00000000000..2a4f9e5fdab --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/InsufficientStockErrorTest.php @@ -0,0 +1,123 @@ + self::SKU, 'price' => 100.00], as: 'product'), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture(AddProductToCart::class, ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 99]), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask') + ] + public function testInsufficientStockError(): void + { + $maskedQuoteId = DataFixtureStorageManager::getStorage()->get('quoteIdMask')->getMaskedId(); + $query = $this->mutationAddProduct($maskedQuoteId, self::SKU, 200); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + + $this->assertEquals( + $responseDataObject->getData('addProductsToCart/user_errors/0/__typename'), + 'InsufficientStockError' + ); + + $this->assertEquals( + $responseDataObject->getData('addProductsToCart/user_errors/0/quantity'), + 100 + ); + } + + #[ + Config('cataloginventory/options/not_available_message', 0), + DataFixture(ProductFixture::class, ['sku' => self::SKU, 'price' => 100.00], as: 'product'), + DataFixture(GuestCartFixture::class, as: 'cart'), + DataFixture(AddProductToCart::class, ['cart_id' => '$cart.id$', 'product_id' => '$product.id$', 'qty' => 99]), + DataFixture(QuoteMaskFixture::class, ['cart_id' => '$cart.id$'], 'quoteIdMask') + ] + public function testCartUserInputError(): void + { + $maskedQuoteId = DataFixtureStorageManager::getStorage()->get('quoteIdMask')->getMaskedId(); + $query = $this->mutationAddProduct($maskedQuoteId, self::SKU, 200); + $response = $this->graphQlMutation($query); + $responseDataObject = new DataObject($response); + + $this->assertEquals( + $responseDataObject->getData('addProductsToCart/user_errors/0/__typename'), + 'CartUserInputError' + ); + + $this->assertArrayNotHasKey( + 'quantity', + $responseDataObject->getData('addProductsToCart/user_errors') + ); + } + + private function mutationAddProduct(string $cartId, string $sku, int $qty = 1): string + { + return << Date: Fri, 18 Oct 2024 17:33:20 +0530 Subject: [PATCH 03/12] LYNX-551: Add order status date tracking and API coverage --- .../Model/InsertOrderStatusChangeHistory.php | 38 ++++++ .../SalesOrderStatusChangeHistory.php | 101 +++++++++++++++ .../Observer/StoreStatusChangeObserver.php | 45 +++++++ app/code/Magento/Sales/etc/db_schema.xml | 16 +++ .../Sales/etc/db_schema_whitelist.json | 13 ++ app/code/Magento/Sales/etc/events.xml | 3 + .../Model/Resolver/OrderStatusChangeDate.php | 48 +++++++ .../Magento/SalesGraphQl/etc/schema.graphqls | 1 + .../Sales/OrderStatusChangeDateTest.php | 122 ++++++++++++++++++ 9 files changed, 387 insertions(+) create mode 100644 app/code/Magento/Sales/Model/InsertOrderStatusChangeHistory.php create mode 100644 app/code/Magento/Sales/Model/ResourceModel/SalesOrderStatusChangeHistory.php create mode 100644 app/code/Magento/Sales/Observer/StoreStatusChangeObserver.php create mode 100644 app/code/Magento/SalesGraphQl/Model/Resolver/OrderStatusChangeDate.php create mode 100644 dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderStatusChangeDateTest.php diff --git a/app/code/Magento/Sales/Model/InsertOrderStatusChangeHistory.php b/app/code/Magento/Sales/Model/InsertOrderStatusChangeHistory.php new file mode 100644 index 00000000000..8a3b03b9e3f --- /dev/null +++ b/app/code/Magento/Sales/Model/InsertOrderStatusChangeHistory.php @@ -0,0 +1,38 @@ +salesOrderStatusChangeHistoryResourceModel->getLatestStatus((int)$order->getId()); + if ((!$latestStatus && $order->getStatus()) || + (isset($latestStatus['status']) && $latestStatus['status'] !== $order->getStatus()) + ) { + $this->salesOrderStatusChangeHistoryResourceModel->insert($order); + } + } +} diff --git a/app/code/Magento/Sales/Model/ResourceModel/SalesOrderStatusChangeHistory.php b/app/code/Magento/Sales/Model/ResourceModel/SalesOrderStatusChangeHistory.php new file mode 100644 index 00000000000..821de316be7 --- /dev/null +++ b/app/code/Magento/Sales/Model/ResourceModel/SalesOrderStatusChangeHistory.php @@ -0,0 +1,101 @@ +resourceConnection->getConnection(); + return $connection->fetchRow( + $connection->select()->from( + $connection->getTableName(self::TABLE_NAME), + ['status', 'created_at'] + )->where( + 'order_id = ?', + $orderId + )->order('created_at DESC') + ) ?: null; + } + + /** + * Insert updated status against an order into the table + * + * @param Order $order + * @return void + */ + public function insert(Order $order): void + { + if (!$this->isOrderExists((int)$order->getId()) || $order->getStatus() === null) { + return; + } + + $connection = $this->resourceConnection->getConnection(); + $connection->insert( + $connection->getTableName(self::TABLE_NAME), + [ + 'order_id' => (int)$order->getId(), + 'status' => $order->getStatus() + ] + ); + } + + /** + * Check if order exists in db or is deleted + * + * @param int $orderId + * @return bool + */ + private function isOrderExists(int $orderId): bool + { + $connection = $this->resourceConnection->getConnection(); + $entityId = $connection->fetchOne( + $connection->select()->from( + $connection->getTableName(self::ORDER_TABLE_NAME), + ['entity_id'] + )->where( + 'entity_id = ?', + $orderId + ) + ); + return (int) $entityId === $orderId; + } +} diff --git a/app/code/Magento/Sales/Observer/StoreStatusChangeObserver.php b/app/code/Magento/Sales/Observer/StoreStatusChangeObserver.php new file mode 100644 index 00000000000..984f4b7523c --- /dev/null +++ b/app/code/Magento/Sales/Observer/StoreStatusChangeObserver.php @@ -0,0 +1,45 @@ +getEvent()->getOrder(); + + if (!$order->getId()) { + //order not saved in the database + return $this; + } + + //Insert order status into sales_order_status_change_history table if the order status is changed + $this->salesOrderStatusChangeHistory->execute($order); + return $this; + } +} diff --git a/app/code/Magento/Sales/etc/db_schema.xml b/app/code/Magento/Sales/etc/db_schema.xml index c01354500eb..a5f0a5f7af9 100644 --- a/app/code/Magento/Sales/etc/db_schema.xml +++ b/app/code/Magento/Sales/etc/db_schema.xml @@ -2068,4 +2068,20 @@ + + + + + + + + + +
diff --git a/app/code/Magento/Sales/etc/db_schema_whitelist.json b/app/code/Magento/Sales/etc/db_schema_whitelist.json index 664c65d36c3..6670f1e3866 100644 --- a/app/code/Magento/Sales/etc/db_schema_whitelist.json +++ b/app/code/Magento/Sales/etc/db_schema_whitelist.json @@ -1246,5 +1246,18 @@ "SALES_ORDER_STATUS_LABEL_STATUS_SALES_ORDER_STATUS_STATUS": true, "SALES_ORDER_STATUS_LABEL_STORE_ID_STORE_STORE_ID": true } + }, + "sales_order_status_change_history": { + "column": { + "entity_id": true, + "order_id": true, + "status": true, + "created_at": true, + "updated_at": true + }, + "constraint": { + "PRIMARY": true, + "SALES_ORDER_STATUS_CHANGE_HISTORY_ORDER_ID_SALES_ORDER_ENTITY_ID": true + } } } diff --git a/app/code/Magento/Sales/etc/events.xml b/app/code/Magento/Sales/etc/events.xml index b3a7a4ab995..f95b9dda6db 100644 --- a/app/code/Magento/Sales/etc/events.xml +++ b/app/code/Magento/Sales/etc/events.xml @@ -56,4 +56,7 @@ name="sales_assign_order_to_customer" instance="Magento\Sales\Observer\AssignOrderToCustomerObserver" /> + + + diff --git a/app/code/Magento/SalesGraphQl/Model/Resolver/OrderStatusChangeDate.php b/app/code/Magento/SalesGraphQl/Model/Resolver/OrderStatusChangeDate.php new file mode 100644 index 00000000000..eba8c4ec9f8 --- /dev/null +++ b/app/code/Magento/SalesGraphQl/Model/Resolver/OrderStatusChangeDate.php @@ -0,0 +1,48 @@ +salesOrderStatusChangeHistory->getLatestStatus((int)$order->getId()); + return ($latestStatus) + ? $this->localeDate->convertConfigTimeToUtc($latestStatus['created_at'], DateTime::DATE_PHP_FORMAT) + : ''; + } +} diff --git a/app/code/Magento/SalesGraphQl/etc/schema.graphqls b/app/code/Magento/SalesGraphQl/etc/schema.graphqls index e1559dc18ae..0cd798f9984 100644 --- a/app/code/Magento/SalesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SalesGraphQl/etc/schema.graphqls @@ -82,6 +82,7 @@ type CustomerOrder @doc(description: "Contains details about each of the custome is_virtual: Boolean! @doc(description: "`TRUE` if the order is virtual") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\OrderIsVirtual") available_actions: [OrderActionType!]! @doc(description: "List of available order actions.") @resolver(class: "\\Magento\\SalesGraphQl\\Model\\Resolver\\OrderAvailableActions") customer_info: OrderCustomerInfo! @doc(description: "Returns customer information from order.") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\OrderCustomerInfo") + order_status_change_date: String! @doc(description: "The date the order status was last updated.") @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\OrderStatusChangeDate") } type OrderCustomerInfo { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderStatusChangeDateTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderStatusChangeDateTest.php new file mode 100644 index 00000000000..beb1dc92b7a --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/OrderStatusChangeDateTest.php @@ -0,0 +1,122 @@ + '$cart.id$', 'product_id' => '$product.id$']), + DataFixture(SetBillingAddressFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetShippingAddressFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetGuestEmailFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetDeliveryMethodFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetPaymentMethodFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(PlaceOrderFixture::class, ['cart_id' => '$cart.id$'], 'order'), +] +class OrderStatusChangeDateTest extends GraphQlAbstract +{ + /** + * Order status mapper + */ + private const STATUS_MAPPER = [ + Order::STATE_HOLDED => 'On Hold', + Order::STATE_CANCELED => 'Canceled' + ]; + + public function testOrderStatusChangeDateWithStatusChange(): void + { + /** + * @var $order OrderInterface + */ + $order = DataFixtureStorageManager::getStorage()->get('order'); + + $this->assertOrderStatusChangeDate($order, Order::STATE_HOLDED); + $this->assertOrderStatusChangeDate($order, Order::STATE_CANCELED); + } + + /** + * Assert order_status_change_date after setting the status + * + * @param OrderInterface $order + * @param string $status + * @return void + */ + private function assertOrderStatusChangeDate(OrderInterface $order, string $status): void + { + $orderRepo = Bootstrap::getObjectManager()->get(OrderRepository::class); + $timeZone = Bootstrap::getObjectManager()->get(TimezoneInterface::class); + + //Update order status + $order->setStatus($status); + $order->setState($status); + $orderRepo->save($order); + + $updatedGuestOrder = $this->graphQlMutation($this->getQuery( + $order->getIncrementId(), + $order->getBillingAddress()->getEmail(), + $order->getBillingAddress()->getPostcode() + )); + self::assertEquals( + self::STATUS_MAPPER[$status], + $updatedGuestOrder['guestOrder']['status'] + ); + self::assertEquals( + $timeZone->convertConfigTimeToUtc($order->getCreatedAt(), DateTime::DATE_PHP_FORMAT), + $updatedGuestOrder['guestOrder']['order_status_change_date'] + ); + } + + /** + * Generates guestOrder query with order_status_change_date + * + * @param string $number + * @param string $email + * @param string $postcode + * @return string + */ + private function getQuery(string $number, string $email, string $postcode): string + { + return << Date: Fri, 18 Oct 2024 17:44:53 +0530 Subject: [PATCH 04/12] LYNX-566 Add pagination for customer addresses --- .../Test/Fixture/CustomerWithAddresses.php | 225 ++++++++++++++++++ .../Model/Formatter/CustomerAddresses.php | 49 ++++ .../Model/Resolver/CustomerAddressesV2.php | 68 ++++++ .../Model/ValidateAddressRequest.php | 38 +++ .../CustomerGraphQl/etc/schema.graphqls | 10 + .../Customer/GetCustomerAddressesV2Test.php | 213 +++++++++++++++++ 6 files changed, 603 insertions(+) create mode 100644 app/code/Magento/Customer/Test/Fixture/CustomerWithAddresses.php create mode 100644 app/code/Magento/CustomerGraphQl/Model/Formatter/CustomerAddresses.php create mode 100644 app/code/Magento/CustomerGraphQl/Model/Resolver/CustomerAddressesV2.php create mode 100644 app/code/Magento/CustomerGraphQl/Model/ValidateAddressRequest.php create mode 100644 dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetCustomerAddressesV2Test.php diff --git a/app/code/Magento/Customer/Test/Fixture/CustomerWithAddresses.php b/app/code/Magento/Customer/Test/Fixture/CustomerWithAddresses.php new file mode 100644 index 00000000000..1739a118315 --- /dev/null +++ b/app/code/Magento/Customer/Test/Fixture/CustomerWithAddresses.php @@ -0,0 +1,225 @@ + null, + AddressInterface::CUSTOMER_ID => null, + AddressInterface::REGION => 'California', + AddressInterface::REGION_ID => '12', + AddressInterface::COUNTRY_ID => 'US', + AddressInterface::STREET => ['%street_number% Test Street%uniqid%'], + AddressInterface::COMPANY => null, + AddressInterface::TELEPHONE => '1234567893', + AddressInterface::FAX => null, + AddressInterface::POSTCODE => '02108', + AddressInterface::CITY => 'Boston', + AddressInterface::FIRSTNAME => 'Firstname%uniqid%', + AddressInterface::LASTNAME => 'Lastname%uniqid%', + AddressInterface::MIDDLENAME => null, + AddressInterface::PREFIX => null, + AddressInterface::SUFFIX => null, + AddressInterface::VAT_ID => null, + AddressInterface::DEFAULT_BILLING => true, + AddressInterface::DEFAULT_SHIPPING => true, + AddressInterface::CUSTOM_ATTRIBUTES => [], + AddressInterface::EXTENSION_ATTRIBUTES_KEY => [], + ], + [ + AddressInterface::ID => null, + AddressInterface::CUSTOMER_ID => null, + AddressInterface::REGION => 'California', + AddressInterface::REGION_ID => '12', + AddressInterface::COUNTRY_ID => 'US', + AddressInterface::STREET => ['%street_number% Sunset Boulevard%uniqid%'], + AddressInterface::COMPANY => null, + AddressInterface::TELEPHONE => '0987654321', + AddressInterface::FAX => null, + AddressInterface::POSTCODE => '90001', + AddressInterface::CITY => 'Los Angeles', + AddressInterface::FIRSTNAME => 'Firstname%uniqid%', + AddressInterface::LASTNAME => 'Lastname%uniqid%', + AddressInterface::MIDDLENAME => null, + AddressInterface::PREFIX => null, + AddressInterface::SUFFIX => null, + AddressInterface::VAT_ID => null, + AddressInterface::DEFAULT_BILLING => false, + AddressInterface::DEFAULT_SHIPPING => false, + AddressInterface::CUSTOM_ATTRIBUTES => [], + AddressInterface::EXTENSION_ATTRIBUTES_KEY => [], + ], + [ + AddressInterface::ID => null, + AddressInterface::CUSTOMER_ID => null, + AddressInterface::REGION => 'New York', + AddressInterface::REGION_ID => '43', + AddressInterface::COUNTRY_ID => 'US', + AddressInterface::STREET => ['%street_number% 5th Avenue%uniqid%'], + AddressInterface::COMPANY => null, + AddressInterface::TELEPHONE => '1112223333', + AddressInterface::FAX => null, + AddressInterface::POSTCODE => '10001', + AddressInterface::CITY => 'New York City', + AddressInterface::FIRSTNAME => 'Firstname%uniqid%', + AddressInterface::LASTNAME => 'Lastname%uniqid%', + AddressInterface::MIDDLENAME => null, + AddressInterface::PREFIX => null, + AddressInterface::SUFFIX => null, + AddressInterface::VAT_ID => null, + AddressInterface::DEFAULT_BILLING => false, + AddressInterface::DEFAULT_SHIPPING => false, + AddressInterface::CUSTOM_ATTRIBUTES => [], + AddressInterface::EXTENSION_ATTRIBUTES_KEY => [], + ] + ]; + + private const DEFAULT_DATA = [ + 'password' => 'password', + CustomerInterface::ID => null, + CustomerInterface::CREATED_AT => null, + CustomerInterface::CONFIRMATION => null, + CustomerInterface::UPDATED_AT => null, + CustomerInterface::DOB => null, + CustomerInterface::CREATED_IN => null, + CustomerInterface::EMAIL => 'customer%uniqid%@mail.com', + CustomerInterface::FIRSTNAME => 'Firstname%uniqid%', + CustomerInterface::GROUP_ID => null, + CustomerInterface::GENDER => null, + CustomerInterface::LASTNAME => 'Lastname%uniqid%', + CustomerInterface::MIDDLENAME => null, + CustomerInterface::PREFIX => null, + CustomerInterface::SUFFIX => null, + CustomerInterface::STORE_ID => null, + CustomerInterface::TAXVAT => null, + CustomerInterface::WEBSITE_ID => null, + CustomerInterface::DEFAULT_SHIPPING => null, + CustomerInterface::DEFAULT_BILLING => null, + CustomerInterface::DISABLE_AUTO_GROUP_CHANGE => null, + CustomerInterface::KEY_ADDRESSES => [], + CustomerInterface::CUSTOM_ATTRIBUTES => [], + CustomerInterface::EXTENSION_ATTRIBUTES_KEY => [], + ]; + + /** + * CustomerWithAddresses Constructor + * + * @param ServiceFactory $serviceFactory + * @param AccountManagementInterface $accountManagement + * @param CustomerRegistry $customerRegistry + * @param ProcessorInterface $dataProcessor + * @param DataMerger $dataMerger + */ + public function __construct( + private readonly ServiceFactory $serviceFactory, + private readonly AccountManagementInterface $accountManagement, + private readonly CustomerRegistry $customerRegistry, + private readonly DataMerger $dataMerger, + private readonly ProcessorInterface $dataProcessor + ) { + } + + /** + * Apply the changes for the fixture + * + * @param array $data + * @return DataObject|null + * @throws NoSuchEntityException + */ + public function apply(array $data = []): ?DataObject + { + $customerSave = $this->serviceFactory->create(CustomerRepositoryInterface::class, 'save'); + $data = $this->prepareCustomerData($data); + $passwordHash = $this->accountManagement->getPasswordHash($data['password']); + unset($data['password']); + + $customerSave->execute( + [ + 'customer' => $data, + 'passwordHash' => $passwordHash + ] + ); + + return $this->customerRegistry->retrieveByEmail($data['email'], $data['website_id']); + } + + /** + * Revert the test customer creation + * + * @param DataObject $data + * @return void + */ + public function revert(DataObject $data): void + { + $data->setCustomerId($data->getId()); + $customerService = $this->serviceFactory->create(CustomerRepositoryInterface::class, 'deleteById'); + + $customerService->execute( + [ + 'customerId' => $data->getId() + ] + ); + } + + /** + * Prepare customer's data + * + * @param array $data + * @return array + */ + private function prepareCustomerData(array $data): array + { + $data = $this->dataMerger->merge(self::DEFAULT_DATA, $data); + $data[CustomerInterface::KEY_ADDRESSES] = $this->prepareAddresses($data[CustomerInterface::KEY_ADDRESSES]); + + return $this->dataProcessor->process($this, $data); + } + + /** + * Prepare customer's addresses + * + * @param array $data + * @return array + */ + private function prepareAddresses(array $data): array + { + $addressesData = []; + $default = self::DEFAULT_DATA_ADDRESS; + $streetNumber = 123; + foreach ($default as $address) { + if ($data) { + $address = $this->dataMerger->merge($default, $address); + } + $placeholders = ['%street_number%' => $streetNumber++]; + $address[AddressInterface::STREET] = array_map( + fn ($str) => strtr($str, $placeholders), + $address[AddressInterface::STREET] + ); + $addressesData[] = $address; + } + + return $addressesData; + } +} diff --git a/app/code/Magento/CustomerGraphQl/Model/Formatter/CustomerAddresses.php b/app/code/Magento/CustomerGraphQl/Model/Formatter/CustomerAddresses.php new file mode 100644 index 00000000000..7c3a0b91dad --- /dev/null +++ b/app/code/Magento/CustomerGraphQl/Model/Formatter/CustomerAddresses.php @@ -0,0 +1,49 @@ +getItems() as $address) { + $addressArray[] = $this->extractCustomerAddressData->execute($address); + } + + return [ + 'total_count' => $searchResult->getTotalCount(), + 'items' => $addressArray, + 'page_info' => [ + 'page_size' => $searchResult->getSearchCriteria()->getPageSize(), + 'current_page' => $searchResult->getSearchCriteria()->getCurrentPage(), + 'total_pages' => (int)ceil($searchResult->getTotalCount() + / (int)$searchResult->getSearchCriteria()->getPageSize()), + ] + ]; + } +} diff --git a/app/code/Magento/CustomerGraphQl/Model/Resolver/CustomerAddressesV2.php b/app/code/Magento/CustomerGraphQl/Model/Resolver/CustomerAddressesV2.php new file mode 100644 index 00000000000..b7f54ec60a6 --- /dev/null +++ b/app/code/Magento/CustomerGraphQl/Model/Resolver/CustomerAddressesV2.php @@ -0,0 +1,68 @@ +validateAddressRequest->execute($value, $args); + + /** @var Customer $customer */ + $customer = $value['model']; + + try { + $this->searchCriteriaBuilder->addFilter('parent_id', (int)$customer->getId()); + $this->searchCriteriaBuilder->setCurrentPage($args['currentPage']); + $this->searchCriteriaBuilder->setPageSize($args['pageSize']); + $searchResult = $this->addressRepository->getList($this->searchCriteriaBuilder->create()); + } catch (InputException $e) { + throw new GraphQlInputException(__($e->getMessage())); + } + + return $this->addressesFormatter->format($searchResult); + } +} diff --git a/app/code/Magento/CustomerGraphQl/Model/ValidateAddressRequest.php b/app/code/Magento/CustomerGraphQl/Model/ValidateAddressRequest.php new file mode 100644 index 00000000000..433da7209af --- /dev/null +++ b/app/code/Magento/CustomerGraphQl/Model/ValidateAddressRequest.php @@ -0,0 +1,38 @@ + 'customer@example.com',], 'customer') +] +class GetCustomerAddressesV2Test extends GraphQlAbstract +{ + /** + * @var CustomerTokenServiceInterface + */ + private $customerTokenService; + + /** + * Initialize fixture namespaces. + * + * @return void + */ + protected function setUp(): void + { + $this->customerTokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class); + } + + /** + * @param int $pageSize + * @param int $currentPage + * @param array $expectedResponse + * @return void + * @throws AuthenticationException + * @dataProvider dataProviderGetCustomerAddressesV2 + */ + public function testGetCustomerAddressesV2(int $pageSize, int $currentPage, array $expectedResponse) + { + $query = $this->getQuery($pageSize, $currentPage); + $response = $this->graphQlQuery( + $query, + [], + '', + $this->getCustomerAuthHeaders('customer@example.com', 'password') + ); + self::assertArrayHasKey('addressesV2', $response['customer']); + $addressesV2 = $response['customer']['addressesV2']; + self::assertNotEmpty($addressesV2); + self::assertIsArray($addressesV2); + self::assertEquals($expectedResponse['items_count'], count($addressesV2['items'])); + self::assertEquals($expectedResponse['total_count'], $addressesV2['total_count']); + self::assertEquals($expectedResponse['page_info'], $addressesV2['page_info']); + } + + public function testAddressesV2NotAuthorized() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('The current customer isn\'t authorized.'); + $query = $this->getQuery(); + $this->graphQlQuery($query); + } + + /** + * @throws AuthenticationException + * @throws Exception + */ + #[ + DataFixture(Customer::class, ['email' => 'customer2@example.com',], 'customer2') + ] + public function testAddressesV2ForCustomerWithoutAddresses() + { + $query = $this->getQuery(); + $response = $this->graphQlQuery( + $query, + [], + '', + $this->getCustomerAuthHeaders('customer2@example.com', 'password') + ); + $addressesV2 = $response['customer']['addressesV2']; + $this->assertEmpty($addressesV2['items']); + $this->assertEquals(0, $addressesV2['total_count']); + $this->assertEquals(0, $addressesV2['page_info']['total_pages']); + } + + /** + * Data provider for customer address input + * + * @return array + */ + public static function dataProviderGetCustomerAddressesV2(): array + { + return [ + 'scenario_1' => [ + 'pageSize' => 1, + 'currentPage' => 1, + 'expectedResponse' => [ + 'items_count' => 1, + 'page_info' => [ + 'page_size' => 1, + 'current_page' => 1, + 'total_pages' => 3 + ], + 'total_count' => 3 + ] + ], + 'scenario_2' => [ + 'pageSize' => 2, + 'currentPage' => 1, + 'expectedResponse' => [ + 'items_count' => 2, + 'page_info' => [ + 'page_size' => 2, + 'current_page' => 1, + 'total_pages' => 2 + ], + 'total_count' => 3 + ] + ], + 'scenario_3' => [ + 'pageSize' => 2, + 'currentPage' => 2, + 'expectedResponse' => [ + 'items_count' => 1, + 'page_info' => [ + 'page_size' => 2, + 'current_page' => 2, + 'total_pages' => 2 + ], + 'total_count' => 3 + ] + ], + 'scenario_4' => [ + 'pageSize' => 3, + 'currentPage' => 1, + 'expectedResponse' => [ + 'items_count' => 3, + 'page_info' => [ + 'page_size' => 3, + 'current_page' => 1, + 'total_pages' => 1 + ], + 'total_count' => 3 + ] + ] + ]; + } + + /** + * Get customer auth headers + * + * @param string $email + * @param string $password + * @return string[] + * @throws AuthenticationException + */ + private function getCustomerAuthHeaders(string $email, string $password): array + { + $customerToken = $this->customerTokenService->createCustomerAccessToken($email, $password); + return ['Authorization' => 'Bearer ' . $customerToken]; + } + + /** + * Get addressesV2 query + * + * @param int $pageSize + * @param int $currentPage + * @return string + */ + private function getQuery(int $pageSize = 5, int $currentPage = 1): string + { + return << Date: Mon, 21 Oct 2024 14:18:20 +0530 Subject: [PATCH 05/12] LYNX-568: Request order token for guest order cancellation --- .../Model/CancelOrderGuest.php | 11 -- ...lOrderGuest.php => ConfirmCancelOrder.php} | 7 +- .../Model/Resolver/CancelOrder.php | 60 +----- .../Model/Resolver/ConfirmCancelOrder.php | 82 ++++++++ .../Resolver/ConfirmCancelOrderGuest.php | 88 --------- .../Resolver/RequestGuestOrderCancel.php | 119 ++++++++++++ .../Validator/GuestOrder/ValidateOrder.php | 81 -------- ...Request.php => ValidateConfirmRequest.php} | 25 +-- .../Model/Validator/ValidateCustomer.php | 43 ----- .../Model/Validator/ValidateGuestRequest.php | 100 ++++++++++ .../Model/Validator/ValidateOrder.php | 11 +- .../Model/Validator/ValidateRequest.php | 12 +- .../OrderCancellationGraphQl/composer.json | 1 + .../etc/schema.graphqls | 9 +- .../CancelGuestOrderTest.php | 178 ++++++++++++------ .../ConfirmCancelGuestOrderTest.php | 43 ++++- 16 files changed, 496 insertions(+), 374 deletions(-) rename app/code/Magento/OrderCancellationGraphQl/Model/{ConfirmCancelOrderGuest.php => ConfirmCancelOrder.php} (95%) create mode 100644 app/code/Magento/OrderCancellationGraphQl/Model/Resolver/ConfirmCancelOrder.php delete mode 100644 app/code/Magento/OrderCancellationGraphQl/Model/Resolver/ConfirmCancelOrderGuest.php create mode 100644 app/code/Magento/OrderCancellationGraphQl/Model/Resolver/RequestGuestOrderCancel.php delete mode 100644 app/code/Magento/OrderCancellationGraphQl/Model/Validator/GuestOrder/ValidateOrder.php rename app/code/Magento/OrderCancellationGraphQl/Model/Validator/{GuestOrder/ValidateRequest.php => ValidateConfirmRequest.php} (75%) delete mode 100644 app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateCustomer.php create mode 100644 app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateGuestRequest.php diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php b/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php index 038e4fa74af..dd93816d18e 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php @@ -17,17 +17,12 @@ namespace Magento\OrderCancellationGraphQl\Model; use Magento\Framework\Exception\LocalizedException; -use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\OrderCancellation\Model\Email\ConfirmationKeySender; use Magento\OrderCancellation\Model\GetConfirmationKey; -use Magento\OrderCancellationGraphQl\Model\Validator\GuestOrder\ValidateRequest; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Sales\Model\Order; use Magento\SalesGraphQl\Model\Formatter\Order as OrderFormatter; -/** - * Class for Guest order cancellation - */ class CancelOrderGuest { /** @@ -35,14 +30,12 @@ class CancelOrderGuest * * @param OrderFormatter $orderFormatter * @param OrderRepositoryInterface $orderRepository - * @param ValidateRequest $validateRequest * @param ConfirmationKeySender $confirmationKeySender * @param GetConfirmationKey $confirmationKey */ public function __construct( private readonly OrderFormatter $orderFormatter, private readonly OrderRepositoryInterface $orderRepository, - private readonly ValidateRequest $validateRequest, private readonly ConfirmationKeySender $confirmationKeySender, private readonly GetConfirmationKey $confirmationKey, ) { @@ -54,13 +47,9 @@ public function __construct( * @param Order $order * @param array $input * @return array - * @throws GraphQlInputException - * @throws LocalizedException */ public function execute(Order $order, array $input): array { - $this->validateRequest->validateCancelGuestOrderInput($input); - try { // send confirmation key and order id $this->sendConfirmationKeyEmail($order, $input['reason']); diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/ConfirmCancelOrderGuest.php b/app/code/Magento/OrderCancellationGraphQl/Model/ConfirmCancelOrder.php similarity index 95% rename from app/code/Magento/OrderCancellationGraphQl/Model/ConfirmCancelOrderGuest.php rename to app/code/Magento/OrderCancellationGraphQl/Model/ConfirmCancelOrder.php index 4f9e7979ab9..e627d4fa031 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/ConfirmCancelOrderGuest.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/ConfirmCancelOrder.php @@ -19,18 +19,17 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\OrderCancellation\Model\CancelOrder as CancelOrderAction; -use Magento\OrderCancellation\Model\ResourceModel\SalesOrderConfirmCancel - as SalesOrderConfirmCancelResourceModel; +use Magento\OrderCancellation\Model\ResourceModel\SalesOrderConfirmCancel as SalesOrderConfirmCancelResourceModel; use Magento\Sales\Model\Order; use Magento\SalesGraphQl\Model\Formatter\Order as OrderFormatter; /** * Class for Guest order cancellation confirmation */ -class ConfirmCancelOrderGuest +class ConfirmCancelOrder { /** - * ConfirmCancelOrderGuest Constructor + * ConfirmCancelOrder Constructor * * @param OrderFormatter $orderFormatter * @param CancelOrderAction $cancelOrderAction diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/CancelOrder.php b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/CancelOrder.php index 86a381efc54..c1e2f320141 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/CancelOrder.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/CancelOrder.php @@ -18,15 +18,12 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Config\Element\Field; +use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException; use Magento\Framework\GraphQl\Query\ResolverInterface; use Magento\Framework\GraphQl\Schema\Type\ResolveInfo; use Magento\OrderCancellation\Model\CancelOrder as CancelOrderAction; -use Magento\OrderCancellation\Model\Config\Config; -use Magento\OrderCancellationGraphQl\Model\CancelOrderGuest; -use Magento\OrderCancellationGraphQl\Model\Validator\ValidateCustomer; use Magento\OrderCancellationGraphQl\Model\Validator\ValidateOrder; use Magento\OrderCancellationGraphQl\Model\Validator\ValidateRequest; -use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\SalesGraphQl\Model\Formatter\Order as OrderFormatter; @@ -42,20 +39,14 @@ class CancelOrder implements ResolverInterface * @param OrderFormatter $orderFormatter * @param OrderRepositoryInterface $orderRepository * @param CancelOrderAction $cancelOrderAction - * @param CancelOrderGuest $cancelOrderGuest * @param ValidateOrder $validateOrder - * @param ValidateCustomer $validateCustomer - * @param Config $config */ public function __construct( private readonly ValidateRequest $validateRequest, private readonly OrderFormatter $orderFormatter, private readonly OrderRepositoryInterface $orderRepository, private readonly CancelOrderAction $cancelOrderAction, - private readonly CancelOrderGuest $cancelOrderGuest, - private readonly ValidateOrder $validateOrder, - private readonly ValidateCustomer $validateCustomer, - private readonly Config $config + private readonly ValidateOrder $validateOrder ) { } @@ -69,12 +60,13 @@ public function resolve( array $value = null, array $args = null ) { - $this->validateRequest->execute($args['input'] ?? []); + $this->validateRequest->execute($context, $args['input'] ?? []); try { $order = $this->orderRepository->get($args['input']['order_id']); - if (!$this->isOrderCancellationEnabled($order)) { - return $this->createErrorResponse('Order cancellation is not enabled for requested store.'); + + if ((int)$order->getCustomerId() !== $context->getUserId()) { + throw new GraphQlAuthorizationException(__('Current user is not authorized to cancel this order')); } $errors = $this->validateOrder->execute($order); @@ -82,48 +74,16 @@ public function resolve( return $errors; } - if ($order->getCustomerIsGuest()) { - return $this->cancelOrderGuest->execute($order, $args['input']); - } - - $this->validateCustomer->execute($order, $context); - $order = $this->cancelOrderAction->execute($order, $args['input']['reason']); return [ 'order' => $this->orderFormatter->format($order) ]; - } catch (LocalizedException $e) { - return $this->createErrorResponse($e->getMessage()); - } - } - /** - * Create error response - * - * @param string $message - * @param OrderInterface|null $order - * @return array - * @throws LocalizedException - */ - private function createErrorResponse(string $message, OrderInterface $order = null): array - { - $response = ['error' => __($message)]; - if ($order) { - $response['order'] = $this->orderFormatter->format($order); + } catch (LocalizedException $e) { + return [ + 'error' => __($e->getMessage()) + ]; } - - return $response; - } - - /** - * Check if order cancellation is enabled in config - * - * @param OrderInterface $order - * @return bool - */ - private function isOrderCancellationEnabled(OrderInterface $order): bool - { - return $this->config->isOrderCancellationEnabledForStore((int)$order->getStoreId()); } } diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/ConfirmCancelOrder.php b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/ConfirmCancelOrder.php new file mode 100644 index 00000000000..f6a17140454 --- /dev/null +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/ConfirmCancelOrder.php @@ -0,0 +1,82 @@ +validateRequest->execute($args['input'] ?? []); + + try { + $order = $this->orderRepository->get($args['input']['order_id']); + + if (!$order->getCustomerIsGuest()) { + return [ + 'error' => __('Current user is not authorized to cancel this order') + ]; + } + + $errors = $this->validateOrder->execute($order); + if (!empty($errors)) { + return $errors; + } + + return $this->confirmCancelOrder->execute($order, $args['input']); + } catch (LocalizedException $e) { + return [ + 'error' => __($e->getMessage()) + ]; + } + } +} diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/ConfirmCancelOrderGuest.php b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/ConfirmCancelOrderGuest.php deleted file mode 100644 index d067855bbd2..00000000000 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/ConfirmCancelOrderGuest.php +++ /dev/null @@ -1,88 +0,0 @@ -validateRequest->execute($args['input'] ?? []); - - $order = $this->loadOrder((int)$args['input']['order_id']); - $errors = $this->validateOrder->execute($order); - if (!empty($errors)) { - return $errors; - } - - return $this->confirmCancelOrderGuest->execute($order, $args['input']); - } - - /** - * Load order interface from order id - * - * @param int $orderId - * @return Order - * @throws LocalizedException - */ - private function loadOrder(int $orderId): Order - { - try { - return $this->orderRepository->get($orderId); - } catch (Exception $e) { - throw new GraphQlInputException(__($e->getMessage())); - } - } -} diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/RequestGuestOrderCancel.php b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/RequestGuestOrderCancel.php new file mode 100644 index 00000000000..f1315c12390 --- /dev/null +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Resolver/RequestGuestOrderCancel.php @@ -0,0 +1,119 @@ +validateRequest->validateInput($args['input'] ?? []); + list($number, $email, $postcode) = $this->getNumberEmailPostcode($args['input']['token']); + + $order = $this->getOrder($number); + $this->validateRequest->validateOrderDetails($order, $postcode, $email); + + $errors = $this->validateOrder->execute($order); + if ($errors) { + return $errors; + } + + return $this->cancelOrderGuest->execute($order, $args['input']); + } + + /** + * Retrieve order details based on order number + * + * @param string $number + * @return OrderInterface + * @throws GraphQlNoSuchEntityException + * @throws NoSuchEntityException + */ + private function getOrder(string $number): OrderInterface + { + $searchCriteria = $this->searchCriteriaBuilderFactory->create() + ->addFilter('increment_id', $number) + ->addFilter('store_id', $this->storeManager->getStore()->getId()) + ->create(); + + $orders = $this->orderRepository->getList($searchCriteria)->getItems(); + if (empty($orders)) { + $this->validateRequest->cannotLocateOrder(); + } + + return reset($orders); + } + + /** + * Retrieve number, email and postcode from token + * + * @param string $token + * @return array + * @throws GraphQlNoSuchEntityException + */ + private function getNumberEmailPostcode(string $token): array + { + $data = $this->token->decrypt($token); + if (count($data) !== 3) { + $this->validateRequest->cannotLocateOrder(); + } + return $data; + } +} diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/GuestOrder/ValidateOrder.php b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/GuestOrder/ValidateOrder.php deleted file mode 100644 index 485cc6577ec..00000000000 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/GuestOrder/ValidateOrder.php +++ /dev/null @@ -1,81 +0,0 @@ -config->isOrderCancellationEnabledForStore((int)$order->getStoreId())) { - return [ - 'error' => __('Order cancellation is not enabled for requested store.') - ]; - } - - if (!$order->getCustomerIsGuest()) { - return [ - 'error' => __('Current user is not authorized to cancel this order') - ]; - } - - if (!$this->canCancelOrder->execute($order)) { - return [ - 'error' => __('Order already closed, complete, cancelled or on hold'), - 'order' => $this->orderFormatter->format($order) - ]; - } - - if ($order->hasShipments()) { - return [ - 'error' => __('Order with one or more items shipped cannot be cancelled'), - 'order' => $this->orderFormatter->format($order) - ]; - } - - return []; - } -} diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/GuestOrder/ValidateRequest.php b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateConfirmRequest.php similarity index 75% rename from app/code/Magento/OrderCancellationGraphQl/Model/Validator/GuestOrder/ValidateRequest.php rename to app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateConfirmRequest.php index 8a7e8cad1ff..e6012f56f7e 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/GuestOrder/ValidateRequest.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateConfirmRequest.php @@ -14,7 +14,7 @@ */ declare(strict_types=1); -namespace Magento\OrderCancellationGraphQl\Model\Validator\GuestOrder; +namespace Magento\OrderCancellationGraphQl\Model\Validator; use Magento\Framework\GraphQl\Exception\GraphQlInputException; use Magento\OrderCancellation\Model\GetConfirmationKey; @@ -22,7 +22,7 @@ /** * Ensure all conditions to cancel guest order are met */ -class ValidateRequest +class ValidateConfirmRequest { /** * Ensure the input to cancel guest order is valid @@ -64,25 +64,4 @@ public function execute(mixed $input): void ); } } - - /** - * Validate cancel guest order input - * - * @param array $input - * @return void - * @throws GraphQlInputException - */ - public function validateCancelGuestOrderInput(array $input): void - { - if (!$input['reason'] || !is_string($input['reason'])) { - throw new GraphQlInputException( - __( - 'Required parameter "%field" is missing or incorrect.', - [ - 'field' => 'reason' - ] - ) - ); - } - } } diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateCustomer.php b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateCustomer.php deleted file mode 100644 index 0bd8887b914..00000000000 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateCustomer.php +++ /dev/null @@ -1,43 +0,0 @@ -getExtensionAttributes()->getIsCustomer() === false) { - throw new GraphQlAuthorizationException(__('The current customer isn\'t authorized.')); - } - - if ((int)$order->getCustomerId() !== $context->getUserId()) { - throw new GraphQlAuthorizationException(__('Current user is not authorized to cancel this order')); - } - } -} diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateGuestRequest.php b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateGuestRequest.php new file mode 100644 index 00000000000..b3d7eafed58 --- /dev/null +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateGuestRequest.php @@ -0,0 +1,100 @@ + 'token' + ] + ) + ); + } + + if (!$input['reason'] || !is_string($input['reason'])) { + throw new GraphQlInputException( + __( + 'Required parameter "%field" is missing or incorrect.', + [ + 'field' => 'reason' + ] + ) + ); + } + } + + /** + * Ensure the order matches the provided criteria + * + * @param OrderInterface $order + * @param string $postcode + * @param string $email + * @return void + * @throws GraphQlAuthorizationException + * @throws GraphQlNoSuchEntityException + */ + public function validateOrderDetails(OrderInterface $order, string $postcode, string $email): void + { + $billingAddress = $order->getBillingAddress(); + + if ($billingAddress->getPostcode() !== $postcode || $billingAddress->getEmail() !== $email) { + $this->cannotLocateOrder(); + } + + if ($order->getCustomerId()) { + throw new GraphQlAuthorizationException(__('Please login to view the order.')); + } + } + + /** + * Throw exception when the order cannot be found or does not match the criteria + * + * @return void + * @throws GraphQlNoSuchEntityException + */ + public function cannotLocateOrder(): void + { + throw new GraphQlNoSuchEntityException(__('We couldn\'t locate an order with the information provided.')); + } +} diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateOrder.php b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateOrder.php index ebc9befc8a0..5f3736cd967 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateOrder.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateOrder.php @@ -17,6 +17,7 @@ namespace Magento\OrderCancellationGraphQl\Model\Validator; use Magento\Framework\Exception\LocalizedException; +use Magento\OrderCancellation\Model\Config\Config; use Magento\Sales\Api\Data\OrderInterface; use Magento\OrderCancellation\Model\CustomerCanCancel; use Magento\SalesGraphQl\Model\Formatter\Order as OrderFormatter; @@ -28,10 +29,12 @@ class ValidateOrder * * @param CustomerCanCancel $customerCanCancel * @param OrderFormatter $orderFormatter + * @param Config $config */ public function __construct( private readonly CustomerCanCancel $customerCanCancel, - private readonly OrderFormatter $orderFormatter + private readonly OrderFormatter $orderFormatter, + private readonly Config $config ) { } @@ -44,6 +47,12 @@ public function __construct( */ public function execute(OrderInterface $order): array { + if (!$this->config->isOrderCancellationEnabledForStore((int)$order->getStoreId())) { + return [ + 'error' => __('Order cancellation is not enabled for requested store.') + ]; + } + if (!$this->customerCanCancel->execute($order)) { return [ 'error' => __('Order already closed, complete, cancelled or on hold'), diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateRequest.php b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateRequest.php index 6d426c470a2..1ef73c99f9c 100644 --- a/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateRequest.php +++ b/app/code/Magento/OrderCancellationGraphQl/Model/Validator/ValidateRequest.php @@ -16,7 +16,9 @@ namespace Magento\OrderCancellationGraphQl\Model\Validator; +use Magento\Framework\GraphQl\Exception\GraphQlAuthorizationException; use Magento\Framework\GraphQl\Exception\GraphQlInputException; +use Magento\Framework\GraphQl\Query\Resolver\ContextInterface; /** * Ensure all conditions to cancel order are met @@ -26,13 +28,19 @@ class ValidateRequest /** * Ensure customer is authorized and the field is populated * + * @param ContextInterface $context * @param array|null $input * @return void - * @throws GraphQlInputException + * @throws GraphQlInputException|GraphQlAuthorizationException */ public function execute( + ContextInterface $context, ?array $input, ): void { + if ($context->getExtensionAttributes()->getIsCustomer() === false) { + throw new GraphQlAuthorizationException(__('The current customer isn\'t authorized.')); + } + if (!is_array($input) || empty($input)) { throw new GraphQlInputException( __('CancelOrderInput is missing.') @@ -50,7 +58,7 @@ public function execute( ); } - if (!$input['reason'] || !is_string($input['reason']) || (string)$input['reason'] === "") { + if (!$input['reason'] || !is_string($input['reason'])) { throw new GraphQlInputException( __( 'Required parameter "%field" is missing or incorrect.', diff --git a/app/code/Magento/OrderCancellationGraphQl/composer.json b/app/code/Magento/OrderCancellationGraphQl/composer.json index d38fc3a2dc3..a1da56ef74a 100644 --- a/app/code/Magento/OrderCancellationGraphQl/composer.json +++ b/app/code/Magento/OrderCancellationGraphQl/composer.json @@ -7,6 +7,7 @@ "require": { "php": "~8.1.0||~8.2.0||~8.3.0", "magento/framework": "*", + "magento/module-store": "*", "magento/module-sales": "*", "magento/module-sales-graph-ql": "*", "magento/module-order-cancellation": "*" diff --git a/app/code/Magento/OrderCancellationGraphQl/etc/schema.graphqls b/app/code/Magento/OrderCancellationGraphQl/etc/schema.graphqls index e0d8f38c432..ae6fbfcd375 100644 --- a/app/code/Magento/OrderCancellationGraphQl/etc/schema.graphqls +++ b/app/code/Magento/OrderCancellationGraphQl/etc/schema.graphqls @@ -11,8 +11,8 @@ type CancellationReason { type Mutation { cancelOrder(input: CancelOrderInput!): CancelOrderOutput @resolver(class: "Magento\\OrderCancellationGraphQl\\Model\\Resolver\\CancelOrder") @doc(description: "Cancel the specified customer order.") - confirmCancelOrder(input: ConfirmCancelOrderInput!): CancelOrderOutput @resolver(class: "Magento\\OrderCancellationGraphQl\\Model\\Resolver\\ConfirmCancelOrderGuest") @doc(description: "Cancel the specified guest customer order.") - + confirmCancelOrder(input: ConfirmCancelOrderInput!): CancelOrderOutput @resolver(class: "Magento\\OrderCancellationGraphQl\\Model\\Resolver\\ConfirmCancelOrder") @doc(description: "Cancel the specified guest customer order.") + requestGuestOrderCancel(input: GuestOrderCancelInput!): CancelOrderOutput @resolver(class: "Magento\\OrderCancellationGraphQl\\Model\\Resolver\\RequestGuestOrderCancel") @doc(description: "Request to cancel specified guest order.") } input CancelOrderInput @doc(description: "Defines the order to cancel.") { @@ -25,6 +25,11 @@ input ConfirmCancelOrderInput { confirmation_key: String! @doc(description: "Confirmation Key to cancel the order.") } +input GuestOrderCancelInput @doc(description: "Input to retrieve a guest order based on token.") { + token: String! @doc(description: "Order token.") + reason: String! @doc(description: "Cancellation reason.") +} + type CancelOrderOutput @doc(description: "Contains the updated customer order and error message if any.") { error: String @doc(description: "Error encountered while cancelling the order.") order: CustomerOrder @doc(description: "Updated customer order.") 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 bb8ece5d1e9..c63c989ee74 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/CancelGuestOrderTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/CancelGuestOrderTest.php @@ -18,7 +18,8 @@ use Exception; use Magento\Checkout\Test\Fixture\SetGuestEmail as SetGuestEmailFixture; -use Magento\Framework\ObjectManagerInterface; +use Magento\Customer\Test\Fixture\Customer; +use Magento\Quote\Test\Fixture\CustomerCart; use Magento\Quote\Test\Fixture\GuestCart; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\OrderRepositoryInterface; @@ -43,6 +44,7 @@ use Magento\Checkout\Test\Fixture\SetPaymentMethod as SetPaymentMethodFixture; use Magento\Checkout\Test\Fixture\SetShippingAddress as SetShippingAddressFixture; use Magento\Quote\Test\Fixture\AddProductToCart as AddProductToCartFixture; +use Magento\SalesGraphQl\Model\Order\Token; /** * Test coverage for cancel order mutation for guest order @@ -66,29 +68,41 @@ class CancelGuestOrderTest extends GraphQlAbstract { /** - * @var ObjectManagerInterface - */ - private $objectManager; - - /** - * @inheritdoc + * @return void + * @throws Exception */ - protected function setUp(): void + public function testAttemptToCancelOrderWhenMissingToken() { - $this->objectManager = Bootstrap::getObjectManager(); + $query = <<expectException(ResponseContainsErrorsException::class); + $this->expectExceptionMessage('Field GuestOrderCancelInput.token of required type String! was not provided.'); + $this->graphQlMutation($query); } /** * @return void * @throws Exception */ - public function testAttemptToCancelOrderWhenMissingOrderId() + public function testAttemptToCancelOrderWhenMissingReason() { $query = <<expectException(ResponseContainsErrorsException::class); - $this->expectExceptionMessage("Field CancelOrderInput.order_id of required type ID! was not provided."); + $this->expectExceptionMessage("Field GuestOrderCancelInput.reason of required type String! was not provided."); $this->graphQlMutation($query); } - /** * @return void * @throws Exception */ - public function testAttemptToCancelOrderWhenMissingReason() + public function testAttemptToCancelOrderWithInvalidToken() { $query = <<expectException(ResponseContainsErrorsException::class); - $this->expectExceptionMessage("Field CancelOrderInput.reason of required type String! was not provided."); + $this->expectExceptionMessage('We couldn\'t locate an order with the information provided.'); $this->graphQlMutation($query); } @@ -132,6 +146,7 @@ public function testAttemptToCancelOrderWhenMissingReason() * @return void * @throws AuthenticationException * @throws LocalizedException + * @throws Exception */ #[ Config('sales/cancellation/enabled', 0) @@ -142,27 +157,11 @@ public function testAttemptToCancelOrderWhenCancellationFeatureDisabled() * @var $order OrderInterface */ $order = DataFixtureStorageManager::getStorage()->get('order'); + $query = $this->getMutation($order); - $query = <<getEntityId()}, - reason: "Sample reason" - } - ){ - errorV2 { - message - } - order { - status - } - } - } -MUTATION; $this->assertEquals( [ - 'cancelOrder' => [ + 'requestGuestOrderCancel' => [ 'errorV2' => [ 'message' => 'Order cancellation is not enabled for requested store.' ], @@ -179,6 +178,7 @@ public function testAttemptToCancelOrderWhenCancellationFeatureDisabled() * @return void * @throws AuthenticationException * @throws LocalizedException + * @throws Exception * * @dataProvider orderStatusProvider */ @@ -195,14 +195,13 @@ public function testAttemptToCancelOrderWithSomeStatuses(string $status, string $order->setState($status); /** @var OrderRepositoryInterface $orderRepo */ - $orderRepo = $this->objectManager->get(OrderRepository::class); + $orderRepo = Bootstrap::getObjectManager()->create(OrderRepository::class); $orderRepo->save($order); - $query = $this->getCancelOrderMutation($order); + $query = $this->getMutation($order); $this->assertEquals( [ - 'cancelOrder' => - [ + 'requestGuestOrderCancel' => [ 'errorV2' => [ 'message' => 'Order already closed, complete, cancelled or on hold' ], @@ -219,6 +218,7 @@ public function testAttemptToCancelOrderWithSomeStatuses(string $status, string * @return void * @throws AuthenticationException * @throws LocalizedException + * @throws Exception */ #[ DataFixture(Store::class), @@ -241,11 +241,10 @@ public function testAttemptToCancelOrderWithOfflinePaymentFullyInvoicedFullyShip * @var $order OrderInterface */ $order = DataFixtureStorageManager::getStorage()->get('order'); - $query = $this->getCancelOrderMutation($order); + $query = $this->getMutation($order); $this->assertEquals( [ - 'cancelOrder' => - [ + 'requestGuestOrderCancel' => [ 'errorV2' => [ 'message' => 'Order already closed, complete, cancelled or on hold' ], @@ -262,6 +261,7 @@ public function testAttemptToCancelOrderWithOfflinePaymentFullyInvoicedFullyShip * @return void * @throws AuthenticationException * @throws LocalizedException + * @throws Exception */ #[ DataFixture(Store::class), @@ -297,11 +297,10 @@ public function testAttemptToCancelOrderWithOfflinePaymentFullyInvoicedPartially * @var $order OrderInterface */ $order = DataFixtureStorageManager::getStorage()->get('order'); - $query = $this->getCancelOrderMutation($order); + $query = $this->getMutation($order); $this->assertEquals( [ - 'cancelOrder' => - [ + 'requestGuestOrderCancel' => [ 'errorV2' => [ 'message' => 'Order with one or more items shipped cannot be cancelled' ], @@ -318,6 +317,7 @@ public function testAttemptToCancelOrderWithOfflinePaymentFullyInvoicedPartially * @return void * @throws AuthenticationException * @throws LocalizedException + * @throws Exception */ #[ DataFixture(Store::class), @@ -340,11 +340,10 @@ public function testAttemptToCancelOrderWithOfflinePaymentFullyInvoicedFullyRefu * @var $order OrderInterface */ $order = DataFixtureStorageManager::getStorage()->get('order'); - $query = $this->getCancelOrderMutation($order); + $query = $this->getMutation($order); $this->assertEquals( [ - 'cancelOrder' => - [ + 'requestGuestOrderCancel' => [ 'errorV2' => [ 'message' => 'Order already closed, complete, cancelled or on hold' ], @@ -357,6 +356,9 @@ public function testAttemptToCancelOrderWithOfflinePaymentFullyInvoicedFullyRefu ); } + /** + * @throws LocalizedException + */ #[ DataFixture(Store::class), DataFixture(ProductFixture::class, as: 'product'), @@ -376,11 +378,10 @@ public function testCancelOrderWithOutAnyAmountPaid() * @var $order OrderInterface */ $order = DataFixtureStorageManager::getStorage()->get('order'); - $query = $this->getCancelOrderMutation($order); + $query = $this->getMutation($order); $this->assertEquals( [ - 'cancelOrder' => - [ + 'requestGuestOrderCancel' => [ 'errorV2' => null, 'order' => [ 'status' => 'Pending' @@ -396,19 +397,61 @@ public function testCancelOrderWithOutAnyAmountPaid() } /** - * Get cancel order mutation + * @throws AuthenticationException + * @throws LocalizedException + * @throws Exception + */ + #[ + DataFixture(Store::class), + DataFixture( + Customer::class, + [ + 'email' => 'customer@example.com', + 'password' => 'password' + ], + '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$', + 'qty' => 3 + ] + ), + DataFixture(SetBillingAddressFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetShippingAddressFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetDeliveryMethodFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(SetPaymentMethodFixture::class, ['cart_id' => '$cart.id$']), + DataFixture(PlaceOrderFixture::class, ['cart_id' => '$cart.id$'], 'order'), + Config('sales/cancellation/enabled', 1) + ] + public function testOrderCancellationForLoggedInCustomerUsingToken() + { + $this->expectException(ResponseContainsErrorsException::class); + $this->expectExceptionMessage('Please login to view the order.'); + + /** @var OrderInterface $order */ + $order = DataFixtureStorageManager::getStorage()->get('order'); + $this->graphQlMutation($this->getMutation($order)); + } + + /** + * Get request guest order cancellation by token mutation * - * @param Order $order + * @param OrderInterface $order * @return string */ - private function getCancelOrderMutation(OrderInterface $order): string + private function getMutation(OrderInterface $order): string { return <<getEntityId()}" - reason: "Cancel sample reason" + token: "{$this->getOrderToken($order)}", + reason: "Sample reason" } ){ errorV2 { @@ -422,10 +465,25 @@ private function getCancelOrderMutation(OrderInterface $order): string MUTATION; } + /** + * Get token from order + * + * @param OrderInterface $order + * @return string + */ + private function getOrderToken(OrderInterface $order): string + { + return Bootstrap::getObjectManager()->create(Token::class)->encrypt( + $order->getIncrementId(), + $order->getBillingAddress()->getEmail(), + $order->getBillingAddress()->getPostcode() + ); + } + /** * @return array[] */ - public static function orderStatusProvider(): array + public function orderStatusProvider(): array { return [ 'On Hold status' => [ diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/ConfirmCancelGuestOrderTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/ConfirmCancelGuestOrderTest.php index e13ea7fed85..aeafa931715 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/ConfirmCancelGuestOrderTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/OrderCancellation/ConfirmCancelGuestOrderTest.php @@ -126,9 +126,16 @@ public function testAttemptToConfirmCancelNonExistingOrder() } } MUTATION; - $this->expectException(ResponseContainsErrorsException::class); - $this->expectExceptionMessage("The entity that was requested doesn't exist. Verify the entity and try again."); - $this->graphQlMutation($query); + + $this->assertEquals( + [ + 'confirmCancelOrder' => [ + 'error' => 'The entity that was requested doesn\'t exist. Verify the entity and try again.', + 'order' => null + ] + ], + $this->graphQlMutation($query) + ); } /** @@ -349,9 +356,18 @@ public function testAttemptToConfirmCancelOrderForWhichConfirmationKeyNotGenerat */ $order = DataFixtureStorageManager::getStorage()->get('order'); $query = $this->getConfirmCancelOrderMutation($order); - $this->expectException(ResponseContainsErrorsException::class); - $this->expectExceptionMessage("The order cancellation could not be confirmed."); - $this->graphQlMutation($query); + $this->assertEquals( + [ + 'confirmCancelOrder' => + [ + 'errorV2' => [ + 'message' => 'The order cancellation could not be confirmed.' + ], + 'order' => null + ] + ], + $this->graphQlMutation($query) + ); } #[ @@ -410,9 +426,18 @@ public function testAttemptToConfirmCancelOrderWithInvalidConfirmationKey() $this->confirmationKey->execute($order, 'Simple reason'); $query = $this->getConfirmCancelOrderMutation($order); - $this->expectException(ResponseContainsErrorsException::class); - $this->expectExceptionMessage("The order cancellation could not be confirmed."); - $this->graphQlMutation($query); + $this->assertEquals( + [ + 'confirmCancelOrder' => + [ + 'errorV2' => [ + 'message' => 'The order cancellation could not be confirmed.' + ], + 'order' => null + ] + ], + $this->graphQlMutation($query) + ); } #[ From 63fdbf0b0370dc55765c3715b40bedb27d623e1e Mon Sep 17 00:00:00 2001 From: Rafal Janicki Date: Tue, 22 Oct 2024 12:48:04 +0100 Subject: [PATCH 06/12] Delivery PR fix static tests --- .../Magento/CustomerGraphQl/etc/schema.graphqls | 4 ++-- .../etc/schema.graphqls | 5 +++-- .../Quote/Model/Cart/AddProductsToCart.php | 4 ++-- .../Quote/Model/Cart/AddProductsToCartError.php | 4 ++-- app/code/Magento/Quote/etc/di.xml | 17 ++++++++++++++--- .../Model/Resolver/AddProductsToCart.php | 4 ++-- .../Magento/QuoteGraphQl/etc/schema.graphqls | 4 ++-- app/code/Magento/Sales/etc/db_schema.xml | 17 ++++++++++++++--- app/code/Magento/Sales/etc/events.xml | 17 ++++++++++++++--- .../Magento/SalesGraphQl/etc/schema.graphqls | 4 ++-- .../Magento/Framework/App/PageCache/Version.php | 5 +++-- 11 files changed, 60 insertions(+), 25 deletions(-) diff --git a/app/code/Magento/CustomerGraphQl/etc/schema.graphqls b/app/code/Magento/CustomerGraphQl/etc/schema.graphqls index 2fd73370c08..c62c9dd73c9 100644 --- a/app/code/Magento/CustomerGraphQl/etc/schema.graphqls +++ b/app/code/Magento/CustomerGraphQl/etc/schema.graphqls @@ -1,5 +1,5 @@ -# Copyright © Magento, Inc. All rights reserved. -# See COPYING.txt for license details. +# Copyright 2024 Adobe +# All Rights Reserved. type StoreConfig { required_character_classes_number : String @doc(description: "The number of different character classes (lowercase, uppercase, digits, special characters) required in a password.") diff --git a/app/code/Magento/OrderCancellationGraphQl/etc/schema.graphqls b/app/code/Magento/OrderCancellationGraphQl/etc/schema.graphqls index ae6fbfcd375..a323ca63559 100644 --- a/app/code/Magento/OrderCancellationGraphQl/etc/schema.graphqls +++ b/app/code/Magento/OrderCancellationGraphQl/etc/schema.graphqls @@ -1,5 +1,6 @@ -# Copyright © Magento, Inc. All rights reserved. -# See COPYING.txt for license details. +# Copyright 2024 Adobe +# All Rights Reserved. + type StoreConfig { order_cancellation_enabled: Boolean! @doc(description: "Indicates whether orders can be cancelled by customers or not.") order_cancellation_reasons: [CancellationReason!]! @resolver(class: "Magento\\OrderCancellationGraphQl\\Model\\Resolver\\CancellationReasons") @doc(description: "An array containing available cancellation reasons.") diff --git a/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php b/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php index 8dc29acebfa..7fa7dd961ca 100644 --- a/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php +++ b/app/code/Magento/Quote/Model/Cart/AddProductsToCart.php @@ -1,7 +1,7 @@ diff --git a/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php b/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php index 802533651f7..bd52ff3bebb 100644 --- a/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php +++ b/app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php @@ -1,7 +1,7 @@ diff --git a/app/code/Magento/SalesGraphQl/etc/schema.graphqls b/app/code/Magento/SalesGraphQl/etc/schema.graphqls index 0cd798f9984..81d30700e33 100644 --- a/app/code/Magento/SalesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SalesGraphQl/etc/schema.graphqls @@ -1,5 +1,5 @@ -# Copyright © Magento, Inc. All rights reserved. -# See COPYING.txt for license details. +# Copyright 2024 Adobe +# All Rights Reserved. type Query { customerOrders: CustomerOrders @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Orders") @deprecated(reason: "Use the `customer` query instead.") @cache(cacheable: false) diff --git a/lib/internal/Magento/Framework/App/PageCache/Version.php b/lib/internal/Magento/Framework/App/PageCache/Version.php index 0cc40217f14..38e9a8c7ca3 100644 --- a/lib/internal/Magento/Framework/App/PageCache/Version.php +++ b/lib/internal/Magento/Framework/App/PageCache/Version.php @@ -1,8 +1,9 @@ Date: Wed, 23 Oct 2024 14:07:25 +0530 Subject: [PATCH 07/12] LYNX-587 Expose Tax Display Settings via GraphQL --- .../Magento/SalesGraphQl/etc/graphql/di.xml | 25 +++++- .../Magento/SalesGraphQl/etc/schema.graphqls | 22 ++++- .../GraphQl/Sales/SalesTaxStoreConfigTest.php | 84 +++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/SalesTaxStoreConfigTest.php diff --git a/app/code/Magento/SalesGraphQl/etc/graphql/di.xml b/app/code/Magento/SalesGraphQl/etc/graphql/di.xml index 7969587eb65..155ba2eca0f 100644 --- a/app/code/Magento/SalesGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/SalesGraphQl/etc/graphql/di.xml @@ -1,8 +1,8 @@ @@ -61,4 +61,25 @@ + + + + tax/display/type + tax/display/shipping + tax/sales_display/price + tax/sales_display/subtotal + tax/sales_display/shipping + tax/sales_display/grandtotal + tax/sales_display/full_summary + tax/sales_display/zero_tax + tax/weee/enable + tax/weee/display_list + tax/weee/display + tax/weee/display_sales + tax/weee/display_email + tax/weee/apply_vat + tax/weee/include_in_subtotal + + + diff --git a/app/code/Magento/SalesGraphQl/etc/schema.graphqls b/app/code/Magento/SalesGraphQl/etc/schema.graphqls index 81d30700e33..4e291a0a262 100644 --- a/app/code/Magento/SalesGraphQl/etc/schema.graphqls +++ b/app/code/Magento/SalesGraphQl/etc/schema.graphqls @@ -1,5 +1,5 @@ -# Copyright 2024 Adobe -# All Rights Reserved. +# Copyright 2024 Adobe +# All Rights Reserved. type Query { customerOrders: CustomerOrders @resolver(class: "Magento\\SalesGraphQl\\Model\\Resolver\\Orders") @deprecated(reason: "Use the `customer` query instead.") @cache(cacheable: false) @@ -316,3 +316,21 @@ input OrderInformationInput @doc(description: "Input to retrieve an order based enum OrderActionType @doc(description: "The list of available order actions.") { REORDER } + +type StoreConfig { + display_product_prices_in_catalog: Boolean! @doc(description: "Configuration data from tax/display/type") + display_shipping_prices: Boolean! @doc(description: "Configuration data from tax/display/shipping") + orders_invoices_credit_memos_display_price: Boolean! @doc(description: "Configuration data from tax/sales_display/price") + orders_invoices_credit_memos_display_subtotal: Boolean! @doc(description: "Configuration data from tax/sales_display/subtotal") + orders_invoices_credit_memos_display_shipping_amount: Boolean! @doc(description: "Configuration data from tax/sales_display/shipping") + orders_invoices_credit_memos_display_grandtotal: Boolean! @doc(description: "Configuration data from tax/sales_display/grandtotal") + orders_invoices_credit_memos_display_full_summary: Boolean! @doc(description: "Configuration data from tax/sales_display/full_summary") + orders_invoices_credit_memos_display_zero_tax: Boolean! @doc(description: "Configuration data from tax/sales_display/zero_tax") + fixed_product_taxes_enable: Boolean! @doc(description: "Configuration data from tax/weee/enable") + fixed_product_taxes_display_prices_in_product_lists: Int @doc(description: "Configuration data from tax/weee/display_list") + fixed_product_taxes_display_prices_on_product_view_page: Int @doc(description: "Configuration data from tax/weee/display") + fixed_product_taxes_display_prices_in_sales_modules: Int @doc(description: "Configuration data from tax/weee/display_sales") + fixed_product_taxes_display_prices_in_emails: Int @doc(description: "Configuration data from tax/weee/display_email") + fixed_product_taxes_apply_tax_to_fpt: Boolean! @doc(description: "Configuration data from tax/weee/apply_vat") + fixed_product_taxes_include_fpt_in_subtotal: Boolean! @doc(description: "Configuration data from tax/weee/include_in_subtotal") +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/SalesTaxStoreConfigTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/SalesTaxStoreConfigTest.php new file mode 100644 index 00000000000..c690783c2b6 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Sales/SalesTaxStoreConfigTest.php @@ -0,0 +1,84 @@ +graphQlQuery($this->getQuery()); + $this->assertArrayHasKey('storeConfig', $response); + $this->assertStoreConfigsExist($response['storeConfig']); + } + + /** + * Check if all the added store configs are returned in graphql response + * + * @param array $response + * @return void + */ + private function assertStoreConfigsExist(array $response): void + { + foreach (self::CONFIG_KEYS as $key) { + $this->assertArrayHasKey($key, $response); + } + } + + /** + * Generates storeConfig query with newly added configurations from sales->tax + * + * @return string + */ + private function getQuery(): string + { + return << Date: Tue, 29 Oct 2024 16:20:11 +0000 Subject: [PATCH 08/12] LYNX-600: Increase max default GraphQL query complexity to 1000 --- app/code/Magento/GraphQl/etc/di.xml | 19 +- .../Framework/QueryComplexityLimiterTest.php | 879 +++++++++++++++++- .../Catalog/_files/product_virtual.php | 13 +- 3 files changed, 902 insertions(+), 9 deletions(-) diff --git a/app/code/Magento/GraphQl/etc/di.xml b/app/code/Magento/GraphQl/etc/di.xml index 6017ce04931..5da2d8142cc 100644 --- a/app/code/Magento/GraphQl/etc/di.xml +++ b/app/code/Magento/GraphQl/etc/di.xml @@ -1,8 +1,19 @@ @@ -104,7 +115,7 @@ 20 - 300 + 1000 diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Framework/QueryComplexityLimiterTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Framework/QueryComplexityLimiterTest.php index 29d793aeea8..394f6e55795 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Framework/QueryComplexityLimiterTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Framework/QueryComplexityLimiterTest.php @@ -1,7 +1,16 @@ graphQlMutation($query); } diff --git a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_virtual.php b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_virtual.php index 838ae2b9a2a..087a49a8fe7 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/_files/product_virtual.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/_files/product_virtual.php @@ -1,7 +1,16 @@ Date: Tue, 12 Nov 2024 16:04:40 +0530 Subject: [PATCH 09/12] LYNX-319 401 and 403 HTTP response codes for GraphQL API --- .../AuthorizationRequestValidator.php | 59 +++++ .../CustomerGraphQl/etc/graphql/di.xml | 11 +- .../Magento/GraphQl/Controller/GraphQl.php | 49 +++- .../GraphQl/Customer/AuthenticationTest.php | 225 ++++++++++++++++++ .../Model/Resolver/CustomerTest.php | 10 +- .../Quote/Customer/GetCustomerCartTest.php | 6 +- .../Wishlist/AddWishlistItemsToCartTest.php | 27 ++- 7 files changed, 357 insertions(+), 30 deletions(-) create mode 100644 app/code/Magento/CustomerGraphQl/Controller/HttpRequestValidator/AuthorizationRequestValidator.php create mode 100644 dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/AuthenticationTest.php diff --git a/app/code/Magento/CustomerGraphQl/Controller/HttpRequestValidator/AuthorizationRequestValidator.php b/app/code/Magento/CustomerGraphQl/Controller/HttpRequestValidator/AuthorizationRequestValidator.php new file mode 100644 index 00000000000..fbd2734917f --- /dev/null +++ b/app/code/Magento/CustomerGraphQl/Controller/HttpRequestValidator/AuthorizationRequestValidator.php @@ -0,0 +1,59 @@ +getHeader(self::AUTH); + if (!$authorizationHeaderValue) { + return; + } + + $headerPieces = explode(' ', $authorizationHeaderValue); + if (count($headerPieces) !== 2 || strtolower($headerPieces[0]) !== self::BEARER) { + return; + } + + try { + $this->tokenValidator->validate($this->tokenReader->read($headerPieces[1])); + } catch (UserTokenException | AuthorizationException $exception) { + throw new GraphQlAuthenticationException(__($exception->getMessage())); + } + } +} diff --git a/app/code/Magento/CustomerGraphQl/etc/graphql/di.xml b/app/code/Magento/CustomerGraphQl/etc/graphql/di.xml index b23e75cf9f8..42d8d0b29a8 100644 --- a/app/code/Magento/CustomerGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/CustomerGraphQl/etc/graphql/di.xml @@ -1,8 +1,8 @@ @@ -212,4 +212,11 @@ + + + + Magento\CustomerGraphQl\Controller\HttpRequestValidator\AuthorizationRequestValidator + + + diff --git a/app/code/Magento/GraphQl/Controller/GraphQl.php b/app/code/Magento/GraphQl/Controller/GraphQl.php index cf553b4bc7a..9c8b5381c6a 100644 --- a/app/code/Magento/GraphQl/Controller/GraphQl.php +++ b/app/code/Magento/GraphQl/Controller/GraphQl.php @@ -1,14 +1,13 @@ jsonFactory->create(); $data = $this->getDataFromRequest($request); - $result = []; + $result = ['errors' => []]; $schema = null; $query = $data['query'] ?? ''; @@ -205,8 +206,14 @@ public function dispatch(RequestInterface $request): ResponseInterface $this->contextFactory->create(), $data['variables'] ?? [] ); - } catch (\Exception $error) { - $result['errors'] = isset($result['errors']) ? $result['errors'] : []; + $statusCode = $this->getHttpResponseCode($result); + } catch (GraphQlAuthenticationException $error) { + $result['errors'][] = $this->graphQlError->create($error); + $statusCode = 401; + } catch (GraphQlAuthorizationException $error) { + $result['errors'][] = $this->graphQlError->create($error); + $statusCode = 403; + } catch (Exception $error) { $result['errors'][] = $this->graphQlError->create($error); $statusCode = ExceptionFormatter::HTTP_GRAPH_QL_SCHEMA_ERROR_STATUS; } @@ -216,7 +223,7 @@ public function dispatch(RequestInterface $request): ResponseInterface $jsonResult->renderResult($this->httpResponse); // log information about the query, unless it is an introspection query - if (strpos($query, 'IntrospectionQuery') === false) { + if (!str_contains($query, 'IntrospectionQuery')) { $queryInformation = $this->logDataHelper->getLogData($request, $data, $schema, $this->httpResponse); $this->loggerPool->execute($queryInformation); } @@ -247,4 +254,30 @@ private function getDataFromRequest(RequestInterface $request): array return $data; } + + /** + * Retrieve http response code based on the error categories + * + * @param array $result + * @return int + */ + private function getHttpResponseCode(array $result): int + { + if (empty($result['errors'])) { + return 200; + } + foreach ($result['errors'] as $error) { + if (!isset($error['extensions']['category'])) { + continue; + } + switch ($error['extensions']['category']) { + case GraphQlAuthenticationException::EXCEPTION_CATEGORY: + return 401; + case GraphQlAuthorizationException::EXCEPTION_CATEGORY: + return 403; + } + } + + return 200; + } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/AuthenticationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/AuthenticationTest.php new file mode 100644 index 00000000000..279c816541f --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/AuthenticationTest.php @@ -0,0 +1,225 @@ +tokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class); + } + + /** + * @throws Exception + */ + public function testNoToken() + { + $response = $this->graphQlQuery(self::QUERY_ACCESSIBLE_BY_GUEST); + + self::assertArrayHasKey('isEmailAvailable', $response); + self::assertArrayHasKey('is_email_available', $response['isEmailAvailable']); + } + + public function testInvalidToken() + { + $this->expectExceptionCode(401); + Bootstrap::getObjectManager()->get(CurlClient::class)->get( + rtrim(TESTS_BASE_URL, '/') . '/graphql', + [ + 'query' => self::QUERY_ACCESSIBLE_BY_GUEST + ], + [ + 'Authorization: Bearer invalid_token' + ] + ); + } + + /** + * @throws AuthenticationException + * @throws LocalizedException + * @throws EmailNotConfirmedException + */ + #[ + DataFixture(Customer::class, as: 'customer'), + ] + public function testRevokedTokenPublicQuery() + { + /** @var CustomerInterface $customer */ + $customer = DataFixtureStorageManager::getStorage()->get('customer'); + $token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password'); + + $response = $this->graphQlQuery( + self::QUERY_ACCESSIBLE_BY_GUEST, + [], + '', + [ + 'Authorization' => 'Bearer ' . $token + ] + ); + + self::assertArrayHasKey('isEmailAvailable', $response); + self::assertArrayHasKey('is_email_available', $response['isEmailAvailable']); + + $this->tokenService->revokeCustomerAccessToken($customer->getId()); + + $this->expectExceptionCode(401); + Bootstrap::getObjectManager()->get(CurlClient::class)->get( + rtrim(TESTS_BASE_URL, '/') . '/graphql', + [ + 'query' => self::QUERY_ACCESSIBLE_BY_GUEST + ], + [ + 'Authorization: Bearer ' . $token + ] + ); + } + + /** + * @throws AuthenticationException + * @throws EmailNotConfirmedException + * @throws LocalizedException + * @throws Exception + */ + #[ + DataFixture(Customer::class, as: 'customer'), + ] + public function testRevokedTokenProtectedQuery() + { + /** @var CustomerInterface $customer */ + $customer = DataFixtureStorageManager::getStorage()->get('customer'); + $token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password'); + + $response = $this->graphQlQuery( + self::QUERY_REQUIRE_AUTHENTICATION, + [], + '', + [ + 'Authorization' => 'Bearer ' . $token + ] + ); + + self::assertEquals( + [ + 'customer' => [ + 'email' => $customer->getEmail() + ] + ], + $response + ); + + $this->tokenService->revokeCustomerAccessToken($customer->getId()); + + $this->expectExceptionCode(401); + Bootstrap::getObjectManager()->get(CurlClient::class)->get( + rtrim(TESTS_BASE_URL, '/') . '/graphql', + [ + 'query' => self::QUERY_REQUIRE_AUTHENTICATION + ], + [ + 'Authorization: Bearer ' . $token + ] + ); + } + + /** + * @throws NoSuchEntityException + * @throws AuthenticationException + * @throws EmailNotConfirmedException + * @throws LocalizedException + */ + #[ + DataFixture(Customer::class, as: 'unauthorizedCustomer'), + DataFixture( + Customer::class, + [ + 'addresses' => [ + [ + 'country_id' => 'US', + 'region_id' => 32, + 'city' => 'Boston', + 'street' => ['10 Milk Street'], + 'postcode' => '02108', + 'telephone' => '1234567890', + 'default_billing' => true, + 'default_shipping' => true + ] + ] + ], + as: 'customerWithAddress' + ), + ] + public function testForbidden() + { + /** @var CustomerInterface $customerWithAddress */ + $customerWithAddressData = DataFixtureStorageManager::getStorage()->get('customerWithAddress'); + $customerWithAddress = Bootstrap::getObjectManager() + ->get(CustomerRepositoryInterface::class) + ->get($customerWithAddressData->getEmail()); + $addressId = $customerWithAddress->getDefaultBilling(); + $mutation + = <<get('unauthorizedCustomer'); + $token = $this->tokenService->createCustomerAccessToken($unauthorizedCustomer->getEmail(), 'password'); + + $this->expectExceptionCode(403); + Bootstrap::getObjectManager()->get(CurlClient::class)->post( + rtrim(TESTS_BASE_URL, '/') . '/graphql', + json_encode(['query' => $mutation]), + [ + 'Authorization: Bearer ' . $token, + 'Accept: application/json', + 'Content-Type: application/json' + ] + ); + } +} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CustomerGraphQl/Model/Resolver/CustomerTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CustomerGraphQl/Model/Resolver/CustomerTest.php index a13d427efa7..330a41c4980 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/CustomerGraphQl/Model/Resolver/CustomerTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/CustomerGraphQl/Model/Resolver/CustomerTest.php @@ -1,12 +1,13 @@ fail('Expected exception not thrown'); - } catch (ResponseContainsErrorsException $e) { + } catch (Exception $e) { // expected exception } @@ -836,7 +836,7 @@ private function getCustomerQuery(): string * @param string $password * @param string $storeCode * @return string - * @throws \Exception + * @throws Exception */ private function generateCustomerToken(string $email, string $password, string $storeCode = 'default'): string { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/GetCustomerCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/GetCustomerCartTest.php index 882ebc4ea4e..6d029fbeddb 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/GetCustomerCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/GetCustomerCartTest.php @@ -1,7 +1,7 @@ expectException(\Exception::class); - $this->expectExceptionMessage('The request is allowed for logged in customer'); + $this->expectExceptionMessage('User token has been revoked'); $customerCartQuery = $this->getCustomerCartQuery(); $headers = $this->getHeaderMap(); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddWishlistItemsToCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddWishlistItemsToCartTest.php index df4002cf748..a1e06089c51 100755 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddWishlistItemsToCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddWishlistItemsToCartTest.php @@ -1,7 +1,7 @@ expectException(Exception::class); - $this->expectExceptionMessage("The account sign-in was incorrect or your account is disabled temporarily. Please wait and try again later."); + $this->expectExceptionMessage( + 'The account sign-in was incorrect or your account is disabled temporarily. ' + . 'Please wait and try again later.' + ); $wishlist = $this->getWishlist(); $customerWishlist = $wishlist['customer']['wishlists'][0]; @@ -145,7 +148,7 @@ public function testAddItemsToCartForGuestUser(): void $query = $this->getQuery($wishlistId, $itemId); - $this->graphQlMutation($query, [], '', ['Authorization' => 'Bearer test_token']); + $this->graphQlMutation($query); } /** @@ -206,12 +209,14 @@ public function testAddItemsToCartWithInvalidItemId(): void $query = $this->getQuery($customerWishlist['id'], $itemId); $this->graphQlMutation($query, [], '', $this->getHeaderMap()); } - /** Add all items from customer's wishlist to cart - * - * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php - * @magentoConfigFixture wishlist/general/active 1 - * @magentoApiDataFixture Magento/Wishlist/_files/wishlist_with_simple_product.php - */ + + /** + * Add all items from customer's wishlist to cart + * + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoConfigFixture wishlist/general/active 1 + * @magentoApiDataFixture Magento/Wishlist/_files/wishlist_with_simple_product.php + */ public function testAddAllWishlistItemsToCart(): void { $wishlist = $this->getWishlist(); @@ -437,6 +442,4 @@ private function getCustomerCartQuery(): string } QUERY; } - - } From 3be8aa04fece6e4a50c7f82c8bf714f7ef3e4669 Mon Sep 17 00:00:00 2001 From: Deepak Soni Date: Tue, 12 Nov 2024 16:37:41 +0530 Subject: [PATCH 10/12] =?UTF-8?q?LYNX-603:=20Product=20attribute=20>=20tra?= =?UTF-8?q?demark=20short=20form=20=E2=84=A2=20is=20returned=20as=20&trade?= =?UTF-8?q?;[Fix]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model/Resolver/Product/ProductName.php | 51 +++++++ .../CatalogGraphQl/etc/schema.graphqls | 6 +- .../ProductNameWithSpecialCharactersTest.php | 137 ++++++++++++++++++ 3 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductName.php create mode 100644 dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php 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..c922fae2957 --- /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..812e5ef4342 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 2024 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/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..781e9be1958 --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php @@ -0,0 +1,137 @@ +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 + { + $cart = $this->fixtures->get('cart'); + $maskedQuoteId = $this->quoteIdToMaskedQuoteId->execute((int) $cart->getId()); + + $query = $this->getAddToCartMutation($maskedQuoteId, $sku); + $response = $this->graphQlMutation($query); + $result = $response['addProductsToCart']; + + self::assertCount(1, $result['cart']['items']); + self::assertEquals(1, $result['cart']['items'][0]['quantity']); + self::assertEquals($expectedName, $result['cart']['items'][0]['product']['name']); + } + + /** + * 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 GraphQl mutation + * + * @param string $maskedQuoteId + * @param string $sku + * @return string + */ + private function getAddToCartMutation(string $maskedQuoteId, string $sku): string + { + return << Date: Thu, 14 Nov 2024 12:41:58 +0100 Subject: [PATCH 11/12] =?UTF-8?q?Revert=20"LYNX-603:=20Product=20attribute?= =?UTF-8?q?=20>=20trademark=20short=20form=20=E2=84=A2=20is=20returned=20a?= =?UTF-8?q?s=20™[Fix]"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3be8aa04fece6e4a50c7f82c8bf714f7ef3e4669. --- .../Model/Resolver/Product/ProductName.php | 51 ------- .../CatalogGraphQl/etc/schema.graphqls | 6 +- .../ProductNameWithSpecialCharactersTest.php | 137 ------------------ 3 files changed, 3 insertions(+), 191 deletions(-) delete mode 100644 app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductName.php delete mode 100644 dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php diff --git a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductName.php b/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductName.php deleted file mode 100644 index c922fae2957..00000000000 --- a/app/code/Magento/CatalogGraphQl/Model/Resolver/Product/ProductName.php +++ /dev/null @@ -1,51 +0,0 @@ -escaper->escapeUrl($value['model']->getName())) - ); - } -} diff --git a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls index 812e5ef4342..3d3875bb5c5 100644 --- a/app/code/Magento/CatalogGraphQl/etc/schema.graphqls +++ b/app/code/Magento/CatalogGraphQl/etc/schema.graphqls @@ -1,5 +1,5 @@ -# Copyright 2024 Adobe -# All Rights Reserved. +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. 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.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductName") + name: String @doc(description: "The product name. Customers use this name to identify the product.") 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/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php deleted file mode 100644 index 781e9be1958..00000000000 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Catalog/ProductNameWithSpecialCharactersTest.php +++ /dev/null @@ -1,137 +0,0 @@ -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 - { - $cart = $this->fixtures->get('cart'); - $maskedQuoteId = $this->quoteIdToMaskedQuoteId->execute((int) $cart->getId()); - - $query = $this->getAddToCartMutation($maskedQuoteId, $sku); - $response = $this->graphQlMutation($query); - $result = $response['addProductsToCart']; - - self::assertCount(1, $result['cart']['items']); - self::assertEquals(1, $result['cart']['items'][0]['quantity']); - self::assertEquals($expectedName, $result['cart']['items'][0]['product']['name']); - } - - /** - * 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 GraphQl mutation - * - * @param string $maskedQuoteId - * @param string $sku - * @return string - */ - private function getAddToCartMutation(string $maskedQuoteId, string $sku): string - { - return << Date: Thu, 14 Nov 2024 12:42:19 +0100 Subject: [PATCH 12/12] Revert "LYNX-319 401 and 403 HTTP response codes for GraphQL API" This reverts commit 8f401aa152be90b87eabdf42e3f5b8cd2057a4d8. --- .../AuthorizationRequestValidator.php | 59 ----- .../CustomerGraphQl/etc/graphql/di.xml | 11 +- .../Magento/GraphQl/Controller/GraphQl.php | 49 +--- .../GraphQl/Customer/AuthenticationTest.php | 225 ------------------ .../Model/Resolver/CustomerTest.php | 10 +- .../Quote/Customer/GetCustomerCartTest.php | 6 +- .../Wishlist/AddWishlistItemsToCartTest.php | 27 +-- 7 files changed, 30 insertions(+), 357 deletions(-) delete mode 100644 app/code/Magento/CustomerGraphQl/Controller/HttpRequestValidator/AuthorizationRequestValidator.php delete mode 100644 dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/AuthenticationTest.php diff --git a/app/code/Magento/CustomerGraphQl/Controller/HttpRequestValidator/AuthorizationRequestValidator.php b/app/code/Magento/CustomerGraphQl/Controller/HttpRequestValidator/AuthorizationRequestValidator.php deleted file mode 100644 index fbd2734917f..00000000000 --- a/app/code/Magento/CustomerGraphQl/Controller/HttpRequestValidator/AuthorizationRequestValidator.php +++ /dev/null @@ -1,59 +0,0 @@ -getHeader(self::AUTH); - if (!$authorizationHeaderValue) { - return; - } - - $headerPieces = explode(' ', $authorizationHeaderValue); - if (count($headerPieces) !== 2 || strtolower($headerPieces[0]) !== self::BEARER) { - return; - } - - try { - $this->tokenValidator->validate($this->tokenReader->read($headerPieces[1])); - } catch (UserTokenException | AuthorizationException $exception) { - throw new GraphQlAuthenticationException(__($exception->getMessage())); - } - } -} diff --git a/app/code/Magento/CustomerGraphQl/etc/graphql/di.xml b/app/code/Magento/CustomerGraphQl/etc/graphql/di.xml index 42d8d0b29a8..b23e75cf9f8 100644 --- a/app/code/Magento/CustomerGraphQl/etc/graphql/di.xml +++ b/app/code/Magento/CustomerGraphQl/etc/graphql/di.xml @@ -1,8 +1,8 @@ @@ -212,11 +212,4 @@ - - - - Magento\CustomerGraphQl\Controller\HttpRequestValidator\AuthorizationRequestValidator - - - diff --git a/app/code/Magento/GraphQl/Controller/GraphQl.php b/app/code/Magento/GraphQl/Controller/GraphQl.php index 9c8b5381c6a..cf553b4bc7a 100644 --- a/app/code/Magento/GraphQl/Controller/GraphQl.php +++ b/app/code/Magento/GraphQl/Controller/GraphQl.php @@ -1,13 +1,14 @@ jsonFactory->create(); $data = $this->getDataFromRequest($request); - $result = ['errors' => []]; + $result = []; $schema = null; $query = $data['query'] ?? ''; @@ -206,14 +205,8 @@ public function dispatch(RequestInterface $request): ResponseInterface $this->contextFactory->create(), $data['variables'] ?? [] ); - $statusCode = $this->getHttpResponseCode($result); - } catch (GraphQlAuthenticationException $error) { - $result['errors'][] = $this->graphQlError->create($error); - $statusCode = 401; - } catch (GraphQlAuthorizationException $error) { - $result['errors'][] = $this->graphQlError->create($error); - $statusCode = 403; - } catch (Exception $error) { + } catch (\Exception $error) { + $result['errors'] = isset($result['errors']) ? $result['errors'] : []; $result['errors'][] = $this->graphQlError->create($error); $statusCode = ExceptionFormatter::HTTP_GRAPH_QL_SCHEMA_ERROR_STATUS; } @@ -223,7 +216,7 @@ public function dispatch(RequestInterface $request): ResponseInterface $jsonResult->renderResult($this->httpResponse); // log information about the query, unless it is an introspection query - if (!str_contains($query, 'IntrospectionQuery')) { + if (strpos($query, 'IntrospectionQuery') === false) { $queryInformation = $this->logDataHelper->getLogData($request, $data, $schema, $this->httpResponse); $this->loggerPool->execute($queryInformation); } @@ -254,30 +247,4 @@ private function getDataFromRequest(RequestInterface $request): array return $data; } - - /** - * Retrieve http response code based on the error categories - * - * @param array $result - * @return int - */ - private function getHttpResponseCode(array $result): int - { - if (empty($result['errors'])) { - return 200; - } - foreach ($result['errors'] as $error) { - if (!isset($error['extensions']['category'])) { - continue; - } - switch ($error['extensions']['category']) { - case GraphQlAuthenticationException::EXCEPTION_CATEGORY: - return 401; - case GraphQlAuthorizationException::EXCEPTION_CATEGORY: - return 403; - } - } - - return 200; - } } diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/AuthenticationTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/AuthenticationTest.php deleted file mode 100644 index 279c816541f..00000000000 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/AuthenticationTest.php +++ /dev/null @@ -1,225 +0,0 @@ -tokenService = Bootstrap::getObjectManager()->get(CustomerTokenServiceInterface::class); - } - - /** - * @throws Exception - */ - public function testNoToken() - { - $response = $this->graphQlQuery(self::QUERY_ACCESSIBLE_BY_GUEST); - - self::assertArrayHasKey('isEmailAvailable', $response); - self::assertArrayHasKey('is_email_available', $response['isEmailAvailable']); - } - - public function testInvalidToken() - { - $this->expectExceptionCode(401); - Bootstrap::getObjectManager()->get(CurlClient::class)->get( - rtrim(TESTS_BASE_URL, '/') . '/graphql', - [ - 'query' => self::QUERY_ACCESSIBLE_BY_GUEST - ], - [ - 'Authorization: Bearer invalid_token' - ] - ); - } - - /** - * @throws AuthenticationException - * @throws LocalizedException - * @throws EmailNotConfirmedException - */ - #[ - DataFixture(Customer::class, as: 'customer'), - ] - public function testRevokedTokenPublicQuery() - { - /** @var CustomerInterface $customer */ - $customer = DataFixtureStorageManager::getStorage()->get('customer'); - $token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password'); - - $response = $this->graphQlQuery( - self::QUERY_ACCESSIBLE_BY_GUEST, - [], - '', - [ - 'Authorization' => 'Bearer ' . $token - ] - ); - - self::assertArrayHasKey('isEmailAvailable', $response); - self::assertArrayHasKey('is_email_available', $response['isEmailAvailable']); - - $this->tokenService->revokeCustomerAccessToken($customer->getId()); - - $this->expectExceptionCode(401); - Bootstrap::getObjectManager()->get(CurlClient::class)->get( - rtrim(TESTS_BASE_URL, '/') . '/graphql', - [ - 'query' => self::QUERY_ACCESSIBLE_BY_GUEST - ], - [ - 'Authorization: Bearer ' . $token - ] - ); - } - - /** - * @throws AuthenticationException - * @throws EmailNotConfirmedException - * @throws LocalizedException - * @throws Exception - */ - #[ - DataFixture(Customer::class, as: 'customer'), - ] - public function testRevokedTokenProtectedQuery() - { - /** @var CustomerInterface $customer */ - $customer = DataFixtureStorageManager::getStorage()->get('customer'); - $token = $this->tokenService->createCustomerAccessToken($customer->getEmail(), 'password'); - - $response = $this->graphQlQuery( - self::QUERY_REQUIRE_AUTHENTICATION, - [], - '', - [ - 'Authorization' => 'Bearer ' . $token - ] - ); - - self::assertEquals( - [ - 'customer' => [ - 'email' => $customer->getEmail() - ] - ], - $response - ); - - $this->tokenService->revokeCustomerAccessToken($customer->getId()); - - $this->expectExceptionCode(401); - Bootstrap::getObjectManager()->get(CurlClient::class)->get( - rtrim(TESTS_BASE_URL, '/') . '/graphql', - [ - 'query' => self::QUERY_REQUIRE_AUTHENTICATION - ], - [ - 'Authorization: Bearer ' . $token - ] - ); - } - - /** - * @throws NoSuchEntityException - * @throws AuthenticationException - * @throws EmailNotConfirmedException - * @throws LocalizedException - */ - #[ - DataFixture(Customer::class, as: 'unauthorizedCustomer'), - DataFixture( - Customer::class, - [ - 'addresses' => [ - [ - 'country_id' => 'US', - 'region_id' => 32, - 'city' => 'Boston', - 'street' => ['10 Milk Street'], - 'postcode' => '02108', - 'telephone' => '1234567890', - 'default_billing' => true, - 'default_shipping' => true - ] - ] - ], - as: 'customerWithAddress' - ), - ] - public function testForbidden() - { - /** @var CustomerInterface $customerWithAddress */ - $customerWithAddressData = DataFixtureStorageManager::getStorage()->get('customerWithAddress'); - $customerWithAddress = Bootstrap::getObjectManager() - ->get(CustomerRepositoryInterface::class) - ->get($customerWithAddressData->getEmail()); - $addressId = $customerWithAddress->getDefaultBilling(); - $mutation - = <<get('unauthorizedCustomer'); - $token = $this->tokenService->createCustomerAccessToken($unauthorizedCustomer->getEmail(), 'password'); - - $this->expectExceptionCode(403); - Bootstrap::getObjectManager()->get(CurlClient::class)->post( - rtrim(TESTS_BASE_URL, '/') . '/graphql', - json_encode(['query' => $mutation]), - [ - 'Authorization: Bearer ' . $token, - 'Accept: application/json', - 'Content-Type: application/json' - ] - ); - } -} diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/CustomerGraphQl/Model/Resolver/CustomerTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/CustomerGraphQl/Model/Resolver/CustomerTest.php index 330a41c4980..a13d427efa7 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/CustomerGraphQl/Model/Resolver/CustomerTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/CustomerGraphQl/Model/Resolver/CustomerTest.php @@ -1,13 +1,12 @@ fail('Expected exception not thrown'); - } catch (Exception $e) { + } catch (ResponseContainsErrorsException $e) { // expected exception } @@ -836,7 +836,7 @@ private function getCustomerQuery(): string * @param string $password * @param string $storeCode * @return string - * @throws Exception + * @throws \Exception */ private function generateCustomerToken(string $email, string $password, string $storeCode = 'default'): string { diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/GetCustomerCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/GetCustomerCartTest.php index 6d029fbeddb..882ebc4ea4e 100644 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/GetCustomerCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Quote/Customer/GetCustomerCartTest.php @@ -1,7 +1,7 @@ expectException(\Exception::class); - $this->expectExceptionMessage('User token has been revoked'); + $this->expectExceptionMessage('The request is allowed for logged in customer'); $customerCartQuery = $this->getCustomerCartQuery(); $headers = $this->getHeaderMap(); diff --git a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddWishlistItemsToCartTest.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddWishlistItemsToCartTest.php index a1e06089c51..df4002cf748 100755 --- a/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddWishlistItemsToCartTest.php +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Wishlist/AddWishlistItemsToCartTest.php @@ -1,7 +1,7 @@ expectException(Exception::class); - $this->expectExceptionMessage( - 'The account sign-in was incorrect or your account is disabled temporarily. ' - . 'Please wait and try again later.' - ); + $this->expectExceptionMessage("The account sign-in was incorrect or your account is disabled temporarily. Please wait and try again later."); $wishlist = $this->getWishlist(); $customerWishlist = $wishlist['customer']['wishlists'][0]; @@ -148,7 +145,7 @@ public function testAddItemsToCartForGuestUser(): void $query = $this->getQuery($wishlistId, $itemId); - $this->graphQlMutation($query); + $this->graphQlMutation($query, [], '', ['Authorization' => 'Bearer test_token']); } /** @@ -209,14 +206,12 @@ public function testAddItemsToCartWithInvalidItemId(): void $query = $this->getQuery($customerWishlist['id'], $itemId); $this->graphQlMutation($query, [], '', $this->getHeaderMap()); } - - /** - * Add all items from customer's wishlist to cart - * - * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php - * @magentoConfigFixture wishlist/general/active 1 - * @magentoApiDataFixture Magento/Wishlist/_files/wishlist_with_simple_product.php - */ + /** Add all items from customer's wishlist to cart + * + * @magentoApiDataFixture Magento/GraphQl/Catalog/_files/simple_product.php + * @magentoConfigFixture wishlist/general/active 1 + * @magentoApiDataFixture Magento/Wishlist/_files/wishlist_with_simple_product.php + */ public function testAddAllWishlistItemsToCart(): void { $wishlist = $this->getWishlist(); @@ -442,4 +437,6 @@ private function getCustomerCartQuery(): string } QUERY; } + + }