diff --git a/includes/class-core-schema-filters.php b/includes/class-core-schema-filters.php index c5c4cdbf..858386bd 100644 --- a/includes/class-core-schema-filters.php +++ b/includes/class-core-schema-filters.php @@ -259,6 +259,12 @@ class Core_Schema_Filters { $args['graphql_plural_name'] = 'shippingClasses'; } + if ( 'product_brand' === $taxonomy ) { + $args['show_in_graphql'] = true; + $args['graphql_single_name'] = 'productBrand'; + $args['graphql_plural_name'] = 'productBrands'; + } + // Filter product attributes taxonomies. $attributes = WooGraphQL::get_product_attribute_taxonomies(); if ( in_array( $taxonomy, $attributes, true ) ) { diff --git a/includes/class-type-registry.php b/includes/class-type-registry.php index d574e12a..9e7be3a4 100644 --- a/includes/class-type-registry.php +++ b/includes/class-type-registry.php @@ -182,6 +182,8 @@ class Type_Registry { Mutation\Cart_Update_Item_Quantities::register_mutation(); Mutation\Cart_Update_Shipping_Method::register_mutation(); Mutation\Checkout::register_mutation(); + Mutation\Product_Category_Create::register(); + Mutation\Product_Category_Update::register(); Mutation\Coupon_Create::register_mutation(); Mutation\Coupon_Update::register_mutation(); Mutation\Coupon_Delete::register_mutation(); diff --git a/includes/class-wp-graphql-woocommerce.php b/includes/class-wp-graphql-woocommerce.php index f9e09e3b..51bc0cdd 100644 --- a/includes/class-wp-graphql-woocommerce.php +++ b/includes/class-wp-graphql-woocommerce.php @@ -366,6 +366,8 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : require $include_directory_path . 'mutation/class-product-attribute-term-delete.php'; require $include_directory_path . 'mutation/class-product-attribute-term-update.php'; require $include_directory_path . 'mutation/class-product-attribute-update.php'; + require $include_directory_path . 'mutation/class-product-category-create.php'; + require $include_directory_path . 'mutation/class-product-category-update.php'; require $include_directory_path . 'mutation/class-product-create.php'; require $include_directory_path . 'mutation/class-product-delete.php'; require $include_directory_path . 'mutation/class-product-update.php'; diff --git a/includes/connection/class-products.php b/includes/connection/class-products.php index 17894114..27db7755 100644 --- a/includes/connection/class-products.php +++ b/includes/connection/class-products.php @@ -301,141 +301,165 @@ class Products { */ public static function get_connection_args( $extra_args = [] ): array { $args = [ - 'slugIn' => [ + 'slugIn' => [ 'type' => [ 'list_of' => 'String' ], 'description' => __( 'Limit result set to products with specific slugs.', 'wp-graphql-woocommerce' ), ], - 'status' => [ + 'status' => [ 'type' => 'String', 'description' => __( 'Limit result set to products assigned a specific status.', 'wp-graphql-woocommerce' ), ], - 'type' => [ + 'type' => [ 'type' => 'ProductTypesEnum', 'description' => __( 'Limit result set to products assigned a specific type.', 'wp-graphql-woocommerce' ), ], - 'typeIn' => [ + 'typeIn' => [ 'type' => [ 'list_of' => 'ProductTypesEnum' ], 'description' => __( 'Limit result set to products assigned to a group of specific types.', 'wp-graphql-woocommerce' ), ], - 'typeNotIn' => [ + 'typeNotIn' => [ 'type' => [ 'list_of' => 'ProductTypesEnum' ], 'description' => __( 'Limit result set to products not assigned to a group of specific types.', 'wp-graphql-woocommerce' ), ], - 'sku' => [ + 'sku' => [ 'type' => 'String', 'description' => __( 'Limit result set to products with specific SKU(s). Use commas to separate.', 'wp-graphql-woocommerce' ), ], - 'featured' => [ + 'featured' => [ 'type' => 'Boolean', 'description' => __( 'Limit result set to featured products.', 'wp-graphql-woocommerce' ), ], - 'category' => [ + 'category' => [ 'type' => 'String', 'description' => __( 'Limit result set to products assigned a specific category name.', 'wp-graphql-woocommerce' ), ], - 'categoryIn' => [ + 'categoryIn' => [ 'type' => [ 'list_of' => 'String' ], 'description' => __( 'Limit result set to products assigned to a group of specific categories by name.', 'wp-graphql-woocommerce' ), ], - 'categoryNotIn' => [ + 'categoryNotIn' => [ 'type' => [ 'list_of' => 'String' ], 'description' => __( 'Limit result set to products not assigned to a group of specific categories by name.', 'wp-graphql-woocommerce' ), ], - 'categoryId' => [ + 'categoryId' => [ 'type' => 'Int', 'description' => __( 'Limit result set to products assigned a specific category name.', 'wp-graphql-woocommerce' ), ], - 'categoryIdIn' => [ + 'categoryIdIn' => [ 'type' => [ 'list_of' => 'Int' ], 'description' => __( 'Limit result set to products assigned to a specific group of category IDs.', 'wp-graphql-woocommerce' ), ], - 'categoryIdNotIn' => [ + 'categoryIdNotIn' => [ 'type' => [ 'list_of' => 'Int' ], 'description' => __( 'Limit result set to products not assigned to a specific group of category IDs.', 'wp-graphql-woocommerce' ), ], - 'tag' => [ + 'tag' => [ 'type' => 'String', 'description' => __( 'Limit result set to products assigned a specific tag name.', 'wp-graphql-woocommerce' ), ], - 'tagIn' => [ + 'tagIn' => [ 'type' => [ 'list_of' => 'String' ], 'description' => __( 'Limit result set to products assigned to a specific group of tags by name.', 'wp-graphql-woocommerce' ), ], - 'tagNotIn' => [ + 'tagNotIn' => [ 'type' => [ 'list_of' => 'String' ], 'description' => __( 'Limit result set to products not assigned to a specific group of tags by name.', 'wp-graphql-woocommerce' ), ], - 'tagId' => [ + 'tagId' => [ 'type' => 'Int', 'description' => __( 'Limit result set to products assigned a specific tag ID.', 'wp-graphql-woocommerce' ), ], - 'tagIdIn' => [ + 'tagIdIn' => [ 'type' => [ 'list_of' => 'Int' ], 'description' => __( 'Limit result set to products assigned to a specific group of tag IDs.', 'wp-graphql-woocommerce' ), ], - 'tagIdNotIn' => [ + 'tagIdNotIn' => [ 'type' => [ 'list_of' => 'Int' ], 'description' => __( 'Limit result set to products not assigned to a specific group of tag IDs.', 'wp-graphql-woocommerce' ), ], - 'shippingClassId' => [ + 'productBrand' => [ + 'type' => 'String', + 'description' => __( 'Limit result set to products assigned a specific brand name.', 'wp-graphql-woocommerce' ), + ], + 'productBrandIn' => [ + 'type' => [ 'list_of' => 'String' ], + 'description' => __( 'Limit result set to products assigned to a specific group of brands by name.', 'wp-graphql-woocommerce' ), + ], + 'productBrandNotIn' => [ + 'type' => [ 'list_of' => 'String' ], + 'description' => __( 'Limit result set to products not assigned to a specific group of brands by name.', 'wp-graphql-woocommerce' ), + ], + 'productBrandId' => [ + 'type' => 'Int', + 'description' => __( 'Limit result set to products assigned a specific brand ID.', 'wp-graphql-woocommerce' ), + ], + 'productBrandIdIn' => [ + 'type' => [ 'list_of' => 'Int' ], + 'description' => __( 'Limit result set to products assigned to a specific group of brand IDs.', 'wp-graphql-woocommerce' ), + ], + 'productBrandIdNotIn' => [ + 'type' => [ 'list_of' => 'Int' ], + 'description' => __( 'Limit result set to products not assigned to a specific group of brand IDs.', 'wp-graphql-woocommerce' ), + ], + 'shippingClassId' => [ 'type' => 'Int', 'description' => __( 'Limit result set to products assigned a specific shipping class ID.', 'wp-graphql-woocommerce' ), ], - 'attributes' => [ + 'attributes' => [ 'type' => 'ProductAttributeQueryInput', 'description' => __( 'Limit result set to products with selected global attribute queries.', 'wp-graphql-woocommerce' ), ], - 'attribute' => [ + 'attribute' => [ 'type' => 'String', 'description' => __( 'Limit result set to products with a specific global product attribute', 'wp-graphql-woocommerce' ), 'deprecationReason' => 'Use attributes instead.', ], - 'attributeTerm' => [ + 'attributeTerm' => [ 'type' => 'String', 'description' => __( 'Limit result set to products with a specific global product attribute term ID (required an assigned attribute).', 'wp-graphql-woocommerce' ), 'deprecationReason' => 'Use attributes instead.', ], - 'stockStatus' => [ + 'stockStatus' => [ 'type' => [ 'list_of' => 'StockStatusEnum' ], 'description' => __( 'Limit result set to products in stock or out of stock.', 'wp-graphql-woocommerce' ), ], - 'onSale' => [ + 'onSale' => [ 'type' => 'Boolean', 'description' => __( 'Limit result set to products on sale.', 'wp-graphql-woocommerce' ), ], - 'minPrice' => [ + 'minPrice' => [ 'type' => 'Float', 'description' => __( 'Limit result set to products based on a minimum price.', 'wp-graphql-woocommerce' ), ], - 'maxPrice' => [ + 'maxPrice' => [ 'type' => 'Float', 'description' => __( 'Limit result set to products based on a maximum price.', 'wp-graphql-woocommerce' ), ], - 'search' => [ + 'search' => [ 'type' => 'String', 'description' => __( 'Limit result set to products based on a keyword search.', 'wp-graphql-woocommerce' ), ], - 'visibility' => [ + 'visibility' => [ 'type' => 'CatalogVisibilityEnum', 'description' => __( 'Limit result set to products with a specific visibility level.', 'wp-graphql-woocommerce' ), ], - 'taxonomyFilter' => [ + 'taxonomyFilter' => [ 'type' => 'ProductTaxonomyInput', 'description' => __( 'Limit result set with complex set of taxonomy filters.', 'wp-graphql-woocommerce' ), ], - 'orderby' => [ + 'orderby' => [ 'type' => [ 'list_of' => 'ProductsOrderbyInput' ], 'description' => __( 'What paramater to use to order the objects by.', 'wp-graphql-woocommerce' ), ], - 'supportedTypesOnly' => [ + 'supportedTypesOnly' => [ 'type' => 'Boolean', 'description' => __( 'Limit result types to types supported by WooGraphQL.', 'wp-graphql-woocommerce' ), ], - 'includeVariations' => [ + 'includeVariations' => [ 'type' => 'Boolean', 'description' => __( 'Include variations in the result set.', 'wp-graphql-woocommerce' ), ], - 'rating' => [ + 'rating' => [ 'type' => [ 'list_of' => 'Integer' ], 'description' => __( 'Limit result set to products with a specific average rating. Must be between 1 and 5', 'wp-graphql-woocommerce' ), ], diff --git a/includes/data/connection/class-product-connection-resolver.php b/includes/data/connection/class-product-connection-resolver.php index f4d885f9..6a4bfa69 100644 --- a/includes/data/connection/class-product-connection-resolver.php +++ b/includes/data/connection/class-product-connection-resolver.php @@ -418,21 +418,27 @@ class Product_Connection_Resolver extends AbstractConnectionResolver { $tax_query = []; $taxonomy_args = [ - 'type' => 'product_type', - 'typeIn' => 'product_type', - 'typeNotIn' => 'product_type', - 'category' => 'product_cat', - 'categoryIn' => 'product_cat', - 'categoryNotIn' => 'product_cat', - 'categoryId' => 'product_cat', - 'categoryIdIn' => 'product_cat', - 'categoryIdNotIn' => 'product_cat', - 'tag' => 'product_tag', - 'tagIn' => 'product_tag', - 'tagNotIn' => 'product_tag', - 'tagId' => 'product_tag', - 'tagIdIn' => 'product_tag', - 'tagIdNotIn' => 'product_tag', + 'type' => 'product_type', + 'typeIn' => 'product_type', + 'typeNotIn' => 'product_type', + 'category' => 'product_cat', + 'categoryIn' => 'product_cat', + 'categoryNotIn' => 'product_cat', + 'categoryId' => 'product_cat', + 'categoryIdIn' => 'product_cat', + 'categoryIdNotIn' => 'product_cat', + 'tag' => 'product_tag', + 'tagIn' => 'product_tag', + 'tagNotIn' => 'product_tag', + 'tagId' => 'product_tag', + 'tagIdIn' => 'product_tag', + 'tagIdNotIn' => 'product_tag', + 'productBrand' => 'product_brand', + 'productBrandIn' => 'product_brand', + 'productBrandNotIn' => 'product_brand', + 'productBrandId' => 'product_brand', + 'productBrandIdIn' => 'product_brand', + 'productBrandIdNotIn' => 'product_brand', ]; foreach ( $taxonomy_args as $field => $taxonomy ) { @@ -481,6 +487,9 @@ class Product_Connection_Resolver extends AbstractConnectionResolver { case 'tag': case 'tagIn': case 'tagNotIn': + case 'productBrand': + case 'productBrandIn': + case 'productBrandNotIn': // Get terms. $terms = $where_args[ $field ]; if ( ! is_array( $terms ) ) { @@ -509,6 +518,9 @@ class Product_Connection_Resolver extends AbstractConnectionResolver { case 'tagId': case 'tagIdIn': case 'tagIdNotIn': + case 'productBrandId': + case 'productBrandIdIn': + case 'productBrandIdNotIn': $tax_query[] = [ 'taxonomy' => $taxonomy, 'field' => 'term_id', diff --git a/includes/mutation/class-product-category-create.php b/includes/mutation/class-product-category-create.php new file mode 100644 index 00000000..6db42e5e --- /dev/null +++ b/includes/mutation/class-product-category-create.php @@ -0,0 +1,110 @@ +> + */ + public static function get_input_fields() { + return [ + 'display' => [ + 'type' => 'ProductCategoryDisplay', + 'description' => __( 'Category archive display type.', 'wp-graphql-woocommerce' ), + ], + 'menuOrder' => [ + 'type' => 'Integer', + 'description' => __( 'Menu order, used to custom sort the category.', 'wp-graphql-woocommerce' ), + ], + 'imageId' => [ + 'type' => 'ID', + 'description' => __( 'The ID of an image attachment to associate with the category.', 'wp-graphql-woocommerce' ), + ], + ]; + } + + /** + * Passes custom input fields through into the prepared args so they are + * available in the `graphql_insert_product_cat` action. + * + * @param array $insert_args The prepared insert args. + * @param array $input The raw GraphQL input. + * @param \WP_Taxonomy $taxonomy The taxonomy object. + * @param string $mutation_name The mutation name. + * + * @return array + */ + public static function pass_through_input_fields( $insert_args, $input, $taxonomy, $mutation_name ) { + if ( 'product_cat' !== $taxonomy->name ) { + return $insert_args; + } + + $custom_fields = [ 'display', 'menuOrder', 'imageId' ]; + foreach ( $custom_fields as $field ) { + if ( isset( $input[ $field ] ) ) { + $insert_args[ $field ] = $input[ $field ]; + } + } + + return $insert_args; + } + + /** + * Processes the additional WooCommerce-specific fields after term insertion. + * + * @param int $term_id The created term ID. + * @param array $args The prepared args from the mutation input. + * + * @return void + */ + public static function process_additional_fields( $term_id, $args ) { + if ( isset( $args['display'] ) ) { + update_term_meta( $term_id, 'display_type', sanitize_text_field( $args['display'] ) ); + } + + if ( isset( $args['menuOrder'] ) ) { + update_term_meta( $term_id, 'order', absint( $args['menuOrder'] ) ); + } + + if ( isset( $args['imageId'] ) ) { + $image_database_id = Utils::get_database_id_from_id( $args['imageId'] ); + if ( $image_database_id ) { + update_term_meta( $term_id, 'thumbnail_id', absint( $image_database_id ) ); + } + } + } +} diff --git a/includes/mutation/class-product-category-update.php b/includes/mutation/class-product-category-update.php new file mode 100644 index 00000000..cc7f038f --- /dev/null +++ b/includes/mutation/class-product-category-update.php @@ -0,0 +1,31 @@ +assertNotWPError( $term, "Failed to create product_brand term: {$name}" ); + + return (int) $term['term_id']; + } + + /** + * Helper: create a simple product assigned to the given brand term IDs. + * + * @param int[] $brand_ids Brand term IDs. + * + * @return int Product ID. + */ + private function createProductWithBrands( array $brand_ids ) { + $product_id = $this->factory->product->createSimple(); + wp_set_object_terms( $product_id, $brand_ids, 'product_brand' ); + + return $product_id; + } + + /** + * Test that the productBrands root query returns brands. + */ + public function testProductBrandsQuery() { + $brand_a = $this->createProductBrand( 'brand-a' ); + $brand_b = $this->createProductBrand( 'brand-b' ); + + $query = ' + query { + productBrands(first: 100) { + nodes { + databaseId + name + slug + } + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + $expected = [ + $this->expectedField( 'productBrands.nodes.#.databaseId', $brand_a ), + $this->expectedField( 'productBrands.nodes.#.databaseId', $brand_b ), + $this->expectedField( 'productBrands.nodes.#.slug', 'brand-a' ), + $this->expectedField( 'productBrands.nodes.#.slug', 'brand-b' ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + /** + * Test that a single productBrand can be queried by slug. + */ + public function testSingleProductBrandBySlug() { + $brand_id = $this->createProductBrand( 'nike' ); + + $query = ' + query ($id: ID!) { + productBrand(id: $id, idType: SLUG) { + databaseId + name + slug + } + } + '; + + $variables = [ 'id' => 'nike' ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedField( 'productBrand.databaseId', $brand_id ), + $this->expectedField( 'productBrand.slug', 'nike' ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + /** + * Test the connection from productBrand to products. + */ + public function testProductBrandToProductsConnection() { + $brand_a = $this->createProductBrand( 'acme' ); + $brand_b = $this->createProductBrand( 'globex' ); + + $acme_product_ids = [ + $this->createProductWithBrands( [ $brand_a ] ), + $this->createProductWithBrands( [ $brand_a ] ), + $this->createProductWithBrands( [ $brand_a ] ), + ]; + $globex_product_ids = [ + $this->createProductWithBrands( [ $brand_b ] ), + $this->createProductWithBrands( [ $brand_b ] ), + ]; + + $query = ' + query ($id: ID!) { + productBrand(id: $id, idType: SLUG) { + databaseId + slug + products(first: 100) { + nodes { + databaseId + } + } + } + } + '; + + // Query acme brand — should contain acme products, not globex. + $variables = [ 'id' => 'acme' ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_merge( + [ + $this->expectedField( 'productBrand.databaseId', $brand_a ), + $this->expectedField( 'productBrand.slug', 'acme' ), + ], + array_map( + function ( $id ) { + return $this->expectedField( 'productBrand.products.nodes.#.databaseId', $id ); + }, + $acme_product_ids, + ), + array_map( + function ( $id ) { + return $this->not()->expectedField( 'productBrand.products.nodes.#.databaseId', $id ); + }, + $globex_product_ids, + ), + ); + + $this->assertQuerySuccessful( $response, $expected ); + + // Query globex brand — should contain globex products, not acme. + $variables = [ 'id' => 'globex' ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_merge( + [ + $this->expectedField( 'productBrand.databaseId', $brand_b ), + $this->expectedField( 'productBrand.slug', 'globex' ), + ], + array_map( + function ( $id ) { + return $this->expectedField( 'productBrand.products.nodes.#.databaseId', $id ); + }, + $globex_product_ids, + ), + array_map( + function ( $id ) { + return $this->not()->expectedField( 'productBrand.products.nodes.#.databaseId', $id ); + }, + $acme_product_ids, + ), + ); + + $this->assertQuerySuccessful( $response, $expected ); + } + + /** + * Test the connection from product to productBrands. + */ + public function testProductToProductBrandsConnection() { + $brand_a = $this->createProductBrand( 'adidas' ); + $brand_b = $this->createProductBrand( 'puma' ); + $brand_c = $this->createProductBrand( 'reebok' ); + + // Product with two brands. + $product_id = $this->createProductWithBrands( [ $brand_a, $brand_b ] ); + + $query = ' + query ($id: ID!) { + product(id: $id, idType: DATABASE_ID) { + databaseId + productBrands { + nodes { + databaseId + slug + } + } + } + } + '; + + $variables = [ 'id' => $product_id ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = [ + $this->expectedField( 'product.databaseId', $product_id ), + $this->expectedField( 'product.productBrands.nodes.#.databaseId', $brand_a ), + $this->expectedField( 'product.productBrands.nodes.#.databaseId', $brand_b ), + $this->not()->expectedField( 'product.productBrands.nodes.#.databaseId', $brand_c ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + } + + /** + * Test hierarchical (parent/child) brand queries. + */ + public function testHierarchicalProductBrands() { + $parent_brand = $this->createProductBrand( 'sportswear' ); + $child_a = $this->createProductBrand( 'running', $parent_brand ); + $child_b = $this->createProductBrand( 'basketball', $parent_brand ); + + // Query top-level brands with children. + $query = ' + query { + productBrands(where: { parent: 0 }) { + nodes { + databaseId + slug + children { + nodes { + databaseId + slug + } + } + } + } + } + '; + + $response = $this->graphql( compact( 'query' ) ); + $expected = [ + $this->expectedField( 'productBrands.nodes.#.slug', 'sportswear' ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + + // Find the sportswear node and verify children. + $sportswear_node = null; + foreach ( $response['data']['productBrands']['nodes'] as $node ) { + if ( 'sportswear' === $node['slug'] ) { + $sportswear_node = $node; + break; + } + } + + $this->assertNotNull( $sportswear_node, 'sportswear brand should exist in response.' ); + $this->assertCount( 2, $sportswear_node['children']['nodes'], 'sportswear should have 2 children.' ); + + $child_slugs = array_column( $sportswear_node['children']['nodes'], 'slug' ); + $this->assertContains( 'running', $child_slugs ); + $this->assertContains( 'basketball', $child_slugs ); + } + + /** + * Test filtering products by brand using the where clause. + */ + public function testProductsFilteredByBrand() { + $brand_a = $this->createProductBrand( 'apple' ); + $brand_b = $this->createProductBrand( 'samsung' ); + + $apple_ids = [ + $this->createProductWithBrands( [ $brand_a ] ), + $this->createProductWithBrands( [ $brand_a ] ), + ]; + $samsung_ids = [ + $this->createProductWithBrands( [ $brand_b ] ), + ]; + + $query = ' + query ($brandSlug: String!) { + products(where: { productBrand: $brandSlug }) { + nodes { + databaseId + } + } + } + '; + + $variables = [ 'brandSlug' => 'apple' ]; + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_merge( + array_map( + function ( $id ) { + return $this->expectedField( 'products.nodes.#.databaseId', $id ); + }, + $apple_ids, + ), + array_map( + function ( $id ) { + return $this->not()->expectedField( 'products.nodes.#.databaseId', $id ); + }, + $samsung_ids, + ), + ); + + $this->assertQuerySuccessful( $response, $expected ); + } + + /** + * Test filtering products by brand using the taxonomyFilter parameter. + */ + public function testProductsFilteredByBrandViaTaxonomyFilter() { + $brand_a = $this->createProductBrand( 'toyota' ); + $brand_b = $this->createProductBrand( 'honda' ); + + $toyota_ids = [ + $this->createProductWithBrands( [ $brand_a ] ), + $this->createProductWithBrands( [ $brand_a ] ), + ]; + $honda_ids = [ + $this->createProductWithBrands( [ $brand_b ] ), + ]; + + $query = ' + query ($input: ProductTaxonomyInput) { + products(where: { taxonomyFilter: $input }) { + nodes { + databaseId + } + } + } + '; + + $variables = [ + 'input' => [ + 'filters' => [ + [ + 'taxonomy' => 'PRODUCT_BRAND', + 'terms' => [ 'toyota' ], + ], + ], + ], + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_merge( + array_map( + function ( $id ) { + return $this->expectedField( 'products.nodes.#.databaseId', $id ); + }, + $toyota_ids, + ), + array_map( + function ( $id ) { + return $this->not()->expectedField( 'products.nodes.#.databaseId', $id ); + }, + $honda_ids, + ), + ); + + $this->assertQuerySuccessful( $response, $expected ); + } + + /** + * Test CRUD operations on product brands using WPGraphQL core term mutations. + */ + public function testProductBrandCrudMutations() { + $this->loginAsShopManager(); + + // Create. + $create_query = ' + mutation ($input: CreateProductBrandInput!) { + createProductBrand(input: $input) { + productBrand { + databaseId + name + slug + } + } + } + '; + + $response = $this->graphql( + [ + 'query' => $create_query, + 'variables' => [ + 'input' => [ + 'name' => 'New Brand', + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'createProductBrand.productBrand.databaseId', self::NOT_NULL ), + $this->expectedField( 'createProductBrand.productBrand.name', 'New Brand' ), + $this->expectedField( 'createProductBrand.productBrand.slug', 'new-brand' ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + $brand_id = $response['data']['createProductBrand']['productBrand']['databaseId']; + + // Update. + $update_query = ' + mutation ($input: UpdateProductBrandInput!) { + updateProductBrand(input: $input) { + productBrand { + databaseId + name + } + } + } + '; + + $response = $this->graphql( + [ + 'query' => $update_query, + 'variables' => [ + 'input' => [ + 'id' => $this->toRelayId( 'term', $brand_id ), + 'name' => 'Updated Brand', + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'updateProductBrand.productBrand.databaseId', $brand_id ), + $this->expectedField( 'updateProductBrand.productBrand.name', 'Updated Brand' ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + // Delete. + $delete_query = ' + mutation ($input: DeleteProductBrandInput!) { + deleteProductBrand(input: $input) { + productBrand { + databaseId + name + } + } + } + '; + + $response = $this->graphql( + [ + 'query' => $delete_query, + 'variables' => [ + 'input' => [ + 'id' => $this->toRelayId( 'term', $brand_id ), + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'deleteProductBrand.productBrand.databaseId', $brand_id ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + $this->assertNull( term_exists( $brand_id, 'product_brand' ) ); + } +} diff --git a/tests/wpunit/ProductCategoryMutationsTest.php b/tests/wpunit/ProductCategoryMutationsTest.php new file mode 100644 index 00000000..d9c51597 --- /dev/null +++ b/tests/wpunit/ProductCategoryMutationsTest.php @@ -0,0 +1,184 @@ +loginAsShopManager(); + + // Create an image attachment for use in the category. + $image_id = $this->factory->post->create( + [ + 'post_status' => 'publish', + 'post_title' => 'Category Image', + 'post_type' => 'attachment', + ] + ); + + // Create a parent category with all supported fields. + $response = $this->graphql( + [ + 'query' => ' + mutation ($input: CreateProductCategoryInput!) { + createProductCategory(input: $input) { + productCategory { + databaseId + name + slug + description + parentDatabaseId + display + menuOrder + image { + databaseId + } + } + } + } + ', + 'variables' => [ + 'input' => [ + 'name' => 'Electronics', + 'slug' => 'electronics', + 'description' => 'All electronic products', + 'display' => 'BOTH', + 'menuOrder' => 5, + 'imageId' => $image_id, + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'createProductCategory.productCategory.databaseId', self::NOT_NULL ), + $this->expectedField( 'createProductCategory.productCategory.name', 'Electronics' ), + $this->expectedField( 'createProductCategory.productCategory.slug', 'electronics' ), + $this->expectedField( 'createProductCategory.productCategory.description', 'All electronic products' ), + $this->expectedField( 'createProductCategory.productCategory.parentDatabaseId', self::IS_NULL ), + $this->expectedField( 'createProductCategory.productCategory.display', 'BOTH' ), + $this->expectedField( 'createProductCategory.productCategory.menuOrder', 5 ), + $this->expectedField( 'createProductCategory.productCategory.image.databaseId', $image_id ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + $parent_id = $response['data']['createProductCategory']['productCategory']['databaseId']; + + // Create a child category with parent. + $response = $this->graphql( + [ + 'query' => ' + mutation ($input: CreateProductCategoryInput!) { + createProductCategory(input: $input) { + productCategory { + databaseId + name + slug + description + parentDatabaseId + } + } + } + ', + 'variables' => [ + 'input' => [ + 'name' => 'Smartphones', + 'slug' => 'smartphones', + 'description' => 'Mobile phones and accessories', + 'parentId' => $this->toRelayId( 'term', $parent_id ), + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'createProductCategory.productCategory.databaseId', self::NOT_NULL ), + $this->expectedField( 'createProductCategory.productCategory.name', 'Smartphones' ), + $this->expectedField( 'createProductCategory.productCategory.slug', 'smartphones' ), + $this->expectedField( 'createProductCategory.productCategory.description', 'Mobile phones and accessories' ), + $this->expectedField( 'createProductCategory.productCategory.parentDatabaseId', $parent_id ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + $child_id = $response['data']['createProductCategory']['productCategory']['databaseId']; + + // Update the child category with new display and menu_order. + $update_image_id = $this->factory->post->create( + [ + 'post_status' => 'publish', + 'post_title' => 'Updated Category Image', + 'post_type' => 'attachment', + ] + ); + $response = $this->graphql( + [ + 'query' => ' + mutation ($input: UpdateProductCategoryInput!) { + updateProductCategory(input: $input) { + productCategory { + databaseId + name + slug + description + display + menuOrder + image { + databaseId + } + } + } + } + ', + 'variables' => [ + 'input' => [ + 'id' => $this->toRelayId( 'term', $child_id ), + 'name' => 'Mobile Phones', + 'slug' => 'mobile-phones', + 'description' => 'Updated description', + 'display' => 'SUBCATEGORIES', + 'menuOrder' => 10, + 'imageId' => $update_image_id, + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'updateProductCategory.productCategory.databaseId', $child_id ), + $this->expectedField( 'updateProductCategory.productCategory.name', 'Mobile Phones' ), + $this->expectedField( 'updateProductCategory.productCategory.slug', 'mobile-phones' ), + $this->expectedField( 'updateProductCategory.productCategory.description', 'Updated description' ), + $this->expectedField( 'updateProductCategory.productCategory.display', 'SUBCATEGORIES' ), + $this->expectedField( 'updateProductCategory.productCategory.menuOrder', 10 ), + $this->expectedField( 'updateProductCategory.productCategory.image.databaseId', $update_image_id ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + // Delete the child category. + $response = $this->graphql( + [ + 'query' => ' + mutation ($input: DeleteProductCategoryInput!) { + deleteProductCategory(input: $input) { + productCategory { + databaseId + name + } + } + } + ', + 'variables' => [ + 'input' => [ + 'id' => $this->toRelayId( 'term', $child_id ), + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'deleteProductCategory.productCategory.databaseId', $child_id ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + $this->assertNull( term_exists( $child_id, 'product_cat' ) ); + } +} diff --git a/tests/wpunit/ProductTaxonomyQueriesTest.php b/tests/wpunit/ProductCategoryQueriesTest.php similarity index 65% rename from tests/wpunit/ProductTaxonomyQueriesTest.php rename to tests/wpunit/ProductCategoryQueriesTest.php index dc895c45..7439075d 100644 --- a/tests/wpunit/ProductTaxonomyQueriesTest.php +++ b/tests/wpunit/ProductCategoryQueriesTest.php @@ -1,6 +1,6 @@ factory->product->createProductCategory( 'clothing' ); @@ -119,114 +119,6 @@ class ProductTaxonomyQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\W $this->assertQuerySuccessful( $response, $expected ); } - public function testProductTagsToProductsQuery() { - // Create tags. - $tag1_id = $this->factory->product->createProductTag( 'tag1' ); - $tag2_id = $this->factory->product->createProductTag( 'tag2' ); - $tag3_id = $this->factory->product->createProductTag( 'tag3' ); - - // Create products. - $tag1_product_ids = $this->factory->product->create_many( 5, [ 'tag_ids' => [ $tag1_id ] ] ); - $tag2_product_ids = $this->factory->product->create_many( 5, [ 'tag_ids' => [ $tag2_id, $tag3_id ] ] ); - $tag3_product_ids = $this->factory->product->create_many( 5, [ 'tag_ids' => [ $tag3_id ] ] ); - - $query = 'query ($id: ID!) { - productTag(id: $id idType: SLUG) { - id - slug - products(first: 100) { - nodes { - databaseId - productTags { - nodes { - id - slug - } - } - } - } - } - }'; - - $variables = [ - 'id' => 'tag1' - ]; - - $response = $this->graphql( compact( 'query', 'variables' ) ); - $expected = array_merge( - [ - $this->expectedField( 'productTag.id', $this->toRelayId( 'term', $tag1_id ) ), - $this->expectedField( 'productTag.slug', 'tag1' ), - ], - array_map( - function( $id ) { - return $this->expectedField( 'productTag.products.nodes.#.databaseId', $id ); - }, - $tag1_product_ids, - ), - array_map( - function( $id ) { - return $this->not()->expectedField( 'productTag.products.nodes.#.databaseId', $id ); - }, - array_merge( $tag2_product_ids, $tag3_product_ids ) - ), - ); - - $this->assertQuerySuccessful( $response, $expected ); - - $variables = [ - 'id' => 'tag2' - ]; - - $response = $this->graphql( compact( 'query', 'variables' ) ); - $expected = array_merge( - [ - $this->expectedField( 'productTag.id', $this->toRelayId( 'term', $tag2_id ) ), - $this->expectedField( 'productTag.slug', 'tag2' ), - ], - array_map( - function( $id ) { - return $this->expectedField( 'productTag.products.nodes.#.databaseId', $id ); - }, - $tag2_product_ids, - ), - array_map( - function( $id ) { - return $this->not()->expectedField( 'productTag.products.nodes.#.databaseId', $id ); - }, - array_merge( $tag1_product_ids, $tag3_product_ids ) - ), - ); - - $this->assertQuerySuccessful( $response, $expected ); - - $variables = [ - 'id' => 'tag3' - ]; - - $response = $this->graphql( compact( 'query', 'variables' ) ); - $expected = array_merge( - [ - $this->expectedField( 'productTag.id', $this->toRelayId( 'term', $tag3_id ) ), - $this->expectedField( 'productTag.slug', 'tag3' ), - ], - array_map( - function( $id ) { - return $this->expectedField( 'productTag.products.nodes.#.databaseId', $id ); - }, - array_merge( $tag2_product_ids, $tag3_product_ids ) - ), - array_map( - function( $id ) { - return $this->not()->expectedField( 'productTag.products.nodes.#.databaseId', $id ); - }, - $tag1_product_ids, - ), - ); - - $this->assertQuerySuccessful( $response, $expected ); - } - public function testProductCategoryChildrenConnection() { // Create parent categories. $parent_a = $this->factory->product->createProductCategory( 'parent-a' ); diff --git a/tests/wpunit/ProductTagMutationsTest.php b/tests/wpunit/ProductTagMutationsTest.php new file mode 100644 index 00000000..a9ccbce3 --- /dev/null +++ b/tests/wpunit/ProductTagMutationsTest.php @@ -0,0 +1,107 @@ +loginAsShopManager(); + + // Create. + $response = $this->graphql( + [ + 'query' => ' + mutation ($input: CreateProductTagInput!) { + createProductTag(input: $input) { + productTag { + databaseId + name + slug + description + } + } + } + ', + 'variables' => [ + 'input' => [ + 'name' => 'Sale Items', + 'slug' => 'sale-items', + 'description' => 'Products currently on sale', + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'createProductTag.productTag.databaseId', self::NOT_NULL ), + $this->expectedField( 'createProductTag.productTag.name', 'Sale Items' ), + $this->expectedField( 'createProductTag.productTag.slug', 'sale-items' ), + $this->expectedField( 'createProductTag.productTag.description', 'Products currently on sale' ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + $tag_id = $response['data']['createProductTag']['productTag']['databaseId']; + + // Update. + $response = $this->graphql( + [ + 'query' => ' + mutation ($input: UpdateProductTagInput!) { + updateProductTag(input: $input) { + productTag { + databaseId + name + slug + description + } + } + } + ', + 'variables' => [ + 'input' => [ + 'id' => $this->toRelayId( 'term', $tag_id ), + 'name' => 'Clearance', + 'slug' => 'clearance', + 'description' => 'Clearance items', + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'updateProductTag.productTag.databaseId', $tag_id ), + $this->expectedField( 'updateProductTag.productTag.name', 'Clearance' ), + $this->expectedField( 'updateProductTag.productTag.slug', 'clearance' ), + $this->expectedField( 'updateProductTag.productTag.description', 'Clearance items' ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + // Delete. + $response = $this->graphql( + [ + 'query' => ' + mutation ($input: DeleteProductTagInput!) { + deleteProductTag(input: $input) { + productTag { + databaseId + name + } + } + } + ', + 'variables' => [ + 'input' => [ + 'id' => $this->toRelayId( 'term', $tag_id ), + ], + ], + ] + ); + $expected = [ + $this->expectedField( 'deleteProductTag.productTag.databaseId', $tag_id ), + ]; + $this->assertQuerySuccessful( $response, $expected ); + + $this->assertNull( term_exists( $tag_id, 'product_tag' ) ); + } +} diff --git a/tests/wpunit/ProductTagQueriesTest.php b/tests/wpunit/ProductTagQueriesTest.php new file mode 100644 index 00000000..2ee75ba6 --- /dev/null +++ b/tests/wpunit/ProductTagQueriesTest.php @@ -0,0 +1,111 @@ +factory->product->createProductTag( 'tag1' ); + $tag2_id = $this->factory->product->createProductTag( 'tag2' ); + $tag3_id = $this->factory->product->createProductTag( 'tag3' ); + + // Create products. + $tag1_product_ids = $this->factory->product->create_many( 5, [ 'tag_ids' => [ $tag1_id ] ] ); + $tag2_product_ids = $this->factory->product->create_many( 5, [ 'tag_ids' => [ $tag2_id, $tag3_id ] ] ); + $tag3_product_ids = $this->factory->product->create_many( 5, [ 'tag_ids' => [ $tag3_id ] ] ); + + $query = 'query ($id: ID!) { + productTag(id: $id idType: SLUG) { + id + slug + products(first: 100) { + nodes { + databaseId + productTags { + nodes { + id + slug + } + } + } + } + } + }'; + + $variables = [ + 'id' => 'tag1' + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_merge( + [ + $this->expectedField( 'productTag.id', $this->toRelayId( 'term', $tag1_id ) ), + $this->expectedField( 'productTag.slug', 'tag1' ), + ], + array_map( + function( $id ) { + return $this->expectedField( 'productTag.products.nodes.#.databaseId', $id ); + }, + $tag1_product_ids, + ), + array_map( + function( $id ) { + return $this->not()->expectedField( 'productTag.products.nodes.#.databaseId', $id ); + }, + array_merge( $tag2_product_ids, $tag3_product_ids ) + ), + ); + + $this->assertQuerySuccessful( $response, $expected ); + + $variables = [ + 'id' => 'tag2' + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_merge( + [ + $this->expectedField( 'productTag.id', $this->toRelayId( 'term', $tag2_id ) ), + $this->expectedField( 'productTag.slug', 'tag2' ), + ], + array_map( + function( $id ) { + return $this->expectedField( 'productTag.products.nodes.#.databaseId', $id ); + }, + $tag2_product_ids, + ), + array_map( + function( $id ) { + return $this->not()->expectedField( 'productTag.products.nodes.#.databaseId', $id ); + }, + array_merge( $tag1_product_ids, $tag3_product_ids ) + ), + ); + + $this->assertQuerySuccessful( $response, $expected ); + + $variables = [ + 'id' => 'tag3' + ]; + + $response = $this->graphql( compact( 'query', 'variables' ) ); + $expected = array_merge( + [ + $this->expectedField( 'productTag.id', $this->toRelayId( 'term', $tag3_id ) ), + $this->expectedField( 'productTag.slug', 'tag3' ), + ], + array_map( + function( $id ) { + return $this->expectedField( 'productTag.products.nodes.#.databaseId', $id ); + }, + array_merge( $tag2_product_ids, $tag3_product_ids ) + ), + array_map( + function( $id ) { + return $this->not()->expectedField( 'productTag.products.nodes.#.databaseId', $id ); + }, + $tag1_product_ids, + ), + ); + + $this->assertQuerySuccessful( $response, $expected ); + } +}