Commit 57c1412251a for woocommerce
commit 57c1412251a650651df9ce8bc3178cda8f875a83
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 15 17:03:52 2026 +0300
[tests] Demote 17 email template update E2E tests to PHPUnit and Jest (#68629)
* test(email-editor): Move template update propagation below E2E
Four update-propagation specs ran eighteen browser titles over what
happens to a merchant's block email when WooCommerce ships a new
canonical template: which posts are flagged, which are updated
silently, how a merchant picks between their copy and core's, and how
an apply is undone. Twelve of the eighteen drove REST only, two more
opened a page solely to attach a Tracks spy, and four touched the real
admin screens.
Move that behavior to where it is decided. The PHPUnit suites for the
sync backfill, the divergence detector, the auto-applier, the selective
applier, the sync tracker and the REST controller take the
classification, apply and undo contracts. A new Jest suite takes the
review drawer's per-conflict choices and what it sends on apply.
Delete the three specs whose titles now have lower owners, and the
Tracks spy, which no surviving title uses.
Keep one installed journey that walks the whole path a merchant does:
the Emails list flags the post, the editor offers the update, the
review drawer applies a mix of the merchant's copy and core's, and the
saved post carries exactly that mix.
Four changes to existing tests are not cosmetic: the auto-applier's
run test now seeds four posts, two of them candidates, so a loop that
stops after the first is caught; its failure candidates are ordered,
and the test checks the ordering took effect, so the one that fails is
the one the test names; a third auto-applier test asserts the status
apply_to_post() returns; and the detector test asserts the persisted
in-sync status.
Two cases are new rather than moved. The review drawer must stay open
when an apply fails, and the detector must not report an update as
available for a post the merchant never edited, which the deleted
auto-apply title observed through the Tracks spy.
Consolidates the mega-branch slices:
- Slice 015: test(email-editor): Move update propagation below E2E
- Slice 063: test(email-editor): Consolidate update flow coverage
Refs TESTOPS-288
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(email-editor): Drop process isolation from the backfill hook test
The new backfill-through-Integration-hooks test carried
@runInSeparateProcess, so PHPUnit forked a child process that re-ran
tests/legacy/bootstrap.php. That bootstrap calls
OrderHelper::toggle_cot_feature_and_usage( true ), which writes
woocommerce_custom_orders_table_enabled = yes over the child's own
database connection, outside the parent's rolled-back transaction. The
write survived the test, and every test that ran afterwards in the
parent process silently switched from the CPT order store back to HPOS.
Three later tests depend on the CPT store being in place and started
failing on all three HPOS-enabled unit:php jobs:
COTMigrationUtilTest::test_get_post_or_object_meta, plus
MetaDataUtilTest::test_update_ignores_non_array_meta_data and
::test_update_passes_default_id, which both count the meta on a freshly
created order and see the two address-index keys the HPOS store keeps
visible.
The isolation was not needed. The test passes in process, both on its
own and in the full suite, because WP_UnitTestCase backs up and restores
the hook globals around every test, so registering Integration's hooks
cannot leak into anything else.
Also reword the setUp comment above \WC_Emails::instance(). The call is
still needed so the mock builders can reflect on \WC_Email, but it has
nothing to do with process isolation any more, so it now matches the
wording WCEmailTemplateSyncBackfillTest already uses.
Verified locally: the full suite, the main suite on its own, and the
HPOS-off suite all pass at 15098 / 12481 / 15098 tests with no failures.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(email-editor): Stop the reset-route test restoring the current user
tear_down() ends with wp_set_current_user( 0 ), so capturing the
previous id and putting it back in the finally repeated it. The
$wp_rest_server swap next to it stays: that global is not part of the
base teardown, and leaving a throwaway server behind would change how
later tests dispatch.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(email-editor): Give the unmapped-post test a positive control
Every assertion in test_unmapped_email_remains_unstamped_without_update_available_event
is an absence: no sync meta on the post, no update-available events. The
backfill has several early returns -- no eligible posts, an empty sync
registry, and run_sweep()'s own completion guard -- and any of them would
leave the whole test green while the pipeline never ran.
Seed a mapped post in the same run and assert it does come out stamped,
the way the sibling integration-hooks test already does. Confirmed by
removing the backfill call: the test now fails on the control with "The
mapped control post must be processed", where before it passed.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(email-editor): Drop the WC_Email load guards from the applier tests
The auto-applier, divergence detector and selective applier tests each
required class-wc-email.php before building a WC_Email double, for the
separate-process run the tracker test used to need. Nothing forks since
3b1269b1b3, and WC_Email is already loaded in the suite's own process:
with the guards removed, each class passes on its own and the whole
directory passes.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(email-editor): Cover backfill, core update and sweep on one post
Each step had an owner, but nothing composed them against one
merchant-customized post the way the deleted BC Case C browser title
did.
The new test runs the backfill, changes the canonical render through
woocommerce_email_content_post_data, then runs the sweep and the
auto-applier, and asserts the merchant text and status after each step.
Stamping sha1( post_content ) in the backfill, or classifying every core
change as uncustomized in the sweep, fails it.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/testops-288-email-editor-update-propagation b/plugins/woocommerce/changelog/testops-288-email-editor-update-propagation
new file mode 100644
index 00000000000..2b32f84ef96
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-email-editor-update-propagation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move email template update propagation coverage below E2E; eighteen browser titles become one installed journey.
+
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/review-drawer.test.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/review-drawer.test.tsx
new file mode 100644
index 00000000000..d7f3b002c8b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/__tests__/review-drawer.test.tsx
@@ -0,0 +1,172 @@
+/**
+ * External dependencies
+ */
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * Internal dependencies
+ */
+import { ReviewDrawer } from '../review-drawer';
+import type { ChangeSummary } from '../hooks/use-change-summary';
+
+const mockUseChangeSummary = jest.fn();
+const mockUseApplyUpdate = jest.fn();
+
+jest.mock( '../hooks/use-change-summary', () => ( {
+ useChangeSummary: () => mockUseChangeSummary(),
+} ) );
+
+jest.mock( '../hooks/use-apply-update', () => ( {
+ useApplyUpdate: () => mockUseApplyUpdate(),
+} ) );
+
+const summary: ChangeSummary = {
+ version_from: '1.0.0',
+ version_to: '1.1.0',
+ source_hash_to: 'abc123',
+ added_blocks: [],
+ removed_blocks: [],
+ copy_changes: [
+ {
+ block: 'Paragraph',
+ before: 'Merchant text one',
+ after: 'Core text one',
+ occurrence: 1,
+ total: 2,
+ path: [ 0 ],
+ auto_resolvable: false,
+ },
+ {
+ block: 'Paragraph',
+ before: 'Merchant text two',
+ after: 'Core text two',
+ occurrence: 2,
+ total: 2,
+ path: [ 1 ],
+ auto_resolvable: false,
+ },
+ ],
+ structural_changes: [],
+ summary_lines: [],
+ is_fallback: false,
+ cache_hit: false,
+};
+
+describe( 'ReviewDrawer', () => {
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'applies only an explicitly selected core conflict and closes after success', async () => {
+ const apply = jest.fn().mockResolvedValue( { status: 'applied' } );
+ const onOpenChange = jest.fn();
+ mockUseChangeSummary.mockReturnValue( {
+ summary,
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ } );
+ mockUseApplyUpdate.mockReturnValue( { apply, isApplying: false } );
+
+ render(
+ <ReviewDrawer
+ postId={ 123 }
+ emailTitle="New order"
+ isOpen
+ onOpenChange={ onOpenChange }
+ />
+ );
+
+ const conflictGroups = screen.getAllByRole( 'radiogroup', {
+ name: 'Choose which version to apply',
+ } );
+ expect( conflictGroups ).toHaveLength( 2 );
+ for ( const conflictGroup of conflictGroups ) {
+ expect(
+ within( conflictGroup ).getByRole( 'radio', {
+ name: /keep yours/i,
+ } )
+ ).toHaveAttribute( 'aria-checked', 'true' );
+ expect(
+ within( conflictGroup ).getByRole( 'radio', {
+ name: /use core/i,
+ } )
+ ).toHaveAttribute( 'aria-checked', 'false' );
+ }
+
+ await userEvent.click(
+ within( conflictGroups[ 0 ] ).getByRole( 'radio', {
+ name: /use core/i,
+ } )
+ );
+
+ expect(
+ within( conflictGroups[ 0 ] ).getByRole( 'radio', {
+ name: /use core/i,
+ } )
+ ).toHaveAttribute( 'aria-checked', 'true' );
+ expect(
+ within( conflictGroups[ 0 ] ).getByRole( 'radio', {
+ name: /keep yours/i,
+ } )
+ ).toHaveAttribute( 'aria-checked', 'false' );
+ expect(
+ within( conflictGroups[ 1 ] ).getByRole( 'radio', {
+ name: /keep yours/i,
+ } )
+ ).toHaveAttribute( 'aria-checked', 'true' );
+ expect(
+ within( conflictGroups[ 1 ] ).getByRole( 'radio', {
+ name: /use core/i,
+ } )
+ ).toHaveAttribute( 'aria-checked', 'false' );
+
+ await userEvent.click(
+ screen.getByRole( 'button', { name: 'Apply (2)' } )
+ );
+
+ await waitFor( () =>
+ expect( apply ).toHaveBeenCalledWith( [
+ { path: [ 0 ], decision: 'use_core' },
+ ] )
+ );
+ expect( apply ).toHaveBeenCalledTimes( 1 );
+ await waitFor( () =>
+ expect( onOpenChange ).toHaveBeenCalledWith( false )
+ );
+ expect( onOpenChange ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'stays open when the apply does not succeed', async () => {
+ // useApplyUpdate resolves null when the request fails, and the drawer must
+ // keep the merchant's choices on screen rather than close on them.
+ const apply = jest.fn().mockResolvedValue( null );
+ const onOpenChange = jest.fn();
+ mockUseChangeSummary.mockReturnValue( {
+ summary,
+ isLoading: false,
+ error: null,
+ refetch: jest.fn(),
+ } );
+ mockUseApplyUpdate.mockReturnValue( { apply, isApplying: false } );
+
+ render(
+ <ReviewDrawer
+ postId={ 123 }
+ emailTitle="New order"
+ isOpen
+ onOpenChange={ onOpenChange }
+ />
+ );
+
+ await userEvent.click(
+ screen.getByRole( 'button', { name: 'Apply (2)' } )
+ );
+
+ await waitFor( () => expect( apply ).toHaveBeenCalledTimes( 1 ) );
+ // Let the rejected-result branch settle before asserting it closed nothing.
+ await apply.mock.results[ 0 ].value;
+ expect( onOpenChange ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/backward-compat.spec.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/backward-compat.spec.ts
deleted file mode 100644
index 463f77c2c39..00000000000
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/backward-compat.spec.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-/**
- * External dependencies
- */
-import { test, expect } from '@playwright/test';
-import { createClient } from '@woocommerce/e2e-utils-playwright';
-
-/**
- * Update-propagation: backward compatibility.
- *
- * Covers the five BC scenarios for sites that had email posts before the
- * RSM-137 stamp meta was introduced: Case A (content matches core, no stamp),
- * Case B (timestamps equal, content behind core), Case C (customized post —
- * critical safety: content must never be overwritten), the no-mass-fire
- * Tracks guard (backfill must not fire _available events), and idempotency
- * (second backfill is a no-op).
- *
- * Reviewing in Playwright UI mode:
- * 1. Run `npx playwright test --project=core-serial tests/email-editor/update-propagation --ui`
- * 2. Filter the tree by `backward-compat` and pick a test.
- * 3. All tests are REST-only except "BC no mass-fire" which attaches a Tracks
- * spy via the page fixture (but does not navigate or interact with the UI).
- * For all tests, the Actions panel in UI mode shows the REST call sequence.
- * 4. "Show browser" eye is not needed for any test in this file.
- */
-
-/**
- * Internal dependencies
- */
-import { ADMIN_STATE_PATH } from '../../../playwright.config';
-import { admin } from '../../../test-data/data';
-import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
-import {
- clearTemplateHtmlOverride,
- setTemplateHtmlOverride,
-} from './helpers/test-helper-plugin';
-import {
- seedWooEmailPost,
- getWooEmailMeta,
- getWooEmailPostContent,
-} from './helpers/seed-woo-email';
-import {
- triggerBackfill,
- triggerDetectionSweep,
-} from './helpers/simulate-plugin-update';
-import { attachTracksSpy } from './helpers/tracks-spy';
-import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
-import {
- STATUS,
- META_KEYS,
- TRACKS_EVENTS,
- TEST_HELPER_API_BASE,
-} from './helpers/classifications';
-
-const BACKFILL_COMPLETE_OPTION =
- 'woocommerce_email_template_sync_backfill_complete';
-
-// One-shot Tracks guard added by RSM-145: fires _backfill_completed at most
-// once per site. Must be cleared alongside BACKFILL_COMPLETE_OPTION so the
-// Tracks spy can observe the event each time a BC test re-runs the backfill.
-const BACKFILL_COMPLETED_TRACKED_OPTION =
- 'wc_email_sync_backfill_completed_tracked';
-
-const OLD_HTML = '<!-- wp:paragraph --><p>OLD</p><!-- /wp:paragraph -->';
-
-async function resetBackfillFence( baseURL: string ): Promise< void > {
- const client = createClient( baseURL, {
- type: 'basic',
- username: admin.username,
- password: admin.password,
- } );
- await client.post( `${ TEST_HELPER_API_BASE }/delete-option`, {
- option_name: BACKFILL_COMPLETE_OPTION,
- } );
- await client.post( `${ TEST_HELPER_API_BASE }/delete-option`, {
- option_name: BACKFILL_COMPLETED_TRACKED_OPTION,
- } );
-}
-
-test.describe( 'Update propagation — backward compatibility', () => {
- test.use( { storageState: ADMIN_STATE_PATH } );
-
- test.beforeAll( async ( { baseURL } ) => {
- await enableEmailEditor( baseURL! );
- } );
-
- test.beforeEach( async ( { baseURL } ) => {
- // RSM-145 stamps this option on fresh installs via woocommerce_newly_installed
- // to suppress backfill on greenfield environments. BC scenarios need a clean
- // "pre-RSM-137" environment, so clear the option-fence before each test.
- await resetBackfillFence( baseURL! );
- } );
-
- test.afterEach( async () => {
- await assertNoLeakedFixtureState();
- } );
-
- /**
- * Verifies that a pre-RSM-137 post whose content already matches the current
- * canonical is stamped in_sync by the backfill and correctly participates in
- * subsequent detection sweeps.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
- * (stripStampMeta) → triggerBackfill → getWooEmailMeta assertions →
- * setTemplateHtmlOverride → triggerDetectionSweep → meta re-check.
- *
- * "Show browser" eye: not needed.
- */
- test( 'BC Case A — content matches current core, no stamp meta', async () => {
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- stripStampMeta: true,
- } );
-
- const backfill = await triggerBackfill();
- expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
-
- const meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
- expect( meta[ META_KEYS.SOURCE_HASH ]?.[ 0 ] ).toBeTruthy();
-
- // Simulate a core bump by setting the override to a different canonical.
- await setTemplateHtmlOverride( 'new_order', OLD_HTML );
- await triggerDetectionSweep();
- await clearTemplateHtmlOverride();
-
- // The sweep classifies the unmodified post as core_updated_uncustomized,
- // then the auto-applier (run inline by /trigger-sweep) silently applies
- // the new canonical and re-stamps the post as in_sync.
- const metaAfter = await getWooEmailMeta( postId );
- expect( metaAfter[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
- } );
-
- /**
- * Verifies that a pre-RSM-137 post with equal created/modified timestamps
- * (indicating the content was never edited) is silently updated to the current
- * canonical during backfill and stamped in_sync.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
- * (stripStampMeta, equal timestamps, old content) → triggerBackfill →
- * meta assertion → REST GET to verify post_content was rewritten to canonical.
- *
- * "Show browser" eye: not needed.
- */
- test( 'BC Case B — timestamps equal and content behind core', async () => {
- const ts = '2024-01-01 12:00:00';
-
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: OLD_HTML,
- postDateGmt: ts,
- postModifiedGmt: ts,
- stripStampMeta: true,
- } );
-
- const backfill = await triggerBackfill();
- expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
-
- const meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
-
- // Critical: the backfill rewrote post_content from OLD_HTML to current canonical.
- const content = await getWooEmailPostContent( postId );
- expect( content ).not.toContain( 'OLD' );
- } );
-
- /**
- * Critical safety test: verifies that a pre-RSM-137 post whose content diverges
- * from canonical (i.e., the merchant edited it) is stamped core_updated_customized
- * by the backfill and that its content is NEVER overwritten — neither during
- * backfill nor during subsequent detection sweeps.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
- * (stripStampMeta, customized content) → triggerBackfill → meta + content
- * assertions → setTemplateHtmlOverride → triggerDetectionSweep → repeat
- * meta + content assertions confirming the merchant text is still intact.
- *
- * "Show browser" eye: not needed.
- */
- test( '@pr BC Case C — customized post content preserved (critical safety)', async () => {
- const customized =
- '<!-- wp:paragraph --><p>MERCHANT CUSTOM 1234</p><!-- /wp:paragraph -->';
-
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: customized,
- postDateGmt: '2024-01-01 12:00:00',
- postModifiedGmt: '2024-06-15 09:00:00',
- stripStampMeta: true,
- } );
-
- const backfill = await triggerBackfill();
- expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
-
- let meta = await getWooEmailMeta( postId );
- // Case C: content differs from canonical AND the post has been edited.
- // Backfill stamps core_updated_customized (does NOT rewrite post_content).
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
- STATUS.CORE_UPDATED_CUSTOMIZED
- );
-
- // CRITICAL: post content must be UNTOUCHED by backfill.
- const contentAfterBackfill = await getWooEmailPostContent( postId );
- expect( contentAfterBackfill ).toContain( 'MERCHANT CUSTOM 1234' );
-
- await setTemplateHtmlOverride( 'new_order', OLD_HTML );
- await triggerDetectionSweep();
- await clearTemplateHtmlOverride();
-
- meta = await getWooEmailMeta( postId );
- // Safety claim: classification is CUSTOMIZED, not UNCUSTOMIZED.
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
- STATUS.CORE_UPDATED_CUSTOMIZED
- );
-
- const contentAfterBump = await getWooEmailPostContent( postId );
- expect( contentAfterBump ).toContain( 'MERCHANT CUSTOM 1234' );
- } );
-
- /**
- * Verifies that running backfill + detection sweep on a full set of 11 email
- * types fires exactly one _backfill_completed Tracks event and zero
- * _available events (guarding against a mass notification storm on upgrade).
- *
- * UI mode walkthrough:
- * The page fixture is used only to attach the Tracks spy — no navigation
- * or UI interaction occurs. The spy intercepts server-side Tracks events via
- * REST. Actions panel shows: seedWooEmailPost (×11) → triggerBackfill →
- * triggerDetectionSweep → spy.drain() → event count assertions.
- *
- * "Show browser" eye: not needed.
- */
- test( 'BC no mass-fire on first upgrade: zero _available, one _backfill_completed', async ( {
- page,
- } ) => {
- const spy = await attachTracksSpy( page );
-
- const emailIds = [
- 'new_order',
- 'cancelled_order',
- 'failed_order',
- 'customer_on_hold_order',
- 'customer_processing_order',
- 'customer_completed_order',
- 'customer_refunded_order',
- 'customer_invoice',
- 'customer_note',
- 'customer_reset_password',
- 'customer_new_account',
- ];
- for ( const id of emailIds ) {
- await seedWooEmailPost( {
- emailId: id,
- stripStampMeta: true,
- } );
- }
-
- const backfill = await triggerBackfill();
- expect( backfill.stamped ).toBeGreaterThanOrEqual( emailIds.length );
-
- await triggerDetectionSweep();
-
- // Drain all server + client events in one call. Each expectFired/expectNotFired
- // call invokes drain() independently, which reads and deletes the server log —
- // a second call would see an empty log. Assert both conditions against the same
- // snapshot to avoid missing events.
- const events = await spy.drain();
- const available = events.filter(
- ( e ) => e.name === TRACKS_EVENTS.AVAILABLE
- );
- const backfillCompleted = events.filter(
- ( e ) => e.name === TRACKS_EVENTS.BACKFILL_COMPLETED
- );
- expect(
- available.length,
- 'No _available events should fire during backfill'
- ).toBe( 0 );
- expect(
- backfillCompleted.length,
- 'Exactly one _backfill_completed event should fire'
- ).toBe( 1 );
- } );
-
- /**
- * Verifies that running the backfill twice produces identical post meta,
- * confirming the migration is safe to re-run (e.g., in case of interrupted
- * deploys or duplicate cron fires).
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
- * (stripStampMeta) → triggerBackfill (first) → getWooEmailMeta snapshot →
- * triggerBackfill (second) → getWooEmailMeta equality assertion.
- *
- * "Show browser" eye: not needed.
- */
- test( 'BC migration is idempotent: second backfill is a no-op', async () => {
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- stripStampMeta: true,
- } );
-
- const first = await triggerBackfill();
- expect( first.stamped ).toBeGreaterThanOrEqual( 1 );
- const metaAfterFirst = await getWooEmailMeta( postId );
-
- await triggerBackfill();
- const metaAfterSecond = await getWooEmailMeta( postId );
-
- expect( metaAfterSecond ).toEqual( metaAfterFirst );
- } );
-} );
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
index 5c4234c3cef..fdf0d14bb39 100644
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
@@ -3,422 +3,136 @@
*/
import { test, expect } from '@playwright/test';
-/**
- * Update-propagation: core flows.
- *
- * Covers the merchant-facing lifecycle of a core-template update: divergence
- * detection, the update-available indicator on the list + editor banner,
- * auto-apply for unmodified posts, selective apply for customized posts, the
- * dismiss flow, and the review-drawer-driven selective merge.
- *
- * Reviewing in Playwright UI mode:
- * 1. Run `npx playwright test --project=core-serial tests/email-editor/update-propagation --ui`
- * 2. Filter the tree by `core-flows` and pick a test.
- * 3. For UI tests, toggle "Show browser" (👁 in the top-left toolbar) to watch the
- * Chromium window drive the admin. For REST-only tests the Actions panel
- * shows the REST call sequence — no browser needed.
- * 4. Per-test JSDoc below indicates whether each test drives the browser.
- */
-
/**
* Internal dependencies
*/
import { ADMIN_STATE_PATH } from '../../../playwright.config';
-import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
-import { accessTheEmailEditor } from '../../../utils/email';
import {
- clearTemplateHtmlOverride,
- setTemplateHtmlOverride,
-} from './helpers/test-helper-plugin';
+ deleteEmailPost,
+ disableEmailEditor,
+ enableEmailEditor,
+} from '../helpers/enable-email-editor-feature';
+import { accessTheEmailEditor } from '../../../utils/email';
+import { setTemplateHtmlOverride } from './helpers/test-helper-plugin';
import {
seedWooEmailPost,
- getWooEmailMeta,
getWooEmailPostContent,
- applyWooEmailTemplate,
} from './helpers/seed-woo-email';
import {
simulateCoreBump,
triggerDetectionSweep,
} from './helpers/simulate-plugin-update';
-import { attachTracksSpy } from './helpers/tracks-spy';
import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
-import { STATUS, META_KEYS, TRACKS_EVENTS } from './helpers/classifications';
-
-const OLD_HTML =
- '<!-- wp:paragraph --><p>OLD CANONICAL</p><!-- /wp:paragraph -->';
+import { STATUS } from './helpers/classifications';
test.describe( 'Update propagation — core flows', () => {
test.use( { storageState: ADMIN_STATE_PATH } );
+ let seededPostId: number | null = null;
test.beforeAll( async ( { baseURL } ) => {
await enableEmailEditor( baseURL! );
} );
- test.afterEach( async () => {
- await assertNoLeakedFixtureState();
- } );
-
- /**
- * Verifies that running the detection sweep after a core bump correctly
- * classifies an unmodified post as auto-applied (in_sync) and a merchant-
- * customized post as core_updated_customized, waiting for manual review.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows the REST call
- * sequence: simulateCoreBump → seedWooEmailPost (×2) → clearTemplateHtmlOverride
- * → triggerDetectionSweep → getWooEmailMeta assertions.
- *
- * "Show browser" eye: not needed.
- */
- test( '@pr Plugin update triggers divergence detection and classifies posts', async () => {
- // Bump and seed the uncustomized post.
- await simulateCoreBump( 'new_order', OLD_HTML );
- const uncustomizedPostId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: OLD_HTML,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- } );
+ test.afterEach( async ( { baseURL } ) => {
+ const cleanupErrors: unknown[] = [];
- // Bump and seed the customized post (override is single-key, replacing the
- // previous one — but new_order's stored hash was already captured at seed time).
- await simulateCoreBump( 'customer_processing_order', OLD_HTML );
- const customizedHtml = OLD_HTML.replace(
- 'OLD CANONICAL',
- 'MERCHANT EDITED'
- );
- const customizedPostId = await seedWooEmailPost( {
- emailId: 'customer_processing_order',
- postContent: customizedHtml,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- } );
-
- await clearTemplateHtmlOverride();
-
- const sweep = await triggerDetectionSweep();
-
- const uncustomizedMeta = await getWooEmailMeta( uncustomizedPostId );
- const customizedMeta = await getWooEmailMeta( customizedPostId );
-
- // The sweep classifies the unmodified post as core_updated_uncustomized,
- // then the auto-applier (also fired by /trigger-sweep inline) silently
- // applies the new canonical and re-stamps the post as in_sync.
- expect( uncustomizedMeta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
- STATUS.IN_SYNC
- );
- // Customized posts are left for the merchant to apply manually.
- expect( customizedMeta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
- STATUS.CORE_UPDATED_CUSTOMIZED
- );
- expect( sweep.touched ).toBeGreaterThanOrEqual( 2 );
- } );
-
- /**
- * Verifies that a core_updated_customized post surfaces a "Review update"
- * button on the email list page and a "Template update available" banner
- * inside the block editor.
- *
- * UI mode walkthrough:
- * After REST setup the test navigates to WP Admin → WooCommerce → Settings →
- * Email. The DataViews table loads and the "New order" row should contain a
- * "Review update" button. The test then opens the email in the block editor
- * and asserts the "Template update available" status banner is visible. No
- * clicks — both assertions are visibility checks only.
- *
- * "Show browser" eye: ON.
- */
- test( '@pr Update-available indicator appears on email list and in editor', async ( {
- page,
- } ) => {
- await simulateCoreBump( 'new_order', OLD_HTML );
- await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' ),
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- // Seed an older version so the registry's current_version is higher.
- // The list cell and editor banner only show when
- // templateVersion < currentVersion; same-version posts don't surface
- // the indicator even when status is core_updated_customized.
- version: '10.0.0',
- } );
- await clearTemplateHtmlOverride();
- await triggerDetectionSweep();
-
- await page.goto( '/wp-admin/admin.php?page=wc-settings&tab=email' );
- // DataViews table rows have no aria-label, so getByRole('row', {name:...})
- // doesn't work. Use filter({ hasText }) to scope to the New order row.
- // The Updates column renders a secondary Button labelled "Review update"
- // when the post is core_updated_customized. The text "Update available"
- // only appears in the filter-dropdown elements, not in the row cell itself.
- const newOrderRow = page
- .locator( 'tr' )
- .filter( { hasText: /New order/i } )
- .first();
- await expect(
- newOrderRow.getByRole( 'button', { name: /review update/i } )
- ).toBeVisible( { timeout: 15000 } );
-
- await accessTheEmailEditor( page, 'New order' );
- // The editor banner title is "Template update available" (role="status").
- await expect(
- page.getByText( /template update available/i ).first()
- ).toBeVisible( { timeout: 15000 } );
- } );
-
- /**
- * Verifies that an unmodified post is silently brought back to in_sync by the
- * auto-applier, with no "Update available" indicator on the list and no
- * Tracks events fired for update-available or dismissed.
- *
- * UI mode walkthrough:
- * The page fixture is used only to attach the Tracks spy and to navigate to
- * the email list for the "no indicator" assertion — no clicks are performed.
- * You'll see the browser open the email settings page and the test confirms
- * the "Update available" text is hidden in the New order row.
- *
- * "Show browser" eye: ON.
- */
- test( '@pr Auto-apply succeeds silently for unmodified posts', async ( {
- page,
- } ) => {
- const spy = await attachTracksSpy( page );
-
- await simulateCoreBump( 'new_order', OLD_HTML );
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: OLD_HTML,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- } );
- await clearTemplateHtmlOverride();
-
- await triggerDetectionSweep();
-
- const meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
-
- // DataViews rows have no aria-label, so getByRole('row', {name:...}) doesn't
- // match. Use locator('tr').filter({hasText}) — same approach as test 2 —
- // then assert the "Review update" button is absent (toHaveCount(0)) which is
- // what actually surfaces when a post is core_updated_customized.
- await page.goto( '/wp-admin/admin.php?page=wc-settings&tab=email' );
- const newOrderRow = page
- .locator( 'tr' )
- .filter( { hasText: /New order/i } )
- .first();
- await expect(
- newOrderRow.getByRole( 'button', { name: /review update/i } )
- ).toHaveCount( 0 );
-
- await spy.expectNotFired( TRACKS_EVENTS.AVAILABLE );
- await spy.expectNotFired( TRACKS_EVENTS.DISMISSED );
- } );
-
- /**
- * Verifies that calling the apply endpoint with choices:[] (keep-yours default)
- * applies core additions while preserving merchant edits, and leaves the post
- * stamped core_updated_customized because the content still diverges from canonical.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: simulateCoreBump
- * → seedWooEmailPost → clearTemplateHtmlOverride → triggerDetectionSweep
- * → applyWooEmailTemplate (REST POST) → getWooEmailMeta + content assertions.
- *
- * "Show browser" eye: not needed.
- */
- test( '@pr Selective apply succeeds and preserves customizations', async () => {
- const oldHtml =
- '<!-- wp:paragraph --><p>OLD CORE</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>SECOND BLOCK</p><!-- /wp:paragraph -->';
- const customized = oldHtml.replace(
- 'SECOND BLOCK',
- 'MERCHANT EDITED SECOND'
- );
-
- await simulateCoreBump( 'new_order', oldHtml );
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: customized,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- } );
- await clearTemplateHtmlOverride();
- await triggerDetectionSweep();
-
- // Use applyWooEmailTemplate (basic auth) instead of request.post (cookie auth)
- // because WP REST POST endpoints require a nonce when using cookie-based auth.
- // choices: [] keeps all merchant edits and applies only core additions.
- const apply = await applyWooEmailTemplate( postId, [] );
- expect( apply.status ).toBe( 'applied' );
-
- const meta = await getWooEmailMeta( postId );
- // With choices:[] the merchant's edits are preserved (keep_yours is the
- // default for copy_changes). The merged result diverges from canonical, so
- // the applier stamps core_updated_customized — not in_sync.
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
- STATUS.CORE_UPDATED_CUSTOMIZED
- );
-
- const content = await getWooEmailPostContent( postId );
- expect( content ).toContain( 'MERCHANT EDITED SECOND' );
- } );
-
- /**
- * Verifies that clicking the "dismiss" button on the editor update banner fires
- * the expected Tracks dismissed event.
- *
- * UI mode walkthrough:
- * After REST setup the test opens the block editor for the New order email.
- * The editor canvas loads, and the update banner is visible at the top. If
- * the review drawer is already open it is closed via Escape. Then the test
- * clicks the banner's dismiss button (`.wc-update-banner__dismiss`) and
- * asserts the Tracks dismissed event fired.
- *
- * "Show browser" eye: ON.
- */
- test( '@pr Dismiss flow records the dismissed Tracks event', async ( {
- page,
- } ) => {
- const customized = OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' );
-
- const spy = await attachTracksSpy( page );
-
- await simulateCoreBump( 'new_order', OLD_HTML );
- await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: customized,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- // Seed an older version so the registry's current_version is higher.
- // The editor banner only shows when templateVersion < currentVersion;
- // same-version posts surface summaryShowsReviewed=true and unmount
- // the banner before the dismiss button can be clicked.
- version: '10.0.0',
- } );
- await clearTemplateHtmlOverride();
- await triggerDetectionSweep();
-
- await accessTheEmailEditor( page, 'New order' );
+ try {
+ await assertNoLeakedFixtureState();
+ } catch ( error ) {
+ cleanupErrors.push( error );
+ }
- // If the review drawer happened to open (e.g., via a deep-link or
- // store state from a prior navigation), close it before looking for
- // the banner's dismiss button so the drawer panel doesn't obscure it.
- const drawer = page.getByRole( 'dialog', {
- name: /review template update/i,
- } );
- if ( await drawer.isVisible() ) {
- await page.keyboard.press( 'Escape' );
- await drawer.waitFor( { state: 'hidden' } );
+ if ( seededPostId !== null ) {
+ try {
+ await deleteEmailPost( baseURL!, String( seededPostId ) );
+ } catch ( error ) {
+ cleanupErrors.push( error );
+ } finally {
+ seededPostId = null;
+ }
}
- // Target the banner's dismiss button by its stable CSS class to avoid
- // matching any other "dismiss"-labelled button that may be on the page.
- const dismissButton = page.locator( '.wc-update-banner__dismiss' );
- await expect( dismissButton ).toBeVisible( { timeout: 15000 } );
- await dismissButton.click();
+ if ( cleanupErrors.length > 0 ) {
+ throw new AggregateError(
+ cleanupErrors,
+ 'Update propagation cleanup failed.'
+ );
+ }
+ } );
- await spy.expectFired( TRACKS_EVENTS.DISMISSED );
+ test.afterAll( async ( { baseURL } ) => {
+ await disableEmailEditor( baseURL! );
} );
/**
- * Verifies that the review drawer allows per-conflict "keep yours" / "use core"
- * choices and that clicking Apply merges exactly the selected blocks into the
- * saved post content.
- *
- * UI mode walkthrough:
- * After REST setup the test navigates directly to the editor with the
- * `wc_email_review_drawer=1` deep-link param, which auto-opens the review
- * drawer. The drawer loads a change summary showing three conflicts. The test
- * leaves block A on "keep yours" (default), switches block B to "use core"
- * via a radio button click, then clicks Apply. The drawer closes and the
- * test verifies the merged content via REST (block A: merchant text kept,
- * block B: core text applied, block C: default kept).
- *
- * "Show browser" eye: ON.
+ * Verifies the installed list-to-editor update flow: the list and editor
+ * surface a customized core update, the review drawer applies one explicit
+ * core choice, and the merged content is persisted.
*/
- test( 'Review drawer: pick per-conflict yours vs core and apply', async ( {
+ test( '@pr Review drawer: pick per-conflict yours vs core and apply', async ( {
page,
} ) => {
- // We need real copy_changes in the change-summary, which only appear when
- // the LCS diff matches blocks by name and finds text differences.
- // Strategy: use setTemplateHtmlOverride for BOTH the "old" canonical
- // (to seed storedSourceHash) AND the "new" canonical (to control the
- // change-summary diff), keeping the same block structure with changed text.
const oldHtml =
'<!-- wp:paragraph --><p>OLD BLOCK A</p><!-- /wp:paragraph -->' +
'<!-- wp:paragraph --><p>OLD BLOCK B</p><!-- /wp:paragraph -->' +
'<!-- wp:paragraph --><p>OLD BLOCK C</p><!-- /wp:paragraph -->';
- // Merchant edited block A; blocks B and C kept the original text.
const customized = oldHtml.replace(
'OLD BLOCK A',
'MERCHANT EDITED A'
);
- // "New canonical" after a core bump: core changed text in B and C,
- // but A still matches nothing (it will conflict with merchant's edit).
const newCanonical =
'<!-- wp:paragraph --><p>NEW CORE A</p><!-- /wp:paragraph -->' +
'<!-- wp:paragraph --><p>NEW CORE B</p><!-- /wp:paragraph -->' +
'<!-- wp:paragraph --><p>NEW CORE C</p><!-- /wp:paragraph -->';
- // Step 1: set override = oldHtml so that AUTO_CURRENT resolves to sha1(oldHtml).
+ // Seed the merchant edit against the old canonical content.
await simulateCoreBump( 'new_order', oldHtml );
-
- // Step 2: seed the post — stored hash = sha1(oldHtml), content = merchant edits.
const postId = await seedWooEmailPost( {
emailId: 'new_order',
postContent: customized,
storedSourceHash: 'AUTO_CURRENT',
status: STATUS.IN_SYNC,
- // Use an older version so the registry's current_version is higher and
- // the editor banner renders (version_from < version_to).
version: '10.0.0',
} );
+ seededPostId = postId;
- // Step 3: swap the override to the "new" canonical. The sweep and the
- // change-summary endpoint will now compare the post against newCanonical.
+ // Move the canonical template and classify the post as requiring review.
await setTemplateHtmlOverride( 'new_order', newCanonical );
-
- // Step 4: sweep classifies the post as core_updated_customized.
await triggerDetectionSweep();
- // Open the editor and click the banner's "Review changes" button — the
- // merchant-facing path to open the drawer. (The wc_email_review_drawer=1
- // deep-link works locally but races with editor mount in CI; clicking the
- // banner button is the realistic flow and is stable.)
- await page.goto( `/wp-admin/post.php?post=${ postId }&action=edit` );
+ // Prove the installed list surfaces the update on the exact email row.
+ await page.goto( '/wp-admin/admin.php?page=wc-settings&tab=email' );
+ const newOrderRow = page
+ .locator( 'tr' )
+ .filter( { hasText: /New order/i } )
+ .first();
+ await expect(
+ newOrderRow.getByRole( 'button', { name: /review update/i } )
+ ).toBeVisible( { timeout: 15000 } );
- // Wait for the editor canvas to be ready.
+ // Enter through the real list/editor helper and open the review drawer
+ // from the editor banner.
+ await accessTheEmailEditor( page, 'New order' );
await expect( page.locator( '#woocommerce-email-editor' ) ).toBeVisible(
{
timeout: 20000,
}
);
-
- // Click "Review changes" in the floating update banner.
+ await expect(
+ page.getByText( /template update available/i ).first()
+ ).toBeVisible( { timeout: 15000 } );
await page.getByRole( 'button', { name: /^review changes$/i } ).click();
- // The drawer's <aside role="dialog"> becomes aria-hidden="false" once the
- // store dispatches openReviewDrawer(). The title text comes from the
- // "Review template update" h2 inside the drawer header.
const drawer = page.getByRole( 'dialog', {
name: /review template update/i,
} );
await expect( drawer ).toBeVisible( { timeout: 15000 } );
-
- // The change-summary fetch is triggered by the drawer's useChangeSummary
- // hook (enabled = isOpen = true). Wait for the "Needs your attention"
- // heading — the diff outcome (how many conflicts vs auto-resolved blocks)
- // depends on the differ; the test stays resilient by interacting only
- // with the first radiogroup and asserting content after apply.
await expect(
drawer.getByRole( 'heading', { name: /needs your attention/i } )
).toBeVisible( { timeout: 15000 } );
- // Pick the first radiogroup's "Use core" — flips the default from
- // "Keep yours" so the merchant's edit on block A is overwritten by core.
const firstRadioGroup = drawer
.getByRole( 'radiogroup', {
name: /choose which version to apply/i,
@@ -427,7 +141,6 @@ test.describe( 'Update propagation — core flows', () => {
await expect(
firstRadioGroup.getByRole( 'radio', { name: /keep yours/i } )
).toHaveAttribute( 'aria-checked', 'true' );
-
await firstRadioGroup
.getByRole( 'radio', { name: /use core/i } )
.click();
@@ -435,16 +148,9 @@ test.describe( 'Update propagation — core flows', () => {
firstRadioGroup.getByRole( 'radio', { name: /use core/i } )
).toHaveAttribute( 'aria-checked', 'true' );
- // Click Apply — label is "Apply (N)" where N = total changes.
await drawer.getByRole( 'button', { name: /^apply/i } ).click();
-
- // Drawer closes after a successful apply.
await expect( drawer ).toBeHidden( { timeout: 15000 } );
- // Verify the merged post content via REST. The single decision we made
- // was on block A's conflict ("use core"), so MERCHANT EDITED A must be
- // gone and NEW CORE A must be present. We don't assert on B/C here
- // because the differ may treat them as conflicts or auto-resolved.
const content = await getWooEmailPostContent( postId );
expect( content ).toContain( 'NEW CORE A' );
expect( content ).not.toContain( 'MERCHANT EDITED A' );
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/classifications.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/classifications.ts
index 993fe9344a1..3411c50cbf6 100644
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/classifications.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/classifications.ts
@@ -2,23 +2,7 @@
* Shared constants for the update-propagation E2E suite.
*
* Mirror the PHP-side meta keys and status values from
- * Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector
- * and the Tracks event names from RSM-145 (PR #64759).
- *
- * Event-name conventions (post-#64759 rename):
- *
- * Client-side events (fired via @woocommerce/tracks recordEvent, captured by
- * the window.wcTracks.recordEvent spy as-is — no prefix added by the package):
- * block_email_update_viewed
- * block_email_update_applied
- * block_email_update_dismissed
- *
- * Server-side events (fired via WC_Tracks::record_event(), captured by the
- * Tracks_Recorder woocommerce_tracks_event_properties filter which receives the
- * name already prefixed with "wcadmin_" by WC_Tracks::PREFIX):
- * wcadmin_block_email_update_available
- * wcadmin_block_email_update_applied
- * wcadmin_block_email_sync_backfill_completed
+ * Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector.
*/
export const STATUS = {
@@ -37,19 +21,6 @@ export const META_KEYS = {
BACKFILLED: '_wc_email_backfilled',
} as const;
-export const TRACKS_EVENTS = {
- // Server-side: WC_Tracks::record_event() adds "wcadmin_" prefix before
- // the woocommerce_tracks_event_properties filter fires, so the recorder
- // captures these with the prefix already applied.
- AVAILABLE: 'wcadmin_block_email_update_available',
- BACKFILL_COMPLETED: 'wcadmin_block_email_sync_backfill_completed',
- // Client-side: @woocommerce/tracks recordEvent() passes the name as-is
- // to window.wcTracks.recordEvent; the spy captures it without any prefix.
- VIEWED: 'block_email_update_viewed',
- APPLIED: 'block_email_update_applied',
- DISMISSED: 'block_email_update_dismissed',
-} as const;
-
export const BACKFILL_CASES = {
A: 'A',
B: 'B',
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/seed-woo-email.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/seed-woo-email.ts
index de878f590b2..9b39c38ce05 100644
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/seed-woo-email.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/seed-woo-email.ts
@@ -179,16 +179,6 @@ export async function seedWooEmailPostDirect(
return Number( first.post_id );
}
-export async function getWooEmailMeta(
- postId: number
-): Promise< Record< string, string[] > > {
- const client = apiClient();
- const res = await client.get(
- `${ TEST_HELPER_API_BASE }/seed-meta/${ postId }`
- );
- return ( res?.data?.meta ?? {} ) as Record< string, string[] >;
-}
-
export async function getWooEmailPostContent(
postId: number
): Promise< string > {
@@ -199,45 +189,6 @@ export async function getWooEmailPostContent(
return String( res?.data?.post_content ?? '' );
}
-export type ApplyChoice = {
- path: ( number | string )[];
- decision: 'keep_yours' | 'use_core';
-};
-
-export type ApplyResult = {
- merged_content: string;
- revision_id: string;
- version_to: string;
- status: string;
- structural_skipped: boolean;
- aliases_migrated: string[];
-};
-
-/**
- * Call the /apply endpoint for a woo_email post using basic-auth credentials,
- * bypassing the cookie+nonce requirement of the WP REST API for authenticated
- * cookie sessions. `choices` defaults to [] (keep all merchant edits, apply
- * only core additions).
- */
-export async function applyWooEmailTemplate(
- postId: number,
- choices: ApplyChoice[] = []
-): Promise< ApplyResult > {
- const client = apiClient();
- const res = await client.post(
- `woocommerce-email-editor/v1/emails/${ postId }/apply`,
- { choices } as Record< string, unknown >
- );
- if ( ! res?.data?.status ) {
- throw new Error(
- `applyWooEmailTemplate: unexpected response for post ${ postId }: ${ JSON.stringify(
- res?.data
- ) }`
- );
- }
- return res.data as ApplyResult;
-}
-
export type ResetResult = {
content: string;
version: string | null;
@@ -252,9 +203,8 @@ export type ResetResult = {
* bypassing the cookie+nonce requirement of the WP REST API for authenticated
* cookie sessions. Resets the post content to the canonical WooCommerce template.
*
- * Note: unlike applyWooEmailTemplate whose `status` field is "applied", the
- * reset endpoint returns the post-reset sync status (e.g. "in_sync") in the
- * `status` field.
+ * The reset endpoint returns the post-reset sync status (for example,
+ * "in_sync") in the `status` field.
*/
export async function resetWooEmailTemplate(
postId: number
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/tracks-spy.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/tracks-spy.ts
deleted file mode 100644
index 5e60ae925c0..00000000000
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/helpers/tracks-spy.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-/**
- * External dependencies
- */
-import type { Page } from '@playwright/test';
-import { createClient } from '@woocommerce/e2e-utils-playwright';
-import { expect } from '@playwright/test';
-
-/**
- * Internal dependencies
- */
-import { admin } from '../../../../test-data/data';
-import playwrightConfig from '../../../../playwright.config';
-import { TEST_HELPER_API_BASE } from './classifications';
-import { enableTracksLog, disableTracksLog } from './test-helper-plugin';
-
-const baseURL = playwrightConfig.use?.baseURL ?? '';
-
-export type TracksEvent = {
- name: string;
- properties: Record< string, unknown >;
- timestamp_ms: number;
-};
-
-export interface TracksSpy {
- drain(): Promise< TracksEvent[] >;
- expectFired( name: string, count?: number ): Promise< void >;
- expectNotFired( name: string ): Promise< void >;
- reset(): Promise< void >;
-}
-
-declare global {
- interface Window {
- __capturedTracksEvents?: TracksEvent[];
- wcTracks?: {
- recordEvent?: (
- name: string,
- properties?: Record< string, unknown >
- ) => void;
- };
- }
-}
-
-function apiClient() {
- return createClient( baseURL, {
- type: 'basic',
- username: admin.username,
- password: admin.password,
- } );
-}
-
-/**
- * Attach a client+server Tracks spy to a Page. The client-side hook patches
- * `window.wcTracks.recordEvent` (the dispatch target used by `@woocommerce/tracks`)
- * to capture events as they fire. The server-side mirror reads the
- * Tracks_Recorder log via the test-helper plugin's REST endpoint.
- *
- * `drain()` merges and dedupes both buffers; tests assert against the merged set.
- */
-export async function attachTracksSpy( page: Page ): Promise< TracksSpy > {
- await enableTracksLog();
-
- await page.addInitScript( () => {
- window.__capturedTracksEvents = [];
-
- const installPatch = (): boolean => {
- if (
- ! window.wcTracks ||
- typeof window.wcTracks.recordEvent !== 'function'
- ) {
- return false;
- }
- const original = window.wcTracks.recordEvent;
- window.wcTracks.recordEvent = function (
- name: string,
- properties?: Record< string, unknown >
- ) {
- try {
- window.__capturedTracksEvents!.push( {
- name,
- properties: properties ?? {},
- timestamp_ms: Date.now(),
- } );
- } catch {}
- return original.call( this, name, properties );
- };
- return true;
- };
-
- if ( ! installPatch() ) {
- document.addEventListener( 'DOMContentLoaded', () => {
- installPatch();
- } );
- }
- } );
-
- // addInitScript only instruments future documents. Patch the currently-loaded
- // page too — idempotent via a __wcSpyWrapped guard so a second attachTracksSpy
- // call (or a same-document re-attach) doesn't double-wrap.
- await page.evaluate( () => {
- window.__capturedTracksEvents = window.__capturedTracksEvents ?? [];
- if (
- ! window.wcTracks ||
- typeof window.wcTracks.recordEvent !== 'function'
- ) {
- return;
- }
- const current = window.wcTracks.recordEvent as ( (
- ...args: unknown[]
- ) => unknown ) & { __wcSpyWrapped?: boolean };
- if ( current.__wcSpyWrapped ) {
- return;
- }
- const original = current;
- const wrapped = function (
- name: string,
- properties?: Record< string, unknown >
- ) {
- try {
- window.__capturedTracksEvents!.push( {
- name,
- properties: properties ?? {},
- timestamp_ms: Date.now(),
- } );
- } catch {}
- return original.call( this, name, properties );
- };
- (
- wrapped as typeof wrapped & { __wcSpyWrapped: boolean }
- ).__wcSpyWrapped = true;
- window.wcTracks.recordEvent =
- wrapped as typeof window.wcTracks.recordEvent;
- } );
-
- const drain = async (): Promise< TracksEvent[] > => {
- const clientEvents = await page.evaluate( () => {
- const events = window.__capturedTracksEvents ?? [];
- window.__capturedTracksEvents = [];
- return events;
- } );
-
- const client = apiClient();
- const serverRes = await client.get(
- `${ TEST_HELPER_API_BASE }/tracks`
- );
- const serverEvents = ( serverRes?.data?.events ?? [] ) as TracksEvent[];
- await client.delete( `${ TEST_HELPER_API_BASE }/tracks`, {} );
-
- const seen = new Set< string >();
- const merged: TracksEvent[] = [];
- for ( const evt of [ ...clientEvents, ...serverEvents ] ) {
- const key = `${ evt.name }|${ evt.timestamp_ms }`;
- if ( seen.has( key ) ) {
- continue;
- }
- seen.add( key );
- merged.push( evt );
- }
- merged.sort( ( a, b ) => a.timestamp_ms - b.timestamp_ms );
- return merged;
- };
-
- return {
- drain,
- expectFired: async ( name: string, count?: number ) => {
- const events = await drain();
- const matches = events.filter( ( e ) => e.name === name );
- if ( count !== undefined ) {
- expect( matches.length ).toBe( count );
- } else {
- expect( matches.length ).toBeGreaterThan( 0 );
- }
- },
- expectNotFired: async ( name: string ) => {
- const events = await drain();
- expect( events.filter( ( e ) => e.name === name ).length ).toBe(
- 0
- );
- },
- reset: async () => {
- await page.evaluate( () => {
- window.__capturedTracksEvents = [];
- } );
- const client = apiClient();
- await client.delete( `${ TEST_HELPER_API_BASE }/tracks`, {} );
- },
- };
-}
-
-export async function detachTracksSpy(): Promise< void > {
- await disableTracksLog();
-}
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts
deleted file mode 100644
index 8ba76aa203f..00000000000
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-/**
- * External dependencies
- */
-import { test, expect } from '@playwright/test';
-
-/**
- * Update-propagation: round-trip and idempotency.
- *
- * Covers four state-machine round-trips: (1) auto-apply returns an unmodified
- * post to in_sync, (2) selective apply with keep-yours keeps the post in the
- * customized state, (3) reset returns a customized post directly to in_sync,
- * and (4) the detection sweep is idempotent — a second run classifies the same
- * post identically and writes no new meta.
- *
- * Reviewing in Playwright UI mode:
- * 1. Run `npx playwright test --project=core-serial tests/email-editor/update-propagation --ui`
- * 2. Filter the tree by `round-trip` and pick a test.
- * 3. All four tests are REST-only — no browser window is driven. The Actions
- * panel in UI mode shows the full REST call sequence for each test.
- * 4. "Show browser" eye is not needed for any test in this file.
- */
-
-/**
- * Internal dependencies
- */
-import { ADMIN_STATE_PATH } from '../../../playwright.config';
-import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
-import { clearTemplateHtmlOverride } from './helpers/test-helper-plugin';
-import {
- seedWooEmailPost,
- getWooEmailMeta,
- applyWooEmailTemplate,
- resetWooEmailTemplate,
-} from './helpers/seed-woo-email';
-import {
- simulateCoreBump,
- triggerDetectionSweep,
-} from './helpers/simulate-plugin-update';
-import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
-import { STATUS, META_KEYS } from './helpers/classifications';
-
-const OLD_HTML =
- '<!-- wp:paragraph --><p>OLD CANONICAL</p><!-- /wp:paragraph -->';
-
-test.describe( 'Update propagation — round-trip and idempotency', () => {
- test.use( { storageState: ADMIN_STATE_PATH } );
-
- test.beforeAll( async ( { baseURL } ) => {
- await enableEmailEditor( baseURL! );
- } );
-
- test.afterEach( async () => {
- await assertNoLeakedFixtureState();
- } );
-
- /**
- * Verifies the full auto-apply round-trip: after a core bump the detection
- * sweep detects the divergence and the inline auto-applier immediately
- * re-stamps an unmodified post as in_sync.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: simulateCoreBump
- * → seedWooEmailPost → clearTemplateHtmlOverride → triggerDetectionSweep
- * → getWooEmailMeta assertion (STATUS.IN_SYNC).
- *
- * "Show browser" eye: not needed.
- */
- test( 'Auto-apply round-trip: uncustomized post returns to in_sync', async () => {
- await simulateCoreBump( 'new_order', OLD_HTML );
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: OLD_HTML,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- } );
- await clearTemplateHtmlOverride();
-
- await triggerDetectionSweep();
-
- const meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
- } );
-
- /**
- * Verifies that when a merchant applies a core update with choices:[] (keep-yours
- * default for all conflicts), the post's diverged block is preserved and the
- * status remains core_updated_customized rather than flipping to in_sync.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: simulateCoreBump
- * → seedWooEmailPost (customized block B) → clearTemplateHtmlOverride →
- * triggerDetectionSweep → meta assertion (CUSTOMIZED) → applyWooEmailTemplate
- * (choices:[]) → meta re-assertion (still CUSTOMIZED).
- *
- * "Show browser" eye: not needed.
- */
- test( 'Selective apply round-trip: edit, bump, apply with keep-yours → stays customized', async () => {
- const oldHtml =
- '<!-- wp:paragraph --><p>OLD A</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>OLD B</p><!-- /wp:paragraph -->';
-
- await simulateCoreBump( 'new_order', oldHtml );
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: oldHtml.replace( 'OLD B', 'MERCHANT B' ),
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- } );
- await clearTemplateHtmlOverride();
- await triggerDetectionSweep();
-
- let meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
- STATUS.CORE_UPDATED_CUSTOMIZED
- );
-
- // Use applyWooEmailTemplate (basic auth) instead of request.post (cookie auth)
- // because WP REST POST endpoints require a nonce when using cookie-based auth.
- // choices: [] keeps all merchant edits (keep_yours default), so the merged
- // result diverges from canonical and the applier stamps core_updated_customized.
- const apply = await applyWooEmailTemplate( postId, [] );
- expect( apply.status ).toBe( 'applied' );
-
- meta = await getWooEmailMeta( postId );
- // With choices:[] the merchant's diverged block is preserved, so the post
- // stays core_updated_customized rather than reaching in_sync.
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
- STATUS.CORE_UPDATED_CUSTOMIZED
- );
- } );
-
- /**
- * Verifies that the reset endpoint replaces a customized post's content with
- * the current canonical and stamps it in_sync in a single REST call.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
- * (customized content) → resetWooEmailTemplate (REST POST) → response status
- * assertion → getWooEmailMeta assertion (STATUS.IN_SYNC).
- *
- * "Show browser" eye: not needed.
- */
- test( 'Reset round-trip: customized → reset → in_sync', async () => {
- const customized =
- '<!-- wp:paragraph --><p>MERCHANT CUSTOM</p><!-- /wp:paragraph -->';
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: customized,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- } );
-
- // Use resetWooEmailTemplate (basic auth) instead of request.post (cookie auth)
- // because WP REST POST endpoints require a nonce when using cookie-based auth.
- // The reset endpoint returns the post-reset sync status directly (not "applied").
- const reset = await resetWooEmailTemplate( postId );
- expect( reset.status ).toBe( STATUS.IN_SYNC );
-
- const meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
- } );
-
- /**
- * Verifies that running the detection sweep twice in a row produces the same
- * classification and identical meta for an already-classified customized post,
- * confirming the sweep does not mutate already-correct state.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows: simulateCoreBump
- * → seedWooEmailPost (merchant edit) → clearTemplateHtmlOverride →
- * triggerDetectionSweep (first) → getWooEmailMeta snapshot →
- * triggerDetectionSweep (second, sweep2.classifications assertion) →
- * getWooEmailMeta equality assertion.
- *
- * "Show browser" eye: not needed.
- */
- test( 'Detection sweep is idempotent: second run touches zero posts', async () => {
- await simulateCoreBump( 'new_order', OLD_HTML );
- const postId = await seedWooEmailPost( {
- emailId: 'new_order',
- postContent: OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' ),
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- } );
- await clearTemplateHtmlOverride();
-
- await triggerDetectionSweep();
- const metaBefore = await getWooEmailMeta( postId );
-
- const sweep2 = await triggerDetectionSweep();
- const metaAfter = await getWooEmailMeta( postId );
-
- expect( sweep2.classifications[ postId ] ).toBe(
- metaBefore[ META_KEYS.STATUS ]?.[ 0 ]
- );
- expect( metaAfter ).toEqual( metaBefore );
- } );
-} );
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/scope.spec.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/scope.spec.ts
deleted file mode 100644
index 892f9e76dbe..00000000000
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/scope.spec.ts
+++ /dev/null
@@ -1,195 +0,0 @@
-/**
- * External dependencies
- */
-import { test, expect } from '@playwright/test';
-
-/**
- * Update-propagation: scope and allow-list.
- *
- * Covers the allow-list (opt-in) boundary for third-party email types: a
- * non-opted-in email is entirely excluded from backfill and detection; an
- * opted-in third-party email that is unedited is auto-applied (in_sync) after
- * a version bump; an opted-in email that is edited is left as
- * core_updated_customized for the merchant to review.
- *
- * Reviewing in Playwright UI mode:
- * 1. Run `npx playwright test --project=core-serial tests/email-editor/update-propagation --ui`
- * 2. Filter the tree by `scope` and pick a test.
- * 3. The first test ("Non-opted-in") attaches a Tracks spy via the page fixture
- * but performs no navigation or UI interaction. The other two tests are
- * purely REST-based. The Actions panel shows the REST call sequence for all.
- * 4. "Show browser" eye is not needed for any test in this file.
- */
-
-/**
- * Internal dependencies
- */
-import { ADMIN_STATE_PATH } from '../../../playwright.config';
-import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
-import {
- setTransactionalEmailsOverride,
- setOptedInOverride,
- setTemplateHtmlOverride,
- clearTemplateHtmlOverride,
- enableFakeThirdPartyEmail,
- disableFakeThirdPartyEmail,
-} from './helpers/test-helper-plugin';
-import {
- seedWooEmailPost,
- seedWooEmailPostDirect,
- getWooEmailMeta,
-} from './helpers/seed-woo-email';
-import {
- triggerBackfill,
- triggerDetectionSweep,
- simulateCoreBump,
-} from './helpers/simulate-plugin-update';
-import { attachTracksSpy } from './helpers/tracks-spy';
-import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
-import { STATUS, META_KEYS, TRACKS_EVENTS } from './helpers/classifications';
-
-const FAKE_EMAIL_ID = 'fake_thirdparty';
-
-const V1_HTML = '<!-- wp:paragraph --><p>V1 CONTENT</p><!-- /wp:paragraph -->';
-const V2_HTML = '<!-- wp:paragraph --><p>V2 CONTENT</p><!-- /wp:paragraph -->';
-
-test.describe( 'Update propagation — scope and allow-list', () => {
- test.use( { storageState: ADMIN_STATE_PATH } );
-
- test.beforeAll( async ( { baseURL } ) => {
- await enableEmailEditor( baseURL! );
- } );
-
- test.beforeEach( async () => {
- await enableFakeThirdPartyEmail();
- } );
-
- test.afterEach( async () => {
- await disableFakeThirdPartyEmail();
- await assertNoLeakedFixtureState();
- } );
-
- /**
- * Verifies that a third-party email type that has not enrolled in block-editor
- * sync is completely ignored by both the backfill and the detection sweep —
- * no stamp meta is written and no Tracks _available event fires.
- *
- * UI mode walkthrough:
- * The page fixture is used only to attach the Tracks spy — no navigation
- * or UI interaction occurs. Actions panel shows: seedWooEmailPostDirect
- * (no options-table mapping) → triggerBackfill → simulateCoreBump →
- * triggerDetectionSweep → meta undefined assertions → spy.expectNotFired.
- *
- * "Show browser" eye: not needed.
- */
- test( 'Non-opted-in third-party email is excluded from sync', async ( {
- page,
- } ) => {
- const spy = await attachTracksSpy( page );
-
- // Deliberately do NOT add FAKE_EMAIL_ID to the transactional emails list:
- // a third-party email that has not enrolled in block-editor sync is excluded
- // from WCEmailTemplateSyncRegistry and therefore skipped by both the backfill
- // and the divergence sweep. Create the woo_email post directly (bypassing the
- // generator) so no options-table mapping exists for the email type — the
- // backfill's get_email_type_from_post_id() will return null and skip the post.
- const postId = await seedWooEmailPostDirect( {
- postContent:
- '<!-- wp:paragraph --><p>Third-party content</p><!-- /wp:paragraph -->',
- stripStampMeta: true,
- } );
-
- await triggerBackfill();
- await simulateCoreBump( FAKE_EMAIL_ID, V1_HTML );
- await triggerDetectionSweep();
- await clearTemplateHtmlOverride();
-
- const meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ] ).toBeUndefined();
- expect( meta[ META_KEYS.SOURCE_HASH ] ).toBeUndefined();
- await spy.expectNotFired( TRACKS_EVENTS.AVAILABLE );
- } );
-
- /**
- * Verifies that an opted-in third-party email with an unedited post is
- * auto-applied after a version bump (1.0.0 → 1.1.0), landing back at in_sync
- * rather than surfacing an update prompt to the merchant.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows:
- * setTransactionalEmailsOverride + setOptedInOverride + setTemplateHtmlOverride
- * (v1 setup) → seedWooEmailPost → triggerDetectionSweep → meta assertion
- * (IN_SYNC) → override swap to v2 → triggerDetectionSweep → meta assertion
- * (IN_SYNC via auto-apply) → cleanup calls.
- *
- * "Show browser" eye: not needed.
- */
- test( 'Opted-in third-party email: version bump flips status when unedited', async () => {
- await setTransactionalEmailsOverride( [ FAKE_EMAIL_ID ] );
- await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.0.0' } } );
- await setTemplateHtmlOverride( FAKE_EMAIL_ID, V1_HTML );
-
- const postId = await seedWooEmailPost( {
- emailId: FAKE_EMAIL_ID,
- postContent: V1_HTML,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- version: '1.0.0',
- } );
-
- await triggerDetectionSweep();
- let meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
-
- await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.1.0' } } );
- await setTemplateHtmlOverride( FAKE_EMAIL_ID, V2_HTML );
-
- await triggerDetectionSweep();
- meta = await getWooEmailMeta( postId );
- // The inline auto-applier runs immediately after the sweep (same HTTP request in
- // the E2E trigger-sweep endpoint). An unedited post classified as
- // core_updated_uncustomized is auto-applied and flipped back to in_sync before
- // this assertion runs — consistent with the lifecycle tested in core-flows
- // scenario 1 and backward-compat Case A.
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
- } );
-
- /**
- * Verifies that an opted-in third-party email with a merchant-edited post is
- * stamped core_updated_customized after a version bump (1.0.0 → 1.1.0),
- * leaving the update for the merchant to review rather than auto-applying.
- *
- * UI mode walkthrough:
- * REST-only — no browser interaction. Actions panel shows:
- * setTransactionalEmailsOverride + setOptedInOverride + setTemplateHtmlOverride
- * (v1 setup) → seedWooEmailPost (customized content) → override swap to v2
- * → triggerDetectionSweep → meta assertion (CORE_UPDATED_CUSTOMIZED)
- * → cleanup calls.
- *
- * "Show browser" eye: not needed.
- */
- test( 'Opted-in third-party email: version bump flips status when edited', async () => {
- const customized = V1_HTML.replace( 'V1 CONTENT', 'MERCHANT EDIT' );
-
- await setTransactionalEmailsOverride( [ FAKE_EMAIL_ID ] );
- await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.0.0' } } );
- await setTemplateHtmlOverride( FAKE_EMAIL_ID, V1_HTML );
-
- const postId = await seedWooEmailPost( {
- emailId: FAKE_EMAIL_ID,
- postContent: customized,
- storedSourceHash: 'AUTO_CURRENT',
- status: STATUS.IN_SYNC,
- version: '1.0.0',
- } );
-
- await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.1.0' } } );
- await setTemplateHtmlOverride( FAKE_EMAIL_ID, V2_HTML );
-
- await triggerDetectionSweep();
- const meta = await getWooEmailMeta( postId );
- expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
- STATUS.CORE_UPDATED_CUSTOMIZED
- );
- } );
-} );
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
index b3a9a76a882..02cbe5b06ff 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/EmailApiControllerTest.php
@@ -416,6 +416,67 @@ class EmailApiControllerTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should dispatch the registered reset route for a capable user and persist canonical sync state.
+ */
+ public function test_reset_route_dispatches_registered_callback_and_persists_sync_state(): void {
+ $email_type = 'customer_new_account';
+
+ WCEmailTemplateSyncRegistry::reset_cache();
+
+ $post_id = $this->create_published_email_post( $email_type );
+ wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => '<!-- wp:paragraph --><p>Customized before REST reset</p><!-- /wp:paragraph -->',
+ )
+ );
+ update_post_meta(
+ $post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED
+ );
+
+ $email = $this->resolve_wc_email( $email_type );
+ $this->assertNotNull( $email );
+ $expected_canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+ global $wp_rest_server;
+
+ $previous_rest_server = $wp_rest_server;
+ $wp_rest_server = new \WP_REST_Server();
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+
+ try {
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- This test invokes the production route-registration action.
+ do_action( 'rest_api_init', $wp_rest_server );
+ $this->email_api_controller->register_routes();
+ $request = new \WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/emails/' . $post_id . '/reset' );
+ $response = $wp_rest_server->dispatch( $request );
+ } finally {
+ // tear_down() resets the current user; $wp_rest_server it does not touch.
+ $wp_rest_server = $previous_rest_server;
+ }
+
+ $this->assertSame( 200, $response->get_status(), 'The registered route must authorize a manage_woocommerce user.' );
+
+ $response_data = $response->get_data();
+ $this->assertSame( $expected_canonical, $response_data['content'], 'The route response must contain the canonical render.' );
+ $this->assertSame( sha1( $expected_canonical ), $response_data['source_hash'], 'The route response must contain the canonical source hash.' );
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $response_data['status'], 'The route response must report in_sync.' );
+ $this->assertNotEmpty( $response_data['version'], 'The route response must contain a template version.' );
+ $this->assertNotEmpty( $response_data['synced_at'], 'The route response must contain a sync timestamp.' );
+
+ $persisted_post = get_post( $post_id );
+ $this->assertInstanceOf( \WP_Post::class, $persisted_post );
+ $this->assertSame( $expected_canonical, $persisted_post->post_content, 'The route callback must persist the canonical content.' );
+ $this->assertSame( $response_data['version'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ), 'Persisted version meta must match the REST response.' );
+ $this->assertSame( $response_data['source_hash'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ), 'Persisted source hash meta must match the REST response.' );
+ $this->assertSame( $response_data['synced_at'], (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY, true ), 'Persisted sync timestamp must match the REST response.' );
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ), 'Persisted status meta must be in_sync.' );
+ $this->assertSame( $expected_canonical, (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY, true ), 'Persisted base render meta must match canonical content.' );
+ }
+
/**
* @testdox Should return 404 when reset post ID has no associated email type.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
index cd2269c0ab7..6caec15c947 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateAutoApplierTest.php
@@ -204,6 +204,7 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
$result = WCEmailTemplateAutoApplier::apply_to_post( $email, $post_id );
$this->assertIsArray( $result );
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $result['status'] );
$this->assertSame(
WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
@@ -617,13 +618,19 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
* 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' );
+ // 4 posts, each with a distinct fixture email so each registers in the registry.
+ $first_uncustomized_post_id = $this->generate_stamped_post( 'wc_test_run_uncustomized_first' );
+ $second_uncustomized_post_id = $this->generate_stamped_post( 'wc_test_run_uncustomized_second' );
+ $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,
+ $first_uncustomized_post_id,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED
+ );
+ update_post_meta(
+ $second_uncustomized_post_id,
WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED
);
@@ -644,9 +651,15 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
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().'
+ array(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ ),
+ array(
+ (string) get_post_meta( $first_uncustomized_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ (string) get_post_meta( $second_uncustomized_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ ),
+ 'Every uncustomized post must flip to in_sync after run().'
);
$this->assertSame(
@@ -687,13 +700,43 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
$captured = array();
WCEmailTemplateAutoApplier::set_logger( $this->build_recording_logger( $captured ) );
+ $ordered_candidate_query = false;
+ $order_candidates_by_id = static function ( \WP_Query $query ) use ( &$ordered_candidate_query ): void {
+ $meta_query = $query->get( 'meta_query' );
+ if (
+ Integration::EMAIL_POST_TYPE !== $query->get( 'post_type' )
+ || 'ids' !== $query->get( 'fields' )
+ || ! is_array( $meta_query )
+ || ! in_array(
+ array(
+ 'key' => WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ 'value' => WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
+ ),
+ $meta_query,
+ true
+ )
+ ) {
+ return;
+ }
+
+ $ordered_candidate_query = true;
+ $query->set( 'orderby', 'ID' );
+ $query->set( 'order', 'ASC' );
+ };
+ add_action( 'pre_get_posts', $order_candidates_by_id );
try {
WCEmailTemplateAutoApplier::run();
} finally {
+ remove_action( 'pre_get_posts', $order_candidates_by_id );
remove_all_filters( 'wp_insert_post_empty_content' );
}
+ // The ordering is what makes the failing candidate the first one processed. If the
+ // candidate query ever changes shape, the callback stops matching and this test would
+ // quietly fall back to tied post dates, so check that it matched.
+ $this->assertTrue( $ordered_candidate_query, 'The candidate query should have been ordered by ID.' );
+
$this->assertSame(
WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED,
(string) get_post_meta( $failing_post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
@@ -1096,6 +1139,7 @@ class WCEmailTemplateAutoApplierTest extends \WC_Unit_Test_Case {
* @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();
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 a95f43602c0..21d78667701 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateDivergenceDetectorTest.php
@@ -211,6 +211,36 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
$this->assertSame( 'block_email_update_available', $captured[0][0] );
}
+ /**
+ * @testdox Should not fire `_update_available` when core moves under a post the merchant never edited.
+ *
+ * The other half of the version-advance guard. An uncustomized post is updated silently by
+ * the auto-applier, so the merchant is never asked to review it and no update-available
+ * event may fire, even though its stamped version is behind the registry's.
+ */
+ public function test_reclassify_does_not_fire_update_available_for_uncustomized_version_advance(): void {
+ $email_id = 'wc_test_divergence_available_silent';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ // Core moves; the post still matches its stamp, so nobody edited it.
+ $this->use_canonical_content( $email_id, '<!-- wp:paragraph --><p>A new core release</p><!-- /wp:paragraph -->' );
+ update_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, '1.0.0' );
+
+ $captured = array();
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder(
+ static function ( string $event_name, array $payload ) use ( &$captured ): void {
+ $captured[] = array( $event_name, $payload );
+ }
+ );
+
+ $status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
+
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder( null );
+
+ $this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_UNCUSTOMIZED, $status );
+ $this->assertSame( array(), $captured, 'An uncustomized post must not fire _update_available on a version advance.' );
+ }
+
/**
* @testdox Should fire `_update_available` on a cross-release sweep even when status stays customized.
*
@@ -596,6 +626,10 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
$status = WCEmailTemplateDivergenceDetector::reclassify( $post_id );
$this->assertSame( WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC, $status );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_IN_SYNC,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true )
+ );
}
/**
@@ -608,6 +642,7 @@ class WCEmailTemplateDivergenceDetectorTest extends \WC_Unit_Test_Case {
* @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();
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
index d819ad6bba4..79b2f043cc7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSelectiveApplierTest.php
@@ -1001,6 +1001,7 @@ class WCEmailTemplateSelectiveApplierTest extends \WC_Unit_Test_Case {
* @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();
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php
index c9003f5f0ef..5af81cc8e25 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncBackfillTest.php
@@ -5,6 +5,7 @@ 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\WCEmailTemplateSyncBackfill;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
@@ -166,6 +167,67 @@ class WCEmailTemplateSyncBackfillTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox A second backfill run should perform no writes and preserve the stamped sync state.
+ */
+ public function test_second_run_is_noop_and_preserves_stamped_meta(): void {
+ $email_id = 'wc_test_backfill_repeat_run';
+ $email = $this->register_fixture_email( $email_id );
+
+ $canonical = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ $legacy_body = "<!-- wp:paragraph -->\n<p>Legacy content that the first run must replace.</p>\n<!-- /wp:paragraph -->";
+ $post_id = $this->create_unstamped_post( $email_id, $legacy_body, true );
+
+ WCEmailTemplateSyncBackfill::run();
+
+ $stamp_keys = array(
+ WCEmailTemplateDivergenceDetector::VERSION_META_KEY,
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+ WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+ );
+
+ $stamps_after_first_run = array();
+ foreach ( $stamp_keys as $meta_key ) {
+ $stamps_after_first_run[ $meta_key ] = get_post_meta( $post_id, $meta_key, true );
+ }
+
+ $post_write_count = 0;
+ $meta_write_count = 0;
+ $post_counter = static function ( array $data, array $postarr ) use ( &$post_write_count, $post_id ): array {
+ if ( (int) ( $postarr['ID'] ?? 0 ) === $post_id ) {
+ ++$post_write_count;
+ }
+ return $data;
+ };
+ $meta_counter = static function ( $check, int $object_id, string $meta_key ) use ( &$meta_write_count, $post_id, $stamp_keys ) {
+ if ( $post_id === $object_id && in_array( $meta_key, $stamp_keys, true ) ) {
+ ++$meta_write_count;
+ }
+ return $check;
+ };
+
+ add_filter( 'wp_insert_post_data', $post_counter, 10, 2 );
+ add_filter( 'update_post_metadata', $meta_counter, 10, 3 );
+ try {
+ WCEmailTemplateSyncBackfill::run();
+ } finally {
+ remove_filter( 'wp_insert_post_data', $post_counter, 10 );
+ remove_filter( 'update_post_metadata', $meta_counter, 10 );
+ }
+
+ $stamps_after_second_run = array();
+ foreach ( $stamp_keys as $meta_key ) {
+ $stamps_after_second_run[ $meta_key ] = get_post_meta( $post_id, $meta_key, true );
+ }
+
+ $this->assertSame( $canonical, (string) $this->require_post( $post_id )->post_content, 'The first run must establish the Case B canonical-content state.' );
+ $this->assertSame( 0, $post_write_count, 'A second run must not attempt to update the already-stamped post.' );
+ $this->assertSame( 0, $meta_write_count, 'A second run must not attempt to update any sync stamp.' );
+ $this->assertSame( $stamps_after_first_run, $stamps_after_second_run, 'A second run must preserve the complete stamped sync state.' );
+ }
+
/**
* Case C: content diverges from canonical AND the post has been edited.
* Expectation: content untouched, source_hash = sha1(canonical) (NOT sha1(post_content)),
@@ -205,6 +267,58 @@ class WCEmailTemplateSyncBackfillTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox A customized post keeps its content through the backfill, a later core template change, and the sweep and auto-apply that follow.
+ */
+ public function test_customized_post_survives_backfill_then_core_update_then_sweep(): void {
+ $email_id = 'wc_test_backfill_core_update_sweep';
+ $email = $this->register_fixture_email( $email_id );
+ $merchant_body = "<!-- wp:paragraph -->\n<p>Merchant-authored customisations must survive every step.</p>\n<!-- /wp:paragraph -->";
+ $post_id = $this->create_unstamped_post( $email_id, $merchant_body, false );
+
+ WCEmailTemplateSyncBackfill::run();
+
+ $this->assertSame( $merchant_body, (string) $this->require_post( $post_id )->post_content, 'The backfill must not touch merchant-edited content.' );
+
+ // Ship different canonical content for the same email, as a core update would. The
+ // fixture template keeps its @version header, so the email stays in the sync registry.
+ $canonical_before = WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+ add_filter(
+ 'woocommerce_email_content_post_data',
+ static function ( $post_data, $email_type ) use ( $email_id ) {
+ if ( $email_id === $email_type ) {
+ $post_data['post_content'] .= "\n<!-- wp:paragraph -->\n<p>Core added this paragraph.</p>\n<!-- /wp:paragraph -->";
+ }
+ return $post_data;
+ },
+ 10,
+ 2
+ );
+ $this->assertNotSame(
+ $canonical_before,
+ WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ),
+ 'The core update must change the canonical render, or the steps below prove nothing.'
+ );
+
+ WCEmailTemplateDivergenceDetector::run_sweep();
+
+ $this->assertSame( $merchant_body, (string) $this->require_post( $post_id )->post_content, 'The sweep must not touch merchant-edited content.' );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'After the core update the sweep must still classify the post as customized.'
+ );
+
+ WCEmailTemplateAutoApplier::run();
+
+ $this->assertSame( $merchant_body, (string) $this->require_post( $post_id )->post_content, 'The auto-applier must not overwrite a customized post.' );
+ $this->assertSame(
+ WCEmailTemplateDivergenceDetector::STATUS_CORE_UPDATED_CUSTOMIZED,
+ (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ),
+ 'The auto-applier must leave a customized post flagged for review.'
+ );
+ }
+
/**
* Case B rewrite failure: wp_update_post() returns a WP_Error (silent
* failure because `$wp_error = true`). The migration is one-shot — the
diff --git a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php
index 6032c14aa11..b5b02ed31a0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/EmailEditor/WCTransactionalEmails/WCEmailTemplateSyncTrackerTest.php
@@ -4,6 +4,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\EmailEditor\WCTransactionalEmails;
+use Automattic\WooCommerce\Internal\EmailEditor\Integration;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill;
use Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry;
@@ -56,6 +57,10 @@ class WCEmailTemplateSyncTrackerTest extends \WC_Unit_Test_Case {
update_option( 'woocommerce_feature_block_email_editor_enabled', 'yes' );
update_option( WCEmailTemplateDivergenceDetector::BACKFILL_COMPLETE_OPTION, 'yes' );
+ // Eagerly boot \WC_Emails so the \WC_Email class is autoloaded before any
+ // test reflects on it via getMockBuilder() / onlyMethods().
+ \WC_Emails::instance();
+
$this->fixtures_base = __DIR__ . '/fixtures/';
$this->posts_manager = WCTransactionalEmailPostsManager::get_instance();
$this->posts_manager->clear_caches();
@@ -255,6 +260,33 @@ class WCEmailTemplateSyncTrackerTest extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox A real backfill should emit only one completion event through the production Integration hooks.
+ */
+ public function test_backfill_records_only_completion_event_through_integration_hooks(): void {
+ $email_id = 'wc_test_tracker_backfill_integration';
+ $post_id = $this->generate_stamped_post( $email_id );
+
+ foreach ( $this->get_sync_meta_keys() as $meta_key ) {
+ delete_post_meta( $post_id, $meta_key );
+ }
+ delete_option( WCEmailTemplateSyncTracker::BACKFILL_COMPLETED_TRACKED_OPTION );
+
+ $integration = new Integration();
+ $integration->init_hooks();
+ $integration->register_hooks();
+
+ WCEmailTemplateSyncBackfill::run();
+
+ $event_names = array_column( $this->captured_events, 0 );
+ $available_events = array_values( array_filter( $event_names, static fn( string $event_name ): bool => WCEmailTemplateSyncTracker::EVENT_UPDATE_AVAILABLE === $event_name ) );
+ $completion_events = array_values( array_filter( $event_names, static fn( string $event_name ): bool => WCEmailTemplateSyncTracker::EVENT_BACKFILL_COMPLETED === $event_name ) );
+
+ $this->assertNotSame( '', (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ), 'The fixture must be processed by the public backfill.' );
+ $this->assertSame( array(), $available_events, 'Backfill and its immediate divergence sweep must not emit update-available events.' );
+ $this->assertSame( array( WCEmailTemplateSyncTracker::EVENT_BACKFILL_COMPLETED ), $completion_events, 'The production hook chain must emit exactly one backfill-completed event.' );
+ }
+
/**
* @testdox Should swallow exceptions thrown inside build_base_payload so callers don't surface failures.
*/
@@ -322,6 +354,49 @@ class WCEmailTemplateSyncTrackerTest extends \WC_Unit_Test_Case {
$this->assertSame( array(), $this->captured_events, 'Unregistered posts should not produce events.' );
}
+ /**
+ * @testdox An unmapped email should remain outside the public backfill and detection pipeline.
+ */
+ public function test_unmapped_email_remains_unstamped_without_update_available_event(): void {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_type' => Integration::EMAIL_POST_TYPE,
+ 'post_status' => 'publish',
+ 'post_content' => '<!-- wp:paragraph --><p>Unmapped third-party content.</p><!-- /wp:paragraph -->',
+ )
+ );
+
+ // Positive control. Every assertion below is an absence, and the backfill has
+ // several early returns -- no eligible posts, an empty registry, the sweep's
+ // own completion guard -- any of which would leave all of them green while
+ // nothing ran at all. A mapped post in the same run has to come out stamped.
+ $mapped_post_id = $this->generate_stamped_post( 'wc_test_tracker_unmapped_control' );
+ foreach ( $this->get_sync_meta_keys() as $meta_key ) {
+ delete_post_meta( $mapped_post_id, $meta_key );
+ }
+
+ WCEmailTemplateSyncBackfill::run();
+ WCEmailTemplateDivergenceDetector::run_sweep();
+
+ $this->assertNotSame(
+ '',
+ (string) get_post_meta( $mapped_post_id, WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY, true ),
+ 'The mapped control post must be processed, or the assertions below prove nothing.'
+ );
+
+ foreach ( $this->get_sync_meta_keys() as $meta_key ) {
+ $this->assertFalse( metadata_exists( 'post', $post_id, $meta_key ), "Unmapped posts must not receive `{$meta_key}`." );
+ }
+
+ $available_events = array_values(
+ array_filter(
+ $this->captured_events,
+ static fn( array $event ): bool => WCEmailTemplateSyncTracker::EVENT_UPDATE_AVAILABLE === $event[0]
+ )
+ );
+ $this->assertSame( array(), $available_events, 'The public pipeline must not emit update-available events for unmapped posts.' );
+ }
+
// ------------------------------------------------------------------
// Helpers (mirror the detector test's fixture flow).
// ------------------------------------------------------------------
@@ -416,6 +491,22 @@ class WCEmailTemplateSyncTrackerTest extends \WC_Unit_Test_Case {
$this->injected_email_keys = array();
}
+ /**
+ * Return the complete sync-meta tuple owned by the backfill and detector pipeline.
+ *
+ * @return string[]
+ */
+ private function get_sync_meta_keys(): array {
+ return array(
+ WCEmailTemplateDivergenceDetector::VERSION_META_KEY,
+ WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY,
+ WCEmailTemplateDivergenceDetector::LAST_SYNCED_AT_META_KEY,
+ WCEmailTemplateDivergenceDetector::STATUS_META_KEY,
+ WCEmailTemplateDivergenceDetector::LAST_CORE_RENDER_META_KEY,
+ WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY,
+ );
+ }
+
/**
* Toggle the static backfill-running flag via reflection so suppress-during-backfill paths can be exercised.
*