From ac5781119023bb02339fd48e0bae1620d3a945a8 Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Tue, 19 Aug 2025 23:53:18 -0400 Subject: [PATCH 1/2] fix: Add direct or/and syntax support to ProductTaxonomyInput and fix test issues --- .gitignore | 2 + bin/_lib.sh | 6 +- composer.lock | 131 ++++--- includes/class-type-registry.php | 3 + includes/class-wp-graphql-woocommerce.php | 3 + includes/connection/class-comments.php | 2 +- .../class-product-connection-resolver.php | 99 +++--- .../data/mutation/class-checkout-mutation.php | 1 + includes/model/class-order.php | 13 + includes/mutation/class-order-note-create.php | 142 ++++++++ includes/mutation/class-order-note-delete.php | 141 ++++++++ .../input/class-product-taxonomy-input.php | 8 + .../type/object/class-order-note-type.php | 84 +++++ includes/type/object/class-order-type.php | 5 +- tests/wpunit/CartMutationsTest.php | 2 +- tests/wpunit/CheckoutMutationTest.php | 63 ++-- tests/wpunit/OrderMutationsTest.php | 193 ++++++++++- tests/wpunit/PaymentGatewayQueriesTest.php | 2 +- tests/wpunit/ProductsQueriesTest.php | 319 +++++++++++++++++- vendor-prefixed/firebase/php-jwt/src/JWT.php | 4 +- 20 files changed, 1085 insertions(+), 138 deletions(-) create mode 100644 includes/mutation/class-order-note-create.php create mode 100644 includes/mutation/class-order-note-delete.php create mode 100644 includes/type/object/class-order-note-type.php diff --git a/.gitignore b/.gitignore index 8d3eea72..141235ef 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ codeception.yml .editorconfig plugin-build bin/strauss.phar +CLAUDE.md +.claude diff --git a/bin/_lib.sh b/bin/_lib.sh index dd7cc3af..22a0f175 100755 --- a/bin/_lib.sh +++ b/bin/_lib.sh @@ -62,7 +62,10 @@ remove_wordpress() { install_local_test_library() { # Install testing library dependencies. composer install + # Pin behat/gherkin to a version compatible with codeception 4.2.2 + # Versions 4.12+ introduced breaking changes that broke codeception's path-based i18n loading composer require --dev -W \ + "behat/gherkin:^4.8 <4.12" \ "lucatume/wp-browser:>3.1 <3.5" \ phpunit/phpunit:^9.6 \ codeception/lib-asserts:* \ @@ -102,7 +105,8 @@ remove_local_test_library() { codeception/util-universalframework \ lucatume/wp-browser \ stripe/stripe-php \ - fakerphp/faker + fakerphp/faker \ + behat/gherkin } cleanup_composer_file() { diff --git a/composer.lock b/composer.lock index 802e32e7..ccf1b762 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "775e640cc122cba00a8653eb87c9dcf8", + "content-hash": "39cb5d24356b65d01b55bfdf193eee0d", "packages": [ { "name": "firebase/php-jwt", - "version": "v6.11.0", + "version": "v6.11.1", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712" + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/8f718f4dfc9c5d5f0c994cdfd103921b43592712", - "reference": "8f718f4dfc9c5d5f0c994cdfd103921b43592712", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", + "reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66", "shasum": "" }, "require": { @@ -65,9 +65,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v6.11.0" + "source": "https://github.com/firebase/php-jwt/tree/v6.11.1" }, - "time": "2025-01-23T05:11:06+00:00" + "time": "2025-04-09T20:32:01+00:00" } ], "packages-dev": [ @@ -1358,16 +1358,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -1380,7 +1380,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1405,7 +1405,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -1421,11 +1421,11 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -1484,7 +1484,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -1495,6 +1495,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -1504,16 +1508,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", "shasum": "" }, "require": { @@ -1562,7 +1566,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" }, "funding": [ { @@ -1573,16 +1577,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-06-27T09:58:17+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -1643,7 +1651,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -1654,6 +1662,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -1663,19 +1675,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -1723,7 +1736,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -1734,16 +1747,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php73", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php73.git", @@ -1799,7 +1816,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" }, "funding": [ { @@ -1810,6 +1827,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -1819,16 +1840,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -1879,7 +1900,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -1890,25 +1911,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", "shasum": "" }, "require": { @@ -1926,7 +1951,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1962,7 +1987,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" }, "funding": [ { @@ -1978,20 +2003,20 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-04-25T09:37:31+00:00" }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v6.4.24", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "f0ce0bd36a3accb4a225435be077b4b4875587f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/f0ce0bd36a3accb4a225435be077b4b4875587f4", + "reference": "f0ce0bd36a3accb4a225435be077b4b4875587f4", "shasum": "" }, "require": { @@ -2048,7 +2073,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v6.4.24" }, "funding": [ { @@ -2059,12 +2084,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-13T13:31:12+00:00" + "time": "2025-07-10T08:14:14+00:00" }, { "name": "szepeviktor/phpstan-wordpress", diff --git a/includes/class-type-registry.php b/includes/class-type-registry.php index 5b04240f..eb4f9ad1 100644 --- a/includes/class-type-registry.php +++ b/includes/class-type-registry.php @@ -101,6 +101,7 @@ public function init() { Type\WPObject\Product_Attribute_Types::register(); Type\WPObject\Order_Item_Type::register(); Type\WPObject\Order_Type::register(); + Type\WPObject\Order_Note_Type::register(); Type\WPObject\Refund_Type::register(); Type\WPObject\Product_Download_Type::register(); Type\WPObject\Customer_Type::register(); @@ -177,6 +178,8 @@ public function init() { Mutation\Order_Update::register_mutation(); Mutation\Order_Delete::register_mutation(); Mutation\Order_Delete_Items::register_mutation(); + Mutation\Order_Note_Create::register_mutation(); + Mutation\Order_Note_Delete::register_mutation(); Mutation\Checkout::register_mutation(); Mutation\Review_Write::register_mutation(); Mutation\Review_Update::register_mutation(); diff --git a/includes/class-wp-graphql-woocommerce.php b/includes/class-wp-graphql-woocommerce.php index 7156baeb..440eeb92 100644 --- a/includes/class-wp-graphql-woocommerce.php +++ b/includes/class-wp-graphql-woocommerce.php @@ -274,6 +274,7 @@ private function includes() { require $include_directory_path . 'type/object/class-meta-data-type.php'; require $include_directory_path . 'type/object/class-order-item-type.php'; require $include_directory_path . 'type/object/class-order-type.php'; + require $include_directory_path . 'type/object/class-order-note-type.php'; require $include_directory_path . 'type/object/class-payment-gateway-type.php'; require $include_directory_path . 'type/object/class-product-attribute-types.php'; require $include_directory_path . 'type/object/class-product-category-type.php'; @@ -337,6 +338,8 @@ private function includes() { require $include_directory_path . 'mutation/class-order-create.php'; require $include_directory_path . 'mutation/class-order-delete-items.php'; require $include_directory_path . 'mutation/class-order-delete.php'; + require $include_directory_path . 'mutation/class-order-note-create.php'; + require $include_directory_path . 'mutation/class-order-note-delete.php'; require $include_directory_path . 'mutation/class-order-update.php'; require $include_directory_path . 'mutation/class-review-write.php'; require $include_directory_path . 'mutation/class-review-delete-restore.php'; diff --git a/includes/connection/class-comments.php b/includes/connection/class-comments.php index 968fd3b5..fb11519f 100644 --- a/includes/connection/class-comments.php +++ b/includes/connection/class-comments.php @@ -71,7 +71,7 @@ public static function register_connections() { self::get_connection_config( [ 'fromType' => 'Order', - 'toType' => 'Comment', + 'toType' => 'OrderNote', 'fromFieldName' => 'orderNotes', 'edgeFields' => [ 'isCustomerNote' => [ diff --git a/includes/data/connection/class-product-connection-resolver.php b/includes/data/connection/class-product-connection-resolver.php index 8a7c4446..7fd96790 100644 --- a/includes/data/connection/class-product-connection-resolver.php +++ b/includes/data/connection/class-product-connection-resolver.php @@ -319,6 +319,57 @@ public function add_search_query_clause( $args, $wp_query ) { return $args; } + /** + * Process taxonomy filters for taxonomyFilter argument. + * + * @param array $filters Array of taxonomy filters. + * @param string $relation The relation between filters (AND/OR). + * + * @return array + */ + private function process_taxonomy_filters( array $filters, string $relation ) { + $tax_groups = []; + + foreach ( $filters as $filter ) { + $common = [ + 'taxonomy' => $filter['taxonomy'], + 'operator' => ! empty( $filter['operator'] ) ? $filter['operator'] : 'IN', + ]; + + if ( ! empty( $filter['ids'] ) ) { + $tax_groups[] = array_merge( + $common, + [ + 'field' => 'ID', + 'terms' => $filter['ids'], + ] + ); + } + + if ( ! empty( $filter['terms'] ) ) { + $tax_groups[] = array_merge( + $common, + [ + 'field' => 'slug', + 'terms' => $filter['terms'], + ] + ); + } + }//end foreach + + if ( empty( $tax_groups ) ) { + return []; + } + + if ( 1 === count( $tax_groups ) ) { + return $tax_groups[0]; + } + + // Add relation if there are multiple groups. + $tax_groups['relation'] = $relation; + return $tax_groups; + } + /** * This sets up the "allowed" args, and translates the GraphQL-friendly keys to WP_Query * friendly keys. There's probably a cleaner/more dynamic way to approach this, but @@ -630,45 +681,17 @@ public function sanitize_input_fields( array $where_args ) { $tax_filter_query = []; if ( ! empty( $where_args['taxonomyFilter'] ) ) { $taxonomy_query = $where_args['taxonomyFilter']; - $relation = ! empty( $taxonomy_query['relation'] ) ? $taxonomy_query['relation'] : 'AND'; - - if ( ! empty( $taxonomy_query['filters'] ) ) { - $tax_groups = []; - foreach ( $taxonomy_query['filters'] as $filter ) { - $common = [ - 'taxonomy' => $filter['taxonomy'], - 'operator' => ! empty( $filter['operator'] ) ? $filter['operator'] : 'IN', - ]; - - if ( ! empty( $filter['ids'] ) ) { - $tax_groups[] = array_merge( - $common, - [ - 'field' => 'ID', - 'terms' => $filter['ids'], - ] - ); - } - if ( ! empty( $filter['terms'] ) ) { - $tax_groups[] = array_merge( - $common, - [ - 'field' => 'slug', - 'terms' => $filter['terms'], - ] - ); - } - }//end foreach - - if ( ! empty( $tax_groups ) ) { - array_push( $tax_filter_query, ...$tax_groups ); - } - - if ( 1 < count( $tax_filter_query ) ) { - $tax_filter_query['relation'] = $relation; - } - }//end if + // Handle new "or" and "and" syntax. + if ( ! empty( $taxonomy_query['or'] ) ) { + $tax_filter_query = $this->process_taxonomy_filters( $taxonomy_query['or'], 'OR' ); + } elseif ( ! empty( $taxonomy_query['and'] ) ) { + $tax_filter_query = $this->process_taxonomy_filters( $taxonomy_query['and'], 'AND' ); + } elseif ( ! empty( $taxonomy_query['filters'] ) ) { + // Handle legacy "relation" + "filters" syntax. + $relation = ! empty( $taxonomy_query['relation'] ) ? $taxonomy_query['relation'] : 'AND'; + $tax_filter_query = $this->process_taxonomy_filters( $taxonomy_query['filters'], $relation ); + } }//end if if ( ! empty( $tax_filter_query ) ) { diff --git a/includes/data/mutation/class-checkout-mutation.php b/includes/data/mutation/class-checkout-mutation.php index fd339dbe..d5f2b7ea 100644 --- a/includes/data/mutation/class-checkout-mutation.php +++ b/includes/data/mutation/class-checkout-mutation.php @@ -492,6 +492,7 @@ protected static function validate_checkout( &$data ) { if ( WC()->cart->needs_payment() ) { $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); + \codecept_debug( $available_gateways ); if ( ! isset( $available_gateways[ $data['payment_method'] ] ) ) { throw new UserError( __( 'Invalid payment method.', 'wp-graphql-woocommerce' ) ); } else { diff --git a/includes/model/class-order.php b/includes/model/class-order.php index 3b95a786..3362de74 100644 --- a/includes/model/class-order.php +++ b/includes/model/class-order.php @@ -153,6 +153,19 @@ public function __construct( $id ) { 'commentStatus', ]; + if ( 'shop_order_refund' === $this->get_type() ) { + $allowed_restricted_fields = array_merge( + $allowed_restricted_fields, + [ + 'title', + 'amount', + 'reason', + 'refunded_by_id', + 'date', + ] + ); + } + $restricted_cap = $this->get_restricted_cap(); parent::__construct( $restricted_cap, $allowed_restricted_fields, 1 ); diff --git a/includes/mutation/class-order-note-create.php b/includes/mutation/class-order-note-create.php new file mode 100644 index 00000000..91c546e0 --- /dev/null +++ b/includes/mutation/class-order-note-create.php @@ -0,0 +1,142 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => self::mutate_and_get_payload(), + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'orderId' => [ + 'type' => 'ID', + 'description' => __( 'Database ID or global ID of the order', 'wp-graphql-woocommerce' ), + ], + 'note' => [ + 'type' => 'String', + 'description' => __( 'Order note.', 'wp-graphql-woocommerce' ), + ], + 'isCustomerNote' => [ + 'type' => 'Boolean', + 'description' => __( 'Shows/define if the note is only for reference or for the customer (the user will be notified).', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'orderNote' => [ + 'type' => 'OrderNote', + 'resolve' => static function ( $payload ) { + return $payload['note']; + }, + ], + 'order' => [ + 'type' => 'Order', + 'resolve' => static function ( $payload ) { + return $payload['order']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input, AppContext $context, ResolveInfo $info ) { + // Retrieve order ID. + $order_id = Utils::get_database_id_from_id( $input['orderId'] ); + + if ( ! $order_id ) { + throw new UserError( __( 'Order ID provided is invalid. Please check input and try again.', 'wp-graphql-woocommerce' ) ); + } + + // Check if authorized to create order notes. + if ( ! Order_Mutation::authorized( $input, $context, $info, 'create', $order_id ) ) { + throw new UserError( __( 'User does not have the capabilities necessary to create an order note.', 'wp-graphql-woocommerce' ) ); + } + + /** + * Get Order model instance for output. + * + * @var \WC_Order $order + */ + $order = new Order( $order_id ); + + if ( ! $order ) { + throw new UserError( __( 'Invalid order ID.', 'wp-graphql-woocommerce' ) ); + } + + $note_content = ! empty( $input['note'] ) ? $input['note'] : ''; + if ( empty( $note_content ) ) { + throw new UserError( __( 'Order note content is required.', 'wp-graphql-woocommerce' ) ); + } + + $is_customer_note = ! empty( $input['isCustomerNote'] ) ? $input['isCustomerNote'] : false; + + // Create the order note. + $note_id = $order->add_order_note( $note_content, $is_customer_note ); + + if ( ! $note_id ) { + throw new UserError( __( 'Unable to create order note.', 'wp-graphql-woocommerce' ) ); + } + + // Get the created note. + $note = get_comment( $note_id ); + $note->ID = $note_id; + + if ( ! $note ) { + throw new UserError( __( 'Unable to retrieve created order note.', 'wp-graphql-woocommerce' ) ); + } + + return [ + 'order' => $order, + 'note' => $note, + ]; + }; + } +} diff --git a/includes/mutation/class-order-note-delete.php b/includes/mutation/class-order-note-delete.php new file mode 100644 index 00000000..c4516485 --- /dev/null +++ b/includes/mutation/class-order-note-delete.php @@ -0,0 +1,141 @@ + self::get_input_fields(), + 'outputFields' => self::get_output_fields(), + 'mutateAndGetPayload' => self::mutate_and_get_payload(), + ] + ); + } + + /** + * Defines the mutation input field configuration + * + * @return array + */ + public static function get_input_fields() { + return [ + 'id' => [ + 'type' => 'ID', + 'description' => __( 'Database ID or global ID of the order note', 'wp-graphql-woocommerce' ), + ], + 'orderId' => [ + 'type' => 'ID', + 'description' => __( 'Database ID or global ID of the order', 'wp-graphql-woocommerce' ), + ], + 'force' => [ + 'type' => 'Boolean', + 'description' => __( 'Delete or simply place in trash.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'orderNote' => [ + 'type' => 'OrderNote', + 'resolve' => static function ( $payload ) { + return $payload['note']; + }, + ], + 'order' => [ + 'type' => 'Order', + 'resolve' => static function ( $payload ) { + return $payload['order']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input, AppContext $context, ResolveInfo $info ) { + // Retrieve order ID. + $order_id = Utils::get_database_id_from_id( $input['orderId'] ); + + if ( ! $order_id ) { + throw new UserError( __( 'Order ID provided is invalid. Please check input and try again.', 'wp-graphql-woocommerce' ) ); + } + + // Check if authorized to delete this order note. + if ( ! Order_Mutation::authorized( $input, $context, $info, 'delete', $order_id ) ) { + throw new UserError( __( 'User does not have the capabilities necessary to delete an order.', 'wp-graphql-woocommerce' ) ); + } + + if ( isset( $input['forceDelete'] ) && false === $input['forceDelete'] ) { + throw new UserError( __( 'woocommerce_rest_trash_not_supported', 'wp-graphql-woocommerce' ) ); + } + + /** + * Get Order model instance for output. + * + * @var \WC_Order $order + */ + $order = new Order( $order_id ); + + if ( ! $order ) { + throw new UserError( __( 'Invalid order ID.', 'wp-graphql-woocommerce' ) ); + } + + $id = Utils::get_database_id_from_id( $input['id'] ); + if ( ! $id ) { + throw new UserError( __( 'Order note ID provided is invalid. Please check input and try again.', 'wp-graphql-woocommerce' ) ); + } + + $note = get_comment( $id ); + + if ( empty( $note ) || intval( $note->comment_post_ID ) !== intval( $order->get_id() ) ) { + throw new UserError( __( 'Invalid resource ID.', 'wp-graphql-woocommerce' ) ); + } + + $result = wc_delete_order_note( $note->comment_ID ); + + if ( ! $result ) { + throw new UserError( __( 'Unable to delete order note.', 'wp-graphql-woocommerce' ) ); + } + + return [ + 'order' => $order, + 'note' => $note, + ]; + }; + } +} diff --git a/includes/type/input/class-product-taxonomy-input.php b/includes/type/input/class-product-taxonomy-input.php index 372700c2..6c1637e4 100644 --- a/includes/type/input/class-product-taxonomy-input.php +++ b/includes/type/input/class-product-taxonomy-input.php @@ -31,6 +31,14 @@ public static function register() { 'type' => [ 'list_of' => 'ProductTaxonomyFilterInput' ], 'description' => __( 'Product taxonomy rules to be filter results by', 'wp-graphql-woocommerce' ), ], + 'or' => [ + 'type' => [ 'list_of' => 'ProductTaxonomyFilterInput' ], + 'description' => __( 'Product taxonomy rules connected by OR logic', 'wp-graphql-woocommerce' ), + ], + 'and' => [ + 'type' => [ 'list_of' => 'ProductTaxonomyFilterInput' ], + 'description' => __( 'Product taxonomy rules connected by AND logic', 'wp-graphql-woocommerce' ), + ], ], ] ); diff --git a/includes/type/object/class-order-note-type.php b/includes/type/object/class-order-note-type.php new file mode 100644 index 00000000..f45aa7e6 --- /dev/null +++ b/includes/type/object/class-order-note-type.php @@ -0,0 +1,84 @@ + ['Node'], + 'eagerlyLoadType' => true, + 'description' => __( 'A order note', 'wp-graphql-woocommerce' ), + 'fields' => apply_filters( 'woographql_order_note_field_definitions', self::get_fields() ), + ] + ); + } + + /** + * Returns the "Order" type fields. + * + * @param array $other_fields Extra fields configs to be added or override the default field definitions. + * @return array + */ + public static function get_fields( $other_fields = [] ) { + return array_merge( + [ + 'id' => [ + 'type' => ['non_null' => 'ID'], + 'description' => __( 'Database ID or global ID of the order note', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $order_note ) { + return Relay::toGlobalId( 'order_note', $order_note->ID ); + }, + ], + 'databaseId' => [ + 'type' => 'Int', + 'description' => __( 'Database ID of the order note', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $order_note ) { + return $order_note->ID; + }, + ], + 'dateCreated' => [ + 'type' => 'String', + 'description' => __( 'The date the order note was created, in the site\'s timezone.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $order_note ) { + return $order_note->comment_date_gmt; + }, + ], + 'note' => [ + 'type' => 'String', + 'description' => __( 'Order note.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $order_note ) { + return $order_note->comment_content; + }, + ], + 'isCustomerNote' => [ + 'type' => 'Boolean', + 'description' => __( 'Whether the note is a customer note', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $order_note ) { + return (bool) get_comment_meta( $order_note->comment_ID, 'is_customer_note', true ); + }, + ], + ], + $other_fields + ); + } +} diff --git a/includes/type/object/class-order-type.php b/includes/type/object/class-order-type.php index 354d34e0..75714391 100644 --- a/includes/type/object/class-order-type.php +++ b/includes/type/object/class-order-type.php @@ -286,8 +286,9 @@ public static function get_fields( $other_fields = [] ) { 'description' => __( 'Cart hash', 'wp-graphql-woocommerce' ), ], 'customerNote' => [ - 'type' => 'String', - 'description' => __( 'Customer note', 'wp-graphql-woocommerce' ), + 'type' => 'String', + 'description' => __( 'Customer note', 'wp-graphql-woocommerce' ), + 'deprecatedReason' => __( 'Use "orderNotes" field instead.', 'wp-graphql-woocommerce' ), ], 'isDownloadPermitted' => [ 'type' => 'Boolean', diff --git a/tests/wpunit/CartMutationsTest.php b/tests/wpunit/CartMutationsTest.php index ff892b1f..740c043e 100644 --- a/tests/wpunit/CartMutationsTest.php +++ b/tests/wpunit/CartMutationsTest.php @@ -1260,7 +1260,7 @@ public function testFillCartMutationAndErrors() { 'cartErrors', [ $this->expectedField( 'type', 'INVALID_COUPON' ), - $this->expectedField( 'reasons', [ "Coupon \"{$invalid_coupon}\" does not exist!" ] ), + $this->expectedField( 'reasons', [ "Coupon "{$invalid_coupon}" cannot be applied because it does not exist." ] ), $this->expectedField( 'code', $invalid_coupon ), ] ), diff --git a/tests/wpunit/CheckoutMutationTest.php b/tests/wpunit/CheckoutMutationTest.php index 3ac4b089..a5f6bc0c 100644 --- a/tests/wpunit/CheckoutMutationTest.php +++ b/tests/wpunit/CheckoutMutationTest.php @@ -18,46 +18,31 @@ public function setUp(): void { update_option( 'woocommerce_enable_guest_checkout', 'yes' ); // Enable payment gateways. - update_option( - 'woocommerce_bacs_settings', - [ - 'enabled' => 'yes', - 'title' => 'Direct bank transfer', - 'description' => 'Make your payment directly into our bank account. Please use your Order ID as the payment reference. Your order will not be shipped until the funds have cleared in our account.', - 'instructions' => 'Instructions that will be added to the thank you page and emails.', - 'account' => '', - ] - ); - - update_option( - 'woocommerce_stripe_settings', - [ - 'enabled' => 'yes', - 'title' => 'Credit Card (Stripe)', - 'description' => 'Pay with your credit card via Stripe', - 'webhook' => '', - 'testmode' => 'yes', - 'test_publishable_key' => defined( 'STRIPE_API_PUBLISHABLE_KEY' ) - ? STRIPE_API_PUBLISHABLE_KEY - : getenv( 'STRIPE_API_PUBLISHABLE_KEY' ), - 'test_secret_key' => defined( 'STRIPE_API_SECRET_KEY' ) - ? STRIPE_API_SECRET_KEY - : getenv( 'STRIPE_API_SECRET_KEY' ), - 'test_webhook_secret' => '', - 'publishable_key' => '', - 'secret_key' => '', - 'webhook_secret' => '', - 'inline_cc_form' => 'no', - 'statement_descriptor' => '', - 'capture' => 'yes', - 'payment_request' => 'yes', - 'payment_request_button_type' => 'buy', - 'payment_request_button_theme' => 'dark', - 'payment_request_button_height' => '44', - 'saved_cards' => 'yes', - 'logging' => 'no', - ] + $gateways = \WC()->payment_gateways->payment_gateways(); + $bacs_gateway = $gateways['bacs']; + $bacs_gateway->settings['enabled'] = 'yes'; + update_option( $bacs_gateway->get_option_key(), $bacs_gateway->settings ); + $stripe_settings = WC_Stripe_Helper::get_stripe_settings(); + $stripe_settings['enabled'] = 'yes'; + $stripe_settings['testmode'] = 'yes'; + $stripe_settings['test_publishable_key'] = defined( 'STRIPE_API_PUBLISHABLE_KEY' ) + ? STRIPE_API_PUBLISHABLE_KEY + : getenv( 'STRIPE_API_PUBLISHABLE_KEY' ); + $stripe_settings['test_secret_key'] = defined( 'STRIPE_API_SECRET_KEY' ) + ? STRIPE_API_SECRET_KEY + : getenv( 'STRIPE_API_SECRET_KEY' ); + WC_Stripe_Helper::update_main_stripe_settings( $stripe_settings ); + $_SERVER['HTTPS'] = false; + add_filter( 'wc_stripe_is_upe_checkout_enabled', '__return_false' ); + add_filter( + 'woocommerce_available_payment_gateways', + function( $available_gateways ) { + $stripe_gateway = new WC_Gateway_Stripe(); + $available_gateways[ $stripe_gateway->id ] = $stripe_gateway; + return $available_gateways; + } ); + \WC()->payment_gateways->init(); // Additional cart fees. add_action( diff --git a/tests/wpunit/OrderMutationsTest.php b/tests/wpunit/OrderMutationsTest.php index b3977f82..baaf2437 100644 --- a/tests/wpunit/OrderMutationsTest.php +++ b/tests/wpunit/OrderMutationsTest.php @@ -2,7 +2,7 @@ use WPGraphQL\Type\WPEnumType; -class OrderMutationsTest extends \Codeception\TestCase\WPTestCase { +class OrderMutationsTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQLTestCase { public function setUp(): void { // before parent::setUp(); @@ -1089,4 +1089,195 @@ public function testDeleteOrderItemsMutation() { $this->assertFalse( \WC_Order_Factory::get_order_item( current( $line_items ) ) ); $this->assertFalse( \WC_Order_Factory::get_order_item( current( $coupon_lines ) ) ); } + + private function orderNoteMutation( $input, $operation_name = 'createOrderNote', $input_type = 'CreateOrderNoteInput' ) { + $mutation = " + mutation {$operation_name}( \$input: {$input_type}! ) { + {$operation_name}( input: \$input ) { + clientMutationId + orderNote { + id + databaseId + dateCreated + note + isCustomerNote + } + order { + id + databaseId + } + } + } + "; + + return $this->graphql( + [ + 'query' => $mutation, + 'operation_name' => $operation_name, + 'variables' => [ 'input' => $input ], + ] + ); + } + + public function testCreateOrderNoteMutation() { + $customer_id = $this->factory->customer->create(); + $order_id = $this->factory->order->createNew([ + 'customer_id' => $customer_id, + ]); + $input = [ + 'clientMutationId' => 'someId', + 'orderId' => $order_id, + 'note' => 'Test order note content', + 'isCustomerNote' => false, + ]; + + /** + * Assertion One + * + * User without necessary capabilities cannot create an order note. + */ + $this->loginAsCustomer(); + $actual = $this->orderNoteMutation( $input ); + + $this->assertQueryError( $actual ); + + /** + * Assertion Two + * + * Test mutation and input. + */ + $this->loginAsShopManager(); + $actual = $this->orderNoteMutation( $input ); + + $expected = [ + $this->expectedField( 'createOrderNote.clientMutationId', 'someId' ), + $this->expectedField( 'createOrderNote.orderNote.note', 'Test order note content' ), + $this->expectedField( 'createOrderNote.orderNote.isCustomerNote', false ), + $this->expectedField( 'createOrderNote.orderNote.id', self::NOT_NULL ), + $this->expectedField( 'createOrderNote.orderNote.databaseId', self::NOT_NULL ), + $this->expectedField( 'createOrderNote.orderNote.dateCreated', self::NOT_NULL ), + $this->expectedField( 'createOrderNote.order.id', $this->toRelayId( 'order', $order_id ) ), + $this->expectedField( 'createOrderNote.order.databaseId', $order_id ), + ]; + + $this->assertQuerySuccessful( $actual, $expected ); + + // Test customer note + $customer_input = [ + 'clientMutationId' => 'customerId', + 'orderId' => $order_id, + 'note' => 'Customer visible note', + 'isCustomerNote' => true, + ]; + + $customer_actual = $this->orderNoteMutation( $customer_input ); + + $customer_expected = [ + $this->expectedField( 'createOrderNote.orderNote.note', 'Customer visible note' ), + $this->expectedField( 'createOrderNote.orderNote.isCustomerNote', true ), + ]; + + $this->assertQuerySuccessful( $customer_actual, $customer_expected ); + + + /** + * Assertion Three + * + * Test mutation and input + */ + $this->loginAs( $customer_id ); + $customer_input = [ + 'clientMutationId' => 'customerId', + 'orderId' => $order_id, + 'note' => 'Customer visible note', + 'isCustomerNote' => true, + ]; + + $customer_actual = $this->orderNoteMutation( $customer_input ); + + $customer_expected = [ + $this->expectedField( 'createOrderNote.orderNote.note', 'Customer visible note' ), + $this->expectedField( 'createOrderNote.orderNote.isCustomerNote', true ), + ]; + + $this->assertQuerySuccessful( $customer_actual, $customer_expected ); + } + + public function testDeleteOrderNoteMutation() { + // First create a note to delete + $this->loginAsShopManager(); + $create_input = [ + 'clientMutationId' => 'createId', + 'orderId' => $this->order_id, + 'note' => 'Note to be deleted', + 'isCustomerNote' => false, + ]; + + $create_result = $this->orderNoteMutation( $create_input ); + // Get note ID from the proper expected field result + $note_id = $this->lodashGet( $create_result, 'data.createOrderNote.orderNote.databaseId' ); + + $delete_input = [ + 'clientMutationId' => 'deleteId', + 'id' => $note_id, + 'orderId' => $this->order_id, + 'force' => true, + ]; + + /** + * Assertion One + * + * User without necessary capabilities cannot delete an order note. + */ + $this->loginAsCustomer(); + $actual = $this->orderNoteMutation( $delete_input, 'deleteOrderNote', 'DeleteOrderNoteInput' ); + + $this->assertQueryError( $actual ); + + /** + * Assertion Two + * + * Test mutation and input. + */ + $this->loginAsShopManager(); + $actual = $this->orderNoteMutation( $delete_input, 'deleteOrderNote', 'DeleteOrderNoteInput' ); + + $expected = [ + $this->expectedField( 'deleteOrderNote.clientMutationId', 'deleteId' ), + $this->expectedField( 'deleteOrderNote.orderNote.note', 'Note to be deleted' ), + $this->expectedField( 'deleteOrderNote.order.databaseId', $this->order_id ), + ]; + + $this->assertQuerySuccessful( $actual, $expected ); + + // Verify the note was deleted by checking it doesn't exist + $deleted_note = get_comment( $note_id ); + $this->assertNull( $deleted_note ); + } + + public function testCreateOrderNoteValidation() { + $this->loginAsShopManager(); + + // Test missing note content + $invalid_input = [ + 'clientMutationId' => 'invalidId', + 'orderId' => $this->order_id, + 'note' => '', + 'isCustomerNote' => false, + ]; + + $actual = $this->orderNoteMutation( $invalid_input ); + $this->assertQueryError( $actual ); + + // Test invalid order ID + $invalid_order_input = [ + 'clientMutationId' => 'invalidOrderId', + 'orderId' => 99999, + 'note' => 'Valid note content', + 'isCustomerNote' => false, + ]; + + $actual = $this->orderNoteMutation( $invalid_order_input ); + $this->assertQueryError( $actual ); + } } diff --git a/tests/wpunit/PaymentGatewayQueriesTest.php b/tests/wpunit/PaymentGatewayQueriesTest.php index 73607214..f4d85420 100644 --- a/tests/wpunit/PaymentGatewayQueriesTest.php +++ b/tests/wpunit/PaymentGatewayQueriesTest.php @@ -147,7 +147,7 @@ public function testPaymentGatewaysQueryAndWhereArgs() { 'paymentGateways.nodes', [ $this->expectedField( 'id', 'stripe' ), - $this->expectedField( 'title', 'Credit Card (Stripe)' ), + $this->expectedField( 'title', 'Credit / Debit Card' ), $this->expectedField( 'icon', static::IS_NULL ), ] ), diff --git a/tests/wpunit/ProductsQueriesTest.php b/tests/wpunit/ProductsQueriesTest.php index 5c6cddb7..b530ef16 100644 --- a/tests/wpunit/ProductsQueriesTest.php +++ b/tests/wpunit/ProductsQueriesTest.php @@ -592,7 +592,70 @@ static function ( $node, $index ) use ( $product_ids, $category_4, $category_3 ) $this->assertQuerySuccessful( $response, $expected ); /** - * Assertion 17-18 + * Assertion Seventeen + * + * Tests "taxonomyFilter" with new "or" syntax + */ + $variables = [ + 'taxonomyFilter' => [ + 'or' => [ + [ + 'taxonomy' => 'PRODUCT_CAT', + 'terms' => [ 'category-three' ], + ], + [ + 'taxonomy' => 'PRODUCT_CAT', + 'terms' => [ 'category-four' ], + ], + ], + ], + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_filter( + $all_expected_product_nodes, + static function ( $node, $index ) use ( $product_ids, $category_4, $category_3 ) { + $product = \wc_get_product( $product_ids[ $index ] ); + return in_array( $category_4, $product->get_category_ids(), true ) + || in_array( $category_3, $product->get_category_ids(), true ); + }, + ARRAY_FILTER_USE_BOTH + ); + $this->assertQuerySuccessful( $response, $expected ); + + /** + * Assertion Eighteen + * + * Tests "taxonomyFilter" with new "and" syntax + */ + $variables = [ + 'taxonomyFilter' => [ + 'and' => [ + [ + 'taxonomy' => 'PRODUCT_CAT', + 'terms' => [ 'category-three' ], + ], + [ + 'taxonomy' => 'PRODUCT_CAT', + 'terms' => [ 'category-four' ], + 'operator' => 'NOT_IN', + ], + ], + ], + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_filter( + $all_expected_product_nodes, + static function ( $node, $index ) use ( $product_ids, $category_4, $category_3 ) { + $product = \wc_get_product( $product_ids[ $index ] ); + return ! in_array( $category_4, $product->get_category_ids(), true ) + && in_array( $category_3, $product->get_category_ids(), true ); + }, + ARRAY_FILTER_USE_BOTH + ); + $this->assertQuerySuccessful( $response, $expected ); + + /** + * Assertion 19-20 * * Tests "include" where argument */ @@ -1113,4 +1176,258 @@ public function testProductsSearchArg() { 'Failed to search products by product slug.' ); } + + public function testProductsQueryWithNewTaxonomyFilterSyntax() { + // Create test categories using WooCommerce factory + $category1 = $this->factory->product->createProductCategory('electronics'); + $category2 = $this->factory->product->createProductCategory('clothing'); + $category3 = $this->factory->product->createProductCategory('books'); + + $products = [ + // Product in Electronics category + $this->factory->product->createSimple([ + 'name' => 'Laptop', + 'category_ids' => [$category1], + ]), + // Product in Clothing category + $this->factory->product->createSimple([ + 'name' => 'T-shirt', + 'category_ids' => [$category2], + ]), + // Product in both Electronics and Books categories + $this->factory->product->createSimple([ + 'name' => 'E-book Reader', + 'category_ids' => [$category1, $category3], + ]), + // Product in Books category only + $this->factory->product->createSimple([ + 'name' => 'Novel', + 'category_ids' => [$category3], + ]), + ]; + + // Query using new "or" syntax + $query = ' + query testProductsWithTaxonomyOr($taxonomyFilter: ProductTaxonomyInput) { + products(where: {taxonomyFilter: $taxonomyFilter}) { + nodes { + id + databaseId + name + } + } + } + '; + + // Test OR syntax - should return products from Electronics OR Books + $variables = [ + 'taxonomyFilter' => [ + 'or' => [ + [ + 'taxonomy' => 'PRODUCT_CAT', + 'ids' => [$category1], + 'operator' => 'IN', + ], + [ + 'taxonomy' => 'PRODUCT_CAT', + 'ids' => [$category3], + 'operator' => 'IN', + ], + ], + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedNode( + 'products.nodes', + [ + $this->expectedField( 'id', $this->toRelayId( 'post', $products[0] ) ), + $this->expectedField( 'name', 'Laptop' ), + ] + ), + $this->expectedNode( + 'products.nodes', + [ + $this->expectedField( 'id', $this->toRelayId( 'post', $products[2] ) ), + $this->expectedField( 'name', 'E-book Reader' ), + ] + ), + $this->expectedNode( + 'products.nodes', + [ + $this->expectedField( 'id', $this->toRelayId( 'post', $products[3] ) ), + $this->expectedField( 'name', 'Novel' ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected, 'OR taxonomy filter should work correctly' ); + + // Test AND syntax - should return products that have BOTH Electronics AND Books + $variables = [ + 'taxonomyFilter' => [ + 'and' => [ + [ + 'taxonomy' => 'PRODUCT_CAT', + 'ids' => [$category1], + 'operator' => 'IN', + ], + [ + 'taxonomy' => 'PRODUCT_CAT', + 'ids' => [$category3], + 'operator' => 'IN', + ], + ], + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedNode( + 'products.nodes', + [ + $this->expectedField( 'id', $this->toRelayId( 'post', $products[2] ) ), + $this->expectedField( 'name', 'E-book Reader' ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected, 'AND taxonomy filter should work correctly' ); + } + + public function testProductsQueryWithLegacyTaxonomyFilterSyntax() { + // Create test categories using WooCommerce factory + $category1 = $this->factory->product->createProductCategory('legacy-electronics'); + $category2 = $this->factory->product->createProductCategory('legacy-clothing'); + + $products = [ + $this->factory->product->createSimple([ + 'name' => 'Legacy Laptop', + 'category_ids' => [$category1], + ]), + $this->factory->product->createSimple([ + 'name' => 'Legacy T-shirt', + 'category_ids' => [$category2], + ]), + ]; + + // Test legacy syntax still works + $query = ' + query testProductsWithLegacyTaxonomy($taxonomyFilter: ProductTaxonomyInput) { + products(where: {taxonomyFilter: $taxonomyFilter}) { + nodes { + id + databaseId + name + } + } + } + '; + + $variables = [ + 'taxonomyFilter' => [ + 'relation' => 'OR', + 'filters' => [ + [ + 'taxonomy' => 'PRODUCT_CAT', + 'ids' => [$category1], + 'operator' => 'IN', + ], + [ + 'taxonomy' => 'PRODUCT_CAT', + 'ids' => [$category2], + 'operator' => 'IN', + ], + ], + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedNode( + 'products.nodes', + [ + $this->expectedField( 'id', $this->toRelayId( 'post', $products[0] ) ), + $this->expectedField( 'name', 'Legacy Laptop' ), + ] + ), + $this->expectedNode( + 'products.nodes', + [ + $this->expectedField( 'id', $this->toRelayId( 'post', $products[1] ) ), + $this->expectedField( 'name', 'Legacy T-shirt' ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected, 'Legacy taxonomy filter syntax should work correctly' ); + } + + public function testTaxonomyFilterPriority() { + // Test that new syntax takes priority over legacy syntax + $category1 = $this->factory->product->createProductCategory('priority-electronics'); + $category2 = $this->factory->product->createProductCategory('priority-clothing'); + + $products = [ + $this->factory->product->createSimple([ + 'name' => 'Priority Laptop', + 'category_ids' => [$category1], + ]), + $this->factory->product->createSimple([ + 'name' => 'Priority T-shirt', + 'category_ids' => [$category2], + ]), + ]; + + $query = ' + query testTaxonomyPriority($taxonomyFilter: ProductTaxonomyInput) { + products(where: {taxonomyFilter: $taxonomyFilter}) { + nodes { + id + databaseId + name + } + } + } + '; + + // Use both new "or" syntax and legacy "filters" syntax - "or" should take priority + $variables = [ + 'taxonomyFilter' => [ + 'or' => [ + [ + 'taxonomy' => 'PRODUCT_CAT', + 'ids' => [$category1], + 'operator' => 'IN', + ], + ], + 'relation' => 'OR', + 'filters' => [ + [ + 'taxonomy' => 'PRODUCT_CAT', + 'ids' => [$category2], + 'operator' => 'IN', + ], + ], + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedNode( + 'products.nodes', + [ + $this->expectedField( 'id', $this->toRelayId( 'post', $products[0] ) ), + $this->expectedField( 'name', 'Priority Laptop' ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected, 'New OR syntax should take priority over legacy filters syntax' ); + } } diff --git a/vendor-prefixed/firebase/php-jwt/src/JWT.php b/vendor-prefixed/firebase/php-jwt/src/JWT.php index fd868c0c..fb006a93 100644 --- a/vendor-prefixed/firebase/php-jwt/src/JWT.php +++ b/vendor-prefixed/firebase/php-jwt/src/JWT.php @@ -160,7 +160,7 @@ public static function decode( // token can actually be used. If it's not yet that time, abort. if (isset($payload->nbf) && floor($payload->nbf) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) $payload->nbf) + 'Cannot handle token with nbf prior to ' . \date(DateTime::ISO8601, (int) floor($payload->nbf)) ); $ex->setPayload($payload); throw $ex; @@ -171,7 +171,7 @@ public static function decode( // correctly used the nbf claim). if (!isset($payload->nbf) && isset($payload->iat) && floor($payload->iat) > ($timestamp + static::$leeway)) { $ex = new BeforeValidException( - 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) $payload->iat) + 'Cannot handle token with iat prior to ' . \date(DateTime::ISO8601, (int) floor($payload->iat)) ); $ex->setPayload($payload); throw $ex; From 39661989d801967e137941e5481c4ecfccfd232d Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Wed, 20 Aug 2025 01:01:45 -0400 Subject: [PATCH 2/2] fix: Add OrderNote visibility filters for queries and mutations --- includes/class-core-schema-filters.php | 89 +++++++++++++++++++ includes/mutation/class-order-note-create.php | 10 +-- includes/mutation/class-order-note-delete.php | 9 +- .../type/object/class-order-note-type.php | 10 +-- tests/wpunit/OrderQueriesTest.php | 63 +++++++++++++ 5 files changed, 167 insertions(+), 14 deletions(-) diff --git a/includes/class-core-schema-filters.php b/includes/class-core-schema-filters.php index c8045ae2..d3c01bdc 100644 --- a/includes/class-core-schema-filters.php +++ b/includes/class-core-schema-filters.php @@ -78,6 +78,22 @@ public static function add_filters() { 3 ); + // Filter to allow order notes to be visible in GraphQL queries. + add_filter( + 'graphql_data_is_private', + [ self::class, 'make_order_notes_visible' ], + 10, + 3 + ); + + // Filter to set order notes visibility to public for authorized users. + add_filter( + 'graphql_object_visibility', + [ self::class, 'set_order_notes_visibility' ], + 10, + 5 + ); + add_filter( 'graphql_dataloader_get_model', [ '\WPGraphQL\WooCommerce\Data\Loader\WC_Customer_Loader', 'inject_user_loader_models' ], @@ -433,4 +449,77 @@ public static function resolve_product_variation_type( $value ) { ) ); } + + /** + * Filter to make order notes visible in GraphQL queries for authorized users. + * + * @param bool $is_private Whether the data is private. + * @param string $model_name The name of the model being checked. + * @param mixed $data The data being checked. + * + * @return bool + */ + public static function make_order_notes_visible( $is_private, $model_name, $data ) { + // Only apply to Comment models. + if ( 'CommentObject' !== $model_name ) { + return $is_private; + } + + // Check if this is an order note. + if ( $data instanceof \WP_Comment && 'order_note' === $data->comment_type ) { + // Get the parent order. + $order_id = absint( $data->comment_post_ID ); + $order = wc_get_order( $order_id ); + + if ( ! $order ) { + return true; // Keep it private if order not found. + } + + // Allow shop managers and admins to see all order notes. + if ( current_user_can( 'edit_shop_orders' ) ) { + return false; // Not private. + } + + // Allow customers to see customer notes on their own orders. + $is_customer_note = get_comment_meta( $data->comment_ID, 'is_customer_note', true ); + if ( $is_customer_note && get_current_user_id() === $order->get_customer_id() ) { + return false; // Not private. + } + + // Otherwise keep it private. + return true; + } + + return $is_private; + } + + /** + * Filter to set order notes visibility to public for authorized users. + * + * @param string $visibility The visibility of the object. + * @param string $model_name The name of the model being checked. + * @param mixed $data The data being checked. + * @param int|null $owner The owner of the object. + * @param \WP_User $current_user The current user. + * + * @return string + */ + public static function set_order_notes_visibility( $visibility, $model_name, $data, $owner, $current_user ) { + // Only apply to Comment models. + if ( 'CommentObject' !== $model_name ) { + return $visibility; + } + + // Check if this is an order note and if user owns the order. + if ( $data instanceof \WP_Comment && 'order_note' === $data->comment_type ) { + $order = wc_get_order( $data->comment_post_ID ); + + // If user is the order owner, make it public. + if ( $order && get_current_user_id() === $order->get_customer_id() ) { + return 'public'; + } + } + + return $visibility; + } } diff --git a/includes/mutation/class-order-note-create.php b/includes/mutation/class-order-note-create.php index 91c546e0..05fd1023 100644 --- a/includes/mutation/class-order-note-create.php +++ b/includes/mutation/class-order-note-create.php @@ -14,6 +14,7 @@ use GraphQL\Type\Definition\ResolveInfo; use WPGraphQL\AppContext; use WPGraphQL\Utils\Utils; +use WPGraphQL\Model\Comment; use WPGraphQL\WooCommerce\Data\Mutation\Order_Mutation; use WPGraphQL\WooCommerce\Model\Order; @@ -69,13 +70,13 @@ public static function get_output_fields() { 'orderNote' => [ 'type' => 'OrderNote', 'resolve' => static function ( $payload ) { - return $payload['note']; + return new Comment( $payload['note'] ); }, ], 'order' => [ 'type' => 'Order', 'resolve' => static function ( $payload ) { - return $payload['order']; + return new Order( $payload['order_id'] ); }, ], ]; @@ -127,15 +128,14 @@ public static function mutate_and_get_payload() { // Get the created note. $note = get_comment( $note_id ); - $note->ID = $note_id; if ( ! $note ) { throw new UserError( __( 'Unable to retrieve created order note.', 'wp-graphql-woocommerce' ) ); } return [ - 'order' => $order, - 'note' => $note, + 'order_id' => $order_id, + 'note' => $note, ]; }; } diff --git a/includes/mutation/class-order-note-delete.php b/includes/mutation/class-order-note-delete.php index c4516485..449f092e 100644 --- a/includes/mutation/class-order-note-delete.php +++ b/includes/mutation/class-order-note-delete.php @@ -14,6 +14,7 @@ use GraphQL\Type\Definition\ResolveInfo; use WPGraphQL\AppContext; use WPGraphQL\Utils\Utils; +use WPGraphQL\Model\Comment; use WPGraphQL\WooCommerce\Data\Mutation\Order_Mutation; use WPGraphQL\WooCommerce\Model\Order; @@ -69,13 +70,13 @@ public static function get_output_fields() { 'orderNote' => [ 'type' => 'OrderNote', 'resolve' => static function ( $payload ) { - return $payload['note']; + return new Comment( $payload['note'] ); }, ], 'order' => [ 'type' => 'Order', 'resolve' => static function ( $payload ) { - return $payload['order']; + return new Order( $payload['order_id'] ); }, ], ]; @@ -133,8 +134,8 @@ public static function mutate_and_get_payload() { } return [ - 'order' => $order, - 'note' => $note, + 'order_id' => $order_id, + 'note' => $note, ]; }; } diff --git a/includes/type/object/class-order-note-type.php b/includes/type/object/class-order-note-type.php index f45aa7e6..e1a4d47b 100644 --- a/includes/type/object/class-order-note-type.php +++ b/includes/type/object/class-order-note-type.php @@ -46,35 +46,35 @@ public static function get_fields( $other_fields = [] ) { 'type' => ['non_null' => 'ID'], 'description' => __( 'Database ID or global ID of the order note', 'wp-graphql-woocommerce' ), 'resolve' => static function ( $order_note ) { - return Relay::toGlobalId( 'order_note', $order_note->ID ); + return Relay::toGlobalId( 'order_note', $order_note->databaseId ); }, ], 'databaseId' => [ 'type' => 'Int', 'description' => __( 'Database ID of the order note', 'wp-graphql-woocommerce' ), 'resolve' => static function ( $order_note ) { - return $order_note->ID; + return $order_note->databaseId; }, ], 'dateCreated' => [ 'type' => 'String', 'description' => __( 'The date the order note was created, in the site\'s timezone.', 'wp-graphql-woocommerce' ), 'resolve' => static function ( $order_note ) { - return $order_note->comment_date_gmt; + return $order_note->date; }, ], 'note' => [ 'type' => 'String', 'description' => __( 'Order note.', 'wp-graphql-woocommerce' ), 'resolve' => static function ( $order_note ) { - return $order_note->comment_content; + return $order_note->contentRaw; }, ], 'isCustomerNote' => [ 'type' => 'Boolean', 'description' => __( 'Whether the note is a customer note', 'wp-graphql-woocommerce' ), 'resolve' => static function ( $order_note ) { - return (bool) get_comment_meta( $order_note->comment_ID, 'is_customer_note', true ); + return (bool) get_comment_meta( $order_note->databaseId, 'is_customer_note', true ); }, ], ], diff --git a/tests/wpunit/OrderQueriesTest.php b/tests/wpunit/OrderQueriesTest.php index 62fafcbc..0c459f1f 100644 --- a/tests/wpunit/OrderQueriesTest.php +++ b/tests/wpunit/OrderQueriesTest.php @@ -433,4 +433,67 @@ public function testOrdersQueryAndWhereArgs() { $this->assertQuerySuccessful( $response, $expected ); $this->clearLoaderCache( 'wc_post' ); } + + public function testOrderNotesQuery() { + // Create an order + $order_id = $this->factory->order->createNew(); + $order = wc_get_order( $order_id ); + + // Add some order notes + $note1_id = $order->add_order_note( 'Test order note 1', false ); + $note2_id = $order->add_order_note( 'Test customer note 2', true ); + + // Ensure we have valid IDs + $this->assertNotEmpty( $note1_id ); + $this->assertNotEmpty( $note2_id ); + + $query = ' + query ($id: ID!) { + order(id: $id) { + id + databaseId + orderNotes { + nodes { + id + databaseId + note + dateCreated + isCustomerNote + } + } + } + } + '; + + $variables = [ + 'id' => $this->toRelayId( 'order', $order_id ), + ]; + + // Must be shop manager to view order notes + $this->loginAsShopManager(); + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedField( 'order.id', $this->toRelayId( 'order', $order_id ) ), + $this->expectedField( 'order.databaseId', $order_id ), + $this->expectedNode( + 'order.orderNotes.nodes', + [ + $this->expectedField( 'note', 'Test customer note 2' ), + $this->expectedField( 'isCustomerNote', true ), + ], + 0 + ), + $this->expectedNode( + 'order.orderNotes.nodes', + [ + $this->expectedField( 'note', 'Test order note 1' ), + $this->expectedField( 'isCustomerNote', false ), + ], + 1 + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } }