Files
wp-graphql-woocommerce/tests/wpunit/OrderMutationsTest.php
T
Geoff TaylorandGitHub 460b101b47 fix: HPOS order mutation data loss, COT cursor pagination, email tests, checkout auth (#1003)
* devops: WC email template tests, COT cursor HPOS fix, checkout account auth

WC Email Template Tests:
- Add WooCommerceEmailTemplatesTest verifying WC email templates are used
  for registerCustomer, checkout with account creation, and password reset
- Use MockPHPMailer to capture emails and verify HTML content type
- Disable deferred transactional emails during tests via GRAPHQL_TESTING flag

COT Cursor HPOS Fix:
- Fix COT_Cursor::compare_with to resolve orderby aliases and legacy meta
  keys (_order_total, _date_completed, etc.) to COT column names
- Add resolve_orderby_alias() mapping short aliases and meta keys to columns
- Add DB_Hooks::clean_query_vars to translate post_* and meta_key orderby
  to COT-compatible equivalents via woocommerce_order_query_args filter

Checkout Account Authentication:
- Add authenticate field to CreateAccountInput type
- Gate wc_set_customer_auth_cookie() behind authenticate flag in checkout
  mutation so account creation doesn't auto-authenticate by default

Closes #882

* devops: codeception.dist.yml updated

* fix: Functional test cleanup and CI coverage condition

Test fixes:
- Enable authorizing URL fields in ProtectedRouterCest and
  DownloadableItemAuthCest via setWooGraphQLSetting
- Add stale data cleanup (sessions, users, products, orders) to
  GraphQLE2E _setupStore/getCatalog/setupStoreAndUsers
- Fix CartTransactionQueueCest and CartQueriesTest for test isolation

CI:
- Only run coverage job when at least one upstream job succeeds

* chore: Linter compliances met

* fix: HPOS order mutation data loss and CI coverage condition

Refactor order create/update mutations to set all props on a single
WC_Order instance before saving, mirroring the WC REST API pattern.
Previously, separate add_order_meta() and add_items() calls each loaded
their own order instance and saved independently, causing HPOS data loss
for payment method, addresses, and other fields.

Also fix CI coverage job to only run when all upstream jobs succeed,
and correct test assertions for RAW format line item totals.

Closes #591

* chore: Linter compliances met

* chore: Remove dead code from Order_Mutation after prepare_order refactor

Removes add_items() and update_address() which are no longer called
after the prepare_order() consolidation.

* chore: Remove dead code add_order_meta and update_item_meta_data
2026-03-30 21:42:00 -04:00

1481 lines
45 KiB
PHP

<?php
use WPGraphQL\Type\WPEnumType;
class OrderMutationsTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQLTestCase {
public function setUp(): void {
// before
parent::setUp();
// Turn on tax calculations. Important!
update_option( 'woocommerce_prices_include_tax', 'no' );
update_option( 'woocommerce_calc_taxes', 'yes' );
update_option( 'woocommerce_tax_round_at_subtotal', 'no' );
// Enable stock management.
update_option( 'woocommerce_manage_stock', 'yes' );
$gateways = \WC()->payment_gateways->payment_gateways();
$bacs_gateway = $gateways['bacs'];
$bacs_gateway->settings['enabled'] = 'yes';
update_option( $bacs_gateway->get_option_key(), $bacs_gateway->settings );
$cod_gateway = $gateways['cod'];
$cod_gateway->settings['enabled'] = 'yes';
update_option( $cod_gateway->get_option_key(), $cod_gateway->settings );
\WC()->payment_gateways->init();
// Create a tax rate.
$this->factory->tax_rate->create(
[
'country' => '',
'state' => '',
'rate' => 20.000,
'name' => 'VAT',
'priority' => '1',
'compound' => '0',
'shipping' => '1',
'class' => '',
]
);
// Create sample order to be used as a parent order.
}
private function orderMutation( $input, $operation_name = 'createOrder', $input_type = 'CreateOrderInput' ) {
$mutation = "
mutation {$operation_name}( \$input: {$input_type}! ) {
{$operation_name}( input: \$input ) {
clientMutationId
order {
id
databaseId
currency
orderVersion
date
modified
status
discountTotal
discountTax
shippingTotal
shippingTax
cartTax
total
totalTax
subtotal
orderNumber
orderKey
createdVia
pricesIncludeTax
parent {
id
}
customer {
id
}
customerIpAddress
customerUserAgent
customerNote
billing {
firstName
lastName
company
address1
address2
city
state
postcode
country
email
phone
}
shipping {
firstName
lastName
company
address1
address2
city
state
postcode
country
}
paymentMethod
paymentMethodTitle
transactionId
dateCompleted
datePaid
cartHash
shippingAddressMapUrl
hasBillingAddress
hasShippingAddress
isDownloadPermitted
needsShippingAddress
hasDownloadableItem
downloadableItems {
nodes {
url
accessExpires
downloadId
downloadsRemaining
name
product {
databaseId
}
}
}
needsPayment
needsProcessing
metaData {
key
value
}
couponLines {
nodes {
databaseId
orderId
code
discount
discountTax
coupon {
id
}
}
}
feeLines {
nodes {
databaseId
orderId
amount
name
taxStatus
total
totalTax
taxClass
}
}
shippingLines {
nodes {
databaseId
orderId
methodTitle
total
totalTax
taxClass
}
}
taxLines {
nodes {
rateCode
label
taxTotal
shippingTaxTotal
isCompound
taxRate {
databaseId
}
}
}
lineItems {
nodes {
productId
variationId
quantity
taxClass
subtotal
subtotalTax
total
totalTax
taxStatus
product {
node {
id
}
}
variation {
node {
id
}
}
}
}
}
}
}
";
return $this->graphql(
[
'query' => $mutation,
'operation_name' => $operation_name,
'variables' => [ 'input' => $input ],
]
);
}
// tests
public function testCreateOrderMutation() {
$variable = $this->factory->product_variation->createSome( $this->factory->product->createVariable() );
$product_ids = [
$this->factory->product->createSimple(),
$this->factory->product->createSimple(),
$variable['product'],
];
$coupon = new WC_Coupon(
$this->factory->coupon->create( [ 'product_ids' => $product_ids ] )
);
$input = [
'clientMutationId' => 'someId',
'customerId' => $this->customer,
'customerNote' => 'Customer test note',
'coupons' => [
$coupon->get_code(),
],
'paymentMethod' => 'bacs',
'paymentMethodTitle' => 'Direct Bank Transfer',
'billing' => [
'firstName' => 'May',
'lastName' => 'Parker',
'address1' => '20 Ingram St',
'city' => 'New York City',
'state' => 'NY',
'postcode' => '12345',
'country' => 'US',
'email' => 'superfreak500@gmail.com',
'phone' => '555-555-1234',
],
'shipping' => [
'firstName' => 'May',
'lastName' => 'Parker',
'address1' => '20 Ingram St',
'city' => 'New York City',
'state' => 'NY',
'postcode' => '12345',
'country' => 'US',
],
'lineItems' => [
[
'productId' => $product_ids[0],
'quantity' => 5,
'metaData' => [
[
'key' => 'test_product_key',
'value' => 'test product value',
],
],
],
[
'productId' => $product_ids[1],
'quantity' => 2,
],
[
'productId' => $product_ids[2],
'quantity' => 6,
'variationId' => $variable['variations'][0],
],
],
'shippingLines' => [
[
'methodId' => 'flat_rate_shipping',
'methodTitle' => 'Flat Rate shipping',
'total' => '10',
],
],
'feeLines' => [
[
'name' => 'Some Fee',
'taxStatus' => 'TAXABLE',
'total' => '100',
'taxClass' => 'STANDARD',
],
],
'metaData' => [
[
'key' => 'test_key',
'value' => 'test value',
],
],
'currency' => 'VND',
'isPaid' => true,
];
/**
* Assertion One
*
* User without necessary capabilities cannot create order an order.
*/
$this->loginAsCustomer();
$response = $this->orderMutation( $input );
$this->assertQueryError( $response );
/**
* Assertion Two
*
* Test mutation and input.
*/
$this->loginAsShopManager();
$response = $this->orderMutation( $input );
$this->assertArrayHasKey( 'data', $response );
$this->assertArrayHasKey( 'createOrder', $response['data'] );
$this->assertArrayHasKey( 'order', $response['data']['createOrder'] );
$this->assertArrayHasKey( 'id', $response['data']['createOrder']['order'] );
$order = \WC_Order_Factory::get_order( $response['data']['createOrder']['order']['databaseId'] );
$expected = [
$this->expectedField( 'createOrder.clientMutationId', 'someId' ),
$this->expectedField( 'createOrder.order.id', $this->toRelayId( 'order', $order->get_id() ) ),
$this->expectedField( 'createOrder.order.databaseId', $order->get_id() ),
$this->expectedField( 'createOrder.order.currency', self::NOT_NULL ),
$this->expectedField( 'createOrder.order.status', self::NOT_NULL ),
$this->expectedField( 'createOrder.order.customerNote', 'Customer test note' ),
$this->expectedField( 'createOrder.order.billing.firstName', 'May' ),
$this->expectedField( 'createOrder.order.billing.lastName', 'Parker' ),
$this->expectedField( 'createOrder.order.billing.address1', '20 Ingram St' ),
$this->expectedField( 'createOrder.order.billing.city', 'New York City' ),
$this->expectedField( 'createOrder.order.billing.state', 'NY' ),
$this->expectedField( 'createOrder.order.billing.postcode', '12345' ),
$this->expectedField( 'createOrder.order.billing.country', 'US' ),
$this->expectedField( 'createOrder.order.billing.email', 'superfreak500@gmail.com' ),
$this->expectedField( 'createOrder.order.billing.phone', '555-555-1234' ),
$this->expectedField( 'createOrder.order.shipping.firstName', 'May' ),
$this->expectedField( 'createOrder.order.shipping.lastName', 'Parker' ),
$this->expectedField( 'createOrder.order.shipping.address1', '20 Ingram St' ),
$this->expectedField( 'createOrder.order.shipping.city', 'New York City' ),
$this->expectedField( 'createOrder.order.shipping.state', 'NY' ),
$this->expectedField( 'createOrder.order.shipping.postcode', '12345' ),
$this->expectedField( 'createOrder.order.shipping.country', 'US' ),
$this->expectedField( 'createOrder.order.paymentMethod', 'bacs' ),
$this->expectedField( 'createOrder.order.paymentMethodTitle', 'Direct Bank Transfer' ),
];
// Validate coupon lines.
$coupon_items = array_values( $order->get_items( 'coupon' ) );
foreach ( $coupon_items as $i => $item ) {
$expected[] = $this->expectedField( "createOrder.order.couponLines.nodes.{$i}.databaseId", $item->get_id() );
$expected[] = $this->expectedField( "createOrder.order.couponLines.nodes.{$i}.orderId", $item->get_order_id() );
$expected[] = $this->expectedField( "createOrder.order.couponLines.nodes.{$i}.code", $item->get_code() );
$expected[] = $this->expectedField(
"createOrder.order.couponLines.nodes.{$i}.coupon.id",
$this->toRelayId( 'shop_coupon', \wc_get_coupon_id_by_code( $item->get_code() ) )
);
}
// Validate fee lines.
$fee_items = array_values( $order->get_items( 'fee' ) );
foreach ( $fee_items as $i => $item ) {
$expected[] = $this->expectedField( "createOrder.order.feeLines.nodes.{$i}.databaseId", $item->get_id() );
$expected[] = $this->expectedField( "createOrder.order.feeLines.nodes.{$i}.name", $item->get_name() );
$expected[] = $this->expectedField( "createOrder.order.feeLines.nodes.{$i}.taxStatus", strtoupper( $item->get_tax_status() ) );
$expected[] = $this->expectedField( "createOrder.order.feeLines.nodes.{$i}.total", $item->get_total() );
}
// Validate shipping lines.
$shipping_items = array_values( $order->get_items( 'shipping' ) );
foreach ( $shipping_items as $i => $item ) {
$expected[] = $this->expectedField( "createOrder.order.shippingLines.nodes.{$i}.databaseId", $item->get_id() );
$expected[] = $this->expectedField( "createOrder.order.shippingLines.nodes.{$i}.methodTitle", $item->get_method_title() );
$expected[] = $this->expectedField( "createOrder.order.shippingLines.nodes.{$i}.total", $item->get_total() );
}
// Validate tax lines.
$tax_items = array_values( $order->get_items( 'tax' ) );
foreach ( $tax_items as $i => $item ) {
$expected[] = $this->expectedField( "createOrder.order.taxLines.nodes.{$i}.rateCode", $item->get_rate_code() );
$expected[] = $this->expectedField( "createOrder.order.taxLines.nodes.{$i}.label", $item->get_label() );
$expected[] = $this->expectedField( "createOrder.order.taxLines.nodes.{$i}.taxRate.databaseId", $item->get_rate_id() );
}
// Validate line items.
$line_items = array_values( $order->get_items() );
foreach ( $line_items as $i => $item ) {
$expected[] = $this->expectedField( "createOrder.order.lineItems.nodes.{$i}.productId", $item->get_product_id() );
$expected[] = $this->expectedField( "createOrder.order.lineItems.nodes.{$i}.quantity", $item->get_quantity() );
$expected[] = $this->expectedField( "createOrder.order.lineItems.nodes.{$i}.taxStatus", strtoupper( $item->get_tax_status() ) );
$expected[] = $this->expectedField(
"createOrder.order.lineItems.nodes.{$i}.product.node.id",
$this->toRelayId( 'post', $item->get_product_id() )
);
if ( ! empty( $item->get_variation_id() ) ) {
$expected[] = $this->expectedField( "createOrder.order.lineItems.nodes.{$i}.variationId", $item->get_variation_id() );
$expected[] = $this->expectedField(
"createOrder.order.lineItems.nodes.{$i}.variation.node.id",
$this->toRelayId( 'post', $item->get_variation_id() )
);
}
}
$this->assertQuerySuccessful( $response, $expected );
}
public function testUpdateOrderMutation() {
// Create products and coupons to be used in order creation.
$variable = $this->factory->product_variation->createSome( $this->factory->product->createVariable() );
$product_ids = [
$this->factory->product->createSimple(),
$this->factory->product->createSimple(),
$variable['product'],
];
$coupon = new WC_Coupon(
$this->factory->coupon->create( [ 'product_ids' => $product_ids ] )
);
// Create initial order input.
$initial_input = [
'clientMutationId' => 'someId',
'customerId' => $this->customer,
'customerNote' => 'Customer test note',
'coupons' => [
$coupon->get_code(),
],
'paymentMethod' => 'bacs',
'paymentMethodTitle' => 'Direct Bank Transfer',
'billing' => [
'firstName' => 'May',
'lastName' => 'Parker',
'address1' => '20 Ingram St',
'city' => 'New York City',
'state' => 'NY',
'postcode' => '12345',
'country' => 'US',
'email' => 'superfreak500@gmail.com',
'phone' => '555-555-1234',
],
'shipping' => [
'firstName' => 'May',
'lastName' => 'Parker',
'address1' => '20 Ingram St',
'city' => 'New York City',
'state' => 'NY',
'postcode' => '12345',
'country' => 'US',
],
'lineItems' => [
[
'productId' => $product_ids[0],
'quantity' => 5,
'metaData' => [
[
'key' => 'test_product_key',
'value' => 'test product value',
],
],
],
[
'productId' => $product_ids[1],
'quantity' => 2,
],
[
'productId' => $product_ids[2],
'quantity' => 6,
'variationId' => $variable['variations'][0],
],
],
'shippingLines' => [
[
'methodId' => 'flat_rate_shipping',
'methodTitle' => 'Flat Rate shipping',
'total' => '10',
],
],
'feeLines' => [
[
'name' => 'Some Fee',
'taxStatus' => 'TAXABLE',
'total' => '100',
'taxClass' => 'STANDARD',
],
],
'metaData' => [
[
'key' => 'test_key',
'value' => 'test value',
],
],
'isPaid' => false,
];
// Create order to update.
$this->loginAsShopManager();
$initial_response = $this->orderMutation( $initial_input );
// use --debug flag to view.
// Retrieve order and items
$order = \WC_Order_Factory::get_order( $initial_response['data']['createOrder']['order']['databaseId'] );
$line_items = $order->get_items();
$shipping_lines = $order->get_items( 'shipping' );
$fee_lines = $order->get_items( 'fee' );
// Create update order input.
$updated_input = [
'id' => $this->toRelayId( 'order', $order->get_id() ),
'clientMutationId' => 'someId',
'customerNote' => 'Customer test note',
'coupons' => [
$coupon->get_code(),
],
'billing' => [
'firstName' => 'Ben',
],
'shipping' => [
'firstName' => 'Ben',
],
'lineItems' => [
[
'id' => array_keys( $line_items )[0],
'quantity' => 6,
'metaData' => [
[
'key' => 'test_product_key',
'value' => 'updated test product value',
],
],
],
[
'id' => array_keys( $line_items )[1],
'quantity' => 1,
],
[
'id' => array_keys( $line_items )[2],
'quantity' => 10,
],
],
'shippingLines' => [
[
'id' => array_keys( $shipping_lines )[0],
'methodId' => 'reduced_rate_shipping',
'methodTitle' => 'reduced Rate shipping',
'total' => '7',
],
],
'feeLines' => [
[
'id' => array_keys( $fee_lines )[0],
'name' => 'Some Updated Fee',
'taxStatus' => 'TAXABLE',
'total' => '125',
'taxClass' => 'STANDARD',
],
],
'metaData' => [
[
'key' => 'test_key',
'value' => 'new test value',
],
],
'isPaid' => true,
];
/**
* Assertion One
*
* User without necessary capabilities cannot update order an order.
*/
wp_set_current_user( $this->factory->user->create( [ 'role' => 'customer' ] ) );
$response = $this->orderMutation(
$updated_input,
'updateOrder',
'UpdateOrderInput'
);
$this->assertQueryError( $response );
/**
* Assertion Two
*
* Test mutation and input.
*/
$this->loginAsShopManager();
$response = $this->orderMutation(
$updated_input,
'updateOrder',
'UpdateOrderInput'
);
// Apply new changes to order instances.
$order = \WC_Order_Factory::get_order( $order->get_id() );
$expected = [
$this->expectedField( 'updateOrder.clientMutationId', 'someId' ),
$this->expectedField( 'updateOrder.order.id', $this->toRelayId( 'order', $order->get_id() ) ),
$this->expectedField( 'updateOrder.order.databaseId', $order->get_id() ),
$this->expectedField( 'updateOrder.order.currency', self::NOT_NULL ),
$this->expectedField( 'updateOrder.order.status', self::NOT_NULL ),
$this->expectedField( 'updateOrder.order.customerNote', 'Customer test note' ),
$this->expectedField( 'updateOrder.order.billing.firstName', 'Ben' ),
$this->expectedField( 'updateOrder.order.shipping.firstName', 'Ben' ),
];
// Validate coupon lines.
$coupon_items = array_values( $order->get_items( 'coupon' ) );
foreach ( $coupon_items as $i => $item ) {
$expected[] = $this->expectedField( "updateOrder.order.couponLines.nodes.{$i}.databaseId", $item->get_id() );
$expected[] = $this->expectedField( "updateOrder.order.couponLines.nodes.{$i}.code", $item->get_code() );
$expected[] = $this->expectedField(
"updateOrder.order.couponLines.nodes.{$i}.coupon.id",
$this->toRelayId( 'shop_coupon', \wc_get_coupon_id_by_code( $item->get_code() ) )
);
}
// Validate fee lines.
$fee_items = array_values( $order->get_items( 'fee' ) );
foreach ( $fee_items as $i => $item ) {
$expected[] = $this->expectedField( "updateOrder.order.feeLines.nodes.{$i}.databaseId", $item->get_id() );
$expected[] = $this->expectedField( "updateOrder.order.feeLines.nodes.{$i}.name", $item->get_name() );
$expected[] = $this->expectedField( "updateOrder.order.feeLines.nodes.{$i}.total", $item->get_total() );
}
// Validate shipping lines.
$shipping_items = array_values( $order->get_items( 'shipping' ) );
foreach ( $shipping_items as $i => $item ) {
$expected[] = $this->expectedField( "updateOrder.order.shippingLines.nodes.{$i}.databaseId", $item->get_id() );
$expected[] = $this->expectedField( "updateOrder.order.shippingLines.nodes.{$i}.methodTitle", $item->get_method_title() );
$expected[] = $this->expectedField( "updateOrder.order.shippingLines.nodes.{$i}.total", $item->get_total() );
}
// Validate tax lines.
$tax_items = array_values( $order->get_items( 'tax' ) );
foreach ( $tax_items as $i => $item ) {
$expected[] = $this->expectedField( "updateOrder.order.taxLines.nodes.{$i}.rateCode", $item->get_rate_code() );
$expected[] = $this->expectedField( "updateOrder.order.taxLines.nodes.{$i}.label", $item->get_label() );
$expected[] = $this->expectedField( "updateOrder.order.taxLines.nodes.{$i}.taxRate.databaseId", $item->get_rate_id() );
}
// Validate line items.
$updated_line_items = array_values( $order->get_items() );
foreach ( $updated_line_items as $i => $item ) {
$expected[] = $this->expectedField( "updateOrder.order.lineItems.nodes.{$i}.productId", $item->get_product_id() );
$expected[] = $this->expectedField( "updateOrder.order.lineItems.nodes.{$i}.quantity", $item->get_quantity() );
$expected[] = $this->expectedField( "updateOrder.order.lineItems.nodes.{$i}.taxStatus", strtoupper( $item->get_tax_status() ) );
$expected[] = $this->expectedField(
"updateOrder.order.lineItems.nodes.{$i}.product.node.id",
$this->toRelayId( 'post', $item->get_product_id() )
);
if ( ! empty( $item->get_variation_id() ) ) {
$expected[] = $this->expectedField( "updateOrder.order.lineItems.nodes.{$i}.variationId", $item->get_variation_id() );
$expected[] = $this->expectedField(
"updateOrder.order.lineItems.nodes.{$i}.variation.node.id",
$this->toRelayId( 'post', $item->get_variation_id() )
);
}
}
$this->assertQuerySuccessful( $response, $expected );
$this->assertNotEquals( $initial_response, $response );
}
public function testDeleteOrderMutation() {
// Create products and coupons to be used in order creation.
$variable = $this->factory->product_variation->createSome( $this->factory->product->createVariable() );
$product_ids = [
$this->factory->product->createSimple(),
$this->factory->product->createSimple(),
$variable['product'],
];
$coupon = new WC_Coupon(
$this->factory->coupon->create( [ 'product_ids' => $product_ids ] )
);
// Create initial order input.
$initial_input = [
'clientMutationId' => 'someId',
'customerId' => $this->customer,
'customerNote' => 'Customer test note',
'coupons' => [
$coupon->get_code(),
],
'paymentMethod' => 'bacs',
'paymentMethodTitle' => 'Direct Bank Transfer',
'billing' => [
'firstName' => 'May',
'lastName' => 'Parker',
'address1' => '20 Ingram St',
'city' => 'New York City',
'state' => 'NY',
'postcode' => '12345',
'country' => 'US',
'email' => 'superfreak500@gmail.com',
'phone' => '555-555-1234',
],
'shipping' => [
'firstName' => 'May',
'lastName' => 'Parker',
'address1' => '20 Ingram St',
'city' => 'New York City',
'state' => 'NY',
'postcode' => '12345',
'country' => 'US',
],
'lineItems' => [
[
'productId' => $product_ids[0],
'quantity' => 5,
'metaData' => [
[
'key' => 'test_product_key',
'value' => 'test product value',
],
],
],
[
'productId' => $product_ids[1],
'quantity' => 2,
],
[
'productId' => $product_ids[2],
'quantity' => 6,
'variationId' => $variable['variations'][0],
],
],
'shippingLines' => [
[
'methodId' => 'flat_rate_shipping',
'methodTitle' => 'Flat Rate shipping',
'total' => '10',
],
],
'feeLines' => [
[
'name' => 'Some Fee',
'taxStatus' => 'TAXABLE',
'total' => '100',
'taxClass' => 'STANDARD',
],
],
'metaData' => [
[
'key' => 'test_key',
'value' => 'test value',
],
],
'isPaid' => false,
];
// Create order to delete.
$this->loginAsShopManager();
$initial_response = $this->orderMutation( $initial_input );
// use --debug flag to view.
// Clear loader cache.
$this->getModule( '\Helper\Wpunit' )->clear_loader_cache( 'wc_post' );
// Retrieve order and items
$order_id = $initial_response['data']['createOrder']['order']['databaseId'];
$order = \WC_Order_Factory::get_order( $order_id );
$line_items = $order->get_items();
$shipping_lines = $order->get_items( 'shipping' );
$fee_lines = $order->get_items( 'fee' );
$coupon_lines = $order->get_items( 'coupon' );
$tax_lines = $order->get_items( 'tax' );
// Create DeleteOrderInput.
$deleted_input = [
'clientMutationId' => 'someId',
'id' => $this->toRelayId( 'order', $order->get_id() ),
'forceDelete' => true,
];
/**
* Assertion One
*
* User without necessary capabilities cannot delete order an order.
*/
wp_set_current_user( $this->factory->user->create( [ 'role' => 'customer' ] ) );
$response = $this->orderMutation(
$deleted_input,
'deleteOrder',
'DeleteOrderInput'
);
$this->assertQueryError( $response );
/**
* Assertion Two
*
* Test mutation and input.
*/
$this->loginAsShopManager();
$response = $this->orderMutation(
$deleted_input,
'deleteOrder',
'DeleteOrderInput'
);
$expected = [
$this->expectedField( 'deleteOrder.clientMutationId', 'someId' ),
$this->expectedField( 'deleteOrder.order.id', $this->toRelayId( 'order', $order->get_id() ) ),
$this->expectedField( 'deleteOrder.order.databaseId', $order->get_id() ),
];
$this->assertQuerySuccessful( $response, $expected );
$this->assertFalse( \WC_Order_Factory::get_order( $order->get_id() ) );
}
public function testDeleteOrderItemsMutation() {
// Create products and coupons to be used in order creation.
$variable = $this->factory->product_variation->createSome( $this->factory->product->createVariable() );
$product_ids = [
$this->factory->product->createSimple(),
$this->factory->product->createSimple(),
$variable['product'],
];
$coupon = new WC_Coupon(
$this->factory->coupon->create( [ 'product_ids' => $product_ids ] )
);
// Create initial order input.
$initial_input = [
'clientMutationId' => 'someId',
'customerId' => $this->customer,
'customerNote' => 'Customer test note',
'coupons' => [
$coupon->get_code(),
],
'paymentMethod' => 'bacs',
'paymentMethodTitle' => 'Direct Bank Transfer',
'billing' => [
'firstName' => 'May',
'lastName' => 'Parker',
'address1' => '20 Ingram St',
'city' => 'New York City',
'state' => 'NY',
'postcode' => '12345',
'country' => 'US',
'email' => 'superfreak500@gmail.com',
'phone' => '555-555-1234',
],
'shipping' => [
'firstName' => 'May',
'lastName' => 'Parker',
'address1' => '20 Ingram St',
'city' => 'New York City',
'state' => 'NY',
'postcode' => '12345',
'country' => 'US',
],
'lineItems' => [
[
'productId' => $product_ids[0],
'quantity' => 5,
'metaData' => [
[
'key' => 'test_product_key',
'value' => 'test product value',
],
],
],
[
'productId' => $product_ids[1],
'quantity' => 2,
],
[
'productId' => $product_ids[2],
'quantity' => 6,
'variationId' => $variable['variations'][0],
],
],
'shippingLines' => [
[
'methodId' => 'flat_rate_shipping',
'methodTitle' => 'Flat Rate shipping',
'total' => '10',
],
],
'feeLines' => [
[
'name' => 'Some Fee',
'taxStatus' => 'TAXABLE',
'total' => '100',
'taxClass' => 'STANDARD',
],
],
'metaData' => [
[
'key' => 'test_key',
'value' => 'test value',
],
],
'isPaid' => false,
];
// Create order to delete.
$this->loginAsShopManager();
$initial_response = $this->orderMutation( $initial_input );
// use --debug flag to view.
// Clear loader cache.
$this->getModule( '\Helper\Wpunit' )->clear_loader_cache( 'wc_post' );
// Retrieve order and items
$order_id = $initial_response['data']['createOrder']['order']['databaseId'];
$order = \WC_Order_Factory::get_order( $order_id );
$line_items = $order->get_items();
$shipping_lines = $order->get_items( 'shipping' );
$fee_lines = $order->get_items( 'fee' );
$coupon_lines = $order->get_items( 'coupon' );
$tax_lines = $order->get_items( 'tax' );
// Create DeleteOrderInput.
$deleted_items_input = [
'clientMutationId' => 'someId',
'orderId' => $order->get_id(),
'itemIds' => [
current( $line_items )->get_id(),
current( $coupon_lines )->get_id(),
],
];
/**
* Assertion One
*
* User without necessary capabilities cannot delete order an order.
*/
wp_set_current_user( $this->factory->user->create( [ 'role' => 'customer' ] ) );
$response = $this->orderMutation(
$deleted_items_input,
'deleteOrderItems',
'DeleteOrderItemsInput'
);
$this->assertQueryError( $response );
/**
* Assertion Two
*
* Test mutation and input.
*/
$this->loginAsShopManager();
$response = $this->orderMutation(
$deleted_items_input,
'deleteOrderItems',
'DeleteOrderItemsInput'
);
$expected = [
$this->expectedField( 'deleteOrderItems.clientMutationId', 'someId' ),
$this->expectedField( 'deleteOrderItems.order.id', $this->toRelayId( 'order', $order->get_id() ) ),
$this->expectedField( 'deleteOrderItems.order.databaseId', $order->get_id() ),
];
$this->assertQuerySuccessful( $response, $expected );
$this->assertFalse( \WC_Order_Factory::get_order_item( current( $line_items ) ) );
$this->assertFalse( \WC_Order_Factory::get_order_item( current( $coupon_lines ) ) );
}
private function orderNoteMutation( $input, $operation_name = 'createOrderNote', $input_type = 'CreateOrderNoteInput' ) {
$mutation = "
mutation {$operation_name}( \$input: {$input_type}! ) {
{$operation_name}( input: \$input ) {
clientMutationId
orderNote {
id
databaseId
dateCreated
note
isCustomerNote
}
order {
id
databaseId
}
}
}
";
return $this->graphql(
[
'query' => $mutation,
'operation_name' => $operation_name,
'variables' => [ 'input' => $input ],
]
);
}
public function testCreateOrderNoteMutation() {
$customer_id = $this->factory->customer->create();
$order_id = $this->factory->order->createNew([
'customer_id' => $customer_id,
]);
$input = [
'clientMutationId' => 'someId',
'orderId' => $order_id,
'note' => 'Test order note content',
'isCustomerNote' => false,
];
/**
* Assertion One
*
* User without necessary capabilities cannot create an order note.
*/
$this->loginAsCustomer();
$response = $this->orderNoteMutation( $input );
$this->assertQueryError( $response );
/**
* Assertion Two
*
* Test mutation and input.
*/
$this->loginAsShopManager();
$response = $this->orderNoteMutation( $input );
$expected = [
$this->expectedField( 'createOrderNote.clientMutationId', 'someId' ),
$this->expectedField( 'createOrderNote.orderNote.note', 'Test order note content' ),
$this->expectedField( 'createOrderNote.orderNote.isCustomerNote', false ),
$this->expectedField( 'createOrderNote.orderNote.id', self::NOT_NULL ),
$this->expectedField( 'createOrderNote.orderNote.databaseId', self::NOT_NULL ),
$this->expectedField( 'createOrderNote.orderNote.dateCreated', self::NOT_NULL ),
$this->expectedField( 'createOrderNote.order.id', $this->toRelayId( 'order', $order_id ) ),
$this->expectedField( 'createOrderNote.order.databaseId', $order_id ),
];
$this->assertQuerySuccessful( $response, $expected );
// Test customer note
$customer_input = [
'clientMutationId' => 'customerId',
'orderId' => $order_id,
'note' => 'Customer visible note',
'isCustomerNote' => true,
];
$customer_actual = $this->orderNoteMutation( $customer_input );
$customer_expected = [
$this->expectedField( 'createOrderNote.orderNote.note', 'Customer visible note' ),
$this->expectedField( 'createOrderNote.orderNote.isCustomerNote', true ),
];
$this->assertQuerySuccessful( $customer_actual, $customer_expected );
/**
* Assertion Three
*
* Test mutation and input
*/
$this->loginAs( $customer_id );
$customer_input = [
'clientMutationId' => 'customerId',
'orderId' => $order_id,
'note' => 'Customer visible note',
'isCustomerNote' => true,
];
$customer_actual = $this->orderNoteMutation( $customer_input );
$customer_expected = [
$this->expectedField( 'createOrderNote.orderNote.note', 'Customer visible note' ),
$this->expectedField( 'createOrderNote.orderNote.isCustomerNote', true ),
];
$this->assertQuerySuccessful( $customer_actual, $customer_expected );
}
public function testDeleteOrderNoteMutation() {
$order_id = $this->factory->order->createNew();
// First create a note to delete
$this->loginAsShopManager();
$create_input = [
'clientMutationId' => 'createId',
'orderId' => $order_id,
'note' => 'Note to be deleted',
'isCustomerNote' => false,
];
$create_result = $this->orderNoteMutation( $create_input );
// Get note ID from the proper expected field result
$note_id = $this->lodashGet( $create_result, 'data.createOrderNote.orderNote.databaseId' );
$delete_input = [
'clientMutationId' => 'deleteId',
'id' => $note_id,
'orderId' => $order_id,
'force' => true,
];
/**
* Assertion One
*
* User without necessary capabilities cannot delete an order note.
*/
$this->loginAsCustomer();
$response = $this->orderNoteMutation( $delete_input, 'deleteOrderNote', 'DeleteOrderNoteInput' );
$this->assertQueryError( $response );
/**
* Assertion Two
*
* Test mutation and input.
*/
$this->loginAsShopManager();
$response = $this->orderNoteMutation( $delete_input, 'deleteOrderNote', 'DeleteOrderNoteInput' );
$expected = [
$this->expectedField( 'deleteOrderNote.clientMutationId', 'deleteId' ),
$this->expectedField( 'deleteOrderNote.orderNote.note', 'Note to be deleted' ),
$this->expectedField( 'deleteOrderNote.order.databaseId', $order_id ),
];
$this->assertQuerySuccessful( $response, $expected );
// Verify the note was deleted by checking it doesn't exist
$deleted_note = get_comment( $note_id );
$this->assertNull( $deleted_note );
}
public function testCreateOrderNoteValidation() {
$order_id = $this->factory->order->createNew();
$this->loginAsShopManager();
// Test missing note content
$invalid_input = [
'clientMutationId' => 'invalidId',
'orderId' => $order_id,
'note' => '',
'isCustomerNote' => false,
];
$response = $this->orderNoteMutation( $invalid_input );
$this->assertQueryError( $response );
// Test invalid order ID
$invalid_order_input = [
'clientMutationId' => 'invalidOrderId',
'orderId' => 99999,
'note' => 'Valid note content',
'isCustomerNote' => false,
];
$response = $this->orderNoteMutation( $invalid_order_input );
$this->assertQueryError( $response );
}
/**
* Test that updateOrder with only metaData does not create a duplicate order.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/591
*/
public function testUpdateOrderMetaDataDoesNotDuplicate() {
$this->loginAsShopManager();
// Create an order.
$order_id = $this->factory->order->create(
[
'status' => 'processing',
'customer_id' => $this->customer,
]
);
// Count orders before mutation.
$orders_before = wc_get_orders( [ 'return' => 'ids', 'limit' => -1 ] );
$count_before = count( $orders_before );
// Update only metaData — the exact scenario from #591.
$mutation = '
mutation updateOrder($input: UpdateOrderInput!) {
updateOrder(input: $input) {
order {
databaseId
metaData {
key
value
}
}
}
}
';
$variables = [
'input' => [
'id' => $order_id,
'metaData' => [
[
'key' => '_tracking_number',
'value' => 'ABC123',
],
],
],
];
$response = $this->graphql(
[
'query' => $mutation,
'variables' => $variables,
]
);
$expected = [
$this->expectedField( 'updateOrder.order.databaseId', $order_id ),
];
$this->assertQuerySuccessful( $response, $expected );
// Verify metaData was set.
$order = wc_get_order( $order_id );
$this->assertEquals( 'ABC123', $order->get_meta( '_tracking_number' ) );
// Count orders after mutation — should be the same.
$orders_after = wc_get_orders( [ 'return' => 'ids', 'limit' => -1 ] );
$count_after = count( $orders_after );
$this->assertEquals(
$count_before,
$count_after,
'updateOrder with new metaData should not create a duplicate order.'
);
// Now update the same meta key with a new value.
$count_before_update = $count_after;
$variables['input']['metaData'] = [
[
'key' => '_tracking_number',
'value' => 'XYZ789',
],
];
$response = $this->graphql(
[
'query' => $mutation,
'variables' => $variables,
]
);
$this->assertQuerySuccessful( $response, $expected );
// Verify metaData was updated.
$order = wc_get_order( $order_id );
$this->assertEquals( 'XYZ789', $order->get_meta( '_tracking_number' ) );
// Count orders after updating existing meta — should still be the same.
$orders_after_update = wc_get_orders( [ 'return' => 'ids', 'limit' => -1 ] );
$count_after_update = count( $orders_after_update );
$this->assertEquals(
$count_before_update,
$count_after_update,
'updateOrder with existing metaData should not create a duplicate order.'
);
}
/**
* Test that createOrder with isPaid reduces stock quantity.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/313
*/
public function testCreateOrderReducesStockQuantity() {
$this->loginAsShopManager();
// Create a product with managed stock.
$product_id = $this->factory->product->createSimple(
[
'manage_stock' => true,
'stock_quantity' => 50,
]
);
$product = wc_get_product( $product_id );
$this->assertEquals( 50, $product->get_stock_quantity(), 'Initial stock should be 50.' );
$mutation = '
mutation createOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
order {
databaseId
status
}
}
}
';
// Test with isPaid: true only (no explicit status).
$variables = [
'input' => [
'clientMutationId' => 'stock-test-paid',
'isPaid' => true,
'paymentMethod' => 'cod',
'lineItems' => [
[
'productId' => $product_id,
'quantity' => 3,
],
],
],
];
$response = $this->graphql(
[
'query' => $mutation,
'variables' => $variables,
]
);
$this->assertQuerySuccessful(
$response,
[ $this->expectedField( 'createOrder.order.databaseId', self::NOT_NULL ) ]
);
$product = wc_get_product( $product_id );
$this->assertEquals(
47,
$product->get_stock_quantity(),
'Stock should be reduced from 50 to 47 after ordering 3 units with isPaid: true.'
);
// Test with bacs payment method — should result in PROCESSING status.
$variables = [
'input' => [
'clientMutationId' => 'stock-test-bacs',
'isPaid' => true,
'paymentMethod' => 'bacs',
'lineItems' => [
[
'productId' => $product_id,
'quantity' => 2,
],
],
],
];
$response = $this->graphql(
[
'query' => $mutation,
'variables' => $variables,
]
);
$this->assertQuerySuccessful(
$response,
[ $this->expectedField( 'createOrder.order.databaseId', self::NOT_NULL ) ]
);
$product = wc_get_product( $product_id );
$this->assertEquals(
45,
$product->get_stock_quantity(),
'Stock should be reduced from 47 to 45 after ordering 2 units with BACS payment.'
);
// Test with isPaid: true and explicit status: COMPLETED.
// Setting status manually alongside isPaid interferes with WooCommerce's
// stock management flow. Users should rely on isPaid alone and let
// WooCommerce determine the correct status.
$variables = [
'input' => [
'clientMutationId' => 'stock-test-completed',
'isPaid' => true,
'paymentMethod' => 'bacs',
'status' => 'COMPLETED',
'lineItems' => [
[
'productId' => $product_id,
'quantity' => 5,
],
],
],
];
$response = $this->graphql(
[
'query' => $mutation,
'variables' => $variables,
]
);
$expected = [
$this->expectedField( 'createOrder.order.databaseId', self::NOT_NULL ),
$this->expectedField( 'createOrder.order.status', 'COMPLETED' ),
];
$this->assertQuerySuccessful( $response, $expected );
// Stock should NOT change when status is explicitly set alongside isPaid,
// because the manual status override bypasses WooCommerce's stock reduction hooks.
$product = wc_get_product( $product_id );
$this->assertEquals(
45,
$product->get_stock_quantity(),
'Stock should remain at 45 when status is explicitly set alongside isPaid.'
);
}
/**
* Test that createOrder auto-fills line item name, subtotal, and total
* when only productId is provided.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/946
*/
public function testCreateOrderWithProductIdOnlyLineItems() {
$product_id = $this->factory->product->createSimple(
[
'regular_price' => '25',
'name' => 'Test Widget',
]
);
$this->loginAsShopManager();
$query = '
mutation ($input: CreateOrderInput!) {
createOrder(input: $input) {
order {
lineItems {
nodes {
productId
quantity
total
subtotal
product {
node {
... on SimpleProduct {
name
}
}
}
}
}
}
}
}
';
$variables = [
'input' => [
'clientMutationId' => 'create-order-minimal',
'paymentMethod' => 'bacs',
'lineItems' => [
[ 'productId' => $product_id ],
],
],
];
$response = $this->graphql( compact( 'query', 'variables' ) );
$expected = [
$this->expectedField( 'createOrder.order.lineItems.nodes.0.productId', $product_id ),
$this->expectedField( 'createOrder.order.lineItems.nodes.0.quantity', 1 ),
$this->expectedField( 'createOrder.order.lineItems.nodes.0.total', '$25.00' ),
$this->expectedField( 'createOrder.order.lineItems.nodes.0.subtotal', '$25.00' ),
$this->expectedField( 'createOrder.order.lineItems.nodes.0.product.node.name', 'Test Widget' ),
];
$this->assertQuerySuccessful( $response, $expected );
}
}