• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

wp-graphql / wp-graphql-woocommerce / 23776405683

31 Mar 2026 01:42AM UTC coverage: 89.968% (+0.5%) from 89.424%
23776405683

push

github

web-flow
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 paym... (continued)

113 of 119 new or added lines in 9 files covered. (94.96%)

2 existing lines in 2 files now uncovered.

15937 of 17714 relevant lines covered (89.97%)

143.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.23
/includes/data/cursor/class-cot-cursor.php
1
<?php
2
/**
3
 * COT Cursor
4
 *
5
 * This class generates the SQL AND operators for cursor based pagination
6
 * for the custom orders table/HPOS
7
 *
8
 * @package WPGraphQL\WooCommerce\Data\Cursor;
9
 * @since   0.14.0
10
 */
11

12
namespace WPGraphQL\WooCommerce\Data\Cursor;
13

14
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
15
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableMetaQuery;
16
use WPGraphQL\Data\Cursor\AbstractCursor;
17

18
/**
19
 * Class COT_Cursor
20
 */
21
class COT_Cursor extends AbstractCursor {
22
        /**
23
         * Stores the cursory order node
24
         *
25
         * @var ?\WC_Abstract_Order
26
         */
27
        public $cursor_node;
28

29
        /**
30
         * Counter for meta value joins
31
         *
32
         * @var integer
33
         */
34
        public $meta_join_alias = 0;
35

36
        /**
37
         * The query instance to use when building the SQL statement.
38
         *
39
         * @var \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableQuery
40
         */
41
        public $query;
42

43
        /**
44
         * Names of all COT tables (orders, addresses, operational_data, meta) in the form 'table_id' => 'table name'.
45
         *
46
         * @var array
47
         */
48
        private $tables;
49

50
        /**
51
         * Undocumented variable
52
         *
53
         * @var array
54
         */
55
        private $column_mappings;
56

57
        /**
58
         * Meta query parser.
59
         *
60
         * @var \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableMetaQuery|null
61
         */
62
        private $meta_query;
63

64
        /**
65
         * COT_Cursor constructor.
66
         *
67
         * @param array                                                               $query_vars  The query vars to use when building the SQL statement.
68
         * @param \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableQuery $query       The query to use when building the SQL statement.
69
         * @param string|null                                                         $cursor      Whether to generate the before or after cursor. Default "after".
70
         */
71
        public function __construct( $query_vars, $query, $cursor = 'after' ) {
72
                // Initialize the class properties.
73
                parent::__construct( $query_vars, $cursor );
9✔
74

75
                $this->query = $query;
9✔
76

77
                // Get tables.
78
                /** @var \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore $order_datastore */
79
                $order_datastore       = wc_get_container()->get( OrdersTableDataStore::class );
9✔
80
                $this->tables          = $order_datastore::get_all_table_names_with_id();
9✔
81
                $mappings              = $order_datastore->get_all_order_column_mappings();
9✔
82
                $this->column_mappings = [];
9✔
83
                foreach ( $mappings['orders'] as $column => $meta_value ) {
9✔
84
                        $this->column_mappings[ "{$this->tables['orders']}.{$column}" ] = $meta_value['name'];
9✔
85
                }
86

87
                if ( ! is_null( $this->get_query_var( 'meta_query' ) ) ) {
9✔
88
                        $this->meta_query = new OrdersTableMetaQuery( $this->query );
9✔
89
                }
90
        }
91

92
        /**
93
         * Resolves an orderby alias (e.g. 'date', 'total') to the fully qualified
94
         * COT column name used in the column_mappings.
95
         *
96
         * @param string $alias The orderby alias.
97
         *
98
         * @return string The resolved column name, or the original alias if no mapping found.
99
         */
100
        public function resolve_orderby_alias( $alias ) {
101
                $alias_map = [
9✔
102
                        // Short aliases used by WC OrdersTableQuery.
103
                        'post_date'       => 'date_created_gmt',
9✔
104
                        'date'            => 'date_created_gmt',
9✔
105
                        'date_created'    => 'date_created_gmt',
9✔
106
                        'modified'        => 'date_updated_gmt',
9✔
107
                        'date_modified'   => 'date_updated_gmt',
9✔
108
                        'type'            => 'type',
9✔
109
                        'parent'          => 'parent_order_id',
9✔
110
                        'total'           => 'total_amount',
9✔
111
                        'order_total'     => 'total_amount',
9✔
112
                        // Legacy meta keys that map to COT columns in HPOS mode.
113
                        '_order_total'    => 'total_amount',
9✔
114
                        '_order_tax'      => 'tax_amount',
9✔
115
                        '_cart_discount'  => 'discount_total_amount',
9✔
116
                        '_date_paid'      => 'date_paid_gmt',
9✔
117
                        '_date_completed' => 'date_completed_gmt',
9✔
118
                        '_order_key'      => 'order_key',
9✔
119
                ];
9✔
120

121
                return $alias_map[ $alias ] ?? $alias;
9✔
122
        }
123

124
        /**
125
         * {@inheritDoc}
126
         *
127
         * @return ?\WC_Abstract_Order
128
         */
129
        public function get_cursor_node() {
130
                // If we have a bad cursor, just skip.
131
                if ( ! $this->cursor_offset ) {
9✔
132
                        return null;
×
133
                }
134

135
                // Get the order.
136
                $order = wc_get_order( $this->cursor_offset );
9✔
137

138
                return $order instanceof \WC_Abstract_Order ? $order : null;
9✔
139
        }
140

141
        /**
142
         * {@inheritDoc}
143
         */
144
        public function to_sql() {
145
                $orderby = isset( $this->query_vars['orderby'] ) ? $this->query_vars['orderby'] : null;
9✔
146

147
                $orderby_should_not_convert_to_sql = isset( $orderby ) && in_array(
9✔
148
                        $orderby,
9✔
149
                        [
9✔
150
                                'include',
9✔
151
                                'id',
9✔
152
                                'parent_order_id',
9✔
153
                        ],
9✔
154
                        true
9✔
155
                );
9✔
156

157
                if ( true === $orderby_should_not_convert_to_sql ) {
9✔
158
                        return '';
×
159
                }
160

161
                $sql = $this->builder->to_sql();
9✔
162

163
                if ( empty( $sql ) ) {
9✔
164
                        return '';
×
165
                }
166

167
                return ' AND ' . $sql;
9✔
168
        }
169

170
        /**
171
         * {@inheritDoc}
172
         */
173
        public function get_where() {
174
                // If we have a bad cursor, just skip.
175
                if ( ! $this->is_valid_offset_and_node() ) {
9✔
176
                        return '';
×
177
                }
178

179
                $orderby = $this->get_query_var( 'orderby' );
9✔
180
                $order   = $this->get_query_var( 'order' );
9✔
181

182
                if ( ! empty( $orderby ) && is_array( $orderby ) ) {
9✔
183

184
                        /**
185
                         * Loop through all order keys if it is an array
186
                         */
187
                        foreach ( $orderby as $by => $order ) {
9✔
188
                                $this->compare_with( $by, $order );
9✔
189
                        }
190
                } elseif ( ! empty( $orderby ) && is_string( $orderby ) ) {
×
191

192
                        /**
193
                         * If $orderby is just a string just compare with it directly as DESC
194
                         */
195
                        $this->compare_with( $orderby, $order );
×
196
                }
197

198
                /**
199
                 * No custom comparing. Use the default date
200
                 */
201
                if ( ! $this->builder->has_fields() ) {
9✔
202
                        $this->compare_with_date();
1✔
203
                }
204

205
                $this->builder->add_field( "{$this->tables['orders']}.id", $this->cursor_offset, 'ID', $order );
9✔
206

207
                return $this->to_sql();
9✔
208
        }
209

210
        /**
211
         * Get AND operator for given order by key
212
         *
213
         * @param string $by    The order by key.
214
         * @param string $order The order direction ASC or DESC.
215
         *
216
         * @return void
217
         */
218
        private function compare_with( $by, $order ) {
219
                if ( null === $this->cursor_node ) {
9✔
220
                        return;
×
221
                }
222

223
                // Check for explicit cursor compare fields set by the connection resolver.
224
                $key   = $this->get_query_var( "graphql_cursor_compare_by_{$by}_key" );
9✔
225
                $value = $this->get_query_var( "graphql_cursor_compare_by_{$by}_value" );
9✔
226
                if ( ! empty( $key ) && ! empty( $value ) ) {
9✔
NEW
227
                        $this->builder->add_field( $key, $value, null, $order );
×
NEW
228
                        return;
×
229
                }
230

231
                // Resolve the orderby key to a COT column. For meta_value/meta_value_num,
232
                // use the meta_key query var since that holds the actual field name.
233
                $table_name  = $this->tables['orders'];
9✔
234
                $source      = in_array( $by, [ 'meta_value', 'meta_value_num' ], true )
9✔
NEW
235
                        ? ( $this->get_query_var( 'meta_key' ) ?? $by )
×
236
                        : $by;
9✔
237
                $column      = $this->resolve_orderby_alias( $source );
9✔
238
                $orderby     = "{$table_name}.{$column}";
9✔
239
                $getter_name = $this->column_mappings[ $orderby ] ?? null;
9✔
240

241
                if ( null !== $getter_name ) {
9✔
242
                        $method = "get_{$getter_name}";
8✔
243
                        $value  = is_callable( [ $this->cursor_node, $method ] ) ? $this->cursor_node->$method() : null;
8✔
244
                } else {
245
                        // Fall back to meta query if the field doesn't map to a COT column.
246
                        $meta_orderby_keys = $this->meta_query ? $this->meta_query->get_orderby_keys() : [];
1✔
247

248
                        if ( in_array( $by, $meta_orderby_keys, true ) && null !== $this->meta_query ) {
1✔
NEW
249
                                $orderby = $this->meta_query->get_orderby_clause_for_key( $by );
×
NEW
250
                                $value   = $this->cursor_node->get_meta( $source, true ) ?? null;
×
251
                        } else {
252
                                return;
1✔
253
                        }
254
                }
255

256
                if ( ! empty( $value ) && is_a( $value, '\WC_DateTime' ) ) {
8✔
257
                        $value = ( new \DateTime( $value ) )->setTimezone( new \DateTimeZone( '+00:00' ) )->format( 'Y-m-d H:i:s' );
6✔
258
                        $this->builder->add_field( $orderby, $value, 'DATETIME', $order );
6✔
259
                        return;
6✔
260
                }
261

262
                /**
263
                 * Compare by the post field if the key matches a value
264
                 */
265
                if ( ! empty( $value ) ) {
2✔
266
                        $this->builder->add_field( $orderby, $value, null, $order );
2✔
267
                }
268
        }
269

270
        /**
271
         * Use post date based comparison
272
         *
273
         * @return void
274
         */
275
        private function compare_with_date() {
276
                $value = null;
1✔
277
                if ( null !== $this->cursor_node ) {
1✔
278
                        $date_created = $this->cursor_node->get_date_created();
1✔
279
                        $value        = ! empty( $date_created ) ? ( new \DateTime( $date_created ) )->setTimezone( new \DateTimeZone( '+00:00' ) )->format( 'Y-m-d H:i:s' ) : null;
1✔
280
                }
281

282
                $this->builder->add_field( "{$this->tables['orders']}.date_created_gmt", $value, 'DATETIME' );
1✔
283
        }
284
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc