From 6fb7b226fce89ea35c854246d081056528ee4019 Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Fri, 27 Mar 2026 16:32:30 -0400 Subject: [PATCH] feat: WC Settings API, compatibility refactor, HPOS fix (#1002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: WC Settings API, compatibility refactor, HPOS fix, CI improvements WC Settings GraphQL API: - Add WCSetting interface with common fields and type-safe resolveType - Add concrete types: WCStringSetting, WCArraySetting, WCRelativeDateSetting, WCImageWidthSetting with typed value/default fields - Add WCSettingGroup type with nested settings field - Add wcSettingGroups and wcSettings root query fields (admin only) - Add updateWCSetting and updateWCSettings mutations - Dynamic WCSettingTypeEnum collected from registered WC settings - Filters: graphql_woocommerce_setting_type_map, graphql_woocommerce_setting_types - Register WC admin settings for GraphQL requests via Compatibility class Compatibility refactor: - Consolidate ACF, JWT Auth, and QL Search filters into Compatibility class - Remove class-acf-schema-filters.php, class-jwt-auth-schema-filters.php, functions.php HPOS compatibility fix: - Replace hardcoded directory name check with dirname(__DIR__) === WP_PLUGIN_DIR - Works with any plugin folder name while still skipping nested vendor installs CI improvements: - Remove STRIPE_API_PUBLISHABLE_KEY restriction from coverage job - Add HPOS to coverage matrix entries - Sort type registry and includes alphabetically Other fixes: - Fix Settings_Mutation::validate_setting_checkbox_field to be static - Update ShippingZone settings field type to WCStringSetting Closes #864, closes #969 * refactor: Rename and split core classes, add order cursor pagination tests Class renames: - WooCommerce_Filters → WooCommerce (class-woocommerce.php), setup() → init() - Core_Schema_Filters → Post_Types (class-post-types.php) Class split: - Extract taxonomy registration from Core_Schema_Filters into Taxonomies (class-taxonomies.php) Access functions: - Add wc_graphql_resolve_product_type() for interface resolveType callbacks - Add wc_graphql_is_session_handler_disabled() - Add wc_graphql_enabled_authorizing_url_fields() - Add wc_graphql_get_authorizing_url_nonce_param_name() - Replace direct class references with global functions in type-registry, compatibility, protected-router, and all product interface files Stripe gateway compatibility: - Move woographql_stripe_gateway_args from WooCommerce to Compatibility class - Rename to woocommerce_gateway_stripe_args Tests: - Add OrderCursorPaginationTest (6 tests) covering COT cursor-based pagination: forward/backward, date ordering ASC/DESC, cursor integrity * fix: Use proper expectedField/expectedNode assertions in cursor pagination tests Replace empty assertQuerySuccessful([]) calls and manual lodashGet assertions with expectedField, expectedNode, and not()->expectedNode() for proper GraphQL response validation. --- .github/workflows/continous-integration.yml | 9 +- access-functions.php | 48 ++ codeception.dist.yml | 10 +- includes/class-acf-schema-filters.php | 74 --- ...ma-filters.php => class-compatibility.php} | 218 ++++++-- ...chema-filters.php => class-post-types.php} | 68 +-- includes/class-taxonomies.php | 80 +++ includes/class-type-registry.php | 164 +++--- ...erce-filters.php => class-woocommerce.php} | 40 +- includes/class-wp-graphql-woocommerce.php | 126 +++-- .../data/mutation/class-settings-mutation.php | 2 +- includes/functions.php | 54 -- includes/mutation/class-setting-update.php | 158 ++++++ includes/mutation/class-settings-update.php | 110 ++++ .../type/enum/class-wc-setting-type-enum.php | 65 ++- .../interface/class-downloadable-product.php | 4 +- .../interface/class-inventoried-product.php | 4 +- .../type/interface/class-product-union.php | 3 +- .../class-product-with-attributes.php | 3 +- .../class-product-with-dimensions.php | 4 +- .../interface/class-product-with-pricing.php | 4 +- .../class-product-with-variations.php | 3 +- includes/type/interface/class-wc-setting.php | 159 ++++++ includes/type/object/class-root-query.php | 36 ++ .../type/object/class-shipping-zone-type.php | 2 +- .../object/class-wc-setting-group-type.php | 69 +++ .../type/object/class-wc-setting-type.php | 196 +++++-- includes/utils/class-protected-router.php | 6 +- tests/wpunit/CartMutationsTest.php | 67 --- tests/wpunit/CheckoutMutationTest.php | 1 + tests/wpunit/OrderCursorPaginationTest.php | 484 ++++++++++++++++++ tests/wpunit/WCSettingsMutationsTest.php | 352 +++++++++++++ tests/wpunit/WCSettingsQueriesTest.php | 136 +++++ wp-graphql-woocommerce.php | 19 +- 34 files changed, 2186 insertions(+), 592 deletions(-) delete mode 100644 includes/class-acf-schema-filters.php rename includes/{class-jwt-auth-schema-filters.php => class-compatibility.php} (51%) rename includes/{class-core-schema-filters.php => class-post-types.php} (88%) create mode 100644 includes/class-taxonomies.php rename includes/{class-woocommerce-filters.php => class-woocommerce.php} (89%) delete mode 100644 includes/functions.php create mode 100644 includes/mutation/class-setting-update.php create mode 100644 includes/mutation/class-settings-update.php create mode 100644 includes/type/interface/class-wc-setting.php create mode 100644 includes/type/object/class-wc-setting-group-type.php create mode 100644 tests/wpunit/OrderCursorPaginationTest.php create mode 100644 tests/wpunit/WCSettingsMutationsTest.php create mode 100644 tests/wpunit/WCSettingsQueriesTest.php diff --git a/.github/workflows/continous-integration.yml b/.github/workflows/continous-integration.yml index 00fae981..c7c33b58 100644 --- a/.github/workflows/continous-integration.yml +++ b/.github/workflows/continous-integration.yml @@ -29,6 +29,7 @@ jobs: wordpress: '6.8' coverage: true xdebug: 1 + hpos: 1 - php: '8.1' wordpress: '6.3' debug: '--debug' @@ -100,6 +101,7 @@ jobs: wordpress: '6.8' coverage: true xdebug: 1 + hpos: 1 - php: '8.1' wordpress: '6.3' debug: '--debug' @@ -171,6 +173,7 @@ jobs: wordpress: '6.8' coverage: true xdebug: 1 + hpos: 1 - php: '8.1' wordpress: '6.3' debug: '--debug' @@ -233,8 +236,6 @@ jobs: if: ${{ always() }} runs-on: ubuntu-latest name: Aggregate Coverage & Push to Coveralls - env: - STRIPE_API_PUBLISHABLE_KEY: ${{ secrets.STRIPE_API_PUBLISHABLE_KEY }} steps: - name: Checkout uses: actions/checkout@v2 @@ -258,7 +259,6 @@ jobs: merge-multiple: true - name: Merge coverage reports - if: ${{ env.STRIPE_API_PUBLISHABLE_KEY != null }} run: | mkdir -p tests/_output echo "Coverage files structure:" @@ -274,14 +274,13 @@ jobs: fi - name: Push Codecoverage to Coveralls.io - if: ${{ env.STRIPE_API_PUBLISHABLE_KEY != null }} env: COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | php-coveralls -v - name: Upload coverage debug artifacts - if: ${{ always() && env.STRIPE_API_PUBLISHABLE_KEY != null }} + if: ${{ always() }} uses: actions/upload-artifact@v4 with: name: coverage-debug diff --git a/access-functions.php b/access-functions.php index bd87c124..effb7d0e 100644 --- a/access-functions.php +++ b/access-functions.php @@ -370,3 +370,51 @@ if ( ! function_exists( 'woographql_verify_nonce' ) ) : } endif; + +if ( ! function_exists( 'wc_graphql_resolve_product_type' ) ) { + /** + * Resolves GraphQL type for provided product model. + * + * @param mixed $value Product model. + * + * @return mixed + */ + function wc_graphql_resolve_product_type( $value ) { + return \WPGraphQL\WooCommerce\Post_Types::resolve_product_type( $value ); + } +} + +if ( ! function_exists( 'wc_graphql_is_session_handler_disabled' ) ) { + /** + * Returns true if the QL Session Handler is disabled. + * + * @return boolean + */ + function wc_graphql_is_session_handler_disabled() { + return \WPGraphQL\WooCommerce\WooCommerce::is_session_handler_disabled(); + } +} + +if ( ! function_exists( 'wc_graphql_enabled_authorizing_url_fields' ) ) { + /** + * Returns array of enabled authorizing URL field slugs. + * + * @return array + */ + function wc_graphql_enabled_authorizing_url_fields() { + return \WPGraphQL\WooCommerce\WooCommerce::enabled_authorizing_url_fields(); + } +} + +if ( ! function_exists( 'wc_graphql_get_authorizing_url_nonce_param_name' ) ) { + /** + * Return the nonce query parameter name for the provided field. + * + * @param string $field URL field slug. + * + * @return string|null + */ + function wc_graphql_get_authorizing_url_nonce_param_name( $field ) { + return \WPGraphQL\WooCommerce\WooCommerce::get_authorizing_url_nonce_param_name( $field ); + } +} diff --git a/codeception.dist.yml b/codeception.dist.yml index 39d4c024..83f5fdba 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -15,12 +15,12 @@ coverage: c3_url: '%WORDPRESS_URL%/wp-content/plugins/wp-graphql-woocommerce/wp-graphql-woocommerce.php' work_dir: /var/www/html/wp-content/plugins/wp-graphql-woocommerce/ include: - - 'includes/*' - - 'access-functions.php' + - './includes/*' + - './access-functions.php' exclude: - - 'local/*' - - 'vendor/*' - - 'wp-graphql-woocommerce.php' + - './local/*' + - './vendor/*' + - './wp-graphql-woocommerce.php' show_only_summary: false extensions: enabled: diff --git a/includes/class-acf-schema-filters.php b/includes/class-acf-schema-filters.php deleted file mode 100644 index 2141f2fa..00000000 --- a/includes/class-acf-schema-filters.php +++ /dev/null @@ -1,74 +0,0 @@ -ID ); - break; - } - - return $id; - } - - /** - * Filters ACF "post_object" field type resolver to ensure that - * the proper Type source is provided for WooCommerce CPTs. - * - * @param mixed|null $source source of the data being provided. - * @param mixed|null $value Post ID. - * - * @return mixed|null - */ - public static function resolve_post_object_source( $source, $value ) { - $post = get_post( $value ); - if ( $post instanceof \WP_Post ) { - switch ( $post->post_type ) { - case 'shop_coupon': - $source = new Model\Coupon( $post->ID ); - break; - case 'shop_order': - $source = new Model\Order( $post->ID ); - break; - case 'product': - $source = new Model\Product( $post->ID ); - break; - case 'product_variation': - $source = new Model\Product_Variation( $post->ID ); - break; - } - } - - return $source; - } -} diff --git a/includes/class-jwt-auth-schema-filters.php b/includes/class-compatibility.php similarity index 51% rename from includes/class-jwt-auth-schema-filters.php rename to includes/class-compatibility.php index 4ab3b0ce..b38002dd 100644 --- a/includes/class-jwt-auth-schema-filters.php +++ b/includes/class-compatibility.php @@ -1,10 +1,9 @@ register_wp_admin_settings(); // @phpstan-ignore method.private (public since WC 9.0) + } + } + + /** + * Register WPGraphQL ACF compatibility filters. + * + * @return void + */ + private static function add_acf_filters() { + add_filter( 'graphql_acf_get_root_id', [ self::class, 'resolve_crud_root_id' ], 10, 2 ); + add_filter( 'graphql_acf_post_object_source', [ self::class, 'resolve_post_object_source' ], 10, 2 ); + } + + /** + * Resolve post object ID from CRUD object Model. + * + * @param integer|null $id Post object database ID. + * @param mixed $root Root resolver. + * + * @return integer|null + */ + public static function resolve_crud_root_id( $id, $root ) { + if ( $root instanceof Model\WC_Post ) { + $id = absint( $root->ID ); + } + + return $id; + } + + /** + * Filters ACF "post_object" field type resolver to ensure that + * the proper Type source is provided for WooCommerce CPTs. + * + * @param mixed|null $source source of the data being provided. + * @param mixed|null $value Post ID. + * + * @return mixed|null + */ + public static function resolve_post_object_source( $source, $value ) { + $post = get_post( $value ); + if ( $post instanceof \WP_Post ) { + switch ( $post->post_type ) { + case 'shop_coupon': + $source = new Model\Coupon( $post->ID ); + break; + case 'shop_order': + $source = new Model\Order( $post->ID ); + break; + case 'product': + $source = new Model\Product( $post->ID ); + break; + case 'product_variation': + $source = new Model\Product_Variation( $post->ID ); + break; + } + } + + return $source; + } + + /** + * Get the JWT auth class if available. * * @return string|null */ @@ -26,9 +109,9 @@ class JWT_Auth_Schema_Filters { return \WPGraphQL\JWT_Authentication\Auth::class; } elseif ( class_exists( 'WPGraphQL\Login\Auth\TokenManager' ) ) { return \WPGraphQL\Login\Auth\TokenManager::class; - } else { - return null; } + + return null; } /** @@ -46,11 +129,10 @@ class JWT_Auth_Schema_Filters { if ( ! $auth_class ) { return null; } + /** - * This method is typed wrong upstream. - * - * @var \WP_Error|string|null $token - */ + * @var \WP_Error|string|null $token + */ $token = null; if ( 'WPGraphQL\JWT_Authentication\Auth' === $auth_class ) { $token = $auth_class::get_token( $user ); @@ -82,8 +164,6 @@ class JWT_Auth_Schema_Filters { } /** - * This method is typed wrong upstream. - * * @var \WP_Error|string|null $refresh_token */ $refresh_token = $auth_class::get_refresh_token( $user ); @@ -96,19 +176,20 @@ class JWT_Auth_Schema_Filters { } /** - * Register filters + * Register WPGraphQL JWT Authentication compatibility filters. * * @return void */ - public static function add_filters() { - // Confirm WPGraphQL JWT Authentication is installed. + private static function add_jwt_auth_filters() { $auth_class = self::get_auth_class(); - if ( ! is_null( $auth_class ) ) { - add_filter( 'graphql_jwt_user_types', [ self::class, 'add_customer_to_jwt_user_types' ], 10 ); - add_filter( 'graphql_registerCustomerPayload_fields', [ self::class, 'add_jwt_output_fields' ], 10, 3 ); - add_filter( 'graphql_updateCustomerPayload_fields', [ self::class, 'add_jwt_output_fields' ], 10, 3 ); - add_action( 'graphql_register_types', [ self::class, 'add_customer_to_login_payload' ], 10 ); + if ( is_null( $auth_class ) ) { + return; } + + add_filter( 'graphql_jwt_user_types', [ self::class, 'add_customer_to_jwt_user_types' ], 10 ); + add_filter( 'graphql_registerCustomerPayload_fields', [ self::class, 'add_jwt_output_fields' ], 10, 3 ); + add_filter( 'graphql_updateCustomerPayload_fields', [ self::class, 'add_jwt_output_fields' ], 10, 3 ); + add_action( 'graphql_register_types', [ self::class, 'add_customer_to_login_payload' ], 10 ); } /** @@ -133,7 +214,7 @@ class JWT_Auth_Schema_Filters { * @return array */ public static function add_jwt_output_fields( $fields, $object_type, $type_registry ): array { - $fields = array_merge( + return array_merge( $fields, [ 'authToken' => [ @@ -164,8 +245,6 @@ class JWT_Auth_Schema_Filters { ], ] ); - - return $fields; } /** @@ -187,7 +266,7 @@ class JWT_Auth_Schema_Filters { ] ); - if ( ! WooCommerce_Filters::is_session_handler_disabled() ) { + if ( ! wc_graphql_is_session_handler_disabled() ) { $token_type = woographql_setting( 'set_session_token_type', 'legacy' ); if ( in_array( $token_type, [ 'legacy', 'both' ], true ) ) { register_graphql_field( @@ -197,11 +276,7 @@ class JWT_Auth_Schema_Filters { 'type' => 'String', 'description' => __( 'A JWT token that can be used in future requests to for WooCommerce session identification', 'wp-graphql-woocommerce' ), 'resolve' => static function () { - /** - * Session Handler. - * - * @var \WPGraphQL\WooCommerce\Utils\QL_Session_Handler $session - */ + /** @var \WPGraphQL\WooCommerce\Utils\QL_Session_Handler $session */ $session = \WC()->session; return apply_filters( 'graphql_customer_session_token', $session->build_token() ); @@ -217,11 +292,7 @@ class JWT_Auth_Schema_Filters { 'type' => 'String', 'description' => __( 'A JWT token that can be used in future requests to for WooCommerce session identification', 'wp-graphql-woocommerce' ), 'resolve' => static function () { - /** - * Session Handler. - * - * @var \WPGraphQL\WooCommerce\Utils\QL_Session_Handler $session - */ + /** @var \WPGraphQL\WooCommerce\Utils\QL_Session_Handler $session */ $session = \WC()->session; return apply_filters( 'graphql_customer_session_token', $session->build_cart_token() ); @@ -231,4 +302,79 @@ class JWT_Auth_Schema_Filters { } } } + + /** + * Register WooCommerce Stripe Gateway compatibility filters. + * + * @return void + */ + private static function add_stripe_gateway_filters() { + add_filter( 'graphql_stripe_process_payment_args', [ self::class, 'woocommerce_gateway_stripe_args' ], 10, 2 ); + } + + /** + * Adds extra arguments to the Stripe Gateway process payment call. + * + * @param array $gateway_args Arguments to be passed to the gateway `process_payment` method. + * @param string $payment_method Payment gateway ID. + * + * @return array + */ + public static function woocommerce_gateway_stripe_args( $gateway_args, $payment_method ) { + /** @var false|\WC_Order|\WC_Order_Refund $order */ + $order = wc_get_order( $gateway_args[0] ); + if ( false === $order ) { + return $gateway_args; + } + + $stripe_source_id = $order->get_meta( '_stripe_source_id' ); + if ( 'stripe' === $payment_method && ! empty( $stripe_source_id ) ) { + $gateway_args = [ + $gateway_args[0], + true, + false, + false, + true, + ]; + } + + return $gateway_args; + } + + /** + * Register SearchWP/QL Search compatibility filters. + * + * @return void + */ + private static function add_swp_filters() { + add_filter( 'graphql_swp_result_possible_types', [ self::class, 'searchwp_result_possible_types' ] ); + } + + /** + * Adds product types to QL Search SWPResult possible types. + * + * @param array $type_names SWPResults possible types. + * + * @return array + */ + public static function searchwp_result_possible_types( array $type_names ) { + if ( in_array( 'Product', $type_names, true ) ) { + $type_names = array_merge( + array_filter( + $type_names, + static function ( $type_name ) { + return 'Product' !== $type_name; + } + ), + [ + 'SimpleProduct', + 'VariableProduct', + 'GroupProduct', + 'ExternalProduct', + ] + ); + } + + return $type_names; + } } diff --git a/includes/class-core-schema-filters.php b/includes/class-post-types.php similarity index 88% rename from includes/class-core-schema-filters.php rename to includes/class-post-types.php index 858386bd..7ad96750 100644 --- a/includes/class-core-schema-filters.php +++ b/includes/class-post-types.php @@ -1,6 +1,6 @@ Use woocommerce email password template when requested. add_filter( 'retrieve_password_message', [ self::class, 'get_reset_password_message' ], 10, 3 ); add_filter( 'retrieve_password_title', [ self::class, 'get_reset_password_title' ] ); @@ -106,7 +103,7 @@ class WooCommerce_Filters { * * @param string $field URL field slug. * - * @return string null + * @return string|null */ public static function get_authorizing_url_nonce_param_name( $field ) { $flag_name = strtoupper( $field ); @@ -184,35 +181,6 @@ class WooCommerce_Filters { return $allowed_headers; } - /** - * Adds extra arguments to the Stripe Gateway process payment call. - * - * @param array $gateway_args Arguments to be passed to the gateway `process_payment` method. - * @param string $payment_method Payment gateway ID. - * - * @return array - */ - public static function woographql_stripe_gateway_args( $gateway_args, $payment_method ) { - /** @var false|\WC_Order|\WC_Order_Refund $order */ - $order = wc_get_order( $gateway_args[0] ); - if ( false === $order ) { - return $gateway_args; - } - - $stripe_source_id = $order->get_meta( '_stripe_source_id' ); - if ( 'stripe' === $payment_method && ! empty( $stripe_source_id ) ) { - $gateway_args = [ - $gateway_args[0], - true, - false, - false, - true, - ]; - } - - return $gateway_args; - } - /** * Customizes the password reset message for ResetPassword Mutation. * diff --git a/includes/class-wp-graphql-woocommerce.php b/includes/class-wp-graphql-woocommerce.php index dc3c4aea..4ad761ea 100644 --- a/includes/class-wp-graphql-woocommerce.php +++ b/includes/class-wp-graphql-woocommerce.php @@ -166,19 +166,19 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'utils/class-ql-session-handler.php'; require $include_directory_path . 'utils/class-session-transaction-manager.php'; - // Include models class files. - require $include_directory_path . 'model/class-customer.php'; + // Include models class files (WC_Post must load before its dependents). require $include_directory_path . 'model/class-wc-post.php'; require $include_directory_path . 'model/class-coupon.php'; - require $include_directory_path . 'model/class-product.php'; - require $include_directory_path . 'model/class-product-variation.php'; - require $include_directory_path . 'model/class-order.php'; + require $include_directory_path . 'model/class-customer.php'; require $include_directory_path . 'model/class-order-item.php'; + require $include_directory_path . 'model/class-order.php'; + require $include_directory_path . 'model/class-product-variation.php'; + require $include_directory_path . 'model/class-product.php'; require $include_directory_path . 'model/class-shipping-method.php'; require $include_directory_path . 'model/class-shipping-zone.php'; require $include_directory_path . 'model/class-tax-rate.php'; - // Include data loaders class files. + // Include data loaders class files (base classes first). require $include_directory_path . 'data/loader/class-wc-cpt-loader.php'; require $include_directory_path . 'data/loader/class-wc-customer-loader.php'; require $include_directory_path . 'data/loader/class-wc-db-loader.php'; @@ -191,9 +191,11 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'data/loader/class-wc-tax-rate-loader.php'; // Include connection resolver trait/class files. - require $include_directory_path . 'data/connection/trait-wc-db-loader-common.php'; require $include_directory_path . 'data/connection/trait-wc-cpt-loader-common.php'; + require $include_directory_path . 'data/connection/trait-wc-db-loader-common.php'; require $include_directory_path . 'data/connection/class-cart-item-connection-resolver.php'; + require $include_directory_path . 'data/connection/class-coupon-connection-resolver.php'; + require $include_directory_path . 'data/connection/class-customer-connection-resolver.php'; require $include_directory_path . 'data/connection/class-downloadable-item-connection-resolver.php'; require $include_directory_path . 'data/connection/class-order-connection-resolver.php'; require $include_directory_path . 'data/connection/class-order-item-connection-resolver.php'; @@ -206,10 +208,6 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'data/connection/class-tax-rate-connection-resolver.php'; require $include_directory_path . 'data/connection/class-variation-attribute-connection-resolver.php'; - // Include deprecated resolver trait/class files. - require $include_directory_path . 'data/connection/class-coupon-connection-resolver.php'; - require $include_directory_path . 'data/connection/class-customer-connection-resolver.php'; - // Include mutation processor class files. require $include_directory_path . 'data/mutation/class-cart-mutation.php'; require $include_directory_path . 'data/mutation/class-checkout-mutation.php'; @@ -217,8 +215,8 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'data/mutation/class-customer-mutation.php'; require $include_directory_path . 'data/mutation/class-order-mutation.php'; require $include_directory_path . 'data/mutation/class-product-mutation.php'; - require $include_directory_path . 'data/mutation/class-shipping-mutation.php'; require $include_directory_path . 'data/mutation/class-settings-mutation.php'; + require $include_directory_path . 'data/mutation/class-shipping-mutation.php'; // Include factory class file. require $include_directory_path . 'data/class-factory.php'; @@ -228,10 +226,13 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'data/class-db-hooks.php'; // Include enum type class files. + require $include_directory_path . 'type/enum/class-attribute-operator-enum.php'; require $include_directory_path . 'type/enum/class-backorders.php'; require $include_directory_path . 'type/enum/class-cart-error-type.php'; + require $include_directory_path . 'type/enum/class-cart-notice-type.php'; require $include_directory_path . 'type/enum/class-catalog-visibility.php'; require $include_directory_path . 'type/enum/class-countries.php'; + require $include_directory_path . 'type/enum/class-currency-enum.php'; require $include_directory_path . 'type/enum/class-customer-connection-orderby-enum.php'; require $include_directory_path . 'type/enum/class-discount-type.php'; require $include_directory_path . 'type/enum/class-id-type-enums.php'; @@ -239,79 +240,80 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'type/enum/class-order-status.php'; require $include_directory_path . 'type/enum/class-post-type-orderby-enum.php'; require $include_directory_path . 'type/enum/class-orders-orderby-enum.php'; - require $include_directory_path . 'type/enum/class-products-orderby-enum.php'; require $include_directory_path . 'type/enum/class-pricing-field-format.php'; + require $include_directory_path . 'type/enum/class-product-attribute-enum.php'; require $include_directory_path . 'type/enum/class-product-attribute-types.php'; + require $include_directory_path . 'type/enum/class-product-attributes-connection-orderby-enum.php'; require $include_directory_path . 'type/enum/class-product-category-display.php'; require $include_directory_path . 'type/enum/class-product-taxonomy.php'; require $include_directory_path . 'type/enum/class-product-types.php'; + require $include_directory_path . 'type/enum/class-products-orderby-enum.php'; + require $include_directory_path . 'type/enum/class-shipping-location-type-enum.php'; require $include_directory_path . 'type/enum/class-stock-status.php'; require $include_directory_path . 'type/enum/class-tax-class.php'; require $include_directory_path . 'type/enum/class-tax-rate-connection-orderby-enum.php'; require $include_directory_path . 'type/enum/class-tax-status.php'; require $include_directory_path . 'type/enum/class-taxonomy-operator.php'; - require $include_directory_path . 'type/enum/class-attribute-operator-enum.php'; - require $include_directory_path . 'type/enum/class-product-attribute-enum.php'; - require $include_directory_path . 'type/enum/class-currency-enum.php'; - require $include_directory_path . 'type/enum/class-shipping-location-type-enum.php'; require $include_directory_path . 'type/enum/class-wc-setting-type-enum.php'; - require $include_directory_path . 'type/enum/class-cart-notice-type.php'; - require $include_directory_path . 'type/enum/class-product-attributes-connection-orderby-enum.php'; // Include interface type class files. require $include_directory_path . 'type/interface/class-attribute.php'; require $include_directory_path . 'type/interface/class-cart-error.php'; - require $include_directory_path . 'type/interface/class-product-attribute.php'; - require $include_directory_path . 'type/interface/class-product.php'; - require $include_directory_path . 'type/interface/class-product-variation.php'; - require $include_directory_path . 'type/interface/class-payment-token-interface.php'; - require $include_directory_path . 'type/interface/class-product-union.php'; require $include_directory_path . 'type/interface/class-cart-item.php'; require $include_directory_path . 'type/interface/class-downloadable-product.php'; require $include_directory_path . 'type/interface/class-inventoried-product.php'; + require $include_directory_path . 'type/interface/class-payment-token-interface.php'; + require $include_directory_path . 'type/interface/class-product-attribute.php'; + require $include_directory_path . 'type/interface/class-product-union.php'; + require $include_directory_path . 'type/interface/class-product-variation.php'; + require $include_directory_path . 'type/interface/class-product-with-attributes.php'; require $include_directory_path . 'type/interface/class-product-with-dimensions.php'; require $include_directory_path . 'type/interface/class-product-with-pricing.php'; require $include_directory_path . 'type/interface/class-product-with-variations.php'; - require $include_directory_path . 'type/interface/class-product-with-attributes.php'; + require $include_directory_path . 'type/interface/class-product.php'; + require $include_directory_path . 'type/interface/class-wc-setting.php'; // Include object type class files. require $include_directory_path . 'type/object/class-cart-error-types.php'; require $include_directory_path . 'type/object/class-cart-notice.php'; require $include_directory_path . 'type/object/class-cart-type.php'; + require $include_directory_path . 'type/object/class-collection-stats-type.php'; + require $include_directory_path . 'type/object/class-country-state-type.php'; require $include_directory_path . 'type/object/class-coupon-type.php'; require $include_directory_path . 'type/object/class-customer-address-type.php'; require $include_directory_path . 'type/object/class-customer-type.php'; require $include_directory_path . 'type/object/class-downloadable-item-type.php'; 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-order-type.php'; require $include_directory_path . 'type/object/class-payment-gateway-type.php'; + require $include_directory_path . 'type/object/class-payment-token-types.php'; + require $include_directory_path . 'type/object/class-product-attribute-object-type.php'; + require $include_directory_path . 'type/object/class-product-attribute-term-object-type.php'; require $include_directory_path . 'type/object/class-product-attribute-types.php'; require $include_directory_path . 'type/object/class-product-category-type.php'; require $include_directory_path . 'type/object/class-product-download-type.php'; require $include_directory_path . 'type/object/class-product-types.php'; require $include_directory_path . 'type/object/class-refund-type.php'; require $include_directory_path . 'type/object/class-root-query.php'; + require $include_directory_path . 'type/object/class-shipping-location-type.php'; require $include_directory_path . 'type/object/class-shipping-method-type.php'; require $include_directory_path . 'type/object/class-shipping-package-type.php'; require $include_directory_path . 'type/object/class-shipping-rate-type.php'; + require $include_directory_path . 'type/object/class-shipping-zone-type.php'; require $include_directory_path . 'type/object/class-simple-attribute-type.php'; + require $include_directory_path . 'type/object/class-tax-class-type.php'; require $include_directory_path . 'type/object/class-tax-rate-type.php'; require $include_directory_path . 'type/object/class-variation-attribute-type.php'; - require $include_directory_path . 'type/object/class-payment-token-types.php'; - require $include_directory_path . 'type/object/class-country-state-type.php'; - require $include_directory_path . 'type/object/class-collection-stats-type.php'; - require $include_directory_path . 'type/object/class-shipping-location-type.php'; - require $include_directory_path . 'type/object/class-shipping-zone-type.php'; - require $include_directory_path . 'type/object/class-tax-class-type.php'; + require $include_directory_path . 'type/object/class-wc-setting-group-type.php'; require $include_directory_path . 'type/object/class-wc-setting-type.php'; - require $include_directory_path . 'type/object/class-product-attribute-object-type.php'; - require $include_directory_path . 'type/object/class-product-attribute-term-object-type.php'; // Include input type class files. require $include_directory_path . 'type/input/class-cart-item-input.php'; require $include_directory_path . 'type/input/class-cart-item-quantity-input.php'; + require $include_directory_path . 'type/input/class-collection-stats-query-input.php'; + require $include_directory_path . 'type/input/class-collection-stats-where-args.php'; require $include_directory_path . 'type/input/class-create-account-input.php'; require $include_directory_path . 'type/input/class-customer-address-input.php'; require $include_directory_path . 'type/input/class-fee-input.php'; @@ -319,21 +321,19 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'type/input/class-line-item-input.php'; require $include_directory_path . 'type/input/class-meta-data-input.php'; require $include_directory_path . 'type/input/class-orderby-inputs.php'; - require $include_directory_path . 'type/input/class-product-attribute-input.php'; - require $include_directory_path . 'type/input/class-product-taxonomy-filter-input.php'; - require $include_directory_path . 'type/input/class-product-taxonomy-input.php'; - require $include_directory_path . 'type/input/class-shipping-line-input.php'; - require $include_directory_path . 'type/input/class-tax-rate-connection-orderby-input.php'; - require $include_directory_path . 'type/input/class-collection-stats-query-input.php'; - require $include_directory_path . 'type/input/class-collection-stats-where-args.php'; require $include_directory_path . 'type/input/class-product-attribute-filter-input.php'; + require $include_directory_path . 'type/input/class-product-attribute-input.php'; require $include_directory_path . 'type/input/class-product-attribute-query-input.php'; - require $include_directory_path . 'type/input/class-shipping-location-input.php'; - require $include_directory_path . 'type/input/class-wc-setting-input.php'; require $include_directory_path . 'type/input/class-product-attributes-input.php'; require $include_directory_path . 'type/input/class-product-dimensions-input.php'; require $include_directory_path . 'type/input/class-product-download-input.php'; require $include_directory_path . 'type/input/class-product-image-input.php'; + require $include_directory_path . 'type/input/class-product-taxonomy-filter-input.php'; + require $include_directory_path . 'type/input/class-product-taxonomy-input.php'; + require $include_directory_path . 'type/input/class-shipping-line-input.php'; + require $include_directory_path . 'type/input/class-shipping-location-input.php'; + require $include_directory_path . 'type/input/class-tax-rate-connection-orderby-input.php'; + require $include_directory_path . 'type/input/class-wc-setting-input.php'; // Include mutation type class files. require $include_directory_path . 'mutation/class-cart-add-fee.php'; @@ -375,11 +375,13 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'mutation/class-product-variation-create.php'; require $include_directory_path . 'mutation/class-product-variation-delete.php'; require $include_directory_path . 'mutation/class-product-variation-update.php'; - require $include_directory_path . 'mutation/class-review-write.php'; require $include_directory_path . 'mutation/class-review-delete-restore.php'; require $include_directory_path . 'mutation/class-review-update.php'; + require $include_directory_path . 'mutation/class-review-write.php'; require $include_directory_path . 'mutation/class-session-delete.php'; require $include_directory_path . 'mutation/class-session-update.php'; + require $include_directory_path . 'mutation/class-setting-update.php'; + require $include_directory_path . 'mutation/class-settings-update.php'; require $include_directory_path . 'mutation/class-shipping-zone-create.php'; require $include_directory_path . 'mutation/class-shipping-zone-delete.php'; require $include_directory_path . 'mutation/class-shipping-zone-locations-clear.php'; @@ -416,15 +418,12 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : // Include main plugin class files. require $include_directory_path . 'class-admin.php'; - require $include_directory_path . 'class-core-schema-filters.php'; - require $include_directory_path . 'class-jwt-auth-schema-filters.php'; - require $include_directory_path . 'class-woocommerce-filters.php'; - require $include_directory_path . 'class-acf-schema-filters.php'; + require $include_directory_path . 'class-post-types.php'; + require $include_directory_path . 'class-taxonomies.php'; + require $include_directory_path . 'class-woocommerce.php'; + require $include_directory_path . 'class-compatibility.php'; require $include_directory_path . 'class-type-registry.php'; - // Required extra plugin function file. - require $include_directory_path . 'functions.php'; - /** * WPGRAPHQL_WOOCOMMERCE_AUTOLOAD can be set to "false" to prevent the autoloader from running. * In most cases, this is not something that should be disabled, but some environments @@ -503,28 +502,23 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : * @return void */ private function setup() { - // Initialize WPGraphQL for WooCommerce Settings. + // Initialize admin settings page. new Admin(); - // Initialize WPGraphQL for WooCommerce DB hooks. + // Initialize database hooks. new Data\DB_Hooks(); - // Setup minor integrations. - Functions\setup_minor_integrations(); + // Register WooCommerce session, cart, and download filters. + WooCommerce::init(); - // Register WooCommerce filters. - WooCommerce_Filters::setup(); + // Register WooCommerce post types and taxonomies to GraphQL schema. + Post_Types::init(); + Taxonomies::init(); - // Register WPGraphQL core filters. - Core_Schema_Filters::add_filters(); + // Register third-party plugin compatibility (ACF, JWT Auth, Stripe, SearchWP). + Compatibility::setup(); - // Register WPGraphQL ACF filters. - ACF_Schema_Filters::add_filters(); - - // Register WPGraphQL JWT Authentication filters. - JWT_Auth_Schema_Filters::add_filters(); - - // Initialize WPGraphQL for WooCommerce TypeRegistry. + // Register GraphQL types, connections, and mutations. $registry = new Type_Registry(); add_action( 'graphql_register_types', [ $registry, 'init' ] ); } diff --git a/includes/data/mutation/class-settings-mutation.php b/includes/data/mutation/class-settings-mutation.php index be6dbdf8..a4736bf5 100644 --- a/includes/data/mutation/class-settings-mutation.php +++ b/includes/data/mutation/class-settings-mutation.php @@ -125,7 +125,7 @@ class Settings_Mutation { * * @return string */ - public function validate_setting_checkbox_field( $value, $setting ) { + public static function validate_setting_checkbox_field( $value, $setting ) { if ( in_array( $value, [ 'yes', 'no' ], true ) ) { return $value; } elseif ( empty( $value ) ) { diff --git a/includes/functions.php b/includes/functions.php deleted file mode 100644 index 7cc4e997..00000000 --- a/includes/functions.php +++ /dev/null @@ -1,54 +0,0 @@ - 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 [ + 'group' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'Settings group ID.', 'wp-graphql-woocommerce' ), + ], + 'id' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'Setting ID.', 'wp-graphql-woocommerce' ), + ], + 'value' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'Setting value.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'setting' => [ + 'type' => 'WCSetting', + 'description' => __( 'The updated setting.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $payload ) { + return $payload['setting']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input ) { + if ( ! \wc_rest_check_manager_permissions( 'settings', 'edit' ) ) { + throw new UserError( __( 'Sorry, you cannot update settings.', 'wp-graphql-woocommerce' ) ); + } + + $group_id = $input['group']; + $setting_id = $input['id']; + $value = $input['value']; + + $controller = new \WC_REST_Setting_Options_Controller(); + + /** @var array|\WP_Error $setting */ + $setting = $controller->get_setting( $group_id, $setting_id ); + + if ( is_wp_error( $setting ) ) { + throw new UserError( $setting->get_error_message() ); + } + + $validated = self::validate_value( $value, $setting ); + self::save_setting( $setting, $validated ); + + /** @var array|\WP_Error $updated */ + $updated = $controller->get_setting( $group_id, $setting_id ); + if ( is_wp_error( $updated ) ) { + throw new UserError( $updated->get_error_message() ); + } + + return [ 'setting' => $updated ]; + }; + } + + /** + * Validate a setting value based on its type. + * + * @param mixed $value Value to validate. + * @param array $setting Setting definition. + * + * @throws \GraphQL\Error\UserError If validation fails. + * + * @return mixed + */ + public static function validate_value( $value, $setting ) { + $type = $setting['type']; + $method = 'validate_setting_' . $type . '_field'; + + if ( method_exists( Settings_Mutation::class, $method ) ) { + return Settings_Mutation::$method( $value, $setting ); + } + + return Settings_Mutation::validate_setting_text_field( $value, $setting ); + } + + /** + * Save a validated setting value. + * + * @param array $setting Setting definition. + * @param mixed $value Validated value. + * + * @return void + */ + public static function save_setting( $setting, $value ) { + $option_key = $setting['option_key']; + + if ( is_array( $option_key ) ) { + $option = get_option( $option_key[0], [] ); + if ( ! is_array( $option ) ) { + $option = []; + } + $option[ $option_key[1] ] = $value; + update_option( $option_key[0], $option ); + } else { + \WC_Admin_Settings::save_fields( + [ $setting ], + [ $setting['id'] => $value ] + ); + } + } +} diff --git a/includes/mutation/class-settings-update.php b/includes/mutation/class-settings-update.php new file mode 100644 index 00000000..6aaf04ee --- /dev/null +++ b/includes/mutation/class-settings-update.php @@ -0,0 +1,110 @@ + 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 [ + 'group' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'Settings group ID.', 'wp-graphql-woocommerce' ), + ], + 'settings' => [ + 'type' => [ 'non_null' => [ 'list_of' => 'WCSettingInput' ] ], + 'description' => __( 'Settings to update.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Defines the mutation output field configuration + * + * @return array + */ + public static function get_output_fields() { + return [ + 'settings' => [ + 'type' => [ 'list_of' => 'WCSetting' ], + 'description' => __( 'The updated settings.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $payload ) { + return $payload['settings']; + }, + ], + ]; + } + + /** + * Defines the mutation data modification closure. + * + * @return callable + */ + public static function mutate_and_get_payload() { + return static function ( $input ) { + if ( ! \wc_rest_check_manager_permissions( 'settings', 'edit' ) ) { + throw new UserError( __( 'Sorry, you cannot update settings.', 'wp-graphql-woocommerce' ) ); + } + + $group_id = $input['group']; + $controller = new \WC_REST_Setting_Options_Controller(); + $updated = []; + + foreach ( $input['settings'] as $setting_input ) { + $setting_id = $setting_input['id']; + $value = $setting_input['value']; + + /** @var array|\WP_Error $setting */ + $setting = $controller->get_setting( $group_id, $setting_id ); + if ( is_wp_error( $setting ) ) { + throw new UserError( $setting->get_error_message() ); + } + + $validated = Setting_Update::validate_value( $value, $setting ); + Setting_Update::save_setting( $setting, $validated ); + + /** @var array|\WP_Error $refreshed */ + $refreshed = $controller->get_setting( $group_id, $setting_id ); + if ( is_wp_error( $refreshed ) ) { + throw new UserError( $refreshed->get_error_message() ); + } + + $updated[] = $refreshed; + } + + return [ 'settings' => $updated ]; + }; + } +} diff --git a/includes/type/enum/class-wc-setting-type-enum.php b/includes/type/enum/class-wc-setting-type-enum.php index cb9a6c85..8e3e38a3 100644 --- a/includes/type/enum/class-wc-setting-type-enum.php +++ b/includes/type/enum/class-wc-setting-type-enum.php @@ -2,12 +2,17 @@ /** * WPEnum Type - WCSettingTypeEnum * + * Dynamically registers WC setting types as a GraphQL enum, + * collected from all registered WC settings groups. + * * @package WPGraphQL\WooCommerce\Type\WPEnum * @since 0.20.0 */ namespace WPGraphQL\WooCommerce\Type\WPEnum; +use WPGraphQL\WooCommerce\Utils\Label; + /** * Class WC_Setting_Type_Enum */ @@ -18,24 +23,58 @@ class WC_Setting_Type_Enum { * @return void */ public static function register() { + $types = self::collect_types(); + $values = []; + + foreach ( $types as $type ) { + $enum_name = Label::get_safe_enum_name( strtoupper( $type ) ); + if ( empty( $enum_name ) ) { + continue; + } + + $values[ $enum_name ] = [ 'value' => $type ]; + } + register_graphql_enum_type( 'WCSettingTypeEnum', [ 'description' => __( 'Type of WC setting.', 'wp-graphql-woocommerce' ), - 'values' => [ - 'TEXT' => [ 'value' => 'text' ], - 'EMAIL' => [ 'value' => 'email' ], - 'NUMBER' => [ 'value' => 'number' ], - 'COLOR' => [ 'value' => 'color' ], - 'PASSWORD' => [ 'value' => 'password' ], - 'TEXTAREA' => [ 'value' => 'textarea' ], - 'SELECT' => [ 'value' => 'select' ], - 'MULTI_SELECT' => [ 'value' => 'multi_select' ], - 'RADIO' => [ 'value' => 'radio' ], - 'IMAGE_WIDTH' => [ 'value' => 'image_width' ], - 'CHECKBOX' => [ 'value' => 'checkbox' ], - ], + 'values' => $values, ] ); } + + /** + * Collects all unique setting types from registered WC settings groups. + * + * @return array + */ + private static function collect_types() { + $groups = apply_filters( 'woocommerce_settings_groups', [] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + $types = []; + + foreach ( $groups as $group ) { + $group_id = $group['id'] ?? ''; + if ( empty( $group_id ) ) { + continue; + } + + $settings = apply_filters( 'woocommerce_settings-' . $group_id, [] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + foreach ( $settings as $setting ) { + $type = $setting['type'] ?? ''; + if ( ! empty( $type ) ) { + $types[ $type ] = $type; + } + } + } + + /** + * Filters the list of WC setting types registered in the WCSettingTypeEnum. + * + * Allows third-party plugins to add custom setting types to the enum. + * + * @param array $types Array of setting type slugs keyed by slug. + */ + return apply_filters( 'graphql_woocommerce_setting_types', $types ); + } } diff --git a/includes/type/interface/class-downloadable-product.php b/includes/type/interface/class-downloadable-product.php index b2c72ee9..ff54c83c 100644 --- a/includes/type/interface/class-downloadable-product.php +++ b/includes/type/interface/class-downloadable-product.php @@ -8,8 +8,6 @@ namespace WPGraphQL\WooCommerce\Type\WPInterface; -use WPGraphQL\WooCommerce\Core_Schema_Filters as Core; - /** * Class Downloadable_Product */ @@ -27,7 +25,7 @@ class Downloadable_Product { 'description' => __( 'A downloadable product.', 'wp-graphql-woocommerce' ), 'interfaces' => [ 'Node' ], 'fields' => self::get_fields(), - 'resolveType' => [ Core::class, 'resolve_product_type' ], + 'resolveType' => 'wc_graphql_resolve_product_type', ] ); } diff --git a/includes/type/interface/class-inventoried-product.php b/includes/type/interface/class-inventoried-product.php index 44fcfa8c..062571dc 100644 --- a/includes/type/interface/class-inventoried-product.php +++ b/includes/type/interface/class-inventoried-product.php @@ -8,8 +8,6 @@ namespace WPGraphQL\WooCommerce\Type\WPInterface; -use WPGraphQL\WooCommerce\Core_Schema_Filters as Core; - /** * Class Inventoried_Product */ @@ -27,7 +25,7 @@ class Inventoried_Product { 'description' => __( 'A product with stock information.', 'wp-graphql-woocommerce' ), 'interfaces' => [ 'Node' ], 'fields' => self::get_fields(), - 'resolveType' => [ Core::class, 'resolve_product_type' ], + 'resolveType' => 'wc_graphql_resolve_product_type', ] ); } diff --git a/includes/type/interface/class-product-union.php b/includes/type/interface/class-product-union.php index 6477af97..fa8d1c7d 100644 --- a/includes/type/interface/class-product-union.php +++ b/includes/type/interface/class-product-union.php @@ -9,7 +9,6 @@ namespace WPGraphQL\WooCommerce\Type\WPInterface; use WPGraphQL\AppContext; -use WPGraphQL\WooCommerce\Core_Schema_Filters as Core; /** * Class Product_Union @@ -28,7 +27,7 @@ class Product_Union { 'description' => __( 'Union between the product and product variation types', 'wp-graphql-woocommerce' ), 'interfaces' => [ 'Node' ], 'fields' => self::get_fields(), - 'resolveType' => [ Core::class, 'resolve_product_type' ], + 'resolveType' => 'wc_graphql_resolve_product_type', ] ); } diff --git a/includes/type/interface/class-product-with-attributes.php b/includes/type/interface/class-product-with-attributes.php index fa855116..0349dad8 100644 --- a/includes/type/interface/class-product-with-attributes.php +++ b/includes/type/interface/class-product-with-attributes.php @@ -10,7 +10,6 @@ namespace WPGraphQL\WooCommerce\Type\WPInterface; use GraphQL\Type\Definition\ResolveInfo; use WPGraphQL\AppContext; -use WPGraphQL\WooCommerce\Core_Schema_Filters as Core; use WPGraphQL\WooCommerce\Data\Connection\Product_Attribute_Connection_Resolver; use WPGraphQL\WooCommerce\Data\Connection\Variation_Attribute_Connection_Resolver; @@ -32,7 +31,7 @@ class Product_With_Attributes { 'interfaces' => [ 'Node' ], 'fields' => self::get_fields(), 'connections' => self::get_connections(), - 'resolveType' => [ Core::class, 'resolve_product_type' ], + 'resolveType' => 'wc_graphql_resolve_product_type', ] ); } diff --git a/includes/type/interface/class-product-with-dimensions.php b/includes/type/interface/class-product-with-dimensions.php index 3a125df6..19d370f5 100644 --- a/includes/type/interface/class-product-with-dimensions.php +++ b/includes/type/interface/class-product-with-dimensions.php @@ -8,8 +8,6 @@ namespace WPGraphQL\WooCommerce\Type\WPInterface; -use WPGraphQL\WooCommerce\Core_Schema_Filters as Core; - /** * Class Product_With_Dimension */ @@ -27,7 +25,7 @@ class Product_With_Dimensions { 'description' => __( 'A physical product.', 'wp-graphql-woocommerce' ), 'interfaces' => [ 'Node' ], 'fields' => self::get_fields(), - 'resolveType' => [ Core::class, 'resolve_product_type' ], + 'resolveType' => 'wc_graphql_resolve_product_type', ] ); } diff --git a/includes/type/interface/class-product-with-pricing.php b/includes/type/interface/class-product-with-pricing.php index 0e1a4753..fa89f41e 100644 --- a/includes/type/interface/class-product-with-pricing.php +++ b/includes/type/interface/class-product-with-pricing.php @@ -8,8 +8,6 @@ namespace WPGraphQL\WooCommerce\Type\WPInterface; -use WPGraphQL\WooCommerce\Core_Schema_Filters as Core; - /** * Class Product_With_Pricing */ @@ -27,7 +25,7 @@ class Product_With_Pricing { 'description' => __( 'Products with pricing.', 'wp-graphql-woocommerce' ), 'interfaces' => [ 'Node' ], 'fields' => self::get_fields(), - 'resolveType' => [ Core::class, 'resolve_product_type' ], + 'resolveType' => 'wc_graphql_resolve_product_type', ] ); } diff --git a/includes/type/interface/class-product-with-variations.php b/includes/type/interface/class-product-with-variations.php index bda2197d..656ca899 100644 --- a/includes/type/interface/class-product-with-variations.php +++ b/includes/type/interface/class-product-with-variations.php @@ -11,7 +11,6 @@ namespace WPGraphQL\WooCommerce\Type\WPInterface; use GraphQL\Type\Definition\ResolveInfo; use WPGraphQL\AppContext; use WPGraphQL\WooCommerce\Connection\Products; -use WPGraphQL\WooCommerce\Core_Schema_Filters as Core; use WPGraphQL\WooCommerce\Data\Connection\Product_Connection_Resolver; /** @@ -32,7 +31,7 @@ class Product_With_Variations { 'interfaces' => [ 'Node', 'ProductWithAttributes' ], 'fields' => self::get_fields(), 'connections' => self::get_connections(), - 'resolveType' => [ Core::class, 'resolve_product_type' ], + 'resolveType' => 'wc_graphql_resolve_product_type', ] ); } diff --git a/includes/type/interface/class-wc-setting.php b/includes/type/interface/class-wc-setting.php new file mode 100644 index 00000000..0182a5f5 --- /dev/null +++ b/includes/type/interface/class-wc-setting.php @@ -0,0 +1,159 @@ + 'multiselect', + 'single_select_country' => 'select', + 'single_select_page' => 'select', + 'single_select_page_with_search' => 'select', + 'thumbnail_cropping' => 'text', + ]; + + /** + * Default setting type to GraphQL concrete type mapping. + * + * @var array + */ + private static $default_graphql_type_map = [ + 'multiselect' => 'WCArraySetting', + 'relative_date_selector' => 'WCRelativeDateSetting', + 'image_width' => 'WCImageWidthSetting', + ]; + + /** + * Registers the WCSetting interface. + * + * @return void + */ + public static function register_interface() { + register_graphql_interface_type( + 'WCSetting', + [ + 'eagerlyLoadType' => true, + 'description' => __( 'A WC setting object', 'wp-graphql-woocommerce' ), + 'resolveType' => [ self::class, 'resolve_type' ], + 'fields' => [ + 'id' => [ + 'type' => [ 'non_null' => 'ID' ], + 'description' => __( 'The globally unique identifier for the WC setting.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return $source['id'] ?? null; + }, + ], + 'label' => [ + 'type' => 'String', + 'description' => __( 'A human readable label for the setting used in user interfaces.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return $source['label'] ?? $source['title'] ?? null; + }, + ], + 'groupId' => [ + 'type' => 'String', + 'description' => __( 'The ID of the settings group this setting belongs to.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return $source['group_id'] ?? null; + }, + ], + 'description' => [ + 'type' => 'String', + 'description' => __( 'A human readable description for the setting used in user interfaces.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source['description'] ) ? $source['description'] : null; + }, + ], + 'type' => [ + 'type' => 'WCSettingTypeEnum', + 'description' => __( 'Type of setting.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + $raw_type = $source['type'] ?? ''; + return self::$type_map[ $raw_type ] ?? ( ! empty( $raw_type ) ? $raw_type : null ); + }, + ], + 'tip' => [ + 'type' => 'String', + 'description' => __( 'Additional help text shown to the user about the setting', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source['desc_tip'] ) ? $source['desc_tip'] : ( ! empty( $source['tip'] ) ? $source['tip'] : null ); + }, + ], + 'placeholder' => [ + 'type' => 'String', + 'description' => __( 'Placeholder text to be displayed in text inputs.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source['placeholder'] ) ? $source['placeholder'] : null; + }, + ], + 'options' => [ + 'type' => [ 'list_of' => 'String' ], + 'description' => __( 'Array of option key/value pairs for select and multiselect types.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + if ( empty( $source['options'] ) || ! is_array( $source['options'] ) ) { + return null; + } + + $options = []; + foreach ( $source['options'] as $key => $label ) { + $options[] = $key . ':' . $label; + } + + return $options; + }, + ], + ], + ] + ); + } + + /** + * Resolves a setting array to the correct concrete GraphQL type. + * + * @param array $source The setting data array. + * + * @return string The GraphQL type name. + */ + public static function resolve_type( $source ) { + $raw_type = $source['type'] ?? ''; + $normalized_type = self::$type_map[ $raw_type ] ?? $raw_type; + if ( empty( $normalized_type ) ) { + $normalized_type = 'text'; + } + + /** + * Filters the mapping of WC setting types to GraphQL concrete type names. + * + * Allows third-party plugins to register custom setting types and map them + * to their own GraphQL types that implement the WCSetting interface. + * + * @param array $type_map Map of WC setting type => GraphQL type name. + * @param string $normalized_type The normalized setting type. + * @param array $source The raw setting data. + */ + $graphql_type_map = apply_filters( + 'graphql_woocommerce_setting_type_map', + self::$default_graphql_type_map, + $normalized_type, + $source + ); + + return $graphql_type_map[ $normalized_type ] ?? 'WCStringSetting'; + } +} diff --git a/includes/type/object/class-root-query.php b/includes/type/object/class-root-query.php index bcb44cb1..e6102e85 100644 --- a/includes/type/object/class-root-query.php +++ b/includes/type/object/class-root-query.php @@ -594,6 +594,42 @@ class Root_Query { return []; }, ], + 'wcSettingGroups' => [ + 'type' => [ 'list_of' => 'WCSettingGroup' ], + 'description' => __( 'WooCommerce setting groups', 'wp-graphql-woocommerce' ), + 'resolve' => static function () { + if ( ! \wc_rest_check_manager_permissions( 'settings', 'read' ) ) { + throw new UserError( __( 'Sorry, you cannot view settings.', 'wp-graphql-woocommerce' ) ); + } + + $groups = apply_filters( 'woocommerce_settings_groups', [] ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + return array_values( $groups ); + }, + ], + 'wcSettings' => [ + 'type' => [ 'list_of' => 'WCSetting' ], + 'description' => __( 'WooCommerce settings for a specific group', 'wp-graphql-woocommerce' ), + 'args' => [ + 'group' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'Settings group ID', 'wp-graphql-woocommerce' ), + ], + ], + 'resolve' => static function ( $_, $args ) { + if ( ! \wc_rest_check_manager_permissions( 'settings', 'read' ) ) { + throw new UserError( __( 'Sorry, you cannot view settings.', 'wp-graphql-woocommerce' ) ); + } + + $controller = new \WC_REST_Setting_Options_Controller(); + $settings = $controller->get_group_settings( $args['group'] ); + + if ( is_wp_error( $settings ) ) { + throw new UserError( $settings->get_error_message() ); + } + + return $settings; + }, + ], 'collectionStats' => [ 'type' => 'CollectionStats', 'args' => [ diff --git a/includes/type/object/class-shipping-zone-type.php b/includes/type/object/class-shipping-zone-type.php index c4aac326..234e65eb 100644 --- a/includes/type/object/class-shipping-zone-type.php +++ b/includes/type/object/class-shipping-zone-type.php @@ -103,7 +103,7 @@ class Shipping_Zone_Type { }, ], 'settings' => [ - 'type' => [ 'list_of' => 'WCSetting' ], + 'type' => [ 'list_of' => 'WCStringSetting' ], 'description' => __( 'Shipping method settings.', 'wp-graphql-woocommerce' ), 'resolve' => static function ( $edge ) { $settings = []; diff --git a/includes/type/object/class-wc-setting-group-type.php b/includes/type/object/class-wc-setting-group-type.php new file mode 100644 index 00000000..23c7aeb9 --- /dev/null +++ b/includes/type/object/class-wc-setting-group-type.php @@ -0,0 +1,69 @@ + true, + 'description' => __( 'A WooCommerce settings group', 'wp-graphql-woocommerce' ), + 'fields' => [ + 'id' => [ + 'type' => [ 'non_null' => 'String' ], + 'description' => __( 'A unique identifier that can be used to link settings together.', 'wp-graphql-woocommerce' ), + ], + 'label' => [ + 'type' => 'String', + 'description' => __( 'A human readable label for the setting group used in interfaces.', 'wp-graphql-woocommerce' ), + ], + 'description' => [ + 'type' => 'String', + 'description' => __( 'A human readable description for the setting group used in interfaces.', 'wp-graphql-woocommerce' ), + ], + 'parentId' => [ + 'type' => 'String', + 'description' => __( 'ID of parent grouping.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source['parent_id'] ) ? $source['parent_id'] : null; + }, + ], + 'subGroups' => [ + 'type' => [ 'list_of' => 'String' ], + 'description' => __( 'IDs for settings sub groups.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source['sub_groups'] ) ? $source['sub_groups'] : []; + }, + ], + 'settings' => [ + 'type' => [ 'list_of' => 'WCSetting' ], + 'description' => __( 'The settings belonging to this group.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + $controller = new \WC_REST_Setting_Options_Controller(); + $settings = $controller->get_group_settings( $source['id'] ); + + return is_wp_error( $settings ) ? [] : $settings; + }, + ], + ], + ] + ); + } +} diff --git a/includes/type/object/class-wc-setting-type.php b/includes/type/object/class-wc-setting-type.php index 55d22ac5..08abba91 100644 --- a/includes/type/object/class-wc-setting-type.php +++ b/includes/type/object/class-wc-setting-type.php @@ -1,8 +1,8 @@ true, - 'description' => __( 'A WC setting object', 'wp-graphql-woocommerce' ), + 'description' => __( 'A relative date value with a number and unit.', 'wp-graphql-woocommerce' ), 'fields' => [ - 'id' => [ - 'type' => [ 'non_null' => 'ID' ], - 'description' => __( 'The globally unique identifier for the WC setting.', 'wp-graphql-woocommerce' ), - 'resolve' => static function ( $source, array $args, $context, $info ) { - return ! empty( $source['id'] ) ? $source['id'] : null; + 'number' => [ + 'type' => 'Int', + 'description' => __( 'The number of periods.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + $number = $source['number'] ?? ''; + return '' !== $number ? absint( $number ) : null; }, ], - 'label' => [ + 'unit' => [ 'type' => 'String', - 'description' => __( 'A human readable label for the setting used in user interfaces.', 'wp-graphql-woocommerce' ), - 'resolve' => static function ( $source, array $args, $context, $info ) { - return ! empty( $source['title'] ) ? $source['title'] : null; - }, + 'description' => __( 'The period unit (days, weeks, months, years).', 'wp-graphql-woocommerce' ), ], - 'description' => [ - 'type' => 'String', - 'description' => __( 'A human readable description for the setting used in user interfaces.', 'wp-graphql-woocommerce' ), - 'resolve' => static function ( $source, array $args, $context, $info ) { - return ! empty( $source['description'] ) ? $source['description'] : null; - }, + ], + ] + ); + + register_graphql_object_type( + 'WCImageWidth', + [ + 'eagerlyLoadType' => true, + 'description' => __( 'An image width value with dimensions and crop flag.', 'wp-graphql-woocommerce' ), + 'fields' => [ + 'width' => [ + 'type' => 'Int', + 'description' => __( 'Image width in pixels.', 'wp-graphql-woocommerce' ), ], - 'type' => [ - 'type' => 'WCSettingTypeEnum', - 'description' => __( 'Type of setting.', 'wp-graphql-woocommerce' ), - 'resolve' => static function ( $source, array $args, $context, $info ) { - return ! empty( $source['type'] ) ? $source['type'] : null; - }, + 'height' => [ + 'type' => 'Int', + 'description' => __( 'Image height in pixels.', 'wp-graphql-woocommerce' ), ], - 'value' => [ - 'type' => 'String', - 'description' => __( 'Setting value.', 'wp-graphql-woocommerce' ), - 'resolve' => static function ( $source, array $args, $context, $info ) { - return ! empty( $source['value'] ) ? $source['value'] : null; - }, - ], - 'default' => [ - 'type' => 'String', - 'description' => __( 'Default value for the setting.', 'wp-graphql-woocommerce' ), - 'resolve' => static function ( $source, array $args, $context, $info ) { - return ! empty( $source['default'] ) ? $source['default'] : null; - }, - ], - 'tip' => [ - 'type' => 'String', - 'description' => __( 'Additional help text shown to the user about the setting', 'wp-graphql-woocommerce' ), - 'resolve' => static function ( $source, array $args, $context, $info ) { - return ! empty( $source['desc_tip'] ) ? $source['desc_tip'] : null; - }, - ], - 'placeholder' => [ - 'type' => 'String', - 'description' => __( 'Placeholder text to be displayed in text inputs.', 'wp-graphql-woocommerce' ), - 'resolve' => static function ( $source, array $args, $context, $info ) { - return ! empty( $source['placeholder'] ) ? $source['placeholder'] : null; + 'crop' => [ + 'type' => 'Boolean', + 'description' => __( 'Whether to crop the image.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + return ! empty( $source['crop'] ); }, ], ], ] ); } + + /** + * Registers concrete setting types that implement the WCSetting interface. + * + * @return void + */ + private static function register_concrete_types() { + register_graphql_object_type( + 'WCStringSetting', + [ + 'eagerlyLoadType' => true, + 'description' => __( 'A WC setting with a string value.', 'wp-graphql-woocommerce' ), + 'interfaces' => [ 'WCSetting' ], + 'fields' => [ + 'value' => [ + 'type' => 'String', + 'description' => __( 'Setting value.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + $value = $source['value'] ?? null; + return is_scalar( $value ) ? (string) $value : null; + }, + ], + 'default' => [ + 'type' => 'String', + 'description' => __( 'Default value for the setting.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + $value = $source['default'] ?? null; + return ! empty( $value ) && is_scalar( $value ) ? (string) $value : null; + }, + ], + ], + ] + ); + + register_graphql_object_type( + 'WCArraySetting', + [ + 'eagerlyLoadType' => true, + 'description' => __( 'A WC setting with an array value.', 'wp-graphql-woocommerce' ), + 'interfaces' => [ 'WCSetting' ], + 'fields' => [ + 'value' => [ + 'type' => [ 'list_of' => 'String' ], + 'description' => __( 'Setting value as a list of strings.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + $value = $source['value'] ?? null; + return is_array( $value ) ? array_values( $value ) : null; + }, + ], + 'default' => [ + 'type' => [ 'list_of' => 'String' ], + 'description' => __( 'Default value as a list of strings.', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $source ) { + $value = $source['default'] ?? null; + return is_array( $value ) ? array_values( $value ) : null; + }, + ], + ], + ] + ); + + register_graphql_object_type( + 'WCRelativeDateSetting', + [ + 'eagerlyLoadType' => true, + 'description' => __( 'A WC setting with a relative date value.', 'wp-graphql-woocommerce' ), + 'interfaces' => [ 'WCSetting' ], + 'fields' => [ + 'value' => [ + 'type' => 'WCRelativeDate', + 'description' => __( 'Setting value as a relative date.', 'wp-graphql-woocommerce' ), + ], + 'default' => [ + 'type' => 'WCRelativeDate', + 'description' => __( 'Default value as a relative date.', 'wp-graphql-woocommerce' ), + ], + ], + ] + ); + + register_graphql_object_type( + 'WCImageWidthSetting', + [ + 'eagerlyLoadType' => true, + 'description' => __( 'A WC setting with an image width value.', 'wp-graphql-woocommerce' ), + 'interfaces' => [ 'WCSetting' ], + 'fields' => [ + 'value' => [ + 'type' => 'WCImageWidth', + 'description' => __( 'Setting value as image dimensions.', 'wp-graphql-woocommerce' ), + ], + 'default' => [ + 'type' => 'WCImageWidth', + 'description' => __( 'Default value as image dimensions.', 'wp-graphql-woocommerce' ), + ], + ], + ] + ); + } } diff --git a/includes/utils/class-protected-router.php b/includes/utils/class-protected-router.php index 79a94323..a3810062 100644 --- a/includes/utils/class-protected-router.php +++ b/includes/utils/class-protected-router.php @@ -8,8 +8,6 @@ namespace WPGraphQL\WooCommerce\Utils; -use WPGraphQL\WooCommerce\WooCommerce_Filters; - /** * Class Protected_Router */ @@ -226,11 +224,11 @@ class Protected_Router { * @return array */ public static function get_nonce_names() { - $enabled_authorizing_url_fields = WooCommerce_Filters::enabled_authorizing_url_fields(); + $enabled_authorizing_url_fields = wc_graphql_enabled_authorizing_url_fields(); $nonce_names = []; if ( ! empty( $enabled_authorizing_url_fields ) ) { foreach ( array_keys( $enabled_authorizing_url_fields ) as $field ) { - $nonce_names[ $field ] = WooCommerce_Filters::get_authorizing_url_nonce_param_name( $field ); + $nonce_names[ $field ] = wc_graphql_get_authorizing_url_nonce_param_name( $field ); } } diff --git a/tests/wpunit/CartMutationsTest.php b/tests/wpunit/CartMutationsTest.php index 8e0ea584..e7a6c13d 100644 --- a/tests/wpunit/CartMutationsTest.php +++ b/tests/wpunit/CartMutationsTest.php @@ -733,73 +733,6 @@ class CartMutationsTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQL ] ); } - - public function testAddFeeMutation() { - $this->markTestSkipped( 'This mutation has been deprecated' ); - // Create product and coupon. - $product_id = $this->factory->product->createSimple(); - $coupon_code = wc_get_coupon_code_by_id( - $this->factory->coupon->create( - [ 'product_ids' => [ $product_id ] ] - ) - ); - - // Add item and coupon to cart. - $cart = \WC()->cart; - $cart->add_to_cart( $product_id, 3 ); - $cart->apply_coupon( $coupon_code ); - - $query = ' - mutation( $input: AddFeeInput! ) { - addFee( input: $input ) { - clientMutationId - cartFee { - id - name - taxClass - taxable - amount - total - } - } - } - '; - - $variables = [ - 'input' => [ - 'clientMutationId' => 'someId', - 'name' => 'extra_fee', - 'amount' => 49.99, - ], - ]; - $response = $this->graphql( compact( 'query', 'variables' ) ); - $this->assertArrayHasKey( 'errors', $response ); - - wp_set_current_user( $this->shop_manager ); - $response = $this->graphql( compact( 'query', 'variables' ) ); - - $cart = WC()->cart; - $fee = ( $cart->get_fees() )['extra_fee']; - - $this->assertQuerySuccessful( - $response, - [ - $this->expectedField( 'addFee.clientMutationId', 'someId' ), - $this->expectedField( - 'addFee.cartFee', - [ - 'id' => $fee->id, - 'name' => $fee->name, - 'taxClass' => ! empty( $fee->tax_class ) ? $fee->tax_class : null, - 'taxable' => $fee->taxable, - 'amount' => (float) $fee->amount, - 'total' => (float) $fee->total, - ] - ), - ] - ); - } - public function testAddToCartMutationErrors() { // Create products. $product_id = $this->factory->product->createSimple( diff --git a/tests/wpunit/CheckoutMutationTest.php b/tests/wpunit/CheckoutMutationTest.php index cf8b55b6..c22eb2d9 100644 --- a/tests/wpunit/CheckoutMutationTest.php +++ b/tests/wpunit/CheckoutMutationTest.php @@ -935,6 +935,7 @@ class CheckoutMutationTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGrap } public function testCheckoutMutationWithStripe() { + $this->markTestSkipped( 'This test is for example purposes only and should NOT be run in CI.' ); WC()->customer->set_billing_email( 'superfreak500@gmail.com' ); WC()->customer->save(); diff --git a/tests/wpunit/OrderCursorPaginationTest.php b/tests/wpunit/OrderCursorPaginationTest.php new file mode 100644 index 00000000..5cef7603 --- /dev/null +++ b/tests/wpunit/OrderCursorPaginationTest.php @@ -0,0 +1,484 @@ +loginAsShopManager(); + + // Create 5 orders with staggered dates and different totals. + for ( $i = 0; $i < 5; $i++ ) { + $this->order_ids[] = $this->factory->order->createNew( + [ + 'status' => 'completed', + 'customer_id' => $this->customer, + ] + ); + + // Stagger dates so ordering is deterministic. + $order = wc_get_order( $this->order_ids[ $i ] ); + $order->set_date_created( gmdate( 'Y-m-d H:i:s', strtotime( "+{$i} minutes" ) ) ); + $order->set_total( ( $i + 1 ) * 10 ); + $order->save(); + } + } + + public function testForwardPaginationWithFirstAfter() { + $query = ' + query ($first: Int, $after: String) { + orders(first: $first, after: $after) { + nodes { + databaseId + } + pageInfo { + hasNextPage + endCursor + } + } + } + '; + + // Page 1: 2 orders. + $variables = [ 'first' => 2 ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.1.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.pageInfo.hasNextPage', true ), + $this->expectedField( 'orders.pageInfo.endCursor', static::NOT_FALSY ), + ] + ); + + $page1_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $end_cursor = $this->lodashGet( $response, 'data.orders.pageInfo.endCursor' ); + + // Page 2. + $variables = [ + 'first' => 2, + 'after' => $end_cursor, + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.1.databaseId', static::NOT_FALSY ), + ]; + foreach ( $page1_nodes as $node ) { + $expected[] = $this->not()->expectedNode( + 'orders.nodes', + [ $this->expectedField( 'databaseId', $node['databaseId'] ) ] + ); + } + + $this->assertQuerySuccessful( $response, $expected ); + + $page2_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $end_cursor = $this->lodashGet( $response, 'data.orders.pageInfo.endCursor' ); + + // Page 3: should have 1 remaining. + $variables = [ + 'first' => 2, + 'after' => $end_cursor, + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.pageInfo.hasNextPage', false ), + ]; + foreach ( array_merge( $page1_nodes, $page2_nodes ) as $node ) { + $expected[] = $this->not()->expectedNode( + 'orders.nodes', + [ $this->expectedField( 'databaseId', $node['databaseId'] ) ] + ); + } + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testBackwardPaginationWithLastBefore() { + $query = ' + query ($last: Int, $before: String) { + orders(last: $last, before: $before) { + nodes { + databaseId + } + pageInfo { + hasPreviousPage + startCursor + } + } + } + '; + + // Last 2 orders. + $variables = [ 'last' => 2 ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.1.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.pageInfo.hasPreviousPage', true ), + $this->expectedField( 'orders.pageInfo.startCursor', static::NOT_FALSY ), + ] + ); + + $page1_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $start_cursor = $this->lodashGet( $response, 'data.orders.pageInfo.startCursor' ); + + // Previous page. + $variables = [ + 'last' => 2, + 'before' => $start_cursor, + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.1.databaseId', static::NOT_FALSY ), + ]; + foreach ( $page1_nodes as $node ) { + $expected[] = $this->not()->expectedNode( + 'orders.nodes', + [ $this->expectedField( 'databaseId', $node['databaseId'] ) ] + ); + } + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testCursorPaginationReturnsConsistentResults() { + $query = ' + query { + orders(first: 100) { + nodes { + databaseId + } + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + + $expected = []; + foreach ( $this->order_ids as $order_id ) { + $expected[] = $this->expectedNode( + 'orders.nodes', + [ $this->expectedField( 'databaseId', $order_id ) ] + ); + } + + $this->assertQuerySuccessful( $response, $expected ); + } + + public function testOrderConnectionWithDateOrdering() { + $query = ' + query { + orders(first: 5, where: { orderby: { field: DATE, order: ASC } }) { + nodes { + databaseId + date + } + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.0.date', static::NOT_FALSY ), + ] + ); + + $nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $dates = array_column( $nodes, 'date' ); + $sorted = $dates; + sort( $sorted ); + $this->assertSame( $sorted, $dates, 'Orders should be sorted by date ascending.' ); + } + + public function testOrderConnectionWithDescDateOrdering() { + $query = ' + query { + orders(first: 5, where: { orderby: { field: DATE, order: DESC } }) { + nodes { + databaseId + date + } + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.0.date', static::NOT_FALSY ), + ] + ); + + $nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $dates = array_column( $nodes, 'date' ); + $sorted = $dates; + rsort( $sorted ); + $this->assertSame( $sorted, $dates, 'Orders should be sorted by date descending.' ); + } + + public function testForwardPaginationWithDateOrderingMaintainsCursorIntegrity() { + $query = ' + query ($first: Int, $after: String) { + orders(first: $first, after: $after, where: { orderby: { field: DATE, order: ASC } }) { + nodes { + databaseId + date + } + pageInfo { + hasNextPage + endCursor + } + } + } + '; + + // Page 1. + $variables = [ 'first' => 2 ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'orders.nodes.0.date', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.1.date', static::NOT_FALSY ), + $this->expectedField( 'orders.pageInfo.endCursor', static::NOT_FALSY ), + ] + ); + + $page1_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $end_cursor = $this->lodashGet( $response, 'data.orders.pageInfo.endCursor' ); + + // Page 2. + $variables = [ + 'first' => 2, + 'after' => $end_cursor, + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedField( 'orders.nodes.0.date', static::NOT_FALSY ), + ]; + foreach ( $page1_nodes as $node ) { + $expected[] = $this->not()->expectedNode( + 'orders.nodes', + [ $this->expectedField( 'databaseId', $node['databaseId'] ) ] + ); + } + + $this->assertQuerySuccessful( $response, $expected ); + + $page2_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $last_date_page1 = end( $page1_nodes )['date']; + $first_date_page2 = $page2_nodes[0]['date']; + $this->assertLessThanOrEqual( + $first_date_page2, + $last_date_page1, + 'Cursor pagination should maintain date ordering across pages.' + ); + } + + public function testCursorPaginationOrderedByTotal() { + $query = ' + query ($first: Int, $after: String) { + orders(first: $first, after: $after, where: { orderby: { field: TOTAL, order: ASC } }) { + nodes { + databaseId + total(format: RAW) + } + pageInfo { + hasNextPage + endCursor + } + } + } + '; + + // Page 1. + $variables = [ 'first' => 2 ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.0.total', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.1.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.pageInfo.hasNextPage', true ), + $this->expectedField( 'orders.pageInfo.endCursor', static::NOT_FALSY ), + ] + ); + + $page1_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $end_cursor = $this->lodashGet( $response, 'data.orders.pageInfo.endCursor' ); + + // Page 2 — triggers COT cursor compare_with() for _order_total column. + $variables = [ + 'first' => 2, + 'after' => $end_cursor, + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.1.databaseId', static::NOT_FALSY ), + ]; + foreach ( $page1_nodes as $node ) { + $expected[] = $this->not()->expectedNode( + 'orders.nodes', + [ $this->expectedField( 'databaseId', $node['databaseId'] ) ] + ); + } + + $this->assertQuerySuccessful( $response, $expected ); + + $page2_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $last_total_page1 = (float) end( $page1_nodes )['total']; + $first_total_page2 = (float) $page2_nodes[0]['total']; + $this->assertLessThanOrEqual( + $first_total_page2, + $last_total_page1, + 'Cursor pagination should maintain total ordering across pages.' + ); + } + + public function testCursorPaginationOrderedByTotalDesc() { + $query = ' + query ($first: Int, $after: String) { + orders(first: $first, after: $after, where: { orderby: { field: TOTAL, order: DESC } }) { + nodes { + databaseId + total(format: RAW) + } + pageInfo { + hasNextPage + endCursor + } + } + } + '; + + // Page 1. + $variables = [ 'first' => 2 ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.0.total', static::NOT_FALSY ), + $this->expectedField( 'orders.pageInfo.endCursor', static::NOT_FALSY ), + ] + ); + + $page1_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $end_cursor = $this->lodashGet( $response, 'data.orders.pageInfo.endCursor' ); + + // Page 2. + $variables = [ + 'first' => 2, + 'after' => $end_cursor, + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $expected = [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + ]; + foreach ( $page1_nodes as $node ) { + $expected[] = $this->not()->expectedNode( + 'orders.nodes', + [ $this->expectedField( 'databaseId', $node['databaseId'] ) ] + ); + } + + $this->assertQuerySuccessful( $response, $expected ); + + $page2_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $last_total_page1 = (float) end( $page1_nodes )['total']; + $first_total_page2 = (float) $page2_nodes[0]['total']; + $this->assertGreaterThanOrEqual( + $first_total_page2, + $last_total_page1, + 'Cursor pagination should maintain descending total ordering across pages.' + ); + } + + public function testCursorPaginationOrderedByDateCompleted() { + // Set completed dates on orders. + foreach ( $this->order_ids as $i => $order_id ) { + $order = wc_get_order( $order_id ); + $order->set_date_completed( gmdate( 'Y-m-d H:i:s', strtotime( "+{$i} hours" ) ) ); + $order->save(); + } + + $query = ' + query ($first: Int, $after: String) { + orders(first: $first, after: $after, where: { orderby: { field: DATE_COMPLETED, order: ASC } }) { + nodes { + databaseId + } + pageInfo { + hasNextPage + endCursor + } + } + } + '; + + // Page 1. + $variables = [ 'first' => 2 ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( $response, + [ + $this->expectedField( 'orders.nodes.0.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.nodes.1.databaseId', static::NOT_FALSY ), + $this->expectedField( 'orders.pageInfo.endCursor', static::NOT_FALSY ), + ] + ); + + $page1_nodes = $this->lodashGet( $response, 'data.orders.nodes' ); + $end_cursor = $this->lodashGet( $response, 'data.orders.pageInfo.endCursor' ); + + // Page 2 — triggers COT cursor compare_with() for _date_completed column. + $variables = [ + 'first' => 2, + 'after' => $end_cursor, + ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + array_map( + function( $order ) { + return $this->not()->expectedNode( + 'orders.nodes', + [ + $this->expectedField( 'databaseId', $order['databaseId'] ), + ] + ); + }, + $page1_nodes + ) + ); + } +} diff --git a/tests/wpunit/WCSettingsMutationsTest.php b/tests/wpunit/WCSettingsMutationsTest.php new file mode 100644 index 00000000..88d2528e --- /dev/null +++ b/tests/wpunit/WCSettingsMutationsTest.php @@ -0,0 +1,352 @@ +loginAsShopManager(); + + $original = get_option( 'woocommerce_store_address', '' ); + + $query = ' + mutation ($input: UpdateWCSettingInput!) { + updateWCSetting(input: $input) { + setting { + id + ... on WCStringSetting { + value + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'group' => 'general', + 'id' => 'woocommerce_store_address', + 'value' => '123 Test Street', + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'updateWCSetting.setting.id', 'woocommerce_store_address' ), + $this->expectedField( 'updateWCSetting.setting.value', '123 Test Street' ), + ] + ); + + $this->assertSame( '123 Test Street', get_option( 'woocommerce_store_address' ) ); + + update_option( 'woocommerce_store_address', $original ); + } + + public function testUpdateWCSettingSelect() { + $this->loginAsShopManager(); + + $original = get_option( 'woocommerce_currency', 'USD' ); + + $query = ' + mutation ($input: UpdateWCSettingInput!) { + updateWCSetting(input: $input) { + setting { + id + ... on WCStringSetting { + value + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'group' => 'general', + 'id' => 'woocommerce_currency', + 'value' => 'EUR', + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'updateWCSetting.setting.id', 'woocommerce_currency' ), + $this->expectedField( 'updateWCSetting.setting.value', 'EUR' ), + ] + ); + + update_option( 'woocommerce_currency', $original ); + } + + public function testUpdateWCSettingCheckbox() { + $this->loginAsShopManager(); + + $original = get_option( 'woocommerce_calc_taxes', 'no' ); + + $query = ' + mutation ($input: UpdateWCSettingInput!) { + updateWCSetting(input: $input) { + setting { + id + ... on WCStringSetting { + value + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'group' => 'general', + 'id' => 'woocommerce_calc_taxes', + 'value' => 'yes', + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'updateWCSetting.setting.id', 'woocommerce_calc_taxes' ), + $this->expectedField( 'updateWCSetting.setting.value', 'yes' ), + ] + ); + + update_option( 'woocommerce_calc_taxes', $original ); + } + + public function testUpdateWCSettingNumber() { + $this->loginAsShopManager(); + + $original = get_option( 'woocommerce_price_num_decimals', '2' ); + + $query = ' + mutation ($input: UpdateWCSettingInput!) { + updateWCSetting(input: $input) { + setting { + id + ... on WCStringSetting { + value + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'group' => 'general', + 'id' => 'woocommerce_price_num_decimals', + 'value' => '4', + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'updateWCSetting.setting.id', 'woocommerce_price_num_decimals' ), + $this->expectedField( 'updateWCSetting.setting.value', '4' ), + ] + ); + + update_option( 'woocommerce_price_num_decimals', $original ); + } + + public function testUpdateWCSettingTextarea() { + $this->loginAsShopManager(); + + $original = get_option( 'woocommerce_registration_privacy_policy_text', '' ); + + $query = ' + mutation ($input: UpdateWCSettingInput!) { + updateWCSetting(input: $input) { + setting { + id + ... on WCStringSetting { + value + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'group' => 'account', + 'id' => 'woocommerce_registration_privacy_policy_text', + 'value' => 'Updated privacy policy text for testing.', + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'updateWCSetting.setting.id', 'woocommerce_registration_privacy_policy_text' ), + $this->expectedField( 'updateWCSetting.setting.value', 'Updated privacy policy text for testing.' ), + ] + ); + + update_option( 'woocommerce_registration_privacy_policy_text', $original ); + } + + public function testUpdateWCSettingAsCustomerFails() { + $customer_id = $this->factory->customer->create(); + $this->loginAs( $customer_id ); + + $query = ' + mutation ($input: UpdateWCSettingInput!) { + updateWCSetting(input: $input) { + setting { + id + } + } + } + '; + + $variables = [ + 'input' => [ + 'group' => 'general', + 'id' => 'woocommerce_store_address', + 'value' => 'Unauthorized Street', + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQueryError( + $response, + [ $this->expectedErrorMessage( 'Sorry, you cannot update settings.', self::MESSAGE_EQUALS ) ] + ); + } + + public function testUpdateWCSettingInvalidSettingFails() { + $this->loginAsShopManager(); + + $query = ' + mutation ($input: UpdateWCSettingInput!) { + updateWCSetting(input: $input) { + setting { + id + } + } + } + '; + + $variables = [ + 'input' => [ + 'group' => 'general', + 'id' => 'nonexistent_setting', + 'value' => 'test', + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQueryError( + $response, + [ $this->expectedErrorMessage( 'Invalid setting', self::MESSAGE_CONTAINS ) ] + ); + } + + public function testUpdateWCSettingsBatchUpdate() { + $this->loginAsShopManager(); + + $original_address = get_option( 'woocommerce_store_address', '' ); + $original_city = get_option( 'woocommerce_store_city', '' ); + + $query = ' + mutation ($input: UpdateWCSettingsInput!) { + updateWCSettings(input: $input) { + settings { + id + ... on WCStringSetting { + value + } + } + } + } + '; + + $variables = [ + 'input' => [ + 'group' => 'general', + 'settings' => [ + [ 'id' => 'woocommerce_store_address', 'value' => '456 Batch Ave' ], + [ 'id' => 'woocommerce_store_city', 'value' => 'Batchville' ], + ], + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'updateWCSettings.settings.#.id', 'woocommerce_store_address' ), + $this->expectedField( 'updateWCSettings.settings.#.value', '456 Batch Ave' ), + $this->expectedField( 'updateWCSettings.settings.#.id', 'woocommerce_store_city' ), + $this->expectedField( 'updateWCSettings.settings.#.value', 'Batchville' ), + ] + ); + + $this->assertSame( '456 Batch Ave', get_option( 'woocommerce_store_address' ) ); + $this->assertSame( 'Batchville', get_option( 'woocommerce_store_city' ) ); + + update_option( 'woocommerce_store_address', $original_address ); + update_option( 'woocommerce_store_city', $original_city ); + } + + public function testQuerySettingsByTypeVariant() { + $this->loginAsShopManager(); + + $query = ' + query { + wcSettings(group: "account") { + id + type + ... on WCStringSetting { + stringValue: value + } + ... on WCArraySetting { + arrayValue: value + } + ... on WCRelativeDateSetting { + relativeDateValue: value { + number + unit + } + } + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + + $this->assertQuerySuccessful( $response, [] ); + + $settings = $this->lodashGet( $response, 'data.wcSettings' ); + $this->assertNotEmpty( $settings ); + + // Verify relative_date_selector settings resolve correctly. + $relative_date_setting = null; + foreach ( $settings as $setting ) { + if ( 'RELATIVE_DATE_SELECTOR' === $setting['type'] ) { + $relative_date_setting = $setting; + break; + } + } + + $this->assertNotNull( $relative_date_setting, 'Should find a relative_date_selector setting in the account group.' ); + $this->assertArrayHasKey( 'relativeDateValue', $relative_date_setting, 'Relative date setting should have a relativeDateValue field.' ); + $this->assertArrayHasKey( 'unit', $relative_date_setting['relativeDateValue'], 'Relative date value should have a unit field.' ); + } +} diff --git a/tests/wpunit/WCSettingsQueriesTest.php b/tests/wpunit/WCSettingsQueriesTest.php new file mode 100644 index 00000000..70f215fb --- /dev/null +++ b/tests/wpunit/WCSettingsQueriesTest.php @@ -0,0 +1,136 @@ +loginAsShopManager(); + + $query = ' + query { + wcSettingGroups { + id + label + description + parentId + subGroups + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + + $this->assertQuerySuccessful( $response, [] ); + + $groups = $this->lodashGet( $response, 'data.wcSettingGroups' ); + $this->assertNotEmpty( $groups, 'Should return at least one setting group.' ); + + $group_ids = array_column( $groups, 'id' ); + $this->assertContains( 'general', $group_ids, 'Should contain the "general" settings group.' ); + } + + public function testWcSettingGroupsQueryAsCustomerFails() { + $customer_id = $this->factory->customer->create(); + $this->loginAs( $customer_id ); + + $query = ' + query { + wcSettingGroups { + id + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + + $this->assertQueryError( + $response, + [ $this->expectedErrorMessage( 'Sorry, you cannot view settings.', self::MESSAGE_EQUALS ) ] + ); + } + + public function testWcSettingsQueryReturnsGroupSettings() { + $this->loginAsShopManager(); + + $query = ' + query ($group: String!) { + wcSettings(group: $group) { + id + label + description + type + tip + placeholder + groupId + options + ... on WCStringSetting { + value + default + } + } + } + '; + + $variables = [ 'group' => 'general' ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQuerySuccessful( $response, [] ); + + $settings = $this->lodashGet( $response, 'data.wcSettings' ); + $this->assertNotEmpty( $settings, 'Should return settings for the "general" group.' ); + + $setting_ids = array_column( $settings, 'id' ); + $this->assertContains( 'woocommerce_store_address', $setting_ids, 'Should contain the store address setting.' ); + } + + public function testWcSettingsQueryInvalidGroupFails() { + $this->loginAsShopManager(); + + $query = ' + query ($group: String!) { + wcSettings(group: $group) { + id + } + } + '; + + $variables = [ 'group' => 'nonexistent_group' ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + + $this->assertQueryError( $response ); + } + + public function testWcSettingGroupSettingsField() { + $this->loginAsShopManager(); + + $query = ' + query { + wcSettingGroups { + id + settings { + id + label + type + ... on WCStringSetting { + value + } + } + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + + $this->assertQuerySuccessful( $response, [] ); + + $groups = $this->lodashGet( $response, 'data.wcSettingGroups' ); + $general = null; + foreach ( $groups as $group ) { + if ( 'general' === $group['id'] ) { + $general = $group; + break; + } + } + + $this->assertNotNull( $general, 'Should find the "general" group.' ); + $this->assertNotEmpty( $general['settings'], 'General group should have settings.' ); + } +} diff --git a/wp-graphql-woocommerce.php b/wp-graphql-woocommerce.php index 805434bd..540693f5 100644 --- a/wp-graphql-woocommerce.php +++ b/wp-graphql-woocommerce.php @@ -203,29 +203,18 @@ constants(); // Load access functions. require_once get_plugin_directory() . 'access-functions.php'; -// Confirm WC HPOS compatibility. +// Confirm WC HPOS and Cart & Checkout Blocks compatibility. +// Only declare when installed as a top-level plugin (not nested in another plugin's vendor directory). add_action( 'before_woocommerce_init', static function () { - if ( get_plugin_directory() !== WP_PLUGIN_DIR . '/wp-graphql-woocommerce/' ) { + $is_top_level_plugin = dirname( __DIR__ ) === WP_PLUGIN_DIR; + if ( ! $is_top_level_plugin ) { return; } if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true ); - } - } -); - -// Confirm WC Cart & Checkout Blocks compatibility. -add_action( - 'before_woocommerce_init', - static function () { - if ( get_plugin_directory() !== WP_PLUGIN_DIR . '/wp-graphql-woocommerce/' ) { - return; - } - - if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true ); } }