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 000000000000..1739a118315a --- /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 000000000000..7c3a0b91dade --- /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 000000000000..b7f54ec60a66 --- /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 000000000000..433da7209af6 --- /dev/null +++ b/app/code/Magento/CustomerGraphQl/Model/ValidateAddressRequest.php @@ -0,0 +1,38 @@ + @@ -104,7 +115,7 @@ 20 - 300 + 1000 diff --git a/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php b/app/code/Magento/OrderCancellationGraphQl/Model/CancelOrderGuest.php index 038e4fa74af1..dd93816d18ee 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 4f9e7979ab9b..e627d4fa0310 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 86a381efc54b..c1e2f3201411 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 000000000000..f6a171404548 --- /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 d067855bbd28..000000000000 --- 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 000000000000..f1315c123900 --- /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 485cc6577ec2..000000000000 --- 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 8a7e8cad1ff4..e6012f56f7e7 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 0bd8887b914f..000000000000 --- 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 000000000000..b3d7eafed587 --- /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 ebc9befc8a0f..5f3736cd967f 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 6d426c470a23..1ef73c99f9ce 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 d38fc3a2dc3e..a1da56ef74a5 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 e0d8f38c432d..a323ca635595 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.") @@ -11,8 +12,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 +26,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/app/code/Magento/Quote/Api/ErrorInterface.php b/app/code/Magento/Quote/Api/ErrorInterface.php new file mode 100644 index 000000000000..3e93c2e71087 --- /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 2b014b4366f3..8b914f9d5edf 100644 --- a/app/code/Magento/Quote/Model/Cart/AddProductsToCartError.php +++ b/app/code/Magento/Quote/Model/Cart/AddProductsToCartError.php @@ -1,55 +1,48 @@ 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 42b14b06d94a..bc5f3e1ef91d 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 000000000000..4f49795692b1 --- /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 04be517537b0..328633b7f55e 100644 --- a/app/code/Magento/Quote/etc/di.xml +++ b/app/code/Magento/Quote/etc/di.xml @@ -1,8 +1,19 @@ @@ -166,4 +177,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 000000000000..4add0607805e --- /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 000000000000..de3ec7d27487 --- /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 000000000000..9e8cded85dfe --- /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 8764b4c2c2d2..68ad5935af17 100644 --- a/app/code/Magento/QuoteGraphQl/etc/schema.graphqls +++ b/app/code/Magento/QuoteGraphQl/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 { """phpcs:ignore Magento2.GraphQL.ValidArgumentName""" @@ -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/app/code/Magento/Sales/Model/InsertOrderStatusChangeHistory.php b/app/code/Magento/Sales/Model/InsertOrderStatusChangeHistory.php new file mode 100644 index 000000000000..8a3b03b9e3fd --- /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 000000000000..821de316be74 --- /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 000000000000..984f4b7523cd --- /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 c01354500eb7..cc48024217d0 100644 --- a/app/code/Magento/Sales/etc/db_schema.xml +++ b/app/code/Magento/Sales/etc/db_schema.xml @@ -1,8 +1,19 @@ + + + + + + + + + +
diff --git a/app/code/Magento/Sales/etc/db_schema_whitelist.json b/app/code/Magento/Sales/etc/db_schema_whitelist.json index 664c65d36c3c..6670f1e3866a 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 b3a7a4ab9957..17a411ddaf65 100644 --- a/app/code/Magento/Sales/etc/events.xml +++ b/app/code/Magento/Sales/etc/events.xml @@ -1,8 +1,19 @@ @@ -56,4 +67,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 000000000000..eba8c4ec9f86 --- /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/graphql/di.xml b/app/code/Magento/SalesGraphQl/etc/graphql/di.xml index 7969587eb659..155ba2eca0f3 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 e1559dc18aee..4e291a0a2623 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) @@ -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 { @@ -315,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/Customer/GetCustomerAddressesV2Test.php b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetCustomerAddressesV2Test.php new file mode 100644 index 000000000000..8a4200be746d --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/GraphQl/Customer/GetCustomerAddressesV2Test.php @@ -0,0 +1,213 @@ + '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 <<graphQlMutation($query); } 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 bb8ece5d1e95..c63c989ee744 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 e13ea7fed85c..aeafa931715e 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) + ); } #[ 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 000000000000..2a4f9e5fdabe --- /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 << '$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 <<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 <<