Commit b917fdcb33b for woocommerce
commit b917fdcb33b7ccc9a5b3f2355bc1f0cea7292a83
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 15 15:54:29 2026 +0300
[tests] Demote order admin coverage below E2E, keeping fifteen browser titles (#68637)
* test(orders): Move order admin coverage below E2E
The classic order admin had twenty-two browser titles across three
specs: creating an order, editing one, and removing a coupon from
it. Most of what they asserted is decided in PHP -- in the order
data meta box that saves the screen, and in the AJAX handlers the
line-item and coupon controls call.
Move those contracts down. class-wc-ajax-test.php gains coverage of
the customer-details payload, order-item persistence across product
types, and coupon removal. class-wc-meta-box-order-data-test.php
gains a provider-driven check of the status, date and transition
note the save writes. Both are additions to existing classes.
Fifteen browser titles stay: creating an order for an existing
customer and adding a product by keyboard, applying a coupon, and
twelve edit-order titles including all six download-permission
canaries.
Two test-only utilities come with the slice, COTMigrationUtilTest
and MetaDataUtilTest. They carry no feature of their own; they fix
the same fixture-determinism defect on the two utilities that read
and write order metadata, and their sibling hunks already shipped
in earlier batches.
class-wc-ajax-test.php is merged onto trunk's version rather than
taken from the migration branch. Trunk added 265 lines to it after
the branch was cut -- three test methods and a provider -- and
taking the branch's copy would have deleted every one of them. One
hunk in the branch's delta belongs to another batch and is left for
it.
Fixture names carrying internal work-item numbers are renamed. Three
of them flow into asserted values: a tax-class label, a product name
inside an order note, and a coupon-removal note.
Consolidates nine mega-branch commits, subjects verbatim:
- test(orders): Move creation matrices below E2E
- test(e2e): Consolidate order edit coverage
- test(order): Move coupon removal below E2E
- test: Isolate final PHPUnit fixtures
- test: Distinguish forwarded tax location
- test(e2e): Filter edited order in list view
- test: Fix lower-layer migration CI regressions
- test(e2e): Fix isolated CI assumptions
- fix(e2e): synchronize Core admin interactions
Four of those also touch files owned by other batches, so they are
taken hunk-wise rather than cherry-picked; a tenth commit in the
range, test(shipping): Move generated slugs below E2E, belongs to
another batch entirely and none of it is taken here.
Refs TESTOPS-288
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(core): Use mode-aware order URLs in the add-new-order specs
This describe carries `tags.HPOS`, and `envs/default-hpos-disabled`
greps on that tag with `DISABLE_HPOS=1`, so these titles also run
against legacy post storage. There `admin.php?page=wc-orders` is not the
screen the URLs assume.
Route both the new-order and edit-order navigations through helpers that
pick `post-new.php` and `post.php` in legacy mode, matching the pattern
`order-edit.spec.ts` already uses.
All three call sites are switched, not only the edit one this PR moved.
Two of them predate the PR, and leaving them hard-coded next to a fixed
one would be worse than the diff it costs.
Refs #68637
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(orders): Stop the order admin tests restoring base-class state
Three of the four new AJAX tests snapshotted $_POST, $_REQUEST and the
current user and put them back in a finally block, one also snapshotted
$this->_last_response, and the tax test restored three tax options.
All of it is already handled: WP_Ajax_UnitTestCase::tear_down() clears
$_POST and $_GET, clean_up_global_scope() clears $_REQUEST before the
next test, and WP_UnitTestCase_Base::tear_down() rolls the transaction
back and resets the current user. _last_response lives on the test
instance, which PHPUnit rebuilds per test.
The fixture deletes went with them, along with the tax-rate and
tax-class deletes and the trailing cache-group invalidation. The rates
and classes are database rows inside the same transaction, and the
group prefix lives only in the object cache, which the base class
flushes before the next test reads it.
Two things stayed because the base class does not own them: the
LegacyProxy reset (this class does not extend WC_Unit_Test_Case, so
nothing else puts the mocked functions back) and the output-buffering
level balancing in the coupon test.
Same for the order data meta box: the new status-transition test no
longer snapshots $_POST around the save.
Same 110 tests and 536 assertions before and after.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(orders): Cover guest-order billing persistence in the meta box save
The deleted "can create a simple guest order" E2E title was the only
thing typing a billing address into the admin form on a guest order and
checking it survived the save. The PHP replacement posted
customer_user => 0 with no billing keys at all, so save() ran its billing
branch against an empty field set and asserted nothing about it. The
surviving browser test only checks billing after a customer is selected,
which is the other side of the guard.
Post real billing values and assert them back off the reloaded order.
Verified by mutation: inverting the guest-or-valid-user guard in
WC_Meta_Box_Order_Data::save() now fails all three provider rows, and
production was restored afterwards.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Restore two create-order checks the move dropped
Moving order admin coverage below E2E dropped two assertions from
retained titles without replacing them anywhere. The keyboard title
stopped checking that opening the Add products modal focuses its search
field, the extra-click half of the #66905 fix it guards. The
existing-customer title stopped looking at the order data meta box's
Profile and View other orders links.
Put the focus check back with trunk's title, and assert both links and
their hrefs without clicking through.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(php): Make the order metadata utility tests able to fail
MetaDataUtilTest compared get_meta_data() with itself: the order hands
back its own WC_Meta_Data objects, so an in-place change compared equal.
get_data() would not catch it either, since it returns the last applied
values. Seed an entry and compare copies of each entry's current id, key
and value; rewriting the meta on non-array input now fails the test.
COTMigrationUtilTest seeded the same value on the order and the post, so
both reads passed whichever source answered. Seed order_value and
post_value instead; reading post meta on the object path now fails it.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(php): Assert guest billing city and postcode on meta box save
The guest billing save test posts _billing_city and _billing_postcode
but never read them back, so a regression in either setter passed.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(php): Explain the second-payload fallback in do_ajax()
The helper decodes only the first JSON payload when a handler sends two.
Say why in the code rather than only in the PR description: under the
test die handler, a handler's catch block catches the exception
wp_send_json_*() throws and sends an error payload after 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-orders-admin b/plugins/woocommerce/changelog/testops-288-orders-admin
new file mode 100644
index 00000000000..447af902b20
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-orders-admin
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move order admin coverage below E2E; twenty-two browser titles become fifteen.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/order/create-order.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/create-order.spec.ts
index 752fc5c763b..c4c536330ac 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/create-order.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/create-order.spec.ts
@@ -17,14 +17,6 @@ const taxClasses = [
name: 'Tax Class Simple',
slug: 'tax-class-simple',
},
- {
- name: 'Tax Class Variable',
- slug: 'tax-class-variable',
- },
- {
- name: 'Tax Class External',
- slug: 'tax-class-external',
- },
];
const taxRates = [
{
@@ -32,16 +24,6 @@ const taxRates = [
rate: '10.0000',
class: 'tax-class-simple',
},
- {
- name: 'Tax Rate Variable',
- rate: '20.0000',
- class: 'tax-class-variable',
- },
- {
- name: 'Tax Rate External',
- rate: '30.0000',
- class: 'tax-class-external',
- },
];
async function getOrderIdFromPage( page: Page ) {
// get order ID from the page
@@ -55,7 +37,11 @@ async function getOrderIdFromPage( page: Page ) {
async function addProductToOrder( page: Page, product, quantity: number ) {
await page.getByRole( 'button', { name: 'Add item(s)' } ).click();
await page.getByRole( 'button', { name: 'Add product(s)' } ).click();
- await page.locator( 'span > .select2-search__field' ).fill( product.name );
+ const productSearch = page.locator(
+ '.select2-container--open input.select2-search__field'
+ );
+ await expect( productSearch ).toBeVisible();
+ await productSearch.fill( product.name );
await page.getByRole( 'option', { name: product.name } ).first().click();
const quantityField = page
@@ -161,130 +147,20 @@ const test = baseTest.extend( {
force: true,
} );
},
+} );
- variableProduct: async ( { restApi }, use ) => {
- let product = {};
-
- const variations = [
- {
- regular_price: '100',
- attributes: [
- {
- name: 'Size',
- option: 'Small',
- },
- {
- name: 'Colour',
- option: 'Yellow',
- },
- ],
- tax_class: 'Tax Class Variable',
- },
- {
- regular_price: '100',
- attributes: [
- {
- name: 'Size',
- option: 'Medium',
- },
- {
- name: 'Colour',
- option: 'Magenta',
- },
- ],
- tax_class: 'Tax Class Variable',
- },
- ];
-
- await restApi
- .post( `${ WC_API_PATH }/products`, {
- name: `Product variable ${ random() }`,
- type: 'variable',
- tax_class: 'Tax Class Variable',
- } )
- .then( ( response ) => {
- product = response.data;
- } );
-
- for ( const variation of variations ) {
- await restApi.post(
- `${ WC_API_PATH }/products/${ product.id }/variations`,
- variation
- );
- }
-
- await use( product );
-
- // Cleanup
- await restApi.delete( `${ WC_API_PATH }/products/${ product.id }`, {
- force: true,
- } );
- },
-
- externalProduct: async ( { restApi }, use ) => {
- let product = {};
-
- await restApi
- .post( `${ WC_API_PATH }/products`, {
- name: `Product external ${ random() }`,
- regular_price: '800',
- tax_class: 'Tax Class External',
- external_url: 'https://wordpress.org/plugins/woocommerce',
- type: 'external',
- button_text: 'Buy now',
- } )
- .then( ( response ) => {
- product = response.data;
- } );
-
- await use( product );
-
- // Cleanup
- await restApi.delete( `${ WC_API_PATH }/products/${ product.id }`, {
- force: true,
- } );
- },
-
- groupedProduct: async ( { restApi }, use ) => {
- let product = {};
- let subProductAId: number;
- let subProductBId: number;
-
- await restApi
- .post( `${ WC_API_PATH }/products`, {
- name: 'Add-on A',
- regular_price: '11.95',
- } )
- .then( ( response: { data: { id: number } } ) => {
- subProductAId = response.data.id;
- } );
- await restApi
- .post( `${ WC_API_PATH }/products`, {
- name: 'Add-on B',
- regular_price: '18.97',
- } )
- .then( ( response: { data: { id: number } } ) => {
- subProductBId = response.data.id;
- } );
- await restApi
- .post( `${ WC_API_PATH }/products`, {
- name: `Product grouped ${ random() }`,
- regular_price: '29.99',
- grouped_products: [ subProductAId, subProductBId ],
- type: 'grouped',
- } )
- .then( ( response ) => {
- product = response.data;
- } );
-
- await use( product );
+// This describe carries `tags.HPOS`, and the `e2e-hpos-disabled` project greps on that tag
+// with `DISABLE_HPOS=1` set. So these titles also run against legacy post storage, where
+// `admin.php?page=wc-orders` is not registered and the editor lives under `post.php`.
+const newOrderUrl = () =>
+ process.env.DISABLE_HPOS === '1'
+ ? 'wp-admin/post-new.php?post_type=shop_order'
+ : 'wp-admin/admin.php?page=wc-orders&action=new';
- // Cleanup
- await restApi.delete( `${ WC_API_PATH }/products/${ product.id }`, {
- force: true,
- } );
- },
-} );
+const editOrderUrl = ( orderId: number | string ) =>
+ process.env.DISABLE_HPOS === '1'
+ ? `wp-admin/post.php?post=${ orderId }&action=edit`
+ : `wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`;
test.describe(
'WooCommerce Orders > Add new order',
@@ -328,103 +204,11 @@ test.describe(
}
} );
- test( 'can create a simple guest order', async ( {
- page,
- simpleProduct,
- order,
- } ) => {
- await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
- order.id = await getOrderIdFromPage( page );
-
- await page
- .locator( '#order_status' )
- .selectOption( 'wc-processing' );
-
- // Enter billing information
- await page
- .getByRole( 'heading', { name: 'Billing Edit' } )
- .getByRole( 'link' )
- .click();
- await page
- .getByRole( 'textbox', { name: 'First name' } )
- .fill( 'Bart' );
- await page
- .getByRole( 'textbox', { name: 'Last name' } )
- .fill( 'Simpson' );
- await page
- .getByRole( 'textbox', { name: 'Company' } )
- .fill( 'Kwik-E-Mart' );
- await page
- .getByRole( 'textbox', { name: 'Address line 1' } )
- .fill( '742 Evergreen Terrace' );
- await page
- .getByRole( 'textbox', { name: 'City' } )
- .fill( 'Springfield' );
- await page
- .getByRole( 'textbox', { name: 'Postcode' } )
- .fill( '12345' );
- // eslint-disable-next-line playwright/no-conditional-in-test
- if (
- await page
- .getByRole( 'textbox', { name: 'Select an option…' } )
- .isVisible()
- ) {
- await page
- .getByRole( 'textbox', { name: 'Select an option…' } )
- .click();
- await page.getByRole( 'option', { name: 'Florida' } ).click();
- }
- await page
- .getByRole( 'textbox', { name: 'Email address' } )
- .fill( 'elbarto@example.com' );
- await page
- .getByRole( 'textbox', { name: 'Phone' } )
- .fill( '555-555-5555' );
- await page
- .getByRole( 'textbox', { name: 'Transaction ID' } )
- .fill( '1234567890' );
-
- // Enter shipping information
- await page
- .getByRole( 'heading', { name: 'Shipping Edit' } )
- .getByRole( 'link' )
- .click();
- page.on( 'dialog', ( dialog ) => dialog.accept() );
- await page
- .getByRole( 'link', { name: 'Copy billing address' } )
- .click();
- await page
- .getByPlaceholder( 'Customer notes about the order' )
- .fill( 'Only asked for a slushie' );
-
- // Add a product
- await addProductToOrder( page, simpleProduct, 2 );
-
- // Create the order
- await page.getByRole( 'button', { name: 'Create' } ).click();
- await expect( page.getByText( 'Order updated' ) ).toBeVisible();
-
- // Confirm the details
- await expect(
- page.getByText(
- 'Billing Edit Load billing address Bart SimpsonKwik-E-Mart742 Evergreen'
- )
- ).toBeVisible();
- await expect(
- page.getByText(
- 'Shipping Edit Load shipping address Copy billing address Bart SimpsonKwik-E-'
- )
- ).toBeVisible();
- await expect(
- page.locator( 'table' ).filter( { hasText: 'Paid: $200.00' } )
- ).toBeVisible();
- } );
-
test( 'can add a product without an extra click or rogue search box', async ( {
page,
simpleProduct,
} ) => {
- await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
+ await page.goto( newOrderUrl() );
// Open the Add products modal.
await page.getByRole( 'button', { name: 'Add item(s)' } ).click();
@@ -488,11 +272,12 @@ test.describe(
test( 'can create an order for an existing customer', async ( {
page,
+ restApi,
simpleProduct,
customer,
order,
} ) => {
- await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
+ await page.goto( newOrderUrl() );
order.id = await getOrderIdFromPage( page );
// Select customer
@@ -506,147 +291,110 @@ test.describe(
} )
.click();
- // Add a product
- await addProductToOrder( page, simpleProduct, 2 );
-
- // Create the order
- await page.getByRole( 'button', { name: 'Create' } ).click();
- await expect( page.getByText( 'Order updated' ) ).toBeVisible();
-
- // Confirm the details
- await expect(
- page.getByText(
- 'Billing Edit Load billing address Sideshow BobDie Bart Die123 Fake'
- )
- ).toBeVisible();
- await expect(
- page.getByText(
- 'Shipping Edit Load shipping address Copy billing address Sideshow BobDie Bart'
- )
- ).toBeVisible();
-
- // View customer profile
- await page.getByRole( 'link', { name: 'Profile →' } ).click();
- await expect(
- page.getByRole( 'heading', {
- name: `Edit User ${ customer.username }`,
- } )
- ).toBeVisible();
-
- // Go back to the order
- await page.goto(
- `wp-admin/admin.php?page=wc-orders&action=edit&id=${ order.id }`
+ await expect( page.locator( '#_billing_first_name' ) ).toHaveValue(
+ 'Sideshow'
+ );
+ await expect( page.locator( '#_billing_address_1' ) ).toHaveValue(
+ '123 Fake St'
+ );
+ await expect( page.locator( '#_shipping_first_name' ) ).toHaveValue(
+ 'Sideshow'
+ );
+ await expect( page.locator( '#_shipping_address_1' ) ).toHaveValue(
+ '321 Fake St'
);
+
+ await page.locator( '#_billing_address_1' ).fill( '124 Fake St' );
+ page.on( 'dialog', ( dialog ) => dialog.accept() );
await page
- .getByRole( 'link', {
- name: 'View other orders',
- } )
+ .getByRole( 'link', { name: 'Copy billing address' } )
.click();
- await expect(
- page.locator( 'h1.wp-heading-inline' )
- ).toContainText( 'Orders' );
- await expect( page.getByRole( 'row' ) ).toHaveCount( 3 ); // 1 order and header and footer rows
- } );
-
- test( 'can create new order', async ( { page, order } ) => {
- await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
- await expect(
- page.locator( 'h1.wp-heading-inline' )
- ).toContainText( 'Add new order' );
- order.id = await getOrderIdFromPage( page );
+ await expect( page.locator( '#_shipping_address_1' ) ).toHaveValue(
+ '124 Fake St'
+ );
await page
.locator( '#order_status' )
.selectOption( 'wc-processing' );
- await page.locator( 'input[name=order_date]' ).fill( '2018-12-13' );
- await page.locator( 'input[name=order_date_hour]' ).fill( '18' );
- await page.locator( 'input[name=order_date_minute]' ).fill( '55' );
+ await page
+ .getByPlaceholder( 'Customer notes about the order' )
+ .fill( 'Leave the order with the prison guard' );
+
+ await addProductToOrder( page, simpleProduct, 2 );
+ await page
+ .getByRole( 'button', { name: 'Recalculate', exact: true } )
+ .click();
+ await expect( page.locator( 'th.line_tax' ) ).toHaveText(
+ 'Tax Rate Simple'
+ );
- await page.locator( 'button.save_order' ).click();
+ await page.getByRole( 'button', { name: 'Create' } ).click();
+ await expect( page.getByText( 'Order updated' ) ).toBeVisible();
+ await page.goto( editOrderUrl( order.id ) );
+ // The order data meta box links the customer's profile and their other orders.
+ await expect(
+ page.getByRole( 'link', { name: 'Profile →' } )
+ ).toHaveAttribute(
+ 'href',
+ new RegExp( `user-edit\\.php\\?user_id=${ customer.id }$` )
+ );
await expect(
- page.locator(
- 'div.updated.notice.notice-success.is-dismissible',
- {
- has: page.locator( 'p' ),
- }
+ page.getByRole( 'link', { name: 'View other orders →' } )
+ ).toHaveAttribute(
+ 'href',
+ new RegExp(
+ `edit\\.php\\?post_status=all&post_type=shop_order&_customer_user=${ customer.id }$`
)
- ).toContainText( 'Order updated.' );
+ );
+ await expect( page.locator( '#_billing_address_1' ) ).toHaveValue(
+ '124 Fake St'
+ );
+ await expect( page.locator( '#_shipping_address_1' ) ).toHaveValue(
+ '124 Fake St'
+ );
await expect( page.locator( '#order_status' ) ).toHaveValue(
'wc-processing'
);
await expect(
- page.locator( 'div.note_content' ).filter( {
- hasText:
- 'Order status changed from Pending payment to Processing.',
+ page.getByPlaceholder( 'Customer notes about the order' )
+ ).toHaveValue( 'Leave the order with the prison guard' );
+ await expect(
+ page.locator( 'td.name > a' ).filter( {
+ hasText: simpleProduct.name,
} )
).toBeVisible();
- } );
-
- test( 'can create new complex order with multiple product types & tax classes', async ( {
- page,
- simpleProduct,
- variableProduct,
- externalProduct,
- groupedProduct,
- order,
- } ) => {
- await page.goto( 'wp-admin/admin.php?page=wc-orders&action=new' );
- order.id = await getOrderIdFromPage( page );
-
- // open modal for adding line items
- await page.locator( 'button.add-line-item' ).click();
- await page.locator( 'button.add-order-item' ).click();
-
- // search for each product to add
- for ( const [ index, product ] of [
- simpleProduct,
- variableProduct,
- groupedProduct,
- externalProduct,
- ].entries() ) {
- if ( index > 0 ) {
- await page.getByText( 'Search for a product…' ).click();
- }
- await page
- .locator( 'span > .select2-search__field' )
- .fill( product.name );
- await page
- .getByRole( 'option', { name: product.name } )
- .first()
- .click();
- }
-
- await page.locator( 'button#btn-ok' ).click();
-
- // assert that products added
- await expect(
- page.locator( 'td.name > a >> nth=0' )
- ).toContainText( simpleProduct.name );
- await expect(
- page.locator( 'td.name > a >> nth=1' )
- ).toContainText( variableProduct.name );
- await expect(
- page.locator( 'td.name > a >> nth=2' )
- ).toContainText( groupedProduct.name );
- await expect(
- page.locator( 'td.name > a >> nth=3' )
- ).toContainText( externalProduct.name );
-
- // Recalculate taxes
- page.on( 'dialog', ( dialog ) => dialog.accept() );
- await page
- .getByRole( 'button', { name: 'Recalculate', exact: true } )
- .click();
+ await expect( page.locator( 'th.line_tax' ) ).toHaveText(
+ 'Tax Rate Simple'
+ );
- // verify tax names
- let i = 0;
- for ( const taxRate of taxRates ) {
- await expect(
- page.locator( `th.line_tax >> nth=${ i }` )
- ).toHaveText( taxRate.name );
- i++;
- }
+ const response = await restApi.get(
+ `${ WC_API_PATH }/orders/${ order.id }`
+ );
+ const persistedOrder = response.data;
+ expect( persistedOrder.customer_id ).toBe( customer.id );
+ expect( persistedOrder.status ).toBe( 'processing' );
+ expect( persistedOrder.customer_note ).toBe(
+ 'Leave the order with the prison guard'
+ );
+ expect( persistedOrder.billing.address_1 ).toBe( '124 Fake St' );
+ expect( persistedOrder.shipping.address_1 ).toBe( '124 Fake St' );
+ expect( persistedOrder.line_items ).toHaveLength( 1 );
+ expect( persistedOrder.line_items[ 0 ].product_id ).toBe(
+ simpleProduct.id
+ );
+ expect( persistedOrder.line_items[ 0 ].quantity ).toBe( 2 );
+ expect( Number( persistedOrder.line_items[ 0 ].total ) ).toBe(
+ 200
+ );
+ expect( persistedOrder.tax_lines ).toHaveLength( 1 );
+ expect( persistedOrder.tax_lines[ 0 ].label ).toBe(
+ 'Tax Rate Simple'
+ );
+ expect( Number( persistedOrder.tax_lines[ 0 ].tax_total ) ).toBe(
+ 20
+ );
+ expect( Number( persistedOrder.total ) ).toBe( 220 );
} );
}
);
diff --git a/plugins/woocommerce/tests/e2e/tests/order/order-coupon.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/order-coupon.spec.ts
index 858f7fdd809..7bc8d21fb00 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/order-coupon.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/order-coupon.spec.ts
@@ -118,9 +118,7 @@ test.describe(
exact: true,
} )
).toBeVisible();
- } );
- test( 'can remove a coupon', async ( { page } ) => {
await page.goto(
`wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
);
diff --git a/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
index 58d5dd90c28..90b6007f0c1 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/order-edit.spec.ts
@@ -14,11 +14,17 @@ import { wpCLI } from '../../utils/cli';
test.use( { storageState: ADMIN_STATE_PATH } );
+const orderEditUrl = ( id: number ) =>
+ process.env.DISABLE_HPOS === '1'
+ ? `wp-admin/post.php?post=${ id }&action=edit`
+ : `wp-admin/admin.php?page=wc-orders&action=edit&id=${ id }`;
+const ordersListUrl = ( id: number ) =>
+ process.env.DISABLE_HPOS === '1'
+ ? `wp-admin/edit.php?post_type=shop_order&s=${ id }`
+ : `wp-admin/admin.php?page=wc-orders&s=${ id }`;
+
test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
- let orderId: number,
- secondOrderId: number,
- orderToCancel: number,
- customerId: number;
+ let orderId: number, secondOrderId: number, customerId: number;
const username = `big.archie.${ Date.now() }`;
test.beforeAll( async ( { restApi } ) => {
@@ -36,13 +42,6 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
.then( ( response: { data: { id: number } } ) => {
secondOrderId = response.data.id;
} );
- await restApi
- .post( `${ WC_API_PATH }/orders`, {
- status: 'processing',
- } )
- .then( ( response: { data: { id: number } } ) => {
- orderToCancel = response.data.id;
- } );
await restApi
.post( `${ WC_API_PATH }/customers`, {
email: `${ username }@email.addr`,
@@ -86,56 +85,29 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
await restApi.delete( `${ WC_API_PATH }/orders/${ secondOrderId }`, {
force: true,
} );
- await restApi.delete( `${ WC_API_PATH }/orders/${ orderToCancel }`, {
- force: true,
- } );
await restApi.delete( `${ WC_API_PATH }/customers/${ customerId }`, {
force: true,
} );
} );
- test( 'can view single order', async ( { page } ) => {
- if ( process.env.DISABLE_HPOS === '1' ) {
- await page.goto( 'wp-admin/edit.php?post_type=shop_order' );
- } else {
- await page.goto( 'wp-admin/admin.php?page=wc-orders' );
- }
-
- // confirm we're on the orders page
- await expect(
- page.locator( 'h1.components-text, h1.wp-heading-inline' )
- ).toContainText( 'Orders' );
- // open order we created
- await page.goto(
- `wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
- );
+ test( 'can persist order status and date', async ( { page } ) => {
+ await page.goto( orderEditUrl( orderId ) );
- // make sure we're on the order details page
- await expect( page.locator( 'h1.wp-heading-inline' ) ).toContainText(
- /Edit [oO]rder/
- );
- } );
-
- test( 'can update order status', async ( { page } ) => {
- // open order we created
- await page.goto(
- `wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
- );
-
- // update order status to Completed
+ await page.locator( 'input[name=order_date]' ).fill( '2018-12-14' );
await page.locator( '#order_status' ).selectOption( 'wc-completed' );
await page.locator( 'button.save_order' ).click();
- // verify order status changed and note added
await expect( page.locator( '#order_status' ) ).toHaveValue(
'wc-completed'
);
+ await expect( page.locator( 'input[name=order_date]' ) ).toHaveValue(
+ '2018-12-14'
+ );
await expect(
page.locator( '#woocommerce-order-notes .note_content >> nth=0' )
).toContainText( 'Order status changed from Processing to Completed.' );
- // load the orders listing and confirm order is completed
- await page.goto( 'wp-admin/admin.php?page=wc-orders' );
+ await page.goto( ordersListUrl( orderId ) );
await expect(
page
@@ -144,59 +116,6 @@ test.describe( 'Edit order', { tag: [ tags.SERVICES, tags.HPOS ] }, () => {
).toBeVisible();
} );
- test( 'can update order status to cancelled', async ( { page } ) => {
- // open order we created
- await page.goto(
- `wp-admin/post.php?post=${ orderToCancel }&action=edit`
- );
-
- // update order status to Completed
- await page.locator( '#order_status' ).selectOption( 'Cancelled' );
- await page.locator( 'button.save_order' ).click();
-
- // verify order status changed and note added
- await expect( page.locator( '#order_status' ) ).toHaveValue(
- 'wc-cancelled'
- );
- await expect(
- page.getByText(
- 'Order status changed from Processing to Cancelled.'
- )
- ).toBeVisible();
-
- // load the orders listing and confirm order is cancelled
- await page.goto( 'wp-admin/admin.php?page=wc-orders' );
-
- await expect(
- page
- .locator(
- `:is(#order-${ orderToCancel }, #post-${ orderToCancel })`
- )
- .getByRole( 'cell', { name: 'Cancelled' } )
- ).toBeVisible();
- } );
-
- test( 'can update order details', async ( { page } ) => {
- // open order we created
- await page.goto(
- `wp-admin/admin.php?page=wc-orders&action=edit&id=${ orderId }`
- );
-
- // update order date
- await page.locator( 'input[name=order_date]' ).fill( '2018-12-14' );
- await page.locator( 'button.save_order' ).click();
-
- // verify changes
- await expect(
- page
- .locator( 'div.notice-success > p' )
- .filter( { hasText: 'Order updated.' } )
- ).toBeVisible();
- await expect( page.locator( 'input[name=order_date]' ) ).toHaveValue(
- '2018-12-14'
- );
- } );
-
test( 'saving an order does not trigger a false unsaved-changes warning', async ( {
page,
} ) => {
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php
index 1df7d6e168c..858d1ce2755 100644
--- a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-data-test.php
@@ -560,6 +560,90 @@ class WC_Meta_Box_Order_Data_Test extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'Extension contact reference: Extension reference 39300', $summary );
}
+ /**
+ * @testdox Saving the order data meta box persists the $status_label status, date, and transition note.
+ *
+ * @dataProvider provider_order_status_transitions
+ *
+ * @param string $status Status to persist.
+ * @param string $status_label Human-readable status label.
+ */
+ public function test_save_persists_status_date_and_transition_note( string $status, string $status_label ): void {
+ $order = wc_create_order( array( 'status' => 'processing' ) );
+ if ( ! $order instanceof WC_Order ) {
+ throw new RuntimeException( 'The order fixture could not be created.' );
+ }
+
+ $this->orders[] = $order;
+
+ // phpcs:disable WordPress.Security.NonceVerification.Missing -- Simulate the nonce-verified meta-box save request.
+ $_POST = array(
+ 'order_status' => 'wc-' . $status,
+ '_payment_method' => $order->get_payment_method(),
+ 'customer_user' => 0,
+ 'order_date' => '2018-12-14',
+ 'order_date_hour' => '13',
+ 'order_date_minute' => '14',
+ 'order_date_second' => '15',
+ // Billing fields on a guest order go through their own branch in save(),
+ // guarded on the customer being a guest or a valid user. Posting them here
+ // is what covers the deleted "create a simple guest order" E2E title; the
+ // surviving browser test only asserts billing after picking a customer.
+ '_billing_first_name' => 'Guest',
+ '_billing_last_name' => 'Buyer',
+ '_billing_company' => 'Guest Co',
+ '_billing_address_1' => '1 Guest Street',
+ '_billing_city' => 'Guestville',
+ '_billing_postcode' => '90210',
+ '_billing_email' => 'guest.buyer@example.com',
+ '_billing_phone' => '555-0100',
+ );
+ // phpcs:enable WordPress.Security.NonceVerification.Missing
+
+ WC_Meta_Box_Order_Data::save( $order->get_id() );
+
+ $saved_order = wc_get_order( $order->get_id() );
+ if ( ! $saved_order instanceof WC_Order ) {
+ throw new RuntimeException( 'The saved order could not be reloaded.' );
+ }
+
+ $date_created = $saved_order->get_date_created();
+ if ( ! $date_created instanceof WC_DateTime ) {
+ throw new RuntimeException( 'The saved order date could not be reloaded.' );
+ }
+
+ $this->assertSame( $status, $saved_order->get_status() );
+ $this->assertSame( '2018-12-14 13:14:15', $date_created->date( 'Y-m-d H:i:s' ) );
+
+ $this->assertSame( 'Guest', $saved_order->get_billing_first_name( 'edit' ) );
+ $this->assertSame( 'Buyer', $saved_order->get_billing_last_name( 'edit' ) );
+ $this->assertSame( 'Guest Co', $saved_order->get_billing_company( 'edit' ) );
+ $this->assertSame( '1 Guest Street', $saved_order->get_billing_address_1( 'edit' ) );
+ $this->assertSame( 'Guestville', $saved_order->get_billing_city( 'edit' ) );
+ $this->assertSame( '90210', $saved_order->get_billing_postcode( 'edit' ) );
+ $this->assertSame( 'guest.buyer@example.com', $saved_order->get_billing_email( 'edit' ) );
+ $this->assertSame( '555-0100', $saved_order->get_billing_phone( 'edit' ) );
+
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $this->assertNotEmpty( $notes, 'The status transition should create an order note.' );
+ $this->assertSame(
+ "Order status changed from Processing to {$status_label}.",
+ $notes[0]->content
+ );
+ }
+
+ /**
+ * Status transitions saved through the order data meta box.
+ *
+ * @return array<string, array{string, string}>
+ */
+ public function provider_order_status_transitions(): array {
+ return array(
+ 'completed' => array( 'completed', 'Completed' ),
+ 'cancelled' => array( 'cancelled', 'Cancelled' ),
+ );
+ }
+
/**
* Create an order with billing-derived shipping data.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index c808e2bac11..c2b5a11fd42 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -8,6 +8,7 @@
declare( strict_types = 1 );
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
@@ -1422,6 +1423,133 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
$this->assertEquals( 45, $order->get_total() );
}
+ /**
+ * @testdox Calculating line taxes persists the tax class, rate, and totals for each supported product type.
+ */
+ public function test_calc_line_taxes_persists_multiple_tax_classes(): void {
+ $suffix = strtolower( wp_generate_password( 8, false, false ) );
+ $class_definitions = array(
+ array( "Ajax order ten {$suffix}", "ajax-order-ten-{$suffix}", '10', "Ajax order Ten {$suffix}" ),
+ array( "Ajax order twenty {$suffix}", "ajax-order-twenty-{$suffix}", '20', "Ajax order Twenty {$suffix}" ),
+ array( "Ajax order thirty {$suffix}", "ajax-order-thirty-{$suffix}", '30', "Ajax order Thirty {$suffix}" ),
+ );
+ $tax_classes = array();
+ $tax_rate_ids = array();
+
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_prices_include_tax', 'no' );
+ update_option( 'woocommerce_tax_based_on', 'shipping' );
+
+ foreach ( $class_definitions as $definition ) {
+ $tax_class = WC_Tax::create_tax_class( $definition[0], $definition[1] );
+ if ( is_wp_error( $tax_class ) ) {
+ throw new RuntimeException( esc_html( $tax_class->get_error_message() ) );
+ }
+ $tax_classes[] = $tax_class['slug'];
+ $tax_rate_id = WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => 'US',
+ 'tax_rate_state' => 'CA',
+ 'tax_rate' => $definition[2],
+ 'tax_rate_name' => $definition[3],
+ 'tax_rate_priority' => 1,
+ 'tax_rate_compound' => 0,
+ 'tax_rate_shipping' => 0,
+ 'tax_rate_order' => 1,
+ 'tax_rate_class' => $tax_class['slug'],
+ )
+ );
+ if ( ! $tax_rate_id ) {
+ throw new RuntimeException( 'Could not create the tax-rate fixture.' );
+ }
+ $tax_rate_ids[] = $tax_rate_id;
+ }
+ WC_Cache_Helper::invalidate_cache_group( 'taxes' );
+
+ $simple_product = WC_Helper_Product::create_simple_product();
+ $simple_product->set_regular_price( '100' );
+ $simple_product->set_tax_class( $tax_classes[0] );
+ $simple_product->save();
+
+ $variable_product = new WC_Product_Variable();
+ $variable_product->set_name( 'Ajax order taxed variable parent' );
+ $variable_product->save();
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $variable_product->get_id() );
+ $variation->set_regular_price( '100' );
+ $variation->set_tax_class( $tax_classes[1] );
+ $variation->save();
+
+ $external_product = WC_Helper_Product::create_external_product();
+ $external_product->set_regular_price( '100' );
+ $external_product->set_tax_class( $tax_classes[2] );
+ $external_product->save();
+
+ $order = wc_create_order();
+ if ( is_wp_error( $order ) ) {
+ throw new RuntimeException( 'Could not create the empty taxed order fixture.' );
+ }
+ $order->set_shipping_country( 'GB' );
+ $order->add_product( $simple_product, 1 );
+ $order->add_product( $variation, 1 );
+ $order->add_product( $external_product, 1 );
+ $order->save();
+
+ $serialized_items = array(
+ 'order_item_id' => array(),
+ 'order_item_name' => array(),
+ 'order_item_qty' => array(),
+ 'order_item_tax_class' => array(),
+ 'line_subtotal' => array(),
+ 'line_total' => array(),
+ );
+ foreach ( $order->get_items( 'line_item' ) as $item_id => $item ) {
+ $serialized_items['order_item_id'][] = $item_id;
+ $serialized_items['order_item_name'][ $item_id ] = $item->get_name();
+ $serialized_items['order_item_qty'][ $item_id ] = 1;
+ $serialized_items['order_item_tax_class'][ $item_id ] = $item->get_tax_class();
+ $serialized_items['line_subtotal'][ $item_id ] = '100';
+ $serialized_items['line_total'][ $item_id ] = '100';
+ }
+
+ $taxes_controller = wc_get_container()->get( TaxesController::class );
+ $taxes_controller->calc_line_taxes(
+ array(
+ 'order_id' => $order->get_id(),
+ 'items' => http_build_query( $serialized_items ),
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '90210',
+ 'city' => 'Beverly Hills',
+ )
+ );
+
+ $fresh_order = wc_get_order( $order->get_id() );
+ if ( ! $fresh_order instanceof WC_Order ) {
+ throw new RuntimeException( 'Could not reload the taxed order fixture.' );
+ }
+ $fresh_items = array_values( $fresh_order->get_items( 'line_item' ) );
+ $expected_tax = array( 10.0, 20.0, 30.0 );
+ $this->assertCount( 3, $fresh_items );
+
+ foreach ( $fresh_items as $index => $item ) {
+ $taxes = $item->get_taxes();
+ $this->assertSame( $tax_classes[ $index ], $item->get_tax_class() );
+ $this->assertSame( array( $tax_rate_ids[ $index ] ), array_map( 'intval', array_keys( $taxes['total'] ) ) );
+ $this->assertSame( $expected_tax[ $index ], (float) current( $taxes['total'] ) );
+ }
+
+ $tax_items = array_values( $fresh_order->get_items( 'tax' ) );
+ $this->assertCount( 3, $tax_items );
+ foreach ( $tax_items as $index => $tax_item ) {
+ $this->assertSame( $tax_rate_ids[ $index ], $tax_item->get_rate_id() );
+ $this->assertSame( $class_definitions[ $index ][3], $tax_item->get_label() );
+ $this->assertSame( $expected_tax[ $index ], (float) $tax_item->get_tax_total() );
+ }
+ $this->assertSame( 60.0, (float) $fresh_order->get_total_tax() );
+ $this->assertSame( 360.0, (float) $fresh_order->get_total() );
+ }
+
/**
* @testdox Product search decodes URL-encoded characters before returning plain text names.
* @dataProvider product_search_name_provider
@@ -1538,18 +1666,54 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
*
* @throws Automattic\WooCommerce\Internal\DependencyManagement\ContainerException If the LegacyProxy cannot be retrieved.
*/
- public function test_get_customer_details(): void {
+ public function test_get_customer_details_returns_exact_billing_and_shipping_payload(): void {
// This class does not inherit from WC_Unit_Test_Case, so we're handling the legacy proxy mechanics ourselves.
$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
$legacy_proxy->reset();
- $customer_id = 0;
- $is_member_of_blog = true;
- $is_multisite = true;
+ $customer_id = 0;
+ $is_member_of_blog = true;
+ $is_multisite = false;
+ $customer = WC_Helper_Customer::create_customer( 'ajaxordercustomer', 'pass2', 'ajaxorder@example.com' );
+ $customer_id = $customer->get_id();
+ $administrator_user = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ $expected_billing = array(
+ 'first_name' => 'Sideshow',
+ 'last_name' => 'Bob',
+ 'company' => 'Die Bart Die',
+ 'address_1' => '123 Fake St',
+ 'address_2' => 'Suite 4',
+ 'city' => 'Springfield',
+ 'postcode' => '12345',
+ 'country' => 'US',
+ 'state' => 'FL',
+ 'email' => 'billing-ajaxorder@example.com',
+ 'phone' => '555-555-5556',
+ );
+ $expected_shipping = array(
+ 'first_name' => 'Robert',
+ 'last_name' => 'Terwilliger',
+ 'company' => 'Springfield Penitentiary',
+ 'address_1' => '321 Fake St',
+ 'address_2' => 'Cell 8',
+ 'city' => 'Springfield',
+ 'postcode' => '54321',
+ 'country' => 'US',
+ 'state' => 'FL',
+ 'phone' => '555-555-5557',
+ );
+
+ foreach ( $expected_billing as $field => $value ) {
+ $customer->{"set_billing_{$field}"}( $value );
+ }
+ foreach ( $expected_shipping as $field => $value ) {
+ $customer->{"set_shipping_{$field}"}( $value );
+ }
+ $customer->update_meta_data( 'ajaxorder_unrelated_meta', 'must-not-leak' );
+ $customer->save();
$legacy_proxy->register_function_mocks(
array(
- 'check_ajax_referer' => fn () => true,
'is_multisite' => function () use ( &$is_multisite ) {
return $is_multisite;
},
@@ -1563,28 +1727,196 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
return filter_input( $method, $key, $filter, $options );
},
- 'wp_die' => fn () => '',
)
);
- $customer_id = WC_Helper_Customer::create_customer( 'test2', 'pass2', 'test2@example.com' )->get_id();
- $admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ try {
+ wp_set_current_user( $administrator_user );
+ $nonce = wp_create_nonce( 'get-customer-details' );
+ $_POST['user_id'] = $customer_id;
+ $_POST['security'] = $nonce;
+ $_REQUEST['user_id'] = $customer_id;
+ $_REQUEST['security'] = $nonce;
+
+ $response = $this->do_ajax( 'woocommerce_get_customer_details' );
+
+ $this->assertIsArray( $response, 'The registered customer-details action should return JSON data.' );
+ $this->assertSame( $customer_id, $response['id'] );
+ $this->assertSame( $expected_billing, $response['billing'] );
+ $this->assertSame( $expected_shipping, $response['shipping'] );
+ $this->assertArrayNotHasKey( 'meta_data', $response, 'Unrelated customer metadata must not be exposed.' );
+
+ $is_multisite = true;
+ $is_member_of_blog = false;
+ $this->_last_response = '';
+ $response = $this->do_ajax( 'woocommerce_get_customer_details' );
+ $this->assertNull( $response, 'Customers outside the current multisite blog must remain inaccessible.' );
+ } finally {
+ // The container keeps the mocked functions; nothing else here outlives
+ // the transaction rollback and the hook restore in tear_down().
+ $legacy_proxy->reset();
+ }
+ }
- wp_set_current_user( $admin_id );
- $_POST['user_id'] = $customer_id;
+ /**
+ * @testdox Registered Add Order Item AJAX persists every supported product type and its quantities.
+ */
+ public function test_add_order_item_via_ajax_persists_supported_product_types(): void {
+ $order = wc_create_order();
+ if ( is_wp_error( $order ) ) {
+ throw new RuntimeException( 'Could not create the empty order fixture.' );
+ }
+ $simple_product = WC_Helper_Product::create_simple_product();
+ $variable_product = new WC_Product_Variable();
+ $grouped_product = new WC_Product_Grouped();
+ $external_product = WC_Helper_Product::create_external_product();
- $response = $this->do_ajax( 'woocommerce_get_customer_details' );
- $this->assertIsArray(
- $response,
- 'If the customer is part of the blog, an array of information is supplied.'
+ $variable_product->set_name( 'Ajax order variable parent' );
+ $variable_product->save();
+
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $variable_product->get_id() );
+ $variation->set_regular_price( '25' );
+ $variation->save();
+
+ $grouped_product->set_name( 'Ajax order grouped' );
+ $grouped_product->save();
+
+ $products = array(
+ array( $simple_product, 2, ProductType::SIMPLE ),
+ array( $variation, 3, ProductType::VARIATION ),
+ array( $grouped_product, 4, ProductType::GROUPED ),
+ array( $external_product, 5, ProductType::EXTERNAL ),
);
- $is_member_of_blog = false;
- $response = $this->do_ajax( 'woocommerce_get_customer_details' );
- $this->assertNull(
- $response,
- 'If the customer is not part of the blog, we do not get back any customer information (in reality, the request was ended with wp_die).'
+ $this->_setRole( 'administrator' );
+ $request_data = array(
+ 'security' => wp_create_nonce( 'order-item' ),
+ 'order_id' => $order->get_id(),
+ 'items' => '',
+ 'data' => array_map(
+ static fn ( array $row ): array => array(
+ 'id' => $row[0]->get_id(),
+ 'qty' => $row[1],
+ ),
+ $products
+ ),
);
+ $_POST = $request_data;
+ $_REQUEST = $request_data;
+
+ $response = $this->do_ajax( 'woocommerce_add_order_item' );
+ $this->assertTrue( $response['success'] ?? false, 'The registered AJAX action should report success.' );
+
+ $fresh_order = wc_get_order( $order->get_id() );
+ if ( ! $fresh_order instanceof WC_Order ) {
+ throw new RuntimeException( 'Could not reload the order-item fixture.' );
+ }
+ $items = array_values( $fresh_order->get_items( 'line_item' ) );
+ $this->assertCount( 4, $items );
+
+ foreach ( $products as $index => $expected ) {
+ list( $product, $quantity, $product_type ) = $expected;
+ $item = $items[ $index ];
+
+ $this->assertSame( $quantity, $item->get_quantity() );
+ $this->assertSame( $product_type, $item->get_product()->get_type() );
+ if ( ProductType::VARIATION === $product_type ) {
+ $this->assertSame( $variable_product->get_id(), $item->get_product_id() );
+ $this->assertSame( $variation->get_id(), $item->get_variation_id() );
+ } else {
+ $this->assertSame( $product->get_id(), $item->get_product_id() );
+ $this->assertSame( 0, $item->get_variation_id() );
+ }
+ }
+
+ $notes = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $this->assertNotEmpty( $notes, 'Adding line items should create an order update note.' );
+ $this->assertStringContainsString( 'Added line items:', $notes[0]->content );
+ foreach ( $products as $expected ) {
+ $this->assertStringContainsString( $expected[0]->get_name(), $notes[0]->content );
+ }
+ }
+
+ /**
+ * @testdox Registered Remove Order Coupon AJAX removes the coupon, recalculates totals, and records its internal note.
+ */
+ public function test_remove_order_coupon(): void {
+ $output_buffering_level = ob_get_level();
+ $coupon_code = 'remove-coupon-' . wp_rand( 1000, 9999 );
+ $product_name = 'Coupon Removal Product';
+ $expected_removal_note = sprintf( 'Coupon removed: "%s".', $coupon_code );
+ $expected_product_total = '10.00';
+ $expected_discounted_total = '5.00';
+
+ try {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_name( $product_name );
+ $product->set_regular_price( $expected_product_total );
+ $product->save();
+
+ $coupon = WC_Helper_Coupon::create_coupon(
+ $coupon_code,
+ array(
+ 'discount_type' => 'fixed_product',
+ 'coupon_amount' => $expected_discounted_total,
+ 'product_ids' => array( $product->get_id() ),
+ )
+ );
+ $order = wc_create_order();
+ if ( is_wp_error( $order ) ) {
+ throw new RuntimeException( 'Could not create the coupon-removal order fixture.' );
+ }
+ $order->add_product( $product, 1 );
+ $order->calculate_totals();
+ $this->assertTrue( $order->apply_coupon( $coupon_code ), 'The fixture order should accept its fixed-product coupon.' );
+ $order->calculate_totals();
+ $order->save();
+ $this->assertSame( $expected_discounted_total, $order->get_total(), 'The fixture order should start with its coupon discount applied.' );
+
+ $this->_setRole( 'administrator' );
+ $request_data = array(
+ 'security' => wp_create_nonce( 'order-item' ),
+ 'order_id' => $order->get_id(),
+ 'coupon' => $coupon_code,
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '94105',
+ 'city' => 'San Francisco',
+ );
+ $_POST = $request_data;
+ $_REQUEST = $request_data;
+
+ $response = $this->do_ajax( 'woocommerce_remove_order_coupon' );
+ $this->assertTrue( $response['success'] ?? false, 'The registered AJAX action should report success.' );
+ $this->assertStringContainsString( $product_name, $response['data']['html'] ?? '', 'The AJAX response should render the order items.' );
+ $this->assertStringContainsString( esc_html( $expected_removal_note ), $response['data']['notes_html'] ?? '', 'The AJAX response should render the coupon-removal note.' );
+
+ $fresh_order = wc_get_order( $order->get_id() );
+ if ( ! $fresh_order instanceof WC_Order ) {
+ throw new RuntimeException( 'Could not reload the coupon-removal order fixture.' );
+ }
+ $this->assertNotContains( $coupon_code, $fresh_order->get_coupon_codes(), 'The fresh order should no longer have the removed coupon.' );
+ $this->assertSame( $expected_product_total, $fresh_order->get_total(), 'Removing the coupon should restore the product total.' );
+
+ $removal_notes = array_values(
+ array_filter(
+ wc_get_order_notes( array( 'order_id' => $fresh_order->get_id() ) ),
+ static fn ( $note ): bool => esc_html( $expected_removal_note ) === $note->content
+ )
+ );
+ $this->assertCount( 1, $removal_notes, 'Removing the coupon should create one exact removal note.' );
+ $this->assertSame( 0, (int) $removal_notes[0]->customer_note, 'The coupon-removal note should remain internal.' );
+ } finally {
+ // Output buffering is process state, so a die handler that unwinds mid-render
+ // would otherwise leave the level where the next test inherits it.
+ while ( ob_get_level() > $output_buffering_level ) {
+ ob_end_clean();
+ }
+ while ( ob_get_level() < $output_buffering_level ) {
+ ob_start();
+ }
+ }
}
/**
@@ -2671,9 +3003,22 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
while ( ob_get_level() > $output_buffering_level ) {
ob_end_clean();
}
+ while ( ob_get_level() < $output_buffering_level ) {
+ ob_start();
+ }
}
- $result = json_decode( $this->_last_response, true );
+ $raw_response = (string) $this->_last_response;
+ $result = json_decode( $raw_response, true );
+ // A handler can send two payloads under the test die handler: wp_send_json_*() throws
+ // to stop the request, and the handler's own catch block catches that and sends an
+ // error payload after it. Decode the first one, which is all a real request receives.
+ if ( null === $result ) {
+ $second_response_offset = strpos( $raw_response, '}{"success":false' );
+ if ( false !== $second_response_offset ) {
+ $result = json_decode( substr( $raw_response, 0, $second_response_offset + 1 ), true );
+ }
+ }
$this->_last_response = false;
return $result;
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/COTMigrationUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/COTMigrationUtilTest.php
index 0493ff5867b..33119e0c1f1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/COTMigrationUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/COTMigrationUtilTest.php
@@ -59,10 +59,13 @@ class COTMigrationUtilTest extends \WC_Unit_Test_Case {
public function test_get_post_or_object_meta() {
$order = OrderHelper::create_order();
$post = get_post( $order->get_id() );
- update_post_meta( $order->get_id(), 'dummy_meta', 'dummy_value' );
+ $order->update_meta_data( 'dummy_meta', 'order_value' );
+ $order->save();
+ // Give the post a different value, so each read proves which source answered.
+ update_post_meta( $order->get_id(), 'dummy_meta', 'post_value' );
- $this->assertEquals( 'dummy_value', $this->sut->get_post_or_object_meta( $post, $order, 'dummy_meta', true ) );
- $this->assertEquals( 'dummy_value', $this->sut->get_post_or_object_meta( $post, null, 'dummy_meta', true ) );
+ $this->assertSame( 'order_value', $this->sut->get_post_or_object_meta( $post, $order, 'dummy_meta', true ) );
+ $this->assertSame( 'post_value', $this->sut->get_post_or_object_meta( $post, null, 'dummy_meta', true ) );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php b/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php
index f93463e522e..6264673388d 100644
--- a/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Utilities/MetaDataUtilTest.php
@@ -145,11 +145,29 @@ class MetaDataUtilTest extends WC_Unit_Test_Case {
*/
public function test_update_ignores_non_array_meta_data(): void {
$order = wc_create_order();
+ $order->update_meta_data( 'color', 'blue' );
+
+ // get_meta_data() hands back the order's own WC_Meta_Data objects, so an in-place change
+ // would compare equal to itself. get_data() does not help either: it returns the last
+ // applied values, not pending ones. Copy each entry's current id, key and value instead.
+ $meta_snapshot = static function ( \WC_Order $order ): array {
+ return array_map(
+ static function ( $meta ) {
+ return array(
+ 'id' => $meta->id,
+ 'key' => $meta->key,
+ 'value' => $meta->value,
+ );
+ },
+ $order->get_meta_data()
+ );
+ };
+ $original_meta_data = $meta_snapshot( $order );
MetaDataUtil::update( null, $order );
MetaDataUtil::update( 'string', $order );
- $this->assertEmpty( $order->get_meta_data(), 'No meta should be added for non-array meta_data' );
+ $this->assertSame( $original_meta_data, $meta_snapshot( $order ), 'Non-array meta_data should leave existing order metadata unchanged.' );
}
/**
@@ -178,7 +196,14 @@ class MetaDataUtilTest extends WC_Unit_Test_Case {
99
);
- $meta_data = $order->get_meta_data();
+ $meta_data = array_values(
+ array_filter(
+ $order->get_meta_data(),
+ static function ( $meta ): bool {
+ return 'k' === $meta->key;
+ }
+ )
+ );
$this->assertCount( 1, $meta_data );
$this->assertSame( 'k', $meta_data[0]->key );
$this->assertSame( 'v', $meta_data[0]->value );