Commit 45a69aefd6e for woocommerce
commit 45a69aefd6e9f2cedd9de4d683f1499a6875007e
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Fri Sep 18 11:51:35 2026 +0300
Fix HPOS migration leaving orders without a created date when post_date_gmt is zero (#68772)
* Fall back to local post dates in HPOS migration when GMT dates are zero
* Keep post dates when backfilling an order that has no created date
* Read the local post date when the GMT one is the zero date
* Keep post dates when creating the backup post for an order without a created date
* Include pending orders without an updated date in unpaid order cancellation
* Add the since tag to the post date fallback helper
* Fill HPOS order dates left empty by earlier migrations from their posts on update
* Repair cleaned-up orders too, drop their cached objects and queue their Analytics import
* Treat a null source date like an empty one in the migrator
* Write repaired HPOS dates only while still empty and flush repaired ids on a failed batch
* Skip queueing the Analytics import for repaired orders when Analytics is disabled
* Guard the import hook lookup for PHPStan
* Stop the HPOS date repair when its progress cursor cannot be saved
* Only move the HPOS date repair cursor forward so concurrent runs do not stop it
* Queue the Analytics import for repaired orders without the per-order duplicate search
* Judge unpaid orders without an updated date by their created date
* Say in the changelog that orders saved since the migration are not repaired
* Shorten the changelog entry to one user-facing line
* Keep storing the zero date in the HPOS migration when a post has no date to fall back to
* Restore the unpaid order query now that the migration stores no NULL dates
* Apply the HPOS date fallback in the post column path only, leaving the shared migrator validation unchanged
diff --git a/plugins/woocommerce/changelog/fix-hpos-migration-zero-gmt-dates b/plugins/woocommerce/changelog/fix-hpos-migration-zero-gmt-dates
new file mode 100644
index 00000000000..0163552b161
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-hpos-migration-zero-gmt-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix orders losing their created date when migrating to HPOS from posts with an empty GMT date.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index b113382eddc..8a4db694b76 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -360,6 +360,9 @@ class WC_Install {
'wc_update_11202_reset_refund_returning_customer_markers',
'wc_update_11203_normalize_stock_notification_emails',
),
+ '11.3.0' => array(
+ 'wc_update_1130_repair_hpos_order_dates_from_posts',
+ ),
);
/**
diff --git a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
index 03593403e73..9b602afb4e2 100644
--- a/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/abstract-wc-order-data-store-cpt.php
@@ -153,8 +153,8 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
$order,
array(
'parent_id' => $post_object->post_parent,
- 'date_created' => $this->string_to_timestamp( $post_object->post_date_gmt ),
- 'date_modified' => $this->string_to_timestamp( $post_object->post_modified_gmt ),
+ 'date_created' => $this->post_date_to_timestamp( $post_object->post_date_gmt, $post_object->post_date ),
+ 'date_modified' => $this->post_date_to_timestamp( $post_object->post_modified_gmt, $post_object->post_modified ),
'status' => $post_object->post_status,
)
);
@@ -426,6 +426,26 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
return wc_generate_order_key();
}
+ /**
+ * Convert a post's GMT datetime to a timestamp, using the local one when the GMT column holds the zero date.
+ *
+ * WordPress leaves the GMT columns at the zero date for some posts (drafts, imports) and treats the local
+ * column as the truth. The HPOS migrator applies the same rule, so both stores read the same date.
+ *
+ * @since 11.3.0
+ *
+ * @param string $gmt_date Datetime in GMT, possibly the zero date.
+ * @param string $local_date The same datetime in the site timezone.
+ * @return int|null
+ */
+ protected function post_date_to_timestamp( $gmt_date, $local_date ) {
+ $timestamp = $this->string_to_timestamp( $gmt_date );
+ if ( null === $timestamp && '' !== $local_date && '0000-00-00 00:00:00' !== $local_date ) {
+ $timestamp = $this->string_to_timestamp( get_gmt_from_date( $local_date ) );
+ }
+ return $timestamp;
+ }
+
/**
* Read order data. Can be overridden by child classes to load other props.
*
@@ -981,8 +1001,6 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
add_filter( 'wp_insert_post_data', array( $this, 'update_post_modified_data' ), 10, 2 );
$post_data = array(
'ID' => $order->get_id(),
- 'post_date' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
- 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
'post_status' => $this->get_post_status( $order ),
'post_parent' => $order->get_parent_id(),
'edit_date' => true,
@@ -991,7 +1009,13 @@ abstract class Abstract_WC_Order_Data_Store_CPT extends WC_Data_Store_WP impleme
'order_modified' => ! is_null( $order->get_date_modified() ) ? gmdate( 'Y-m-d H:i:s', $order->get_date_modified( 'edit' )->getOffsetTimestamp() ) : '',
'order_modified_gmt' => ! is_null( $order->get_date_modified() ) ? gmdate( 'Y-m-d H:i:s', $order->get_date_modified( 'edit' )->getTimestamp() ) : '',
);
- $updated = wp_update_post( $post_data );
+ // An order with no created date leaves the post's date columns to WordPress, which keeps them unless they are zero too.
+ $date_created = $order->get_date_created( 'edit' );
+ if ( ! is_null( $date_created ) ) {
+ $post_data['post_date'] = gmdate( 'Y-m-d H:i:s', $date_created->getOffsetTimestamp() );
+ $post_data['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', $date_created->getTimestamp() );
+ }
+ $updated = wp_update_post( $post_data );
remove_filter( 'wp_insert_post_data', array( $this, 'update_post_modified_data' ) );
return $updated;
}
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index e1c84d16af1..43b066bd32a 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -4010,3 +4010,164 @@ function wc_update_11203_normalize_stock_notification_emails() {
return false;
}
+
+/**
+ * Give HPOS orders migrated without a created or updated date the dates their posts still hold.
+ *
+ * Earlier migrations copied a zero post_date_gmt verbatim, so the HPOS row ended up with no created date and the next
+ * save stamped it with the current time. Only rows whose date is NULL or the zero date are touched, and only when the
+ * order's post has a usable date. Placeholder posts count too: legacy cleanup keeps the date columns when it converts a
+ * post, and placeholders created for new HPOS orders carry the order's own date. Repaired orders are dropped from the
+ * order caches and queued for the Analytics import, which skipped them while they had no date. Batched, returns true
+ * while rows remain.
+ *
+ * @return bool True to run again.
+ */
+function wc_update_1130_repair_hpos_order_dates_from_posts() {
+ global $wpdb;
+
+ $orders_table = \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore::get_orders_table_name();
+ if ( $orders_table !== $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $orders_table ) ) ) {
+ return false;
+ }
+
+ $last_id_option = 'woocommerce_update_1130_last_repaired_order_id';
+ $batch_size = 500;
+ $zero = '0000-00-00 00:00:00';
+ $type_list = array();
+ $post_types = array_merge( wc_get_order_types( 'cot-migration' ), array( \Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE ) );
+ foreach ( $post_types as $post_type ) {
+ $escaped = esc_sql( $post_type );
+ if ( is_string( $escaped ) ) {
+ $type_list[] = "'" . $escaped . "'";
+ }
+ }
+ $type_list = implode( ',', $type_list );
+
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names and the escaped type list cannot be prepared.
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT o.id, o.date_created_gmt, o.date_updated_gmt, p.post_date, p.post_date_gmt, p.post_modified, p.post_modified_gmt
+ FROM {$orders_table} o
+ INNER JOIN {$wpdb->posts} p ON p.ID = o.id AND p.post_type IN ({$type_list})
+ WHERE o.id > %d
+ AND ( o.date_created_gmt IS NULL OR o.date_created_gmt = %s OR o.date_updated_gmt IS NULL OR o.date_updated_gmt = %s )
+ ORDER BY o.id ASC
+ LIMIT %d",
+ (int) get_option( $last_id_option, 0 ),
+ $zero,
+ $zero,
+ $batch_size
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ if ( '' !== $wpdb->last_error ) {
+ wc_get_logger()->error( sprintf( 'Stopped repairing HPOS order dates: %s', $wpdb->last_error ), array( 'source' => 'wc-updater' ) );
+ delete_option( $last_id_option );
+ return false;
+ }
+
+ // The rule WordPress applies to its own posts: the GMT column, or the local one when the GMT one is the zero date.
+ $gmt_from_post = function ( $gmt_date, $local_date ) use ( $zero ) {
+ if ( $gmt_date && $zero !== $gmt_date ) {
+ return $gmt_date;
+ }
+ if ( ! $local_date || $zero === $local_date ) {
+ return null;
+ }
+ $datetime = date_create( $local_date, wp_timezone() );
+ return $datetime ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ) : null;
+ };
+
+ // Repaired orders leave the caches (a cached object still has no date and would stamp the current time on its next save)
+ // and get queued for the Analytics import, which skipped them while they had no date.
+ $forget_and_import = function ( array $order_ids ) {
+ if ( ! $order_ids ) {
+ return;
+ }
+ wc_get_container()->get( \Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore::class )->clear_cached_data( $order_ids );
+ $order_cache = wc_get_container()->get( \Automattic\WooCommerce\Caches\OrderCache::class );
+ // With Analytics disabled nothing handles the import action, and queueing it would only leave failed actions behind.
+ $import_hook = \Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler::get_action( 'import' );
+ $import_handled = is_string( $import_hook ) && has_action( $import_hook );
+ /**
+ * Filters whether Analytics runs its imports inline instead of queueing them.
+ *
+ * @since 4.0.0
+ * @param bool $disable Whether Action Scheduler is bypassed.
+ */
+ $import_inline = ! get_option( 'schema-ActionScheduler_StoreSchema' ) || apply_filters( 'woocommerce_analytics_disable_action_scheduling', false );
+ foreach ( $order_ids as $order_id ) {
+ $order_cache->remove( $order_id );
+ if ( ! $import_handled ) {
+ continue;
+ }
+ if ( $import_inline ) {
+ \Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler::import( $order_id );
+ continue;
+ }
+ // Queued directly: OrdersScheduler::schedule_action() first searches every pending action for a duplicate, which gets
+ // slower with each order queued here. A duplicate import only rewrites the same stats row.
+ WC()->queue()->schedule_single( time() + 5, $import_hook, array( $order_id ), (string) \Automattic\WooCommerce\Internal\Admin\Schedulers\OrdersScheduler::$group );
+ }
+ };
+
+ $repaired_ids = array();
+ foreach ( $rows as $row ) {
+ $columns = array();
+ if ( ! $row->date_created_gmt || $zero === $row->date_created_gmt ) {
+ $columns['date_created_gmt'] = $gmt_from_post( $row->post_date_gmt, $row->post_date );
+ }
+ if ( ! $row->date_updated_gmt || $zero === $row->date_updated_gmt ) {
+ $columns['date_updated_gmt'] = $gmt_from_post( $row->post_modified_gmt, $row->post_modified );
+ }
+ $columns = array_filter( $columns );
+ if ( empty( $columns ) ) {
+ continue;
+ }
+ // Each column is written only while it is still empty, so a save that lands between the read and the write wins.
+ $assignments = array();
+ $values = array();
+ foreach ( $columns as $column => $value ) {
+ $assignments[] = "{$column} = IF( {$column} IS NULL OR {$column} = %s, %s, {$column} )";
+ $values[] = $zero;
+ $values[] = $value;
+ }
+ $values[] = (int) $row->id;
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- Table and column names are code-defined, values go through prepare().
+ $updated = $wpdb->query( $wpdb->prepare( "UPDATE {$orders_table} SET " . implode( ', ', $assignments ) . ' WHERE id = %d', $values ) );
+ if ( false === $updated ) {
+ wc_get_logger()->error( sprintf( 'Stopped repairing HPOS order dates at order #%d: %s', (int) $row->id, $wpdb->last_error ), array( 'source' => 'wc-updater' ) );
+ $forget_and_import( $repaired_ids );
+ delete_option( $last_id_option );
+ return false;
+ }
+ // Zero rows means a save or another run filled the dates first, and that writer already took care of the rest.
+ if ( $updated > 0 ) {
+ $repaired_ids[] = (int) $row->id;
+ }
+ }
+
+ $forget_and_import( $repaired_ids );
+
+ if ( count( $rows ) === $batch_size ) {
+ // The cursor only ever moves forward: a concurrent run (the queue plus `wp wc update`) may already have saved this id or a
+ // later one, and update_option() reports that as false just like a failed write. Without a saved cursor the next run would
+ // pick the same rows again, and rows whose post has no date never leave the selection.
+ $cursor = (int) end( $rows )->id;
+ if ( (int) get_option( $last_id_option, 0 ) < $cursor && ! update_option( $last_id_option, $cursor, false ) ) {
+ wp_cache_delete( $last_id_option, 'options' );
+ if ( (int) get_option( $last_id_option, 0 ) < $cursor ) {
+ wc_get_logger()->error( 'Stopped repairing HPOS order dates: the progress cursor could not be saved.', array( 'source' => 'wc-updater' ) );
+ delete_option( $last_id_option );
+ return false;
+ }
+ }
+ return true;
+ }
+
+ delete_option( $last_id_option );
+
+ return false;
+}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 896874daa16..0c2f7717aab 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -16570,13 +16570,13 @@ parameters:
-
message: '#^Cannot call method getOffsetTimestamp\(\) on WC_DateTime\|null\.$#'
identifier: method.nonObject
- count: 5
+ count: 4
path: includes/data-stores/abstract-wc-order-data-store-cpt.php
-
message: '#^Cannot call method getTimestamp\(\) on WC_DateTime\|null\.$#'
identifier: method.nonObject
- count: 5
+ count: 4
path: includes/data-stores/abstract-wc-order-data-store-cpt.php
-
@@ -60100,18 +60100,6 @@ parameters:
count: 1
path: src/Internal/DataStores/Orders/OrdersTableDataStore.php
- -
- message: '#^Cannot call method getOffsetTimestamp\(\) on WC_DateTime\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/DataStores/Orders/OrdersTableDataStore.php
-
- -
- message: '#^Cannot call method getTimestamp\(\) on WC_DateTime\|null\.$#'
- identifier: method.nonObject
- count: 1
- path: src/Internal/DataStores/Orders/OrdersTableDataStore.php
-
-
message: '#^Cannot call method get_download_permissions_granted\(\) on WC_Order\|WC_Order_Refund\|false\.$#'
identifier: method.nonObject
diff --git a/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php b/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php
index f524ce438f5..8af67d980f2 100644
--- a/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php
+++ b/plugins/woocommerce/src/Database/Migrations/CustomOrderTable/PostToOrderTableMigrator.php
@@ -69,13 +69,18 @@ class PostToOrderTableMigrator extends MetaToCustomTableMigrator {
'type' => 'string',
'destination' => 'status',
),
+ // WordPress leaves the GMT columns at the zero date for some posts (drafts, imports) and treats the local
+ // columns as the truth in that case. Without the fallback such orders arrive here with no date at all, and the
+ // next save stamps them with the current time.
'post_date_gmt' => array(
- 'type' => 'date',
- 'destination' => 'date_created_gmt',
+ 'type' => 'date',
+ 'destination' => 'date_created_gmt',
+ 'fallback_column' => 'post_date',
),
'post_modified_gmt' => array(
- 'type' => 'date',
- 'destination' => 'date_updated_gmt',
+ 'type' => 'date',
+ 'destination' => 'date_updated_gmt',
+ 'fallback_column' => 'post_modified',
),
'post_parent' => array(
'type' => 'int',
diff --git a/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php b/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php
index 597703121cc..2f0f82678f0 100644
--- a/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php
+++ b/plugins/woocommerce/src/Database/Migrations/MetaToCustomTableMigrator.php
@@ -13,6 +13,11 @@ namespace Automattic\WooCommerce\Database\Migrations;
*/
abstract class MetaToCustomTableMigrator extends TableMigrator {
+ /**
+ * The value MySQL stores for a DATETIME that was never set.
+ */
+ private const ZERO_DATE = '0000-00-00 00:00:00';
+
/**
* Config for tables being migrated and migrated from. See __construct() for detailed config.
*
@@ -79,6 +84,7 @@ abstract class MetaToCustomTableMigrator extends TableMigrator {
* '$source_column_name_1' => array( // $source_column_name_1 is column name in source table, or a select statement.
* 'type' => 'type of value, could be string/int/date/float.',
* 'destination' => 'name of the column in column name where this data should be inserted in.',
+ * 'fallback_column' => 'optional, for the date type only: source column holding the same datetime in the site timezone, used when the source column is empty or the zero date.',
* ),
* '$source_column_name_2' => array(
* ......
@@ -444,8 +450,11 @@ WHERE source.`$source_primary_key_column` IN ( $entity_id_placeholder ) $additio
} else {
$entity_keys[] = "$source_entity_table.$column_name";
}
+ if ( isset( $column_schema['fallback_column'] ) ) {
+ $entity_keys[] = "$source_entity_table.{$column_schema['fallback_column']}";
+ }
}
- $entity_column_string = implode( ', ', $entity_keys );
+ $entity_column_string = implode( ', ', array_unique( $entity_keys ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $source_meta_rel_id_column, $source_destination_rel_id_column etc is escaped for backticks. $where clause and $order_by should already be escaped.
$query = $wpdb->prepare(
"
@@ -536,7 +545,11 @@ WHERE
foreach ( $this->core_column_mapping as $column_name => $schema ) {
$custom_table_column_name = $schema['destination'] ?? $column_name;
$value = $entity->$column_name;
- $value = $this->validate_data( $value, $schema['type'] );
+ if ( isset( $schema['fallback_column'] ) && ( null === $value || '' === $value || self::ZERO_DATE === $value ) ) {
+ // With nothing to fall back to, the original value is stored as it always was.
+ $value = $this->local_date_to_gmt( $entity->{$schema['fallback_column']} ?? null ) ?? $value;
+ }
+ $value = $this->validate_data( $value, $schema['type'] );
if ( is_wp_error( $value ) ) {
$error_records[ $entity->primary_key_id ][ $custom_table_column_name ] = $value->get_error_code();
} else {
@@ -655,6 +668,9 @@ WHERE
$source_select_column = isset( $schema['select_clause'] ) ? $schema['select_clause'] : "$source_table.$column_name";
$source_select_clauses[] = "$source_select_column as {$source_table}_{$column_name}";
$destination_select_clauses[] = "$destination_table.{$schema['destination']} as {$destination_table}_{$schema['destination']}";
+ if ( isset( $schema['fallback_column'] ) ) {
+ $source_select_clauses[] = "$source_table.{$schema['fallback_column']} as {$source_table}_{$schema['fallback_column']}";
+ }
}
foreach ( $this->meta_column_mapping as $meta_key => $schema ) {
@@ -866,13 +882,44 @@ WHERE $where_clause
} else {
$row[ $alias ] = ( new \DateTime( "@{$row[ $alias ]}" ) )->format( 'Y-m-d H:i:s' );
}
- if ( '0000-00-00 00:00:00' === $row[ $destination_alias ] ) {
+ if ( self::ZERO_DATE === $row[ $destination_alias ] ) {
+ $row[ $destination_alias ] = null;
+ }
+ }
+ if ( 'date' === $schema['type'] ) {
+ if ( '' === $row[ $alias ] || self::ZERO_DATE === $row[ $alias ] ) {
+ $row[ $alias ] = null;
+ }
+ if ( null === $row[ $alias ] && isset( $schema['fallback_column'] ) ) {
+ $fallback_alias = "{$this->schema_config['source']['entity']['table_name']}_{$schema['fallback_column']}";
+ $row[ $alias ] = $this->local_date_to_gmt( $row[ $fallback_alias ] ?? null );
+ }
+ if ( self::ZERO_DATE === $row[ $destination_alias ] ) {
$row[ $destination_alias ] = null;
}
}
return $row;
}
+ /**
+ * Convert a datetime string expressed in the site's timezone to its GMT equivalent.
+ *
+ * Unlike get_gmt_from_date(), an empty, zero or unparsable value yields null rather than the Unix epoch.
+ *
+ * @param string|null $value Datetime string in the site's timezone.
+ * @return string|null GMT datetime string, or null when there is no usable value.
+ */
+ private function local_date_to_gmt( ?string $value ): ?string {
+ if ( null === $value || '' === $value || self::ZERO_DATE === $value ) {
+ return null;
+ }
+ $datetime = date_create( $value, wp_timezone() );
+ if ( false === $datetime ) {
+ return null;
+ }
+ return $datetime->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
+ }
+
/**
* Helper method to get default value of a type.
*
diff --git a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
index ace31dd00ef..5d481d63b96 100644
--- a/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
+++ b/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php
@@ -2347,13 +2347,18 @@ FROM $order_meta_table
$data_sync = wc_get_container()->get( DataSynchronizer::class );
$data = array(
- 'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
- 'post_status' => 'draft',
- 'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
- 'post_date' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
- 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
+ 'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
+ 'post_status' => 'draft',
+ 'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
);
+ // An order with no created date leaves the post dates to WordPress rather than failing the whole sync batch.
+ $date_created = $order->get_date_created( 'edit' );
+ if ( ! is_null( $date_created ) ) {
+ $data['post_date'] = gmdate( 'Y-m-d H:i:s', $date_created->getOffsetTimestamp() );
+ $data['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', $date_created->getTimestamp() );
+ }
+
if ( 'backfill' === $context ) {
if ( ! $order->get_id() ) {
return 0;
diff --git a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
index 3ac20437fea..f4fbe8c3b5e 100644
--- a/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
+++ b/plugins/woocommerce/tests/php/includes/data-stores/class-wc-order-data-store-cpt-test.php
@@ -57,6 +57,52 @@ class WC_Order_Data_Store_CPT_Test extends WC_Unit_Test_Case {
parent::tearDown();
}
+ /**
+ * @testdox Should read the created and modified dates from the local post columns when the GMT ones hold the zero date.
+ */
+ public function test_read_falls_back_to_local_dates_when_gmt_dates_are_zero(): void {
+ global $wpdb;
+
+ update_option( 'timezone_string', 'Europe/Amsterdam' );
+ $order = OrderHelper::create_order();
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_date_gmt' => '0000-00-00 00:00:00',
+ 'post_modified_gmt' => '0000-00-00 00:00:00',
+ ),
+ array( 'ID' => $order->get_id() )
+ );
+ clean_post_cache( $order->get_id() );
+ $post = get_post( $order->get_id() );
+
+ $read = new WC_Order();
+ $read->set_id( $order->get_id() );
+ ( new WC_Order_Data_Store_CPT() )->read( $read );
+
+ $this->assertSame( get_gmt_from_date( $post->post_date ), $read->get_date_created()->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ), 'The created date should come from post_date.' );
+ $this->assertSame( get_gmt_from_date( $post->post_modified ), $read->get_date_modified()->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ), 'The modified date should come from post_modified.' );
+ }
+
+ /**
+ * @testdox Should leave the post dates untouched when backfilling an order that has no created date.
+ */
+ public function test_update_order_from_object_keeps_post_dates_for_order_without_created_date(): void {
+ $order = OrderHelper::create_order();
+ $before = get_post( $order->get_id() );
+ $dateless = new WC_Order();
+ $dateless->set_id( $order->get_id() );
+ $dateless->set_status( OrderStatus::COMPLETED );
+ $dateless->set_date_created( null );
+
+ $this->assertNotFalse( ( new WC_Order_Data_Store_CPT() )->update_order_from_object( $dateless ) );
+
+ $after = get_post( $order->get_id() );
+ $this->assertSame( $before->post_date, $after->post_date, 'post_date should be kept.' );
+ $this->assertSame( $before->post_date_gmt, $after->post_date_gmt, 'post_date_gmt should be kept.' );
+ $this->assertSame( 'wc-completed', $after->post_status, 'Other fields should still be written.' );
+ }
+
/**
* Test that refund cache are invalidated correctly when refund is deleted.
*/
diff --git a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
index b9e25ef4e5c..d83e01e5576 100644
--- a/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-update-functions-test.php
@@ -5,6 +5,12 @@
* @package WooCommerce\Tests\Functions.
*/
+use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
+use Automattic\WooCommerce\Caches\OrderCache;
+use Automattic\WooCommerce\Utilities\OrderUtil;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
+use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\API\Reports\Cache as ReportsCache;
use Automattic\WooCommerce\Admin\Notes\Note;
@@ -21,10 +27,30 @@ use Automattic\WooCommerce\Internal\VariationGallery\Package as VariationGallery
*/
class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
+ /**
+ * Whether HPOS was authoritative before the test.
+ *
+ * @var bool
+ */
+ private $previous_hpos_state;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ // Tests that migrate orders leave the two storages out of sync, which would otherwise block restoring the storage setting.
+ add_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
+ $this->previous_hpos_state = OrderUtil::custom_orders_table_usage_is_enabled();
+ OrderHelper::create_order_custom_table_if_not_exist();
+ }
+
/**
* Tear down test fixtures.
*/
public function tearDown(): void {
+ OrderHelper::toggle_cot_feature_and_usage( $this->previous_hpos_state );
+ remove_filter( 'wc_allow_changing_orders_storage_while_sync_is_pending', '__return_true' );
Constants::clear_single_constant( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' );
delete_option( 'woocommerce_feature_customer_stock_notifications_enabled' );
parent::tearDown();
@@ -726,6 +752,169 @@ class WC_Update_Functions_Test extends \WC_Unit_Test_Case {
return $variation_id;
}
+ /**
+ * @testdox wc_update_1130_repair_hpos_order_dates_from_posts should fill HPOS dates that an earlier migration left empty from the order's post.
+ */
+ public function test_wc_update_1130_repairs_hpos_dates_from_posts(): void {
+ global $wpdb;
+
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ update_option( 'timezone_string', 'Europe/Amsterdam' );
+ $order_id = OrderHelper::create_complex_wp_post_order();
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_date_gmt' => '0000-00-00 00:00:00',
+ 'post_modified_gmt' => '0000-00-00 00:00:00',
+ ),
+ array( 'ID' => $order_id )
+ );
+ clean_post_cache( $order_id );
+ wc_get_container()->get( PostsToOrdersMigrationController::class )->migrate_orders( array( $order_id ) );
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+ // The shape earlier migrations left behind.
+ $wpdb->update(
+ $orders_table,
+ array(
+ 'date_created_gmt' => '0000-00-00 00:00:00',
+ 'date_updated_gmt' => null,
+ ),
+ array( 'id' => $order_id )
+ );
+ $post = get_post( $order_id );
+
+ // A second order whose legacy data was cleaned up: the post is a placeholder but keeps its date columns.
+ $cleaned_id = OrderHelper::create_complex_wp_post_order();
+ $wpdb->update( $wpdb->posts, array( 'post_date_gmt' => '0000-00-00 00:00:00' ), array( 'ID' => $cleaned_id ) );
+ clean_post_cache( $cleaned_id );
+ wc_get_container()->get( PostsToOrdersMigrationController::class )->migrate_orders( array( $cleaned_id ) );
+ $wpdb->update( $orders_table, array( 'date_created_gmt' => '0000-00-00 00:00:00' ), array( 'id' => $cleaned_id ) );
+ $wpdb->update( $wpdb->posts, array( 'post_type' => DataSynchronizer::PLACEHOLDER_ORDER_POST_TYPE ), array( 'ID' => $cleaned_id ) );
+ clean_post_cache( $cleaned_id );
+ $cleaned_post = get_post( $cleaned_id );
+
+ // A cached order object without a date must not survive the repair.
+ $order_cache = wc_get_container()->get( OrderCache::class );
+ $order_cache->set( wc_get_order( $order_id ), $order_id );
+ // Creating the orders queued their own imports: clear them so the assertion below is about the routine.
+ as_unschedule_all_actions( 'wc-admin_import_orders' );
+
+ $this->assertFalse( wc_update_1130_repair_hpos_order_dates_from_posts(), 'A single small batch should not request another run' );
+
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT date_created_gmt, date_updated_gmt FROM {$orders_table} WHERE id = %d", $order_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $this->assertSame( get_gmt_from_date( $post->post_date ), $row->date_created_gmt, 'Created date should come from post_date converted with the site timezone' );
+ $this->assertSame( get_gmt_from_date( $post->post_modified ), $row->date_updated_gmt, 'Updated date should come from post_modified' );
+ $this->assertNotSame( $post->post_date, $row->date_created_gmt, 'The local date must have been converted' );
+ $this->assertSame( get_gmt_from_date( $cleaned_post->post_date ), $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$orders_table} WHERE id = %d", $cleaned_id ) ), 'A cleaned-up order is repaired from its placeholder post' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $this->assertFalse( $order_cache->is_cached( $order_id ), 'The cached order object must be dropped' );
+ $this->assertNotFalse( as_next_scheduled_action( 'wc-admin_import_orders', array( $order_id ) ), 'The Analytics import must be queued for the repaired order' );
+ }
+
+ /**
+ * @testdox wc_update_1130_repair_hpos_order_dates_from_posts should import repaired orders inline when Analytics scheduling is disabled.
+ */
+ public function test_wc_update_1130_imports_inline_when_analytics_scheduling_is_disabled(): void {
+ global $wpdb;
+
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ $order_id = OrderHelper::create_complex_wp_post_order();
+ wc_get_container()->get( PostsToOrdersMigrationController::class )->migrate_orders( array( $order_id ) );
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+ $wpdb->update( $orders_table, array( 'date_created_gmt' => '0000-00-00 00:00:00' ), array( 'id' => $order_id ) );
+ OrderHelper::toggle_cot_feature_and_usage( true );
+ // Creating the order queued its own import. Start from a clean slate so only the routine's behaviour is measured.
+ as_unschedule_all_actions( 'wc-admin_import_orders' );
+ $wpdb->delete( $wpdb->prefix . 'wc_order_stats', array( 'order_id' => $order_id ) );
+ add_filter( 'woocommerce_analytics_disable_action_scheduling', '__return_true' );
+
+ wc_update_1130_repair_hpos_order_dates_from_posts();
+
+ $this->assertFalse( as_next_scheduled_action( 'wc-admin_import_orders', array( $order_id ) ), 'Nothing should be queued when scheduling is disabled' );
+ $this->assertNotNull( $wpdb->get_var( $wpdb->prepare( "SELECT order_id FROM {$wpdb->prefix}wc_order_stats WHERE order_id = %d", $order_id ) ), 'The order should have been imported inline' );
+ }
+
+ /**
+ * Insert a full batch of HPOS rows with no created date whose posts have no date either, so the repair has to advance its cursor.
+ *
+ * @return int The id of the last row in the batch.
+ */
+ private function insert_full_batch_of_unrepairable_orders(): int {
+ global $wpdb;
+
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+ $first_id = (int) $wpdb->get_var( "SELECT GREATEST( COALESCE( ( SELECT MAX( ID ) FROM {$wpdb->posts} ), 0 ), COALESCE( ( SELECT MAX( id ) FROM {$orders_table} ), 0 ) )" ) + 1; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $posts = array();
+ $orders = array();
+ for ( $id = $first_id; $id < $first_id + 500; $id++ ) {
+ $posts[] = "( {$id}, 'shop_order', 'wc-completed', '0000-00-00 00:00:00', '0000-00-00 00:00:00', '0000-00-00 00:00:00', '0000-00-00 00:00:00', '', '', '', '', '', '' )";
+ $orders[] = "( {$id}, 'shop_order', 'wc-completed', NULL, '2026-01-01 00:00:00' )";
+ }
+ // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Generated integers and literals only.
+ $wpdb->query( "INSERT INTO {$wpdb->posts} ( ID, post_type, post_status, post_date, post_date_gmt, post_modified, post_modified_gmt, post_content, post_title, post_excerpt, to_ping, pinged, post_content_filtered ) VALUES " . implode( ',', $posts ) );
+ $wpdb->query( "INSERT INTO {$orders_table} ( id, type, status, date_created_gmt, date_updated_gmt ) VALUES " . implode( ',', $orders ) );
+ // phpcs:enable
+
+ return $first_id + 499;
+ }
+
+ /**
+ * @testdox wc_update_1130_repair_hpos_order_dates_from_posts should keep going when another run has already saved the same or a later cursor.
+ *
+ * @testWith [0]
+ * [1000]
+ *
+ * @param int $ahead How far past this batch the other run has already moved the cursor.
+ */
+ public function test_wc_update_1130_accepts_a_cursor_saved_by_a_concurrent_run( int $ahead ): void {
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ $last_id = $this->insert_full_batch_of_unrepairable_orders();
+ $option = 'woocommerce_update_1130_last_repaired_order_id';
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+ // The other run saves its cursor right after this one has read its batch.
+ $concurrent_save = function ( $query ) use ( $option, $last_id, $ahead, $orders_table ) {
+ if ( false !== strpos( $query, "FROM {$orders_table} o" ) ) {
+ update_option( $option, $last_id + $ahead, false );
+ }
+ return $query;
+ };
+ add_filter( 'query', $concurrent_save );
+
+ $this->assertTrue( wc_update_1130_repair_hpos_order_dates_from_posts(), 'A cursor saved by another run is progress, not a failure' );
+ $this->assertSame( $last_id + $ahead, (int) get_option( $option ), 'The cursor must never move backwards' );
+ }
+
+ /**
+ * @testdox wc_update_1130_repair_hpos_order_dates_from_posts should leave rows alone when they have dates or their post has none.
+ */
+ public function test_wc_update_1130_leaves_dated_rows_and_dateless_posts_alone(): void {
+ global $wpdb;
+
+ include_once WC_ABSPATH . 'includes/wc-update-functions.php';
+
+ $dated_id = OrderHelper::create_complex_wp_post_order();
+ $dateless_id = OrderHelper::create_complex_wp_post_order();
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_date' => '0000-00-00 00:00:00',
+ 'post_date_gmt' => '0000-00-00 00:00:00',
+ ),
+ array( 'ID' => $dateless_id )
+ );
+ clean_post_cache( $dateless_id );
+ wc_get_container()->get( PostsToOrdersMigrationController::class )->migrate_orders( array( $dated_id, $dateless_id ) );
+ $orders_table = OrdersTableDataStore::get_orders_table_name();
+ $dated_before = $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$orders_table} WHERE id = %d", $dated_id ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $this->assertFalse( wc_update_1130_repair_hpos_order_dates_from_posts() );
+
+ $this->assertSame( $dated_before, $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$orders_table} WHERE id = %d", $dated_id ) ), 'A dated row must not change' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $this->assertSame( '0000-00-00 00:00:00', $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$orders_table} WHERE id = %d", $dateless_id ) ), 'A post with no date gives nothing to repair from' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
/**
* @testdox Migration rewrites stored stock notification emails in canonical form and leaves canonical rows alone.
*/
diff --git a/plugins/woocommerce/tests/php/src/Database/Migrations/CustomOrderTable/PostsToOrdersMigrationControllerTest.php b/plugins/woocommerce/tests/php/src/Database/Migrations/CustomOrderTable/PostsToOrdersMigrationControllerTest.php
index bbfc038375d..35c54b87dcf 100644
--- a/plugins/woocommerce/tests/php/src/Database/Migrations/CustomOrderTable/PostsToOrdersMigrationControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Database/Migrations/CustomOrderTable/PostsToOrdersMigrationControllerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
+use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostToOrderTableMigrator;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
@@ -124,6 +125,72 @@ class PostsToOrdersMigrationControllerTest extends \WC_Unit_Test_Case {
$this->assert_metadata_is_migrated( $order );
}
+ /**
+ * @testdox Should derive the GMT created and modified dates from the local ones when the post holds the zero date in the GMT columns.
+ */
+ public function test_migration_for_order_with_zero_gmt_dates(): void {
+ global $wpdb;
+
+ update_option( 'timezone_string', 'Europe/Amsterdam' );
+ $order = wc_get_order( OrderHelper::create_complex_wp_post_order() );
+ $this->clear_all_orders();
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_date_gmt' => '0000-00-00 00:00:00',
+ 'post_modified_gmt' => '0000-00-00 00:00:00',
+ ),
+ array( 'ID' => $order->get_id() )
+ );
+ clean_post_cache( $order->get_id() );
+ $post = get_post( $order->get_id() );
+
+ $this->sut->migrate_order( $order->get_id() );
+
+ $db_order = $this->get_order_from_cot( $order );
+ $this->assertEquals( get_gmt_from_date( $post->post_date ), $db_order->date_created_gmt, 'The created date should be derived from post_date.' );
+ $this->assertEquals( get_gmt_from_date( $post->post_modified ), $db_order->date_updated_gmt, 'The modified date should be derived from post_modified.' );
+ $this->assertNotEquals( $post->post_date, $db_order->date_created_gmt, 'The local date should have been converted to GMT.' );
+
+ $migrator = new PostToOrderTableMigrator();
+ $this->assertEmpty( $migrator->verify_migrated_data( array( $order->get_id() ) ), 'Verification should accept the derived dates.' );
+
+ // A row migrated before the fallback existed still holds the zero date: verification must report it.
+ $wpdb->update( $this->data_store::get_orders_table_name(), array( 'date_created_gmt' => '0000-00-00 00:00:00' ), array( 'id' => $order->get_id() ) );
+ $failures = $migrator->verify_migrated_data( array( $order->get_id() ) );
+ $this->assertSame( 'post_date_gmt', $failures[ $order->get_id() ][0]['column'] ?? null, 'Verification should flag a created date that was not derived.' );
+ }
+
+ /**
+ * @testdox Should keep the zero date when both the GMT and the local columns hold the zero date.
+ */
+ public function test_migration_for_order_with_zero_gmt_and_local_dates(): void {
+ global $wpdb;
+
+ $order = wc_get_order( OrderHelper::create_complex_wp_post_order() );
+ $this->clear_all_orders();
+ $wpdb->update(
+ $wpdb->posts,
+ array(
+ 'post_date' => '0000-00-00 00:00:00',
+ 'post_date_gmt' => '0000-00-00 00:00:00',
+ 'post_modified' => '0000-00-00 00:00:00',
+ 'post_modified_gmt' => '0000-00-00 00:00:00',
+ ),
+ array( 'ID' => $order->get_id() )
+ );
+ clean_post_cache( $order->get_id() );
+
+ $this->sut->migrate_order( $order->get_id() );
+
+ $db_order = $this->get_order_from_cot( $order );
+ $this->assertSame( '0000-00-00 00:00:00', $db_order->date_created_gmt, 'With no date to derive the created date from, the zero date is stored as before.' );
+ $this->assertSame( '0000-00-00 00:00:00', $db_order->date_updated_gmt, 'With no date to derive the modified date from, the zero date is stored as before.' );
+
+ $migrator = new PostToOrderTableMigrator();
+ $this->assertEmpty( $migrator->verify_migrated_data( array( $order->get_id() ) ), 'Verification should accept the empty dates.' );
+ }
+
/**
* Test that already migrated order isn't migrated twice.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
index ad4decc7ad4..877c187b13f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/DataStores/Orders/OrdersTableDataStoreTests.php
@@ -1478,6 +1478,30 @@ class OrdersTableDataStoreTests extends \HposTestCase {
}
}
+ /**
+ * @testDox Backfilling an order that has no created date and no post row should create the post instead of failing.
+ */
+ public function test_backfill_post_record_for_order_without_created_date_or_post() {
+ global $wpdb;
+ $this->toggle_cot_feature_and_usage( true );
+ $this->disable_cot_sync();
+ $order = $this->create_complex_cot_order();
+ $wpdb->delete( $wpdb->posts, array( 'ID' => $order->get_id() ) );
+ $wpdb->update( $this->sut::get_orders_table_name(), array( 'date_created_gmt' => null ), array( 'id' => $order->get_id() ) );
+ clean_post_cache( $order->get_id() );
+ $this->sut->clear_cached_data( array( $order->get_id() ) );
+
+ $dateless = new WC_Order();
+ $dateless->set_id( $order->get_id() );
+ $this->switch_data_store( $dateless, $this->sut );
+ $this->sut->read( $dateless );
+ $this->assertNull( $dateless->get_date_created(), 'Precondition: the order has no created date.' );
+
+ $this->sut->backfill_post_record( $dateless );
+
+ $this->assertInstanceOf( \WP_Post::class, get_post( $order->get_id() ), 'The backup post should exist after the backfill.' );
+ }
+
/**
* @testDox Test `get_unpaid_orders()`.
*/
@@ -1558,6 +1582,29 @@ class OrdersTableDataStoreTests extends \HposTestCase {
remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
}
+ /**
+ * @testDox Sync on read should keep the created date when the post's GMT date column holds the zero date.
+ */
+ public function test_sync_on_read_keeps_created_date_when_post_gmt_date_is_zero() {
+ global $wpdb;
+ $this->toggle_cot_feature_and_usage( true );
+ $this->enable_cot_sync();
+ add_filter( 'woocommerce_hpos_enable_sync_on_read', '__return_true' );
+ $order = $this->create_complex_cot_order();
+ $created = $order->get_date_created()->getTimestamp();
+ $wpdb->update( $wpdb->posts, array( 'post_date_gmt' => '0000-00-00 00:00:00' ), array( 'ID' => $order->get_id() ) );
+ clean_post_cache( $order->get_id() );
+
+ $refreshed_order = new WC_Order();
+ $refreshed_order->set_id( $order->get_id() );
+ $this->switch_data_store( $refreshed_order, $this->sut );
+ $this->sut->read( $refreshed_order );
+
+ $this->assertSame( $created, $refreshed_order->get_date_created()->getTimestamp(), 'The posts reader must agree with HPOS, so nothing is copied back.' );
+ $this->assertSame( gmdate( 'Y-m-d H:i:s', $created ), $wpdb->get_var( $wpdb->prepare( "SELECT date_created_gmt FROM {$this->sut::get_orders_table_name()} WHERE id = %d", $order->get_id() ) ), 'The HPOS row must keep its date.' ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ remove_all_filters( 'woocommerce_hpos_enable_sync_on_read' );
+ }
+
/**
* @testDox When there are direct writes to posts data, order should synced upon reading.
*/