Commit 2c40fb65560 for woocommerce
commit 2c40fb65560bed47d40c3e5aca287c1a9881bcb2
Author: Patrick Zielinski <patrick.zielinski@a8c.com>
Date: Wed Apr 29 11:42:10 2026 -0400
[Email Editor] Auto-apply core template updates for unmodified email posts (#64450)
* [Email Editor] Fire divergence-sweep completion action
Append do_action( 'woocommerce_email_template_divergence_sweep_complete' )
at the end of WCEmailTemplateDivergenceDetector::run_sweep() so RSM-139's
auto-applier can hook the completion event without inspecting detector
internals.
* [Email Editor] Add WCEmailTemplateAutoApplier::apply_to_post atom
Introduces the per-post atom that rewrites a woo_email post to its current
core canonical content and stamps four sync meta keys (version, source_hash,
last_synced_at, status=in_sync) inside a single SQL transaction.
Two-mode contract via $opts['require_uncustomized']:
- Auto-applier path (default): hash-gated; rejects WP_Error when the post
has been edited since stamping, has no stored hash, or belongs to a
non-sync-enabled email.
- Reset endpoint path (false): unconditional rewrite; non-sync-enabled
emails get a content-only reset with all-nulls return shape.
* [Email Editor] Cover apply_to_post error paths in auto-applier tests
Adds coverage for the four WP_Error outcomes the per-post atom can return
when called from the auto-applier path: post_modified_since_stamp (race
safety bail-out), no_stored_hash, not_sync_enabled, post_not_found.
* [Email Editor] Cover apply_to_post require_uncustomized=false BC paths
Adds the two backward-compatibility regression tests for the reset endpoint
code path: unconditional rewrite of a modified post, and content-only reset
for non-sync-enabled emails returning all-nulls in the sync fields.
* [Email Editor] Cover apply_to_post transaction rollback on wp_update_post failure
Locks in the all-or-nothing guarantee: when wp_update_post returns WP_Error,
the transaction rolls back so neither post_content nor any of the four sync
meta keys are modified.
* [Email Editor] Cover is_auto_applying re-entrancy flag
Future save_post listeners (RSM-143 customised-post detector, RSM-145 Tracks
event firing) need to differentiate merchant edits from system-initiated
writes. This test locks in that the flag reads true during wp_update_post
and false again after apply_to_post returns.
* [Email Editor] Schedule auto-apply as an Action Scheduler async action
WCEmailTemplateAutoApplier::schedule() enqueues the batched runner via
as_enqueue_async_action so the upgrade request is not blocked. Includes a
guard against double-enqueueing for the case where the detector sweep
runs twice in one request (woocommerce_updated + BACKFILL_COMPLETE_ACTION).
* [Email Editor] Add WCEmailTemplateAutoApplier::run batched runner
The Action Scheduler callback finds every woo_email post whose status meta
is core_updated_uncustomized and applies the per-post atom inside a try/catch
loop. Per-post failures are logged via WC_Logger with context
'email_template_auto_applier' at the right severity:
- post_modified_since_stamp (race outcome) -> info
- no_stored_hash / not_sync_enabled -> warning
- WP_Error from wp_update_post / Throwable -> error
- email plugin deactivated mid-cycle -> silent
run() returns false (one-shot - no AS retry, matches WCEmailTemplateSyncBackfill).
* [Email Editor] Wire auto-applier into the email-editor integration
Registers two hooks in Integration::register_hooks():
- woocommerce_email_template_divergence_sweep_complete -> schedule()
- AUTO_APPLY_AS_HOOK -> run()
This closes the trigger chain: woocommerce_updated -> detector sweep ->
completion action -> schedule -> AS job -> run -> apply_to_post per post.
* [Email Editor] Delegate reset endpoint per-post write to auto-applier
EmailApiController::reset_response now delegates the canonical content
rewrite + sync meta stamp to WCEmailTemplateAutoApplier::apply_to_post
(passing require_uncustomized=false), so reset and auto-apply share one
canonical-write code path.
Wire-level BC contract preserved verbatim: same URL, same response shape,
same null semantics for non-sync-enabled emails, same error codes
(woocommerce_email_editor_not_initialized / woocommerce_email_not_found /
woocommerce_email_reset_failed).
Adds one BC regression test: transaction rollback leaves post + meta
unchanged when wp_update_post fails (a strict improvement, observably
identical to pre-refactor behaviour because the old code short-circuited
before reaching the meta writes). The all-nulls non-sync-enabled BC
contract is already locked in by the existing
test_reset_response_resets_content_without_meta_for_non_sync_enabled_email
test.
* Add changelog entry for RSM-139 auto-apply core template updates
* [Email Editor] Fix lint / PHPStan findings on RSM-139 work
Change WCEmailTemplateAutoApplier::run() return type from bool to void.
Action Scheduler async-action callbacks discard the return value, so the
bool return tripped PHPStan's return.void rule on the add_action wiring
in Integration.php without providing any signal.
* [Email Editor] Document transaction scope and AS-hook visibility on auto-applier
Add two docblock notes raised by final review:
- apply_to_post(): note that third-party callbacks on save_post,
post_updated, and wp_after_insert_post fire inside the transaction
and participate in the rollback.
- AUTO_APPLY_AS_HOOK / AUTO_APPLY_AS_GROUP: mark as internal AS plumbing
and point extension authors at the upgrade-time signal hook
(woocommerce_email_template_divergence_sweep_complete) instead.
No code change.
* [Email Editor] Drop transaction wrap from apply_to_post per #64355 review
The pre-RSM-139 reset endpoint shipped without a transaction (PR #64355
review on commit 80bbaea, accepted before merge into 2fa660b3b9): if
wp_update_post fails, no meta writes happen, so there's nothing to roll
back. The transaction added in this branch reintroduced the rejected
pattern. Drop it to match the shipped contract.
Cascade:
- Remove START TRANSACTION / COMMIT / ROLLBACK from apply_to_post.
- Drop the \Throwable catch handler; only the try/finally for the
re-entrancy flag remains. global $wpdb is no longer needed.
- Update apply_to_post docblock: drop the atomicity claim, point at the
PR review for context.
- Rename test_apply_to_post_rolls_back_on_wp_update_post_failure to
test_apply_to_post_returns_wp_error_when_wp_update_post_fails.
- Rename test_reset_response_rolls_back_when_wp_update_post_fails to
test_reset_response_returns_wp_error_when_wp_update_post_fails.
Trade-off accepted (consistent with #64355): if wp_update_post succeeds
but a meta write fails on entry 2-of-4, the post is half-stamped until
the next divergence sweep re-classifies and the auto-applier retries.
update_post_meta failures are environmental and rare; self-healing.
* [Email Editor] Fix branch-lint findings on RSM-139
Addresses phpcs errors surfaced by lint:changes:branch in CI:
- WCEmailTemplateAutoApplier.php: add the four missing
Squiz.Commenting.LongConditionClosingComment markers (//end if,
//end try x2, //end foreach).
- WCEmailTemplateAutoApplierTest.php: align two assignments in
whitelist_email_page_template, convert the do_action trigger comment
to a /** */ docblock per WooCommerce.Commenting.CommentHooks, and
add @param tags on the recording-logger anonymous class methods per
Squiz.Commenting.FunctionComment.MissingParamTag (inheritDoc alone
does not satisfy the rule).
- WCEmailTemplateDivergenceDetectorTest.php: align $fired = 0 with the
adjacent $listener assignment.
* [Email Editor] Tighten apply_to_post TOCTOU window per CodeRabbit
Move the post-content hash re-check from the early validation block to
immediately before wp_update_post, so the snapshot we hash is not stale
across compute_canonical_post_content's filter pipeline (a few ms but
non-zero). The stored-hash existence check stays early — it's a sanity
check on meta we own, not a race-sensitive read.
Net change: one fewer get_post-derived hash compare, one new in-try
get_post + sha1 immediately before the write. Same WP_Error code
(post_modified_since_stamp) and contract; existing tests still pass.
Does not eliminate the race entirely (a window remains between this
re-check and wp_update_post), but no longer can the gap span the
filesystem read + filter pipeline. Genuinely closing the race would
require DB row locking, which we deliberately dropped per #64355
review.
* [Email Editor] Address PR #64450 reviewer feedback
Three changes from triple0t's review:
- Rename AUTO_APPLY_AS_GROUP value from 'woocommerce-email-editor' to
'woocommerce-email-editor-integration'. The bare 'woocommerce-email-editor'
namespace is reserved for the standalone email editor package; integration
glue under Internal/EmailEditor lives under the *-integration suffix.
- Broaden the candidate query post_status filter from 'publish' to 'any'.
Generated woo_email posts default to publish, but third-party flows may
move them to draft / private / future and those should not be silently
skipped. WP's 'any' excludes trash / auto-draft / inherit.
- Reorder the two add_action calls in Integration::register_hooks() so the
AUTO_APPLY_AS_HOOK callback (the executor) is registered before the
woocommerce_email_template_divergence_sweep_complete listener (the
dispatcher).
Plus test coverage for the broadened post_status filter: a data-provider
test that asserts non-publish statuses (draft/private/future) are still
processed, and an explicit trash test that asserts trashed posts are
skipped. 'pending' is omitted from the data set because WP's wp_update_post
invokes map_meta_cap('publish_post') for any write touching a pending post,
which trips a _doing_it_wrong notice in the unit-test bootstrap; pending
isn't a realistic status for transactional templates anyway.
---------
Co-authored-by: PZ01 <patrick.zielinski@automattic.com>
diff --git a/plugins/woocommerce/changelog/rsm-139-auto-apply-core-template-updates b/plugins/woocommerce/changelog/rsm-139-auto-apply-core-template-updates
new file mode 100644
index 00000000000..0046eabb407
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-139-auto-apply-core-template-updates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Auto-apply the current core block template render to woo_email posts classified as core_updated_uncustomized after a plugin upgrade. Batched via Action Scheduler so the upgrade request is not blocked. Posts edited by the merchant are left untouched.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
index 0c16132de39..be4e396fdd5 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/EmailApiController.php
@@ -5,7 +5,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\EmailEditor;
use Automattic\WooCommerce\EmailEditor\Validator\Builder;
-use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
@@ -445,49 +445,24 @@ class EmailApiController {
);
}
- $canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
- $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_type );
-
- $update_result = wp_update_post(
- array(
- 'ID' => $post_id,
- 'post_content' => $canonical,
- ),
- true
+ $result = WCEmailTemplateAutoApplier::apply_to_post(
+ $email,
+ $post_id,
+ array( 'require_uncustomized' => false )
);
- if ( is_wp_error( $update_result ) ) {
+ if ( is_wp_error( $result ) ) {
return new WP_Error(
'woocommerce_email_reset_failed',
sprintf(
/* translators: %s: underlying error message */
__( 'Failed to reset email content: %s', 'woocommerce' ),
- $update_result->get_error_message()
+ $result->get_error_message()
),
array( 'status' => 500 )
);
}
- $source_hash = null;
- $synced_at = null;
- if ( null !== $sync_config ) {
- $source_hash = sha1( $canonical );
- $synced_at = gmdate( 'Y-m-d H:i:s' );
- update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, (string) $sync_config['version'] );
- update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
- update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
- update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC );
- }
-
- return new WP_REST_Response(
- array(
- 'content' => $canonical,
- 'version' => null !== $sync_config ? (string) $sync_config['version'] : null,
- 'source_hash' => $source_hash,
- 'synced_at' => $synced_at,
- 'status' => null !== $sync_config ? WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC : null,
- ),
- 200
- );
+ return new WP_REST_Response( $result, 200 );
}
}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index 309de62f81d..7410c5742f1 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\EmailEditor\Engine\Dependency_Check;
use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
use Automattic\WooCommerce\Internal\EmailEditor\EmailPatterns\PatternsController;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplatesController;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
@@ -149,6 +150,8 @@ class Integration {
add_action( 'rest_api_init', array( $this->email_api_controller, 'register_routes' ) );
add_action( 'woocommerce_updated', array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 20 );
add_action( WCEmailTemplateSyncBackfill::BACKFILL_COMPLETE_ACTION, array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 10 );
+ add_action( WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK, array( WCEmailTemplateAutoApplier::class, 'run' ), 10 );
+ add_action( 'woocommerce_email_template_divergence_sweep_complete', array( WCEmailTemplateAutoApplier::class, 'schedule' ), 10 );
}
/**
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
new file mode 100644
index 00000000000..b061e9abf47
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplier.php
@@ -0,0 +1,386 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface;
+use Automattic\WooCommerce\Internal\EmailEditor\Logger;
+
+/**
+ * Auto-applies the current core block template to `woo_email` posts that have
+ * been classified `core_updated_uncustomized` by {@see WCEmailTemplateDivergenceDetector}.
+ *
+ * Mirrors how legacy static-file emails always reflected the latest core
+ * template: when the merchant has not customised a generated post since it was
+ * last stamped, we silently rewrite its content to the new core render and
+ * flip its status meta back to `in_sync`.
+ *
+ * The per-post atom ({@see self::apply_to_post()}) is shared with the
+ * `POST /woocommerce-email-editor/v1/emails/{id}/reset` endpoint
+ * (see {@see \Automattic\WooCommerce\Internal\EmailEditor\EmailApiController::reset_response()})
+ * so reset and auto-apply share one canonical-write code path.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 10.8.0
+ */
+class WCEmailTemplateAutoApplier {
+ /**
+ * Action Scheduler hook name for the batched auto-apply runner.
+ *
+ * Internal AS plumbing — not intended for extension consumption. To react to
+ * the upgrade-time signal, hook {@see 'woocommerce_email_template_divergence_sweep_complete'}
+ * instead.
+ *
+ * @var string
+ */
+ public const AUTO_APPLY_AS_HOOK = 'woocommerce_email_template_auto_apply_uncustomized';
+
+ /**
+ * Action Scheduler group for the batched auto-apply runner.
+ *
+ * Internal AS plumbing — not intended for extension consumption. The
+ * `woocommerce-email-editor` namespace is reserved for the standalone email
+ * editor package; integration glue lives under
+ * `woocommerce-email-editor-integration` to keep the boundary explicit.
+ *
+ * @var string
+ */
+ public const AUTO_APPLY_AS_GROUP = 'woocommerce-email-editor-integration';
+
+ /**
+ * Re-entrancy flag set while the atom rewrites a post.
+ *
+ * Future `save_post` listeners (e.g. RSM-143's customised-post detector,
+ * RSM-145's Tracks event firing) should consult {@see self::is_auto_applying()}
+ * before interpreting a write as a merchant edit.
+ *
+ * @var bool
+ */
+ private static bool $is_auto_applying = false;
+
+ /**
+ * Logger instance. Lazily instantiated on first use; overridable for tests.
+ *
+ * @var Email_Editor_Logger_Interface|null
+ */
+ private static ?Email_Editor_Logger_Interface $logger = null;
+
+ /**
+ * Apply the current core template render to a single `woo_email` post and stamp sync meta.
+ *
+ * Two callers, two modes:
+ * - Auto-applier (default): `$opts['require_uncustomized'] === true`. Rejects with
+ * `WP_Error` when the post has no stored hash, has been edited since stamping,
+ * or belongs to a non-sync-enabled email.
+ * - Reset endpoint: `$opts['require_uncustomized'] === false`. Unconditional rewrite.
+ * Non-sync-enabled emails receive a content-only reset and the return shape carries
+ * `null` for the four sync fields (BC contract with the pre-RSM-139 reset endpoint).
+ *
+ * The four meta writes are skipped entirely if `wp_update_post` fails, so a
+ * `WP_Error` return leaves the post and existing meta untouched. Matches the
+ * pre-RSM-139 reset endpoint shape (see PR #64355 review on `2fa660b3b9`).
+ *
+ * @param \WC_Email $email The transactional email instance.
+ * @param int $post_id The post ID.
+ * @param array $opts Options. Recognised keys:
+ * - `require_uncustomized` (bool, default true): see above.
+ * @return array|\WP_Error On success, an array with keys `content`, `version`,
+ * `source_hash`, `synced_at`, `status`. On failure, a `WP_Error`.
+ *
+ * @since 10.8.0
+ */
+ public static function apply_to_post( \WC_Email $email, int $post_id, array $opts = array() ) {
+ $require_uncustomized = ! isset( $opts['require_uncustomized'] ) || (bool) $opts['require_uncustomized'];
+
+ $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email->id );
+
+ if ( $require_uncustomized && null === $sync_config ) {
+ return new \WP_Error(
+ 'not_sync_enabled',
+ sprintf(
+ /* translators: %s: email ID */
+ __( 'Email "%s" is not registered for template sync.', 'woocommerce' ),
+ (string) $email->id
+ )
+ );
+ }
+
+ $post = get_post( $post_id );
+ if ( ! $post instanceof \WP_Post || \Automattic\WooCommerce\Internal\EmailEditor\Integration::EMAIL_POST_TYPE !== $post->post_type ) {
+ return new \WP_Error(
+ 'post_not_found',
+ sprintf(
+ /* translators: %d: post ID */
+ __( 'No woo_email post found for ID %d.', 'woocommerce' ),
+ $post_id
+ )
+ );
+ }
+
+ $stored_source_hash = '';
+ if ( $require_uncustomized ) {
+ $stored_source_hash = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true );
+ if ( '' === $stored_source_hash || ! self::is_sha1_hash( $stored_source_hash ) ) {
+ return new \WP_Error(
+ 'no_stored_hash',
+ sprintf(
+ /* translators: %d: post ID */
+ __( 'Post %d has no stored source hash; cannot safely auto-apply.', 'woocommerce' ),
+ $post_id
+ )
+ );
+ }
+ }//end if
+
+ $canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $source_hash = null;
+ $synced_at = null;
+ $status = null;
+ $version = null;
+
+ self::$is_auto_applying = true;
+ try {
+ // Re-hash post_content immediately before the write to minimise the
+ // TOCTOU gap between the snapshot and wp_update_post. The first $post
+ // load above is too early — `compute_canonical_post_content` runs in
+ // between and yields the window where a merchant save could otherwise
+ // be silently overwritten.
+ if ( $require_uncustomized ) {
+ $latest_post = get_post( $post_id );
+ if ( ! $latest_post instanceof \WP_Post
+ || sha1( (string) $latest_post->post_content ) !== $stored_source_hash
+ ) {
+ return new \WP_Error(
+ 'post_modified_since_stamp',
+ sprintf(
+ /* translators: %d: post ID */
+ __( 'Post %d has been modified since the last sync stamp; skipping auto-apply.', 'woocommerce' ),
+ $post_id
+ )
+ );
+ }
+ }
+
+ $updated = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $canonical,
+ ),
+ true
+ );
+
+ if ( is_wp_error( $updated ) ) {
+ return $updated;
+ }
+
+ if ( null !== $sync_config ) {
+ $source_hash = sha1( $canonical );
+ $synced_at = gmdate( 'Y-m-d H:i:s' );
+ $status = WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC;
+ $version = (string) $sync_config['version'];
+
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, $version );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, $source_hash );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, $synced_at );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, $status );
+ }
+ } finally {
+ self::$is_auto_applying = false;
+ }//end try
+
+ return array(
+ 'content' => $canonical,
+ 'version' => $version,
+ 'source_hash' => $source_hash,
+ 'synced_at' => $synced_at,
+ 'status' => $status,
+ );
+ }
+
+ /**
+ * Enqueue the batched auto-apply runner as an Action Scheduler async action.
+ *
+ * Hooked to {@see 'woocommerce_email_template_divergence_sweep_complete'}. The
+ * `as_has_scheduled_action()` short-circuit guards against double-enqueueing
+ * when the detector sweep runs twice in one request — once on
+ * `woocommerce_updated`, once on `BACKFILL_COMPLETE_ACTION`.
+ *
+ * @since 10.8.0
+ */
+ public static function schedule(): void {
+ if ( as_has_scheduled_action( self::AUTO_APPLY_AS_HOOK, array(), self::AUTO_APPLY_AS_GROUP ) ) {
+ return;
+ }
+
+ as_enqueue_async_action( self::AUTO_APPLY_AS_HOOK, array(), self::AUTO_APPLY_AS_GROUP );
+ }
+
+ /**
+ * Action Scheduler callback. Apply the canonical core render to every
+ * `woo_email` post whose status meta is `core_updated_uncustomized`.
+ *
+ * Per-post `try`/`catch` ensures one bad post never breaks the rest of the
+ * batch (acceptance criterion). Status meta is never mutated by this method
+ * on failure — the next sweep re-classifies.
+ *
+ * Declared `void` because Action Scheduler async-action callbacks discard
+ * the return value; a return type would just be noise (and trip PHPStan's
+ * `return.void` rule on `add_action`).
+ *
+ * @since 10.8.0
+ */
+ public static function run(): void {
+ if ( 'yes' !== get_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION ) ) {
+ return;
+ }
+
+ // `post_status=any` includes draft / private / pending / future and
+ // excludes trash / auto-draft / inherit — anything not in the trash bucket
+ // is fair game for auto-apply.
+ $candidate_ids = get_posts(
+ array(
+ 'post_type' => \Automattic\WooCommerce\Internal\EmailEditor\Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'any',
+ 'posts_per_page' => -1,
+ 'fields' => 'ids',
+ 'no_found_rows' => true,
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded set; sync-registered emails only.
+ array(
+ 'key' => WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ 'value' => WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ ),
+ ),
+ )
+ );
+
+ if ( empty( $candidate_ids ) ) {
+ return;
+ }
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+ $posts_manager = WCTransactionalEmailPostsManager::get_instance();
+ $emails_by_id = $posts_manager->get_emails_by_id();
+
+ foreach ( $candidate_ids as $post_id ) {
+ $post_id = (int) $post_id;
+ try {
+ $email_id = (string) $posts_manager->get_email_type_from_post_id( $post_id );
+ if ( '' === $email_id || ! isset( $registry[ $email_id ] ) ) {
+ continue;
+ }
+
+ $email = $emails_by_id[ $email_id ] ?? null;
+ if ( ! $email instanceof \WC_Email ) {
+ continue;
+ }
+
+ $result = self::apply_to_post( $email, $post_id );
+
+ if ( is_wp_error( $result ) ) {
+ self::log_apply_error( $result, $post_id, $email_id );
+ }
+ } catch ( \Throwable $e ) {
+ self::get_logger()->error(
+ sprintf(
+ 'Email template auto-apply failed for post %d: %s',
+ $post_id,
+ $e->getMessage()
+ ),
+ array(
+ 'post_id' => $post_id,
+ 'context' => 'email_template_auto_applier',
+ )
+ );
+ continue;
+ }//end try
+ }//end foreach
+ }
+
+ /**
+ * Map a per-post `WP_Error` from {@see self::apply_to_post()} to the right
+ * log severity and emit it.
+ *
+ * `post_modified_since_stamp` is the expected race outcome (merchant edit in
+ * the AS lag window) and is logged at `info`. Everything else is at `warning`
+ * or `error` so it surfaces in the default WC log UI.
+ *
+ * @param \WP_Error $error The error returned by apply_to_post.
+ * @param int $post_id Post ID being processed.
+ * @param string $email_id Email ID being processed.
+ */
+ private static function log_apply_error( \WP_Error $error, int $post_id, string $email_id ): void {
+ $message = sprintf(
+ 'Email template auto-apply skipped post %d for email "%s": %s',
+ $post_id,
+ $email_id,
+ $error->get_error_message()
+ );
+
+ $context = array(
+ 'post_id' => $post_id,
+ 'email_id' => $email_id,
+ 'context' => 'email_template_auto_applier',
+ );
+
+ switch ( $error->get_error_code() ) {
+ case 'post_modified_since_stamp':
+ self::get_logger()->info( $message, $context );
+ return;
+ case 'no_stored_hash':
+ case 'not_sync_enabled':
+ self::get_logger()->warning( $message, $context );
+ return;
+ default:
+ self::get_logger()->error( $message, $context );
+ }
+ }
+
+ /**
+ * Whether the auto-applier is currently rewriting a post.
+ *
+ * Future `save_post` listeners (RSM-143, RSM-145) should consult this flag
+ * to differentiate merchant edits from system-initiated writes.
+ *
+ * @return bool
+ *
+ * @since 10.8.0
+ */
+ public static function is_auto_applying(): bool {
+ return self::$is_auto_applying;
+ }
+
+ /**
+ * Override the logger implementation. Intended for tests only.
+ *
+ * @internal
+ *
+ * @param Email_Editor_Logger_Interface|null $logger The logger implementation, or null to restore the default.
+ */
+ public static function set_logger( ?Email_Editor_Logger_Interface $logger ): void {
+ self::$logger = $logger;
+ }
+
+ /**
+ * Return the logger instance, lazily creating it the first time.
+ *
+ * @return Email_Editor_Logger_Interface
+ */
+ private static function get_logger(): Email_Editor_Logger_Interface {
+ if ( null === self::$logger ) {
+ self::$logger = new Logger( wc_get_logger() );
+ }
+
+ return self::$logger;
+ }
+
+ /**
+ * Validate that a string is shaped like a SHA-1 hex digest.
+ *
+ * @param string $hash Candidate hash value.
+ * @return bool True when the value is a 40-character hex string.
+ */
+ private static function is_sha1_hash( string $hash ): bool {
+ return 40 === strlen( $hash ) && ctype_xdigit( $hash );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
index 717e6c51eb5..02d438eb93d 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -181,6 +181,19 @@ class WCEmailTemplateDivergenceDetector {
continue;
}//end try
}//end foreach
+
+ /**
+ * Fires once after the post-upgrade divergence sweep finishes classifying
+ * every sync-registered email post.
+ *
+ * Hooked by {@see \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier::schedule()}
+ * to enqueue the batched auto-apply job for posts classified as
+ * `core_updated_uncustomized`. Fires unconditionally — auto-applier
+ * short-circuits when no candidates exist.
+ *
+ * @since 10.8.0
+ */
+ do_action( 'woocommerce_email_template_divergence_sweep_complete' );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index fd592a6de1f..691fe2debcb 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -526,6 +526,52 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should leave post and sync meta untouched when wp_update_post fails.
+ */
+ public function test_reset_response_returns_wp_error_when_wp_update_post_fails(): void {
+ $email_type = 'customer_new_account';
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+
+ $post_manager = WCTransactionalEmailPostsManager::get_instance();
+ $post_manager->clear_caches();
+ $post_manager->delete_email_template( $email_type );
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_type );
+ $this->assertIsInt( $post_id );
+
+ $pre_call_content = (string) get_post( $post_id )->post_content;
+ $pre_call_meta = array(
+ 'version' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+ 'source_hash' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+ 'last_synced_at' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+ 'status' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ );
+
+ add_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/reset' );
+ $request->set_param( 'id', $post_id );
+
+ $result = $this->email_api_controller->reset_response( $request );
+
+ remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'woocommerce_email_reset_failed', $result->get_error_code() );
+ $this->assertSame( 500, $result->get_error_data()['status'] );
+
+ clean_post_cache( $post_id );
+ $this->assertSame( $pre_call_content, (string) get_post( $post_id )->post_content, 'post_content must be untouched after wp_update_post failure.' );
+ $this->assertSame( $pre_call_meta['version'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) );
+ $this->assertSame( $pre_call_meta['source_hash'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+ $this->assertSame( $pre_call_meta['last_synced_at'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ) );
+ $this->assertSame( $pre_call_meta['status'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+ }
+
/**
* @testdox Should return 500 when controller has not been initialized.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
new file mode 100644
index 00000000000..10700fd80bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
@@ -0,0 +1,1035 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator;
+use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
+
+/**
+ * Tests for the WCEmailTemplateAutoApplier class.
+ */
+class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
+ /**
+ * Absolute path to the fixtures directory shared with the divergence detector tests.
+ *
+ * @var string
+ */
+ private string $fixtures_base;
+
+ /**
+ * Keys injected into \WC_Emails::$emails during the current test.
+ *
+ * @var string[]
+ */
+ private array $injected_email_keys = array();
+
+ /**
+ * Transactional email post manager singleton.
+ *
+ * @var WCTransactionalEmailPostsManager
+ */
+ private WCTransactionalEmailPostsManager $posts_manager;
+
+ /**
+ * Setup test case.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
+ update_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION, 'yes' );
+
+ // Reuse the divergence-detector fixture file — same shape, same @version header.
+ $this->fixtures_base = dirname( __DIR__ ) . '/WCTransactionalEmails/fixtures/';
+ $this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+
+ $this->posts_manager->clear_caches();
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ // In integration runtime the email editor's Templates_Registry registers
+ // the `wooemailtemplate` slug via register_block_template() during admin
+ // bootstrap. Unit tests skip that bootstrap, so wp_update_post( $wp_error = true )
+ // would reject the slug as `invalid_page_template`. Whitelist it via the
+ // theme_{post_type}_templates filter for the duration of the test.
+ add_filter( 'theme_woo_email_templates', array( $this, 'whitelist_email_page_template' ) );
+ add_filter( 'theme_templates', array( $this, 'whitelist_email_page_template' ) );
+ }
+
+ /**
+ * Cleanup after test.
+ */
+ public function tearDown(): void {
+ $this->cleanup_injected_emails();
+
+ remove_filter( 'theme_woo_email_templates', array( $this, 'whitelist_email_page_template' ) );
+ remove_filter( 'theme_templates', array( $this, 'whitelist_email_page_template' ) );
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ remove_all_filters( 'woocommerce_email_content_post_data' );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateAutoApplier::set_logger( null );
+
+ delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Filter callback — register the `wooemailtemplate` slug so wp_update_post
+ * with `$wp_error = true` does not bail with `invalid_page_template`.
+ *
+ * @param array $templates Existing page templates keyed by slug.
+ * @return array
+ */
+ public function whitelist_email_page_template( $templates ): array {
+ $templates = is_array( $templates ) ? $templates : array();
+ $templates['wooemailtemplate'] = 'Woo Email Template';
+ return $templates;
+ }
+
+ /**
+ * apply_to_post() on a sync-enabled post that still matches its stamp must
+ * rewrite content to the canonical render and stamp all four sync meta keys.
+ */
+ public function test_apply_to_post_writes_canonical_content_and_stamps_meta(): void {
+ $email_id = 'wc_test_auto_apply_happy_path';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ // Simulate a core-template change by mutating the canonical content via the
+ // woocommerce_email_content_post_data filter for the duration of this test —
+ // keeps the stored hash from RSM-137 stamping intact while making the
+ // auto-applier's recomputed canonical hash differ.
+ add_filter(
+ 'woocommerce_email_content_post_data',
+ static function ( array $post_data ) use ( $email_id ): array {
+ if ( ( $post_data['post_name'] ?? '' ) === $email_id ) {
+ $post_data['post_content'] = (string) ( $post_data['post_content'] ?? '' ) . "\n<!-- new core release -->";
+ }
+ return $post_data;
+ }
+ );
+
+ $expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $expected_hash = sha1( $expected_canonical );
+ $expected_version = (string) WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id )['version'];
+
+ $result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+
+ $this->assertIsArray( $result, 'Atom must return an array on success.' );
+ $this->assertArrayHasKey( 'content', $result );
+ $this->assertSame( $expected_canonical, $result['content'] );
+ $this->assertSame( $expected_version, $result['version'] );
+ $this->assertSame( $expected_hash, $result['source_hash'] );
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $result['status'] );
+ $this->assertMatchesRegularExpression( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', (string) $result['synced_at'] );
+
+ $post = get_post( $post_id );
+ $this->assertSame( $expected_canonical, (string) $post->post_content, 'Post content must be the new canonical render.' );
+
+ $this->assertSame( $expected_hash, (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+ $this->assertSame( $expected_version, (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) );
+ $this->assertNotSame( '', (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ) );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
+ /**
+ * apply_to_post() with require_uncustomized=true must return a WP_Error and
+ * leave the post untouched when the merchant has edited it since stamping.
+ */
+ public function test_apply_to_post_with_require_uncustomized_returns_wp_error_when_post_modified(): void {
+ $email_id = 'wc_test_auto_apply_modified_since_stamp';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ // Simulate a merchant edit: rewrite post_content directly so its hash no longer
+ // matches the stored stamp, but leave the meta keys in place.
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => '<p>Merchant-edited content</p>',
+ )
+ );
+
+ $pre_call_meta = array(
+ 'source_hash' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+ 'version' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+ 'last_synced_at' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+ );
+
+ $result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'post_modified_since_stamp', $result->get_error_code() );
+
+ $post = get_post( $post_id );
+ $this->assertSame( '<p>Merchant-edited content</p>', (string) $post->post_content, 'Atom must not rewrite content when hash gate fails.' );
+
+ $this->assertSame( $pre_call_meta['source_hash'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+ $this->assertSame( $pre_call_meta['version'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) );
+ $this->assertSame( $pre_call_meta['last_synced_at'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ) );
+ }
+
+ /**
+ * apply_to_post() must return WP_Error('no_stored_hash') when the source-hash
+ * meta is missing, even if the post itself looks valid.
+ */
+ public function test_apply_to_post_returns_wp_error_when_no_stored_hash(): void {
+ $email_id = 'wc_test_auto_apply_no_stored_hash';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ delete_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY );
+ $pre_call_content = (string) get_post( $post_id )->post_content;
+
+ $result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'no_stored_hash', $result->get_error_code() );
+
+ $this->assertSame( $pre_call_content, (string) get_post( $post_id )->post_content );
+ }
+
+ /**
+ * apply_to_post() with require_uncustomized=true on a non-sync-enabled email
+ * must return WP_Error('not_sync_enabled') and not write anything.
+ */
+ public function test_apply_to_post_for_non_sync_enabled_email_with_require_uncustomized_true(): void {
+ $email_id = 'wc_test_auto_apply_non_sync_enabled_strict';
+
+ // Generate a stamped post, then nuke its registry membership so the email is
+ // no longer sync-enabled at apply time. Using a registry-cache reset keeps the
+ // post itself intact (with all four meta keys) so we can assert no writes.
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ // Drop the email out of the block-editor opt-in filter for the rest of the test.
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $pre_call_content = (string) get_post( $post_id )->post_content;
+ $pre_call_status = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );
+
+ $result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'not_sync_enabled', $result->get_error_code() );
+
+ $this->assertSame( $pre_call_content, (string) get_post( $post_id )->post_content );
+ $this->assertSame( $pre_call_status, (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+ }
+
+ /**
+ * apply_to_post() must return WP_Error('post_not_found') when the post ID
+ * doesn't resolve to a woo_email post.
+ */
+ public function test_apply_to_post_returns_wp_error_when_post_not_found(): void {
+ // Register a sync-enabled fixture email but do NOT generate a post for it.
+ $email_id = 'wc_test_auto_apply_post_not_found';
+ $email = $this->register_fixture_email( $email_id );
+
+ $result = WCEmailTemplateAutoApplier::apply_to_post( $email, 999999999 );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertSame( 'post_not_found', $result->get_error_code() );
+ }
+
+ /**
+ * apply_to_post() with require_uncustomized=false must overwrite a modified
+ * post unconditionally — no hash gate. This is the reset-endpoint contract.
+ */
+ public function test_apply_to_post_without_require_uncustomized_overwrites_modified_post(): void {
+ $email_id = 'wc_test_auto_apply_reset_overwrites_modified';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => '<p>Merchant-edited content that the reset must overwrite</p>',
+ )
+ );
+
+ $expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+ $result = WCEmailTemplateAutoApplier::apply_to_post(
+ $email,
+ $post_id,
+ array( 'require_uncustomized' => false )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertSame( $expected_canonical, $result['content'] );
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $result['status'] );
+
+ $this->assertSame( $expected_canonical, (string) get_post( $post_id )->post_content );
+ }
+
+ /**
+ * apply_to_post() with require_uncustomized=false on a non-sync-enabled email
+ * must rewrite content but stamp NO meta. Return shape carries null for the
+ * four sync fields. BC contract from the pre-RSM-139 reset endpoint.
+ */
+ public function test_apply_to_post_for_non_sync_enabled_email_with_require_uncustomized_false(): void {
+ $email_id = 'wc_test_auto_apply_reset_non_sync_enabled';
+ $email = $this->register_fixture_email( $email_id );
+
+ // Generate a post via direct post insert (bypass the generator's RSM-137 stamping)
+ // so the post has no sync meta to begin with — closest analogue to a non-sync-enabled
+ // email's persisted state.
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => \Automattic\WooCommerce\Internal\EmailEditor\Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'publish',
+ 'post_name' => $email_id,
+ 'post_title' => 'Non-sync-enabled fixture',
+ 'post_content' => '<p>Initial non-canonical content</p>',
+ )
+ );
+ $this->assertIsInt( $post_id );
+
+ // Drop the email out of the registry so apply_to_post sees null sync_config.
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+ $result = WCEmailTemplateAutoApplier::apply_to_post(
+ $email,
+ $post_id,
+ array( 'require_uncustomized' => false )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'content', $result );
+ $this->assertSame( $expected_canonical, $result['content'] );
+ $this->assertNull( $result['version'] );
+ $this->assertNull( $result['source_hash'] );
+ $this->assertNull( $result['synced_at'] );
+ $this->assertNull( $result['status'] );
+
+ $this->assertSame( $expected_canonical, (string) get_post( $post_id )->post_content );
+ $this->assertSame( '', (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+ $this->assertSame( '', (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+ }
+
+ /**
+ * When wp_update_post() fails the atom must short-circuit before the meta
+ * writes, so neither post_content nor any of the four sync meta keys are
+ * modified. Matches the pre-RSM-139 reset endpoint contract.
+ */
+ public function test_apply_to_post_returns_wp_error_when_wp_update_post_fails(): void {
+ $email_id = 'wc_test_auto_apply_rollback';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ $pre_call_content = (string) get_post( $post_id )->post_content;
+ $pre_call_meta = array(
+ 'source_hash' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+ 'version' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ),
+ 'last_synced_at' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ),
+ 'status' => (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ );
+
+ // Force wp_update_post to fail by short-circuiting it through the
+ // 'wp_insert_post_empty_content' filter — when this returns true,
+ // wp_update_post returns WP_Error('empty_content').
+ add_filter( 'wp_insert_post_empty_content', '__return_true' );
+
+ try {
+ $result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
+ } finally {
+ remove_filter( 'wp_insert_post_empty_content', '__return_true' );
+ }
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+
+ clean_post_cache( $post_id );
+ $this->assertSame( $pre_call_content, (string) get_post( $post_id )->post_content );
+ $this->assertSame( $pre_call_meta['source_hash'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ) );
+ $this->assertSame( $pre_call_meta['version'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ) );
+ $this->assertSame( $pre_call_meta['last_synced_at'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ) );
+ $this->assertSame( $pre_call_meta['status'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ) );
+ }
+
+ /**
+ * is_auto_applying() must read true while wp_update_post is running inside the
+ * atom and false again once apply_to_post returns. Verified by hooking save_post
+ * (which fires from inside wp_update_post) and capturing the flag value there.
+ */
+ public function test_apply_to_post_sets_is_auto_applying_flag_during_write(): void {
+ $email_id = 'wc_test_auto_apply_reentrancy_flag';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $emails_by_id = $this->posts_manager->get_emails_by_id();
+ $email = $emails_by_id[ $email_id ];
+
+ $captured_during_save = null;
+ $listener = static function () use ( &$captured_during_save ): void {
+ $captured_during_save = WCEmailTemplateAutoApplier::is_auto_applying();
+ };
+ add_action( 'save_post', $listener );
+
+ // Drive the auto-applier path via the require_uncustomized=false branch so
+ // we don't have to manufacture a core-template change here.
+ try {
+ WCEmailTemplateAutoApplier::apply_to_post(
+ $email,
+ $post_id,
+ array( 'require_uncustomized' => false )
+ );
+ } finally {
+ remove_action( 'save_post', $listener );
+ }
+
+ $this->assertTrue( $captured_during_save, 'is_auto_applying() must read true inside the write block.' );
+ $this->assertFalse( WCEmailTemplateAutoApplier::is_auto_applying(), 'is_auto_applying() must read false after apply_to_post returns.' );
+ }
+
+ /**
+ * schedule() must enqueue an async Action Scheduler action under the
+ * dedicated email-editor group.
+ */
+ public function test_schedule_enqueues_action_scheduler_job(): void {
+ // Make sure no leftover action exists from a previous test or session.
+ as_unschedule_all_actions(
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ array(),
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+ );
+
+ WCEmailTemplateAutoApplier::schedule();
+
+ $this->assertTrue(
+ (bool) as_has_scheduled_action(
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ array(),
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+ )
+ );
+
+ // Cleanup so subsequent tests start with a clean queue.
+ as_unschedule_all_actions(
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ array(),
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+ );
+ }
+
+ /**
+ * Calling schedule() twice in the same request (e.g. once from
+ * woocommerce_updated and once from BACKFILL_COMPLETE_ACTION) must not
+ * enqueue two pending actions.
+ */
+ public function test_schedule_does_not_double_enqueue(): void {
+ as_unschedule_all_actions(
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ array(),
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+ );
+
+ WCEmailTemplateAutoApplier::schedule();
+ WCEmailTemplateAutoApplier::schedule();
+
+ $pending = as_get_scheduled_actions(
+ array(
+ 'hook' => WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ 'group' => WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP,
+ 'status' => \ActionScheduler_Store::STATUS_PENDING,
+ ),
+ 'ids'
+ );
+
+ $this->assertCount( 1, $pending, 'schedule() must guard against double-enqueueing.' );
+
+ as_unschedule_all_actions(
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ array(),
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+ );
+ }
+
+ /**
+ * run() must apply only to posts whose status meta is core_updated_uncustomized,
+ * leaving in_sync and core_updated_customized posts untouched.
+ */
+ public function test_run_applies_to_every_uncustomized_post(): void {
+ // 3 posts, each with a distinct fixture email so each registers in the registry.
+ $uncustomized_post_id = $this->generate_stamped_post( 'wc_test_run_uncustomized' );
+ $customized_post_id = $this->generate_stamped_post( 'wc_test_run_customized' );
+ $in_sync_post_id = $this->generate_stamped_post( 'wc_test_run_in_sync' );
+
+ update_post_meta(
+ $uncustomized_post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED
+ );
+ update_post_meta(
+ $customized_post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
+ );
+ update_post_meta(
+ $in_sync_post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC
+ );
+
+ $content_before_customized = (string) get_post( $customized_post_id )->post_content;
+ $content_before_in_sync = (string) get_post( $in_sync_post_id )->post_content;
+
+ WCEmailTemplateAutoApplier::run();
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $uncustomized_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Uncustomized post must flip to in_sync after run().'
+ );
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ (string) get_post_meta( $customized_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Customized post status must be untouched.'
+ );
+ $this->assertSame( $content_before_customized, (string) get_post( $customized_post_id )->post_content );
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $in_sync_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ $this->assertSame( $content_before_in_sync, (string) get_post( $in_sync_post_id )->post_content );
+ }
+
+ /**
+ * One bad post must not break the rest of the batch. The atom failure is
+ * logged at error severity with context=email_template_auto_applier.
+ */
+ public function test_run_logs_and_continues_when_one_post_fails(): void {
+ $failing_post_id = $this->generate_stamped_post( 'wc_test_run_failure_isolation_fail' );
+ $good_post_id = $this->generate_stamped_post( 'wc_test_run_failure_isolation_good' );
+
+ update_post_meta( $failing_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+ update_post_meta( $good_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+ // Force wp_update_post to fail for the FIRST post only by toggling the filter
+ // on/off based on which post ID is being saved.
+ add_filter(
+ 'wp_insert_post_empty_content',
+ static function ( $maybe_empty, $postarr ) use ( $failing_post_id ) {
+ return ( ( (int) ( $postarr['ID'] ?? 0 ) ) === $failing_post_id ) ? true : $maybe_empty;
+ },
+ 10,
+ 2
+ );
+
+ $captured = array();
+ WCEmailTemplateAutoApplier::set_logger( $this->build_recording_logger( $captured ) );
+
+ try {
+ WCEmailTemplateAutoApplier::run();
+ } finally {
+ remove_all_filters( 'wp_insert_post_empty_content' );
+ }
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ (string) get_post_meta( $failing_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Failing post status must be untouched (no rewrite happened).'
+ );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $good_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Good post must still be applied despite the prior failure.'
+ );
+
+ $error_logs = array_filter(
+ $captured,
+ static fn( array $entry ) => 'error' === $entry['level']
+ && ( $entry['context']['context'] ?? '' ) === 'email_template_auto_applier'
+ );
+ $this->assertCount( 1, $error_logs, 'Exactly one error log entry must be emitted for the failing post.' );
+ }
+
+ /**
+ * If the post was modified since stamping (race window between sweep and AS job),
+ * run() must skip it at info severity, leave content/meta untouched, and not
+ * roll the status meta back.
+ */
+ public function test_run_skips_post_with_post_modified_since_stamp_at_info_severity(): void {
+ $post_id = $this->generate_stamped_post( 'wc_test_run_race_safety' );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+ // Simulate merchant edit during the AS lag window.
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => '<p>Merchant-edited content during AS lag</p>',
+ )
+ );
+
+ $captured = array();
+ WCEmailTemplateAutoApplier::set_logger( $this->build_recording_logger( $captured ) );
+
+ WCEmailTemplateAutoApplier::run();
+
+ $this->assertSame( '<p>Merchant-edited content during AS lag</p>', (string) get_post( $post_id )->post_content );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Status must remain core_updated_uncustomized so the next sweep can re-classify.'
+ );
+
+ $info_logs = array_filter(
+ $captured,
+ static fn( array $entry ) => 'info' === $entry['level']
+ && ( $entry['context']['context'] ?? '' ) === 'email_template_auto_applier'
+ );
+ $this->assertCount( 1, $info_logs, 'Race outcome must be logged at info severity.' );
+
+ $error_logs = array_filter( $captured, static fn( array $entry ) => 'error' === $entry['level'] );
+ $this->assertCount( 0, $error_logs, 'Race outcome must NOT log at error severity.' );
+ }
+
+ /**
+ * run() with no candidates must be a no-op.
+ */
+ public function test_run_is_a_no_op_when_no_uncustomized_posts_exist(): void {
+ $post_id = $this->generate_stamped_post( 'wc_test_run_no_op' );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC );
+
+ $content_before = (string) get_post( $post_id )->post_content;
+
+ $write_count = 0;
+ $counter = static function ( $check, int $object_id, string $meta_key ) use ( &$write_count ) {
+ unset( $object_id, $meta_key );
+ ++$write_count;
+ return $check;
+ };
+ add_filter( 'update_post_metadata', $counter, 10, 3 );
+
+ try {
+ WCEmailTemplateAutoApplier::run();
+ } finally {
+ remove_filter( 'update_post_metadata', $counter, 10 );
+ }
+
+ $this->assertSame( 0, $write_count, 'No meta writes must occur when there are no candidates.' );
+ $this->assertSame( $content_before, (string) get_post( $post_id )->post_content );
+ }
+
+ /**
+ * Two consecutive run() calls on the same state — the first applies and flips
+ * status to in_sync, the second writes zero rows. (Acceptance criterion.)
+ */
+ public function test_run_is_idempotent_across_repeat_invocations(): void {
+ $post_id = $this->generate_stamped_post( 'wc_test_run_idempotency' );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+ WCEmailTemplateAutoApplier::run();
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+
+ $write_count = 0;
+ $counter = static function ( $check ) use ( &$write_count ) {
+ ++$write_count;
+ return $check;
+ };
+ add_filter( 'update_post_metadata', $counter, 10, 1 );
+ add_filter( 'wp_insert_post_data', $counter, 10, 1 );
+
+ try {
+ WCEmailTemplateAutoApplier::run();
+ } finally {
+ remove_filter( 'update_post_metadata', $counter, 10 );
+ remove_filter( 'wp_insert_post_data', $counter, 10 );
+ }
+
+ $this->assertSame( 0, $write_count, 'Second run must write zero rows.' );
+ }
+
+ /**
+ * run() must short-circuit when BACKFILL_COMPLETE_OPTION is not 'yes', even if
+ * core_updated_uncustomized posts exist in the DB.
+ */
+ public function test_run_respects_backfill_complete_option_gate(): void {
+ $post_id = $this->generate_stamped_post( 'wc_test_run_backfill_gate' );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+ update_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION, 'no' );
+
+ $content_before = (string) get_post( $post_id )->post_content;
+
+ WCEmailTemplateAutoApplier::run();
+
+ $this->assertSame( $content_before, (string) get_post( $post_id )->post_content );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
+ /**
+ * Posts whose registered email is no longer in the sync registry at run time
+ * must be skipped silently — no log, no write. (Plugin deactivated mid-cycle.)
+ */
+ public function test_run_skips_posts_for_deactivated_email_plugins(): void {
+ $post_id = $this->generate_stamped_post( 'wc_test_run_deactivated' );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+ $content_before = (string) get_post( $post_id )->post_content;
+
+ // Drop the email out of the registry.
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $captured = array();
+ WCEmailTemplateAutoApplier::set_logger( $this->build_recording_logger( $captured ) );
+
+ WCEmailTemplateAutoApplier::run();
+
+ $this->assertSame( $content_before, (string) get_post( $post_id )->post_content );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ $this->assertSame( array(), $captured, 'Deactivated-plugin skip must be silent (no log emission).' );
+ }
+
+ /**
+ * The candidate query must accept any non-trash post status — generated
+ * woo_email posts default to `publish` but third-party flows may move them
+ * to `draft` / `private` / `pending` / `future` and those must not be
+ * silently skipped.
+ *
+ * @dataProvider provider_non_trash_post_statuses
+ *
+ * @param string $post_status Post status to stamp on the candidate.
+ */
+ public function test_run_applies_to_candidate_regardless_of_non_trash_post_status( string $post_status ): void {
+ global $wpdb;
+
+ $post_id = $this->generate_stamped_post( 'wc_test_run_status_' . $post_status );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+ // Flip status via a direct UPDATE: wp_update_post calls map_meta_cap, which
+ // emits a _doing_it_wrong notice in the unit-test bootstrap because the
+ // woo_email post type is not registered there (Integration::initialize() is
+ // not run). The candidate query is the surface under test; status flipping
+ // is incidental.
+ $wpdb->update( $wpdb->posts, array( 'post_status' => $post_status ), array( 'ID' => $post_id ) );
+ clean_post_cache( $post_id );
+
+ WCEmailTemplateAutoApplier::run();
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ "Auto-applier must process candidates whose post_status is {$post_status}."
+ );
+ }
+
+ /**
+ * Data provider for {@see self::test_run_applies_to_candidate_regardless_of_non_trash_post_status()}.
+ *
+ * `pending` is intentionally omitted: WP's `wp_update_post` invokes
+ * `map_meta_cap('publish_post', ...)` for any write touching a pending post,
+ * which emits a _doing_it_wrong notice in the unit-test bootstrap because the
+ * `woo_email` post type is not registered there. Pending isn't a realistic
+ * status for transactional email templates anyway.
+ *
+ * @return array<string, array<int, string>>
+ */
+ public function provider_non_trash_post_statuses(): array {
+ return array(
+ 'draft' => array( 'draft' ),
+ 'private' => array( 'private' ),
+ 'future' => array( 'future' ),
+ );
+ }
+
+ /**
+ * Trashed posts must NOT be picked up by the candidate query — the WP_Query
+ * `post_status=any` clause already excludes the trash bucket and we want to
+ * lock that in.
+ */
+ public function test_run_skips_trashed_posts(): void {
+ $post_id = $this->generate_stamped_post( 'wc_test_run_trashed' );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED );
+
+ wp_trash_post( $post_id );
+ clean_post_cache( $post_id );
+
+ $content_before = (string) get_post( $post_id )->post_content;
+
+ WCEmailTemplateAutoApplier::run();
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Trashed posts must not have their status flipped by the auto-applier.'
+ );
+ $this->assertSame( $content_before, (string) get_post( $post_id )->post_content );
+ }
+
+ /**
+ * Firing the divergence-sweep completion action must trigger the auto-applier
+ * to enqueue an AS job. This locks in the wiring that lives in
+ * Integration::register_hooks().
+ */
+ public function test_detector_sweep_complete_action_triggers_schedule(): void {
+ // The unit-test bootstrap does not run Integration::initialize() (which calls
+ // register_hooks()). Force it for this single test so the wiring under test
+ // is actually present on the global hook table.
+ wc_get_container()->get( Integration::class )->initialize();
+
+ as_unschedule_all_actions(
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ array(),
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+ );
+
+ /**
+ * Trigger the production hook the auto-applier listens to; the assertions
+ * below confirm `Integration::register_hooks()` routes it to `schedule()`.
+ *
+ * @since 10.8.0
+ */
+ do_action( 'woocommerce_email_template_divergence_sweep_complete' );
+
+ $this->assertTrue(
+ (bool) as_has_scheduled_action(
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ array(),
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+ ),
+ 'Integration::register_hooks() must wire schedule() to the divergence-sweep completion action.'
+ );
+
+ as_unschedule_all_actions(
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_HOOK,
+ array(),
+ WCEmailTemplateAutoApplier::AUTO_APPLY_AS_GROUP
+ );
+ }
+
+ /**
+ * Build a recording logger that captures every call into a flat array. Each entry
+ * is shaped: [ 'level' => 'info'|'warning'|'error', 'message' => string, 'context' => array ].
+ *
+ * @param array<int, array<string, mixed>> $sink Reference to the array that will receive entries.
+ * @return \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface
+ */
+ private function build_recording_logger( array &$sink ): \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface {
+ return new class( $sink ) implements \Automattic\WooCommerce\EmailEditor\Engine\Logger\Email_Editor_Logger_Interface {
+ /** @var array<int, array<string, mixed>> */
+ private array $sink;
+
+ /**
+ * @param array<int, array<string, mixed>> $sink Reference to the array that will receive entries.
+ */
+ public function __construct( array &$sink ) {
+ $this->sink = &$sink;
+ }
+
+ /**
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function emergency( string $message, array $context = array() ): void {
+ $this->capture( 'emergency', $message, $context );
+ }
+
+ /**
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function alert( string $message, array $context = array() ): void {
+ $this->capture( 'alert', $message, $context );
+ }
+
+ /**
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function critical( string $message, array $context = array() ): void {
+ $this->capture( 'critical', $message, $context );
+ }
+
+ /**
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function error( string $message, array $context = array() ): void {
+ $this->capture( 'error', $message, $context );
+ }
+
+ /**
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function warning( string $message, array $context = array() ): void {
+ $this->capture( 'warning', $message, $context );
+ }
+
+ /**
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function notice( string $message, array $context = array() ): void {
+ $this->capture( 'notice', $message, $context );
+ }
+
+ /**
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function info( string $message, array $context = array() ): void {
+ $this->capture( 'info', $message, $context );
+ }
+
+ /**
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function debug( string $message, array $context = array() ): void {
+ $this->capture( 'debug', $message, $context );
+ }
+
+ /**
+ * @param string $level The log level.
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ public function log( string $level, string $message, array $context = array() ): void {
+ $this->capture( $level, $message, $context );
+ }
+
+ /**
+ * Append a captured log call to the sink.
+ *
+ * @param string $level The log level.
+ * @param string $message The log message.
+ * @param array<string, mixed> $context The log context.
+ */
+ private function capture( string $level, string $message, array $context ): void {
+ $this->sink[] = array(
+ 'level' => $level,
+ 'message' => $message,
+ 'context' => $context,
+ );
+ }
+ };
+ }
+
+ /**
+ * Build a WC_Email stub backed by the third-party-with-version.php fixture, inject it
+ * into WC_Emails::$emails, and opt the email ID into the block-editor filter so the
+ * sync registry picks it up.
+ *
+ * @param string $email_id Email ID to assign to the stub.
+ * @return \WC_Email Registered fixture email instance.
+ */
+ private function register_fixture_email( string $email_id ): \WC_Email {
+ $stub = $this->getMockBuilder( \WC_Email::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $stub->method( 'get_title' )->willReturn( 'Fixture email for auto-applier tests' );
+ $stub->method( 'get_description' )->willReturn( 'Fixture email used to cover auto-apply scenarios.' );
+ $stub->id = $email_id;
+ $stub->template_base = $this->fixtures_base;
+ $stub->template_block = 'block/third-party-with-version.php';
+ $stub->template_plain = null;
+
+ $class_key = 'WC_Test_Email_' . $email_id;
+
+ $emails_container = \WC_Emails::instance();
+ $reflection = new \ReflectionClass( $emails_container );
+ $property = $reflection->getProperty( 'emails' );
+ $property->setAccessible( true );
+ $current = $property->getValue( $emails_container );
+ $current[ $class_key ] = $stub;
+ $property->setValue( $emails_container, $current );
+
+ $this->injected_email_keys[] = $class_key;
+
+ add_filter(
+ 'woocommerce_transactional_emails_for_block_editor',
+ static function ( array $emails ) use ( $email_id ): array {
+ if ( ! in_array( $email_id, $emails, true ) ) {
+ $emails[] = $email_id;
+ }
+ return $emails;
+ }
+ );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ return $stub;
+ }
+
+ /**
+ * Drive the real generator flow to produce a stamped woo_email post for the given fixture.
+ *
+ * @param string $email_id Email ID to generate a post for.
+ * @return int The generated post ID.
+ */
+ private function generate_stamped_post( string $email_id ): int {
+ $this->register_fixture_email( $email_id );
+
+ $generator = new WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+ $this->posts_manager->delete_email_template( $email_id );
+
+ $post_id = $generator->generate_email_template_if_not_exists( $email_id );
+
+ $this->assertIsInt( $post_id );
+ $this->assertGreaterThan( 0, $post_id );
+
+ return $post_id;
+ }
+
+ /**
+ * Remove any stubs we injected into WC_Emails::$emails during the test.
+ */
+ private function cleanup_injected_emails(): void {
+ if ( empty( $this->injected_email_keys ) ) {
+ return;
+ }
+
+ $emails_container = \WC_Emails::instance();
+ $reflection = new \ReflectionClass( $emails_container );
+ $property = $reflection->getProperty( 'emails' );
+ $property->setAccessible( true );
+ $current = $property->getValue( $emails_container );
+ foreach ( $this->injected_email_keys as $key ) {
+ unset( $current[ $key ] );
+ }
+ $property->setValue( $emails_container, $current );
+ $this->injected_email_keys = array();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
index 38f0ff3434b..e6e6b764037 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -214,6 +214,30 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * The sweep must fire `woocommerce_email_template_divergence_sweep_complete`
+ * unconditionally at end of run, so downstream listeners (RSM-139 auto-applier)
+ * can hook the completion event without inspecting detector internals.
+ */
+ public function test_run_sweep_fires_completion_action(): void {
+ $email_id = 'wc_test_divergence_completion_action';
+ $this->generate_stamped_post( $email_id );
+
+ $fired = 0;
+ $listener = static function () use ( &$fired ): void {
+ ++$fired;
+ };
+ add_action( 'woocommerce_email_template_divergence_sweep_complete', $listener );
+
+ try {
+ WCEmailTemplateDivergenceDetector::run_sweep();
+ } finally {
+ remove_action( 'woocommerce_email_template_divergence_sweep_complete', $listener );
+ }
+
+ $this->assertSame( 1, $fired, 'Completion action must fire exactly once per sweep.' );
+ }
+
/**
* Build a WC_Email stub backed by the third-party-with-version.php fixture, inject it
* into WC_Emails::$emails, and opt the email ID into the block-editor filter so the