Commit 00bed1a7bf5 for woocommerce
commit 00bed1a7bf5dc3a36432ac0c1c9873e653469d92
Author: Seun Olorunsola <30554163+triple0t@users.noreply.github.com>
Date: Thu Apr 23 14:35:45 2026 +0100
Detect block email template divergence on plugin update (#64328)
* Detect block email template divergence on plugin update
Hook WCEmailTemplateDivergenceDetector on woocommerce_updated so each
plugin upgrade classifies every sync-registered woo_email post into
in_sync, core_updated_uncustomized, or core_updated_customized and
stamps _wc_email_template_status accordingly.
Extract build_filtered_post_data, compute_canonical_post_content, and
render_block_template_html static helpers on the generator so stamping
and detection hash the same canonical content by construction.
Made-with: Cursor
* Add changelog entry for email template divergence detection
Made-with: Cursor
* Fix phpcs findings in email template divergence detector
Add //end try and //end foreach markers for Squiz.Commenting.LongConditionClosingComment
on the long run_sweep() block, and move the $post_id unset comment to
the preceding line to satisfy Squiz.Commenting.PostStatementComment.
Made-with: Cursor
* Harden email template divergence detector against bad input
Validate third-party filter return values on `woocommerce_email_block_template_html`
and `woocommerce_email_content_post_data` in WCTransactionalEmailPostsGenerator,
falling back to the unfiltered value when an extension returns a non-string or
non-array. Prevents a TypeError fatal mid-plugin-update.
Guard classify_post() in WCEmailTemplateDivergenceDetector against malformed
stored_source_hash meta via a new is_sha1_hash() helper (40 hex chars). Non-SHA-1
values now short-circuit to null instead of silently falling through every equality
check and misreporting posts as core_updated_customized.
Made-with: Cursor
diff --git a/plugins/woocommerce/changelog/add-email-template-divergence-detection b/plugins/woocommerce/changelog/add-email-template-divergence-detection
new file mode 100644
index 00000000000..d3a4d2aa04d
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-email-template-divergence-detection
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Detect divergence between generated block email posts and their source templates after plugin updates, classifying each post as in-sync, core-updated-uncustomized, or core-updated-customized.
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/Integration.php b/plugins/woocommerce/src/Internal/EmailEditor/Integration.php
index cfa01a187fb..5dca63d20e6 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\WCEmailTemplateDivergenceDetector;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmails;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager;
use Automattic\WooCommerce\Internal\EmailEditor\EmailTemplates\TemplateApiController;
@@ -145,6 +146,7 @@ class Integration {
add_action( 'woocommerce_email_editor_send_preview_email_after_wp_mail', array( $this, 'send_preview_email_after_wp_mail' ), 10 );
add_filter( 'woocommerce_email_editor_send_preview_email_subject', array( $this, 'update_email_subject_for_send_preview_email' ), 10, 2 );
add_action( 'rest_api_init', array( $this->email_api_controller, 'register_routes' ) );
+ add_action( 'woocommerce_updated', array( WCEmailTemplateDivergenceDetector::class, 'run_sweep' ), 20 );
}
/**
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
new file mode 100644
index 00000000000..bde1fbcc986
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetector.php
@@ -0,0 +1,284 @@
+<?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;
+
+/**
+ * Detects divergence between generated `woo_email` posts and their source block
+ * email templates after WooCommerce is upgraded.
+ *
+ * For every sync-enabled email (see {@see WCEmailTemplateSyncRegistry}) that has
+ * a generated post carrying the `_wc_email_template_source_hash` stamp written by
+ * {@see WCTransactionalEmailPostsGenerator}, this class recomputes the current
+ * core and current post hashes and classifies the post into one of:
+ *
+ * - `in_sync` — core and post still match the stamped baseline.
+ * - `core_updated_uncustomized` — core changed but the post kept pace (no merchant edits).
+ * - `core_updated_customized` — core changed and the post diverges (merchant customisations).
+ *
+ * The classification is persisted on the post's `_wc_email_template_status` meta
+ * so downstream UI can surface an accurate status. The sweep is idempotent: runs
+ * with unchanged state write zero rows.
+ *
+ * Hash input parity with the stamping path is guaranteed by construction because
+ * both paths route through {@see WCTransactionalEmailPostsGenerator::compute_canonical_post_content()}.
+ *
+ * @package Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails
+ * @since 10.8.0
+ */
+class WCEmailTemplateDivergenceDetector {
+ /**
+ * Option written by the RSM-149 backfill to signal that every existing `woo_email`
+ * post has been stamped with the RSM-137 sync meta. The detector refuses to run
+ * until this flag flips to `yes` — otherwise legacy posts would be evaluated with
+ * no stored hash and silently skipped, giving a misleadingly quiet sweep.
+ *
+ * @var string
+ */
+ public const BACKFILL_COMPLETE_OPTION = 'woocommerce_email_template_sync_backfill_complete';
+
+ /**
+ * Post meta key the detector writes.
+ *
+ * @var string
+ */
+ public const STATUS_META_KEY = '_wc_email_template_status';
+
+ /**
+ * Post meta key written by the generator; required for classification.
+ *
+ * @var string
+ */
+ private const SOURCE_HASH_META_KEY = '_wc_email_template_source_hash';
+
+ /**
+ * Classification outcomes.
+ */
+ public const STATUS_IN_SYNC = 'in_sync';
+ public const STATUS_CORE_UPDATED_UNCUSTOMIZED = 'core_updated_uncustomized';
+ public const STATUS_CORE_UPDATED_CUSTOMIZED = 'core_updated_customized';
+
+ /**
+ * Logger instance. Lazily instantiated on first use; overridable for tests.
+ *
+ * @var Email_Editor_Logger_Interface|null
+ */
+ private static $logger = null;
+
+ /**
+ * Run the post-upgrade divergence sweep.
+ *
+ * Intended to be hooked on `woocommerce_updated`, which fires once per WC
+ * upgrade inside {@see \WC_Install::check_version()} under a distributed
+ * install lock — that guarantees the sweep runs at most once per upgrade
+ * without any additional fence option or cache lock on our side.
+ *
+ * Early-returns when the RSM-149 backfill has not yet flagged completion, so
+ * we never classify a half-populated set of posts.
+ *
+ * @return void
+ *
+ * @since 10.8.0
+ */
+ public static function run_sweep(): void {
+ if ( 'yes' !== get_option( self::BACKFILL_COMPLETE_OPTION ) ) {
+ return;
+ }
+
+ $registry = WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+ if ( empty( $registry ) ) {
+ return;
+ }
+
+ $wc_emails = \WC_Emails::instance();
+ /**
+ * WooCommerce Transactional Emails instance.
+ *
+ * @var \WC_Email[]
+ */
+ $all_emails = $wc_emails->get_emails();
+ $posts_manager = WCTransactionalEmailPostsManager::get_instance();
+ $canonical_emails = self::index_emails_by_id( $all_emails );
+
+ foreach ( $registry as $email_id => $_config ) {
+ try {
+ $email = $canonical_emails[ $email_id ] ?? null;
+ if ( ! $email instanceof \WC_Email ) {
+ // Extension providing the email was deactivated; nothing to classify.
+ continue;
+ }
+
+ $post = $posts_manager->get_email_post( (string) $email_id );
+ if ( ! $post instanceof \WP_Post ) {
+ continue;
+ }
+
+ $stored_source_hash = (string) get_post_meta( (int) $post->ID, self::SOURCE_HASH_META_KEY, true );
+ if ( '' === $stored_source_hash ) {
+ // This should not normally occur post-backfill: the generator always stamps
+ // this meta and RSM-149 is supposed to have backfilled pre-existing posts.
+ // Surface at warning so it's visible in the default WC log UI without
+ // requiring operators to lower the email-editor logging threshold.
+ self::get_logger()->warning(
+ sprintf(
+ 'Email template divergence sweep skipped post %d for email "%s": no stored source hash.',
+ (int) $post->ID,
+ (string) $email_id
+ ),
+ array(
+ 'email_id' => (string) $email_id,
+ 'post_id' => (int) $post->ID,
+ 'context' => 'email_template_divergence_detector',
+ )
+ );
+ continue;
+ }
+
+ $status = self::classify_post(
+ (int) $post->ID,
+ $email,
+ array(
+ 'post_content' => (string) $post->post_content,
+ 'stored_source_hash' => $stored_source_hash,
+ )
+ );
+
+ if ( null === $status ) {
+ continue;
+ }
+
+ $existing_status = (string) get_post_meta( (int) $post->ID, self::STATUS_META_KEY, true );
+ if ( $existing_status === $status ) {
+ continue;
+ }
+
+ update_post_meta( (int) $post->ID, self::STATUS_META_KEY, $status );
+ } catch ( \Throwable $e ) {
+ self::get_logger()->error(
+ sprintf(
+ 'Email template divergence sweep failed for email "%s": %s',
+ (string) $email_id,
+ $e->getMessage()
+ ),
+ array(
+ 'email_id' => (string) $email_id,
+ 'context' => 'email_template_divergence_detector',
+ )
+ );
+ continue;
+ }//end try
+ }//end foreach
+ }
+
+ /**
+ * Classify a single generated `woo_email` post.
+ *
+ * Pure function: given the inputs, always returns the same outcome.
+ *
+ * Classification hinges on two independent questions:
+ * 1. Has core moved since we stamped the post? (`currentCoreHash !== storedSourceHash`)
+ * 2. Has the merchant edited the post since we stamped it? (`currentPostHash !== storedSourceHash`)
+ *
+ * Note that "uncustomized" here means the merchant has **not** edited the post, which
+ * is detected by `currentPostHash === storedSourceHash` — NOT by comparing against the
+ * new core hash. The latter would only hold after an auto-apply step which this code
+ * path does not perform.
+ *
+ * Returns `null` when the stored baseline is ambiguous — i.e. core has not moved but
+ * the post has drifted from the stamp. In that case the existing status is preserved
+ * rather than overwritten with a new guess.
+ *
+ * @param int $post_id The post ID (kept in the signature for context in tests and logs).
+ * @param \WC_Email $email The registered email instance.
+ * @param array $stamps Map with keys `post_content` (current persisted content) and
+ * `stored_source_hash` (value of `_wc_email_template_source_hash`).
+ * @return string|null One of the STATUS_* constants, or null when the status should not be updated.
+ *
+ * @since 10.8.0
+ */
+ public static function classify_post( int $post_id, \WC_Email $email, array $stamps ): ?string {
+ // $post_id is surfaced in the signature for future instrumentation and log context; no current use.
+ unset( $post_id );
+
+ $current_core_hash = sha1( WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ) );
+ $current_post_hash = sha1( (string) ( $stamps['post_content'] ?? '' ) );
+ $stored_source_hash = (string) ( $stamps['stored_source_hash'] ?? '' );
+
+ if ( ! self::is_sha1_hash( $stored_source_hash ) ) {
+ return null;
+ }
+
+ // Core has not moved since stamping. If the post also matches the stamp we're in sync;
+ // otherwise the merchant drifted without a core update — ambiguous, leave prior status.
+ if ( $current_core_hash === $stored_source_hash ) {
+ return $current_post_hash === $stored_source_hash ? self::STATUS_IN_SYNC : null;
+ }
+
+ // Core has moved. Did the merchant edit the post since we stamped it?
+ return $current_post_hash === $stored_source_hash
+ ? self::STATUS_CORE_UPDATED_UNCUSTOMIZED
+ : self::STATUS_CORE_UPDATED_CUSTOMIZED;
+ }
+
+ /**
+ * 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.
+ *
+ * `_wc_email_template_source_hash` is produced by `sha1()` (40 hex chars), but as
+ * persisted post meta it is theoretically reachable from DB migrations, direct
+ * `update_post_meta` calls, or misbehaving extensions. Any non-SHA-1 value would
+ * otherwise be compared byte-for-byte against real hashes and always report
+ * `core_updated_customized`, so we short-circuit instead.
+ *
+ * @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 );
+ }
+
+ /**
+ * Build an `email_id => WC_Email` lookup from the list returned by
+ * `WC_Emails::get_emails()` (which is keyed by class name).
+ *
+ * @param array<string, \WC_Email> $all_emails Registered WC_Email instances keyed by class.
+ * @return array<string, \WC_Email>
+ */
+ private static function index_emails_by_id( array $all_emails ): array {
+ $indexed = array();
+ foreach ( $all_emails as $email ) {
+ if ( $email instanceof \WC_Email && is_string( $email->id ) && '' !== $email->id ) {
+ $indexed[ $email->id ] = $email;
+ }
+ }
+
+ return $indexed;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
index d3685e8a22d..2c822bd28f2 100644
--- a/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
+++ b/plugins/woocommerce/src/Internal/EmailEditor/WCTransactionalEmails/WCTransactionalEmailPostsGenerator.php
@@ -165,6 +165,24 @@ class WCTransactionalEmailPostsGenerator {
* @return string The email template.
*/
public function get_email_template( $email ) {
+ return self::render_block_template_html( $email );
+ }
+
+ /**
+ * Render the block template HTML for a given email.
+ *
+ * Resolves the block template (honouring theme overrides), falls back to the
+ * default block content on failure, and applies the
+ * `woocommerce_email_block_template_html` filter. Stateless so both the
+ * generator (via {@see self::get_email_template()}) and the divergence
+ * detector observe an identical rendering pipeline.
+ *
+ * @param \WC_Email $email The email object.
+ * @return string The rendered template HTML.
+ *
+ * @since 10.8.0
+ */
+ public static function render_block_template_html( $email ): string {
$template_name = self::resolve_block_template_name( $email );
try {
@@ -204,9 +222,9 @@ class WCTransactionalEmailPostsGenerator {
* @param \WC_Email $email The email object.
* @since 10.7.0
*/
- $template_html = apply_filters( 'woocommerce_email_block_template_html', $template_html, $email );
+ $filtered_template_html = apply_filters( 'woocommerce_email_block_template_html', $template_html, $email );
- return $template_html;
+ return is_string( $filtered_template_html ) ? $filtered_template_html : $template_html;
}
/**
@@ -304,23 +322,28 @@ class WCTransactionalEmailPostsGenerator {
}
/**
- * Generate a single email template.
+ * Build the `wp_insert_post()` payload for a given email and apply the
+ * `woocommerce_email_content_post_data` filter.
*
- * This function generates a single email template post and sets its postmeta association.
+ * Extracted so the generator and the divergence detector observe the exact
+ * same pre-insert post payload, guaranteeing by construction that the hash
+ * stamped in {@see self::generate_single_template()} and the hash recomputed
+ * in `WCEmailTemplateDivergenceDetector` hash identical input.
*
- * @param string $email_type The email type.
- * @param \WC_Email $email_data The transactional email data.
- * @return int The post ID of the generated template.
- * @throws \Exception When post creation fails.
+ * @param string $email_type The email type identifier (e.g. `customer_processing_order`).
+ * @param \WC_Email $email The transactional email instance.
+ * @return array The post data array after the `woocommerce_email_content_post_data` filter runs.
+ *
+ * @since 10.8.0
*/
- private function generate_single_template( $email_type, $email_data ) {
+ public static function build_filtered_post_data( string $email_type, $email ): array {
$post_data = array(
'post_type' => Integration::EMAIL_POST_TYPE,
'post_status' => 'publish',
'post_name' => $email_type,
- 'post_title' => $email_data->get_title(),
- 'post_excerpt' => $email_data->get_description(),
- 'post_content' => $this->get_email_template( $email_data ),
+ 'post_title' => $email->get_title(),
+ 'post_excerpt' => $email->get_description(),
+ 'post_content' => self::render_block_template_html( $email ),
'meta_input' => array(
'_wp_page_template' => ( new WooEmailTemplate() )->get_slug(),
),
@@ -335,9 +358,45 @@ class WCTransactionalEmailPostsGenerator {
* @since 10.5.0
* @param array $post_data The post data array to be used for wp_insert_post().
* @param string $email_type The email type identifier (e.g., 'customer_processing_order').
- * @param \WC_Email $email_data The WooCommerce email object.
+ * @param \WC_Email $email The WooCommerce email object.
*/
- $post_data = apply_filters( 'woocommerce_email_content_post_data', $post_data, $email_type, $email_data );
+ $filtered_post_data = apply_filters( 'woocommerce_email_content_post_data', $post_data, $email_type, $email );
+
+ return is_array( $filtered_post_data ) ? $filtered_post_data : $post_data;
+ }
+
+ /**
+ * Compute the canonical `post_content` for a given email.
+ *
+ * Returns the `post_content` value that the generator would persist for this
+ * email after the `woocommerce_email_content_post_data` filter runs, i.e.
+ * the exact string whose sha1 is stamped into `_wc_email_template_source_hash`.
+ *
+ * Callers can hash the return value to obtain `currentCoreHash` for
+ * divergence detection.
+ *
+ * @param \WC_Email $email The transactional email instance.
+ * @return string The canonical post content.
+ *
+ * @since 10.8.0
+ */
+ public static function compute_canonical_post_content( $email ): string {
+ $post_data = self::build_filtered_post_data( (string) $email->id, $email );
+ return (string) ( $post_data['post_content'] ?? '' );
+ }
+
+ /**
+ * Generate a single email template.
+ *
+ * This function generates a single email template post and sets its postmeta association.
+ *
+ * @param string $email_type The email type.
+ * @param \WC_Email $email_data The transactional email data.
+ * @return int The post ID of the generated template.
+ * @throws \Exception When post creation fails.
+ */
+ private function generate_single_template( $email_type, $email_data ) {
+ $post_data = self::build_filtered_post_data( (string) $email_type, $email_data );
// Sync meta stamp for emails participating in template update propagation.
$sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( (string) $email_data->id );
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
new file mode 100644
index 00000000000..38f0ff3434b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -0,0 +1,312 @@
+<?php
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+
+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 WCEmailTemplateDivergenceDetector class.
+ */
+class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
+ /**
+ * Absolute path to the fixtures directory.
+ *
+ * @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' );
+
+ $this->fixtures_base = __DIR__ . '/fixtures/';
+ $this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
+
+ // Singleton caches survive test transaction rollback and would otherwise leak
+ // stale post_id <-> email_type mappings into subsequent tests.
+ $this->posts_manager->clear_caches();
+ WCEmailTemplateSyncRegistry::reset_cache();
+ }
+
+ /**
+ * Cleanup after test.
+ */
+ public function tearDown(): void {
+ $this->cleanup_injected_emails();
+
+ remove_all_filters( 'woocommerce_transactional_emails_for_block_editor' );
+ remove_all_filters( 'woocommerce_email_content_post_data' );
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+ WCEmailTemplateDivergenceDetector::set_logger( null );
+
+ delete_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION );
+ update_option( 'woocommerce_feature_block_email_editor_enabled', 'no' );
+
+ parent::tearDown();
+ }
+
+ /**
+ * classify_post() returns each of the three ladder states when fed the corresponding inputs.
+ *
+ * This is the pure unit-level coverage of the classifier; integration coverage is
+ * provided by the remaining tests. Kept as a single method rather than a data provider
+ * because all three scenarios derive from the same fixture-email setup and benefit
+ * from a shared `currentCoreHash`.
+ *
+ * Scenarios exercise the two independent classification axes:
+ * - has core moved since stamping? (compare currentCoreHash vs. storedSourceHash)
+ * - has the merchant edited the post since stamping? (compare currentPostHash vs. storedSourceHash)
+ */
+ public function test_classification_ladder_covers_all_three_outcomes(): void {
+ $email = $this->register_fixture_email( 'wc_test_divergence_ladder' );
+ $canonical_content = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $current_core_hash = sha1( $canonical_content );
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ WCEmailTemplateDivergenceDetector::classify_post(
+ 1,
+ $email,
+ array(
+ 'post_content' => $canonical_content,
+ 'stored_source_hash' => $current_core_hash,
+ )
+ ),
+ 'Core unchanged and post matches stamp must be in_sync.'
+ );
+
+ // Core has moved (stored !== current core) and merchant has not edited the post
+ // (post_content still matches the stored stamp) → safe to auto-apply new core.
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ WCEmailTemplateDivergenceDetector::classify_post(
+ 1,
+ $email,
+ array(
+ 'post_content' => 'stamped-content-from-older-version',
+ 'stored_source_hash' => sha1( 'stamped-content-from-older-version' ),
+ )
+ ),
+ 'Core moved but post still matches stamp must be core_updated_uncustomized.'
+ );
+
+ // Core has moved AND merchant edited the post (post_content no longer matches
+ // the stored stamp) → merchant customisations would be overwritten by auto-apply.
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ WCEmailTemplateDivergenceDetector::classify_post(
+ 1,
+ $email,
+ array(
+ 'post_content' => 'merchant-edited content that diverges from the stamp',
+ 'stored_source_hash' => sha1( 'stamped-content-from-older-version' ),
+ )
+ ),
+ 'Core moved and merchant edited post must be core_updated_customized.'
+ );
+ }
+
+ /**
+ * Running the sweep twice on unchanged state must write the status meta at most once.
+ *
+ * Guards the equality-gated upsert in run_sweep(): the second sweep observes the status
+ * meta written by the first run and short-circuits, so no second write reaches the DB.
+ */
+ public function test_second_sweep_on_unchanged_state_writes_zero_rows(): void {
+ $email_id = 'wc_test_divergence_idempotency';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ $write_count = 0;
+ $counter = static function ( $check, int $object_id, string $meta_key ) use ( &$write_count, $post_id ) {
+ if ( $object_id === $post_id && WCEmailTemplateDivergenceDetector::STATUS_META_KEY === $meta_key ) {
+ ++$write_count;
+ }
+ return $check;
+ };
+ add_filter( 'update_post_metadata', $counter, 10, 3 );
+
+ WCEmailTemplateDivergenceDetector::run_sweep();
+ $writes_after_first_sweep = $write_count;
+
+ WCEmailTemplateDivergenceDetector::run_sweep();
+ $writes_after_second_sweep = $write_count;
+
+ remove_filter( 'update_post_metadata', $counter, 10 );
+
+ $this->assertSame( 1, $writes_after_first_sweep, 'First sweep should write the status meta exactly once.' );
+ $this->assertSame( 1, $writes_after_second_sweep, 'Second sweep on unchanged state must be a no-op.' );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
+ }
+
+ /**
+ * Posts lacking _wc_email_template_source_hash must be skipped without writing status.
+ */
+ public function test_post_with_missing_source_hash_is_skipped(): void {
+ $email_id = 'wc_test_divergence_missing_hash';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ // Simulate a legacy (pre-RSM-137) post by removing the source-hash stamp.
+ delete_post_meta( $post_id, '_wc_email_template_source_hash' );
+
+ WCEmailTemplateDivergenceDetector::run_sweep();
+
+ $this->assertSame(
+ '',
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Legacy posts without a stored source hash must not be classified.'
+ );
+ }
+
+ /**
+ * When the stamping path captures post-filter content and the same filter stays active
+ * during the sweep, the detector must classify the post as in_sync by construction.
+ *
+ * This is the cross-issue contract between RSM-137 (stamp) and RSM-138 (detect):
+ * both sides route through {@see WCTransactionalEmailPostsGenerator::compute_canonical_post_content()}
+ * so any filter mutation that shifts post_content shifts both hashes identically.
+ */
+ public function test_contract_equivalence_under_woocommerce_email_content_post_data_filter(): void {
+ add_filter(
+ 'woocommerce_email_content_post_data',
+ static function ( array $post_data ): array {
+ $post_data['post_content'] = (string) ( $post_data['post_content'] ?? '' ) . "\n<!-- filter mutation -->";
+ return $post_data;
+ }
+ );
+
+ $email_id = 'wc_test_divergence_filter_contract';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ WCEmailTemplateDivergenceDetector::run_sweep();
+
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'Stamping hash and detection hash must agree when the content-post-data filter is active.'
+ );
+ }
+
+ /**
+ * 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. Resets the registry cache so subsequent reads see the
+ * fixture.
+ *
+ * @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 divergence tests' );
+ $stub->method( 'get_description' )->willReturn( 'Fixture email used to cover divergence 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.
+ *
+ * Using the live generator ensures RSM-137's stamping logic actually runs — the detector
+ * then observes precisely what the generator would persist in production.
+ *
+ * @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 );
+
+ // Sanity check: RSM-137 stamped all three sync meta keys.
+ $this->assertNotSame( '', (string) get_post_meta( $post_id, '_wc_email_template_source_hash', true ) );
+ $this->assertNotSame( '', (string) get_post_meta( $post_id, '_wc_email_template_version', true ) );
+ $this->assertNotSame( '', (string) get_post_meta( $post_id, '_wc_email_last_synced_at', true ) );
+
+ 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();
+ }
+}