Files
Geoff TaylorandGitHub 6fb7b226fc feat: WC Settings API, compatibility refactor, HPOS fix (#1002)
* 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.
2026-03-27 16:32:30 -04:00

1483 lines
38 KiB
PHP

<?php
class CartMutationsTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQLTestCase {
// tests
public function testAddToCartMutationWithProduct() {
$product_id = $this->factory->product->createSimple();
$query = '
mutation( $input: AddToCartInput! ) {
addToCart( input: $input ) {
clientMutationId
cartItem {
key
product {
node {
id
}
}
variation {
node {
id
}
}
quantity
subtotal
subtotalTax
total
tax
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'someId',
'productId' => $product_id,
'quantity' => 2,
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
// Confirm valid response
$this->assertResponseIsValid( $response );
// Get/validate cart item key.
$cart_item_key = $this->lodashGet( $response, 'data.addToCart.cartItem.key' );
$this->assertNotEmpty( $cart_item_key );
// Get cart item data.
$cart = \WC()->cart;
$cart_item = $cart->get_cart_item( $cart_item_key );
$this->assertNotEmpty( $cart_item );
$this->assertQuerySuccessful(
$response,
[
$this->expectedField( 'addToCart.clientMutationId', 'someId' ),
$this->expectedField( 'addToCart.cartItem.key', $cart_item_key ),
$this->expectedField( 'addToCart.cartItem.product.node.id', $this->toRelayId( 'post', $product_id ) ),
$this->expectedField( 'addToCart.cartItem.quantity', 2 ),
$this->expectedField( 'addToCart.cartItem.subtotal', wc_graphql_price( $cart_item['line_subtotal'] ) ),
$this->expectedField( 'addToCart.cartItem.subtotalTax', wc_graphql_price( $cart_item['line_subtotal_tax'] ) ),
$this->expectedField( 'addToCart.cartItem.total', wc_graphql_price( $cart_item['line_total'] ) ),
$this->expectedField( 'addToCart.cartItem.tax', wc_graphql_price( $cart_item['line_tax'] ) ),
]
);
}
public function testAddToCartMutationWithProductVariation() {
$ids = $this->factory->product_variation->createSome();
$query = '
mutation( $input: AddToCartInput! ) {
addToCart( input: $input ) {
clientMutationId
cartItem {
key
product {
node {
id
}
}
variation {
node {
id
}
}
quantity
subtotal
subtotalTax
total
tax
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'someId',
'productId' => $ids['product'],
'quantity' => 3,
'variationId' => $ids['variations'][0],
'variation' => [
[
'attributeName' => 'color',
'attributeValue' => 'red',
],
],
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
// Confirm valid response
$this->assertResponseIsValid( $response );
// Get/validate cart item key.
$cart_item_key = $this->lodashGet( $response, 'data.addToCart.cartItem.key' );
$this->assertNotEmpty( $cart_item_key );
// Get cart item data.
$cart = \WC()->cart;
$cart_item = $cart->get_cart_item( $cart_item_key );
$this->assertNotEmpty( $cart_item );
$this->assertQuerySuccessful(
$response,
[
$this->expectedField( 'addToCart.clientMutationId', 'someId' ),
$this->expectedField( 'addToCart.cartItem.key', $cart_item_key ),
$this->expectedField( 'addToCart.cartItem.product.node.id', $this->toRelayId( 'post', $ids['product'] ) ),
$this->expectedField( 'addToCart.cartItem.variation.node.id', $this->toRelayId( 'post', $ids['variations'][0] ) ),
$this->expectedField( 'addToCart.cartItem.quantity', 3 ),
$this->expectedField( 'addToCart.cartItem.subtotal', wc_graphql_price( $cart_item['line_subtotal'] ) ),
$this->expectedField( 'addToCart.cartItem.subtotalTax', wc_graphql_price( $cart_item['line_subtotal_tax'] ) ),
$this->expectedField( 'addToCart.cartItem.total', wc_graphql_price( $cart_item['line_total'] ) ),
$this->expectedField( 'addToCart.cartItem.tax', wc_graphql_price( $cart_item['line_tax'] ) ),
]
);
}
public function testUpdateCartItemQuantitiesMutation() {
// Create/add some products to the cart.
$cart_item_data = [
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 2,
],
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 5,
],
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 1,
],
];
// Store cart item keys for use in mutation.
$keys = $this->factory->cart->add( ...$cart_item_data );
// Define mutation.
$query = '
mutation( $input: UpdateItemQuantitiesInput! ) {
updateItemQuantities( input: $input ) {
clientMutationId
updated {
key
quantity
}
removed {
key
quantity
}
items {
key
quantity
}
}
}
';
// Define variables
$variables = [
'input' => [
'clientMutationId' => 'someId',
'items' => [
[
'key' => $keys[0],
'quantity' => 4,
],
[
'key' => $keys[1],
'quantity' => 2,
],
[
'key' => $keys[2],
'quantity' => 0,
],
],
],
];
// Execute mutation.
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQuerySuccessful(
$response,
[
$this->expectedField( 'updateItemQuantities.clientMutationId', 'someId' ),
$this->expectedNode(
'updateItemQuantities.updated',
[
'key' => $keys[0],
'quantity' => 4,
]
),
$this->expectedNode(
'updateItemQuantities.updated',
[
'key' => $keys[1],
'quantity' => 2,
]
),
$this->expectedNode(
'updateItemQuantities.removed',
[
'key' => $keys[2],
'quantity' => 1,
]
),
$this->expectedNode(
'updateItemQuantities.items',
[
'key' => $keys[0],
'quantity' => 4,
]
),
$this->expectedNode(
'updateItemQuantities.items',
[
'key' => $keys[1],
'quantity' => 2,
]
),
$this->expectedNode(
'updateItemQuantities.items',
[
'key' => $keys[2],
'quantity' => 1,
]
),
]
);
}
public function testRemoveItemsFromCartMutation() {
// Create/add some products to the cart.
$cart_item_data = [
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 2,
],
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 5,
],
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 1,
],
];
// Store cart item keys for use in mutation.
$keys = $this->factory->cart->add( ...$cart_item_data );
$query = '
mutation( $input: RemoveItemsFromCartInput! ) {
removeItemsFromCart( input: $input ) {
clientMutationId
cartItems {
key
}
}
}
';
// Define expected response data.
$expected = [ $this->expectedField( 'removeItemsFromCart.clientMutationId', 'someId' ) ];
foreach ( $keys as $key ) {
$expected[] = $this->expectedNode( 'removeItemsFromCart.cartItems', compact( 'key' ) );
}
$variables = [
'input' => [
'clientMutationId' => 'someId',
'keys' => $keys,
],
];
// Execute mutation w/ "keys" array.
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQuerySuccessful( $response, $expected );
// Confirm none of the items in cart.
foreach ( $keys as $key ) {
$this->assertEmpty(
\WC()->cart->get_cart_item( $key ),
"{$key} still in cart after \"removeItemsFromCart\" mutation."
);
}
// Add more items and execute mutation with "all" flag.
$keys = $this->factory->cart->add( ...$cart_item_data );
$variables = [
'input' => [
'clientMutationId' => 'someId',
'all' => true,
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQuerySuccessful( $response, $expected );
// Confirm none of the items in cart.
foreach ( $keys as $key ) {
$this->assertEmpty(
\WC()->cart->get_cart_item( $key ),
"{$key} still in cart after \"removeItemsFromCart\" mutation with \"all\" flag."
);
}
}
public function testRestoreCartItemsMutation() {
// Create/add some products to the cart.
$cart_item_data = [
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 2,
],
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 5,
],
[
'product_id' => $this->factory->product->createSimple(),
'quantity' => 1,
],
];
$keys = $this->factory->cart->add( ...$cart_item_data );
$this->factory->cart->remove( ...$keys );
$query = '
mutation( $input: RestoreCartItemsInput! ) {
restoreCartItems( input: $input ) {
clientMutationId
cartItems {
key
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'someId',
'keys' => $keys,
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$expected = [ $this->expectedField( 'restoreCartItems.clientMutationId', 'someId' ) ];
foreach ( $keys as $key ) {
$expected[] = $this->expectedNode( 'restoreCartItems.cartItems', compact( 'key' ) );
}
$this->assertQuerySuccessful( $response, $expected );
// Confirm items in cart.
foreach ( $keys as $key ) {
$this->assertNotEmpty(
\WC()->cart->get_cart_item( $key ),
"{$key} not found in cart after \"restoreCartItems\" mutation."
);
}
}
public function testEmptyCartMutation() {
// Create/add some products to the cart.
$product_id = $this->factory->product->createSimple();
$cart = \WC()->cart;
$cart_item_key = $cart->add_to_cart( $product_id, 1 );
$cart_item = $cart->get_cart_item( $cart_item_key );
$query = '
mutation( $input: EmptyCartInput! ) {
emptyCart( input: $input ) {
clientMutationId
deletedCart {
contents {
nodes {
key
product {
node {
id
}
}
variation {
node {
id
}
}
quantity
subtotal
subtotalTax
total
tax
}
}
}
}
}
';
$variables = [
'input' => [ 'clientMutationId' => 'someId' ],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQuerySuccessful(
$response,
[
$this->expectedField( 'emptyCart.clientMutationId', 'someId' ),
$this->expectedNode(
'emptyCart.deletedCart.contents.nodes',
[
'key' => $cart_item['key'],
'product' => [
'node' => [
'id' => $this->toRelayId( 'post', $cart_item['product_id'] ),
],
],
'variation' => null,
'quantity' => $cart_item['quantity'],
'subtotal' => wc_graphql_price( $cart_item['line_subtotal'] ),
'subtotalTax' => wc_graphql_price( $cart_item['line_subtotal_tax'] ),
'total' => wc_graphql_price( $cart_item['line_total'] ),
'tax' => wc_graphql_price( $cart_item['line_tax'] ),
]
),
]
);
$this->assertTrue( \WC()->cart->is_empty() );
}
public function testApplyCouponMutation() {
// Create products.
$product_id = $this->factory->product->createSimple(
[ 'regular_price' => 100 ]
);
// Create coupon.
$coupon_code = wc_get_coupon_code_by_id(
$this->factory->coupon->create(
[
'amount' => 0.5,
'product_ids' => [ $product_id ],
'description' => 'lorem ipsum dolor',
]
)
);
// Add items to carts.
$cart = \WC()->cart;
$cart_item_key = $cart->add_to_cart( $product_id, 1 );
$old_total = \WC()->cart->get_cart_contents_total();
$query = '
mutation( $input: ApplyCouponInput! ) {
applyCoupon( input: $input ) {
clientMutationId
cart {
appliedCoupons {
code
description
}
contents {
nodes {
key
product {
node {
id
}
}
quantity
subtotal
subtotalTax
total
tax
}
}
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'someId',
'code' => $coupon_code,
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
// Get updated cart item.
$cart_item = $cart->get_cart_item( $cart_item_key );
$this->assertQuerySuccessful(
$response,
[
$this->expectedField( 'applyCoupon.clientMutationId', 'someId' ),
$this->expectedNode(
'applyCoupon.cart.appliedCoupons',
[
'code' => $coupon_code,
'description' => 'lorem ipsum dolor',
]
),
$this->expectedNode(
'applyCoupon.cart.contents.nodes',
[
'key' => $cart_item['key'],
'product' => [
'node' => [
'id' => $this->toRelayId( 'post', $cart_item['product_id'] ),
],
],
'quantity' => $cart_item['quantity'],
'subtotal' => wc_graphql_price( $cart_item['line_subtotal'] ),
'subtotalTax' => wc_graphql_price( $cart_item['line_subtotal_tax'] ),
'total' => wc_graphql_price( $cart_item['line_total'] ),
'tax' => wc_graphql_price( $cart_item['line_tax'] ),
]
),
]
);
$new_total = \WC()->cart->get_cart_contents_total();
// Use --debug to view.
codecept_debug(
[
'old' => $old_total,
'new' => $new_total,
]
);
$this->assertTrue( $old_total > $new_total );
}
public function testApplyCouponMutationWithInvalidCoupons() {
$cart = WC()->cart;
// Create products.
$product_id = $this->factory->product->createSimple();
// Create invalid coupon codes.
$coupon_id = $this->factory->coupon->create(
[ 'product_ids' => [ $product_id ] ]
);
$expired_coupon_code = wc_get_coupon_code_by_id(
$this->factory->coupon->create(
[
'product_ids' => [ $product_id ],
'date_expires' => time() - 20,
]
)
);
$applied_coupon_code = wc_get_coupon_code_by_id(
$this->factory->coupon->create(
[ 'product_ids' => [ $product_id ] ]
)
);
// Add items to carts.
$cart_item_key = $cart->add_to_cart( $product_id, 1 );
$cart->apply_coupon( $applied_coupon_code );
$old_total = \WC()->cart->get_cart_contents_total();
$query = '
mutation( $input: ApplyCouponInput! ) {
applyCoupon( input: $input ) {
clientMutationId
}
}
';
/**
* Assertion One
*
* Can't pass coupon ID as coupon 'code'. Mutation should fail.
*/
$variables = [
'input' => [
'clientMutationId' => 'someId',
'code' => '$coupon_id',
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertNotEmpty( $response['errors'] );
$this->assertEmpty( $response['data']['applyCoupon'] );
/**
* Assertion Two
*
* Can't pass expired coupon code. Mutation should fail.
*/
$variables = [
'input' => [
'clientMutationId' => 'someId',
'code' => $expired_coupon_code,
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertNotEmpty( $response['errors'] );
$this->assertEmpty( $response['data']['applyCoupon'] );
/**
* Assertion Three
*
* Can't pass coupon already applied to the cart. Mutation should fail.
*/
$variables = [
'input' => [
'clientMutationId' => 'someId',
'code' => $applied_coupon_code,
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertNotEmpty( $response['errors'] );
$this->assertEmpty( $response['data']['applyCoupon'] );
$this->assertEquals( $old_total, \WC()->cart->get_cart_contents_total() );
}
public function testRemoveCouponMutation() {
// 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 and get total..
$cart = \WC()->cart;
$cart_item_key = $cart->add_to_cart( $product_id, 3 );
$cart->apply_coupon( $coupon_code );
$query = '
mutation removeCoupons( $input: RemoveCouponsInput! ) {
removeCoupons( input: $input ) {
clientMutationId
cart {
appliedCoupons {
code
}
contents {
nodes {
key
product {
node {
id
}
}
quantity
subtotal
subtotalTax
total
tax
}
}
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'someId',
'codes' => [ $coupon_code ],
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
// Get updated cart item.
$cart_item = \WC()->cart->get_cart_item( $cart_item_key );
$this->assertQuerySuccessful(
$response,
[
$this->expectedField( 'removeCoupons.clientMutationId', 'someId' ),
$this->expectedField( 'removeCoupons.cart.appliedCoupons', static::IS_NULL ),
$this->expectedNode(
'removeCoupons.cart.contents.nodes',
[
'key' => $cart_item['key'],
'product' => [
'node' => [
'id' => $this->toRelayId( 'post', $cart_item['product_id'] ),
],
],
'quantity' => $cart_item['quantity'],
'subtotal' => wc_graphql_price( $cart_item['line_subtotal'] ),
'subtotalTax' => wc_graphql_price( $cart_item['line_subtotal_tax'] ),
'total' => wc_graphql_price( $cart_item['line_total'] ),
'tax' => wc_graphql_price( $cart_item['line_tax'] ),
]
),
]
);
}
public function testAddToCartMutationErrors() {
// Create products.
$product_id = $this->factory->product->createSimple(
[
'manage_stock' => true,
'stock_quantity' => 1,
]
);
$variation_ids = $this->factory->product_variation->createSome();
$product = \wc_get_product( $variation_ids['product'] );
$attribute = new WC_Product_Attribute();
$attribute->set_id( 0 );
$attribute->set_name( 'test' );
$attribute->set_options( [ 'yes', 'no' ] );
$attribute->set_position( 3 );
$attribute->set_visible( true );
$attribute->set_variation( true );
$attributes = array_values( $product->get_attributes() );
$attributes[] = $attribute;
$product->set_attributes( $attributes );
$product->save();
$query = '
mutation( $input: AddToCartInput! ) {
addToCart( input: $input ) {
clientMutationId
cartItem {
key
product {
node {
id
}
}
variation {
node {
id
}
}
quantity
subtotal
subtotalTax
total
tax
}
}
}
';
\WC()->session->set( 'wc_notices', null );
$variables = [
'input' => [
'clientMutationId' => 'someId',
'productId' => $variation_ids['product'],
'quantity' => 5,
'variationId' => $variation_ids['variations'][0],
],
];
$missing_attributes = $this->graphql( compact( 'query', 'variables' ) );
$this->assertArrayHasKey( 'errors', $missing_attributes );
\WC()->session->set( 'wc_notices', null );
$variables = [
'input' => [
'clientMutationId' => 'someId',
'productId' => $product_id,
'quantity' => 5,
],
];
$not_enough_stock = $this->graphql( compact( 'query', 'variables' ) );
$this->assertArrayHasKey( 'errors', $not_enough_stock );
}
public function testAddToCartMutationItemEdgeData() {
// Create variable product for later use.
$variation_ids = $this->factory->product_variation->createSome();
$product = \wc_get_product( $variation_ids['product'] );
$attribute = new WC_Product_Attribute();
$attribute->set_id( 0 );
$attribute->set_name( 'test' );
$attribute->set_options( [ 'yes', 'no' ] );
$attribute->set_position( 3 );
$attribute->set_visible( true );
$attribute->set_variation( true );
$attributes = array_values( $product->get_attributes() );
$attributes[] = $attribute;
$product->set_attributes( $attributes );
$product->save();
$query = '
mutation( $input: AddToCartInput! ) {
addToCart(input: $input) {
cartItem {
product {
simpleVariations {
name
value
}
node {
databaseId
}
}
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'someId',
'productId' => $variation_ids['product'],
'quantity' => 3,
'variationId' => $variation_ids['variations'][1],
'variation' => [
[
'attributeName' => 'test',
'attributeValue' => 'yes',
],
[
'attributeName' => 'color',
'attributeValue' => 'green',
],
],
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$expected = [
$this->expectedObject(
'addToCart.cartItem.product',
[
$this->expectedField( 'node.databaseId', $variation_ids['product'] ),
$this->expectedObject(
'simpleVariations.#',
[
$this->expectedField( 'name', 'attribute_test' ),
$this->expectedField( 'value', 'yes' ),
]
),
$this->expectedObject(
'simpleVariations.#',
[
$this->expectedField( 'name', 'attribute_pa_color' ),
$this->expectedField( 'value', 'green' ),
]
),
]
),
];
$this->assertQuerySuccessful( $response, $expected );
}
public function testAddCartItemsMutationAndErrors() {
// Create variable product for later use.
$variation_ids = $this->factory->product_variation->createSome();
$product = \wc_get_product( $variation_ids['product'] );
$attribute = new WC_Product_Attribute();
$attribute->set_id( 0 );
$attribute->set_name( 'test' );
$attribute->set_options( [ 'yes', 'no' ] );
$attribute->set_position( 3 );
$attribute->set_visible( true );
$attribute->set_variation( true );
$attributes = array_values( $product->get_attributes() );
$attributes[] = $attribute;
$product->set_attributes( $attributes );
$product->save();
$product_one = $this->factory->product->createSimple();
$invalid_product = 1000;
$query = '
mutation ($input: AddCartItemsInput!) {
addCartItems(input: $input) {
clientMutationId
added {
product {
node { databaseId }
}
variation {
node { databaseId }
}
quantity
}
cartErrors {
type
reasons
productId
quantity
variationId
variation {
attributeName
attributeValue
}
extraData
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'someId',
'items' => [
[
'productId' => $product_one,
'quantity' => 2,
],
[
'productId' => $variation_ids['product'],
'quantity' => 5,
'variationId' => $variation_ids['variations'][0],
],
[
'productId' => $invalid_product,
'quantity' => 4,
],
[
'productId' => $variation_ids['product'],
'quantity' => 3,
'variationId' => $variation_ids['variations'][1],
'variation' => [
[
'attributeName' => 'test',
'attributeValue' => 'yes',
],
[
'attributeName' => 'color',
'attributeValue' => 'green',
],
],
],
],
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQuerySuccessful(
$response,
[
$this->expectedField( 'addCartItems.clientMutationId', 'someId' ),
$this->expectedNode(
'addCartItems.added',
[
'product' => [
'node' => [ 'databaseId' => $product_one ],
],
'variation' => null,
'quantity' => 2,
]
),
$this->expectedNode(
'addCartItems.added',
[
'product' => [
'node' => [ 'databaseId' => $variation_ids['product'] ],
],
'variation' => [
'node' => [ 'databaseId' => $variation_ids['variations'][1] ],
],
'quantity' => 3,
]
),
$this->expectedNode(
'addCartItems.cartErrors',
[
$this->expectedField( 'type', 'INVALID_CART_ITEM' ),
$this->expectedField( 'reasons.0', static::NOT_NULL ),
$this->expectedField( 'productId', $variation_ids['product'] ),
$this->expectedField( 'quantity', 5 ),
$this->expectedField( 'variationId', $variation_ids['variations'][0] ),
$this->expectedField( 'variation', static::IS_NULL ),
$this->expectedField( 'extraData', static::IS_NULL ),
]
),
$this->expectedNode(
'addCartItems.cartErrors',
[
$this->expectedField( 'type', 'INVALID_CART_ITEM' ),
$this->expectedField( 'reasons.0', 'No product found matching the ID provided' ),
$this->expectedField( 'productId', $invalid_product ),
$this->expectedField( 'quantity', 4 ),
$this->expectedField( 'variationId', static::IS_NULL ),
$this->expectedField( 'variation', static::IS_NULL ),
$this->expectedField( 'extraData', static::IS_NULL ),
]
),
]
);
}
public function testFillCartMutationAndErrors() {
// Create products.
$product_one = $this->factory->product->createSimple( [ 'regular_price' => 100 ] );
$product_two = $this->factory->product->createSimple( [ 'regular_price' => 40 ] );
// Create coupons.
$coupon_code_one = wc_get_coupon_code_by_id(
$this->factory->coupon->create(
[
'amount' => 0.5,
'product_ids' => [ $product_one ],
]
)
);
$coupon_code_two = wc_get_coupon_code_by_id(
$this->factory->coupon->create(
[
'amount' => 0.2,
'product_ids' => [ $product_two ],
]
)
);
$invalid_product = 1000;
$invalid_coupon = 'failed';
$invalid_shipping_method = 'fakityfake-shipping';
$this->factory->shipping_zone->createLegacyFlatRate();
$query = '
mutation ($input: FillCartInput!) {
fillCart( input: $input ) {
clientMutationId
cart {
chosenShippingMethods
contents {
nodes {
product {
node { databaseId }
}
quantity
variation {
node { databaseId }
}
}
}
appliedCoupons {
code
discountAmount
discountTax
}
}
cartErrors {
type
... on CartItemError {
reasons
productId
quantity
}
... on CouponError {
reasons
code
}
... on ShippingMethodError {
chosenMethod
package
}
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'someId',
'items' => [
[
'productId' => $product_one,
'quantity' => 3,
],
[
'productId' => $product_two,
'quantity' => 2,
],
[
'productId' => $invalid_product,
'quantity' => 4,
],
],
'coupons' => [ $coupon_code_one, $coupon_code_two, $invalid_coupon ],
'shippingMethods' => [ 'legacy_flat_rate', $invalid_shipping_method ],
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$expected = [
$this->expectedObject(
'fillCart',
[
$this->expectedField( 'clientMutationId', 'someId' ),
$this->expectedObject(
'cart',
[
$this->expectedField( 'chosenShippingMethods.0', 'legacy_flat_rate' ),
$this->expectedObject(
'contents',
[
$this->expectedNode(
'nodes',
[
$this->expectedField( 'product.node.databaseId', $product_one ),
$this->expectedField( 'quantity', 3 ),
$this->expectedField( 'variation', static::IS_NULL ),
]
),
$this->expectedNode(
'nodes',
[
$this->expectedField( 'product.node.databaseId', $product_two ),
$this->expectedField( 'quantity', 2 ),
$this->expectedField( 'variation', static::IS_NULL ),
]
),
]
),
$this->expectedNode(
'appliedCoupons',
[
$this->expectedField( 'code', $coupon_code_one ),
$this->expectedField(
'discountAmount',
\wc_graphql_price( \WC()->cart->get_coupon_discount_amount( $coupon_code_one, true ) )
),
$this->expectedField(
'discountTax',
\wc_graphql_price( \WC()->cart->get_coupon_discount_tax_amount( $coupon_code_one ) )
),
]
),
$this->expectedNode(
'appliedCoupons',
[
$this->expectedField( 'code', $coupon_code_two ),
$this->expectedField(
'discountAmount',
\wc_graphql_price( \WC()->cart->get_coupon_discount_amount( $coupon_code_two, true ) )
),
$this->expectedField(
'discountTax',
\wc_graphql_price( \WC()->cart->get_coupon_discount_tax_amount( $coupon_code_two ) )
),
]
),
]
),
$this->expectedNode(
'cartErrors',
[
$this->expectedField( 'type', 'INVALID_CART_ITEM' ),
$this->expectedField( 'reasons', [ 'No product found matching the ID provided' ] ),
$this->expectedField( 'productId', $invalid_product ),
$this->expectedField( 'quantity', 4 ),
]
),
$this->expectedNode(
'cartErrors',
[
$this->expectedField( 'type', 'INVALID_COUPON' ),
$this->expectedField( 'reasons', [ "Coupon &quot;{$invalid_coupon}&quot; cannot be applied because it does not exist." ] ),
$this->expectedField( 'code', $invalid_coupon ),
]
),
$this->expectedNode(
'cartErrors',
[
$this->expectedField( 'type', 'INVALID_SHIPPING_METHOD' ),
$this->expectedField( 'chosenMethod', $invalid_shipping_method ),
$this->expectedField( 'package', 1 ),
]
),
]
),
];
$this->assertQuerySuccessful( $response, $expected );
}
/**
* Test that fillCart reports coupon errors when coupon minimum spend is not met.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/458
*/
public function testFillCartReportsCouponMinimumSpendError() {
$product_id = $this->factory->product->createSimple(
[
'regular_price' => 10,
'price' => 10,
]
);
$coupon_id = $this->factory->coupon->create(
[
'code' => 'minspend500',
'discount_type' => 'percent',
'amount' => 10,
'minimum_amount' => 500,
]
);
$query = '
mutation fillCart($input: FillCartInput!) {
fillCart(input: $input) {
added {
key
product {
node {
databaseId
}
}
}
applied {
code
}
cartErrors {
type
reasons
... on CouponError {
code
reasons
}
}
}
}
';
$variables = [
'input' => [
'items' => [
[
'productId' => $product_id,
'quantity' => 1,
],
],
'coupons' => [ 'minspend500' ],
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$expected = [
$this->expectedField( 'fillCart.added.0.product.node.databaseId', $product_id ),
];
$this->assertQuerySuccessful( $response, $expected );
$applied = $this->lodashGet( $response, 'data.fillCart.applied' );
$this->assertNull( $applied, 'Coupon should not be applied when minimum spend is not met.' );
$cart_errors = $this->lodashGet( $response, 'data.fillCart.cartErrors', [] );
$this->assertNotEmpty( $cart_errors, 'cartErrors should contain the coupon error.' );
$this->assertEquals( 'INVALID_COUPON', $cart_errors[0]['type'] );
$this->assertEquals( 'minspend500', $cart_errors[0]['code'] );
$this->assertNotEmpty( $cart_errors[0]['reasons'], 'Coupon error should have a reason.' );
}
/**
* Test that fillCart reports errors for completely invalid coupon codes.
*/
public function testFillCartReportsInvalidCouponCodeError() {
$product_id = $this->factory->product->createSimple(
[
'regular_price' => 10,
'price' => 10,
]
);
$query = '
mutation fillCart($input: FillCartInput!) {
fillCart(input: $input) {
added {
key
}
applied {
code
}
cartErrors {
type
reasons
... on CouponError {
code
reasons
}
}
}
}
';
$variables = [
'input' => [
'items' => [
[
'productId' => $product_id,
'quantity' => 1,
],
],
'coupons' => [ 'nonexistent_coupon' ],
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQuerySuccessful( $response, [] );
$applied = $this->lodashGet( $response, 'data.fillCart.applied' );
$this->assertNull( $applied, 'Invalid coupon should not be applied.' );
$cart_errors = $this->lodashGet( $response, 'data.fillCart.cartErrors', [] );
$this->assertNotEmpty( $cart_errors, 'cartErrors should contain the invalid coupon error.' );
$this->assertEquals( 'INVALID_COUPON', $cart_errors[0]['type'] );
}
/**
* Test that applyCoupon returns the same error message regardless of coupon code casing.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/600
*/
public function testApplyCouponErrorMessageConsistentAcrossCasing() {
$product_id = $this->factory->product->createSimple(
[
'regular_price' => 10,
'price' => 10,
]
);
\WC()->cart->add_to_cart( $product_id );
$query = '
mutation applyCoupon($input: ApplyCouponInput!) {
applyCoupon(input: $input) {
cart {
appliedCoupons {
code
}
}
}
}
';
$expected_error = [
$this->expectedErrorMessage( 'does not exist', self::MESSAGE_CONTAINS ),
];
// Test with lowercase.
$variables = [ 'input' => [ 'code' => 'fakecoupon' ] ];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQueryError( $response, $expected_error );
// Test with uppercase.
$variables = [ 'input' => [ 'code' => 'FAKECOUPON' ] ];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQueryError( $response, $expected_error );
// Test with mixed case.
$variables = [ 'input' => [ 'code' => 'FakeCoupon' ] ];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQueryError( $response, $expected_error );
}
/**
* Test that removeCoupons recalculates cart totals after coupon removal.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/260
*/
public function testRemoveCouponsRecalculatesTotals() {
$product_id = $this->factory->product->createSimple(
[
'regular_price' => 100,
'price' => 100,
]
);
$coupon_id = $this->factory->coupon->create(
[
'code' => 'half-off',
'discount_type' => 'percent',
'amount' => 50,
]
);
// Add product to cart.
\WC()->cart->add_to_cart( $product_id );
// Apply coupon.
$apply_query = '
mutation applyCoupon($input: ApplyCouponInput!) {
applyCoupon(input: $input) {
cart {
total
discountTotal
appliedCoupons {
code
}
}
}
}
';
$query = $apply_query;
$variables = [ 'input' => [ 'code' => 'half-off' ] ];
$response = $this->graphql( compact( 'query', 'variables' ) );
$this->assertQuerySuccessful( $response, [] );
$total_with_coupon = $this->lodashGet( $response, 'data.applyCoupon.cart.total' );
$discount_with_coupon = $this->lodashGet( $response, 'data.applyCoupon.cart.discountTotal' );
$this->assertNotEmpty( $discount_with_coupon, 'Discount should be applied.' );
// Remove coupon.
$remove_query = '
mutation removeCoupons($input: RemoveCouponsInput!) {
removeCoupons(input: $input) {
cart {
total
discountTotal
discountTax
appliedCoupons {
code
}
}
}
}
';
$variables = [ 'input' => [ 'codes' => [ 'half-off' ] ] ];
$response = $this->graphql(
[
'query' => $remove_query,
'variables' => $variables,
]
);
$this->assertQuerySuccessful( $response, [] );
// Coupon should be removed.
$applied = $this->lodashGet( $response, 'data.removeCoupons.cart.appliedCoupons' );
$this->assertEmpty( $applied, 'No coupons should be applied after removal.' );
// Totals should be recalculated — discount should be zero.
$discount_after = $this->lodashGet( $response, 'data.removeCoupons.cart.discountTotal' );
$total_after = $this->lodashGet( $response, 'data.removeCoupons.cart.total' );
$this->assertNotEquals(
$total_with_coupon,
$total_after,
'Cart total should change after removing coupon.'
);
}
}