Commit 5c973679cbf for woocommerce
commit 5c973679cbf2daba90b323dd102114fa669cb0a9
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 15 16:03:30 2026 +0300
[tests] Reduce Cart and Checkout shopper E2E from 21 titles to 8 (#68581)
* test(blocks): Reduce Cart and Checkout shopper E2E from 21 titles to 8
Four Cart and Checkout shopper specs ran 21 browser titles, two of
them already skipped: the cart page lifecycle and discount labels,
single-use coupons, a truth table of shipping topologies, and the
Store API nonce refresh.
Most of them checked decisions the Store API and the price component
make on their own: which rates a shipping topology produces, whether
a coupon over its usage limit is rejected, whether a price renders as
discounted. A browser run added a page, a cart, and an address form to
each case. The nonce title also waited on the wall clock, so it could
fail when the replacement nonce expired before the retry reached the
server.
Move the shipping topology matrix into the Store API Cart PHPUnit
tests, the usage-limit rejection into CartApplyCoupon (now as a
logged-in user, with the exact message), and the discount-label cases
into a CartLineItemRow and ProductPrice render matrix. Merge four cart
lifecycle titles into one journey, keep one merchant and two shopper
shipping titles and the coupon checkout journey, and make the nonce
title forward one invalid nonce instead of waiting for expiry, which
retires the short-nonce-life test plugin. coupon.php also runs one
test in a separate process, a CI isolation fix.
Consolidates the mega-branch slices:
- Slice 067: test(blocks): Consolidate cart shopper coverage
- Slice 006: test(blocks): right-size single-use coupon coverage
- Slice 012: test(blocks): Consolidate shipping topology coverage
- Slice terminalization-retained-system: fix(e2e): stabilize Store
API nonce expiry; refactor(e2e): simplify migrated Blocks test
contracts
- Slice 003 (cart-line identity tests carried in Cart.php):
test(blocks): right-size cart-line identity coverage
- test: Fix lower-layer migration CI regressions (coupon.php)
Refs TESTOPS-234
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Pass requestUtils to CheckoutPage in the shipping spec
`CheckoutPage` declares `requestUtils: RequestUtils` with no default, so
constructing it with only `page` is a type error under the strict e2e
tsconfig, and leaves the page object holding `undefined`. Nothing caught
it because no script runs tsc over tests/e2e and Playwright's esbuild
transpile strips the types.
The omission came from trunk and this file only reformatted the call, so
this is the narrow fix; several other Blocks specs construct the page
object the same way and want a sweep of their own.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(store-api): Stop the cart topology test restoring five options
capture_options()/restore_options() snapshotted the five shipping
options the topology test writes and put them back in the finally.
They are options-table rows inside the per-test transaction, which
tear_down() rolls back first, so both helpers and the array naming
them go.
Deliberately leaving the rest of that teardown alone, and it is worth
saying why rather than trimming it by reflex:
- This class extends WP_Test_REST_TestCase, not WC_Unit_Test_Case, so
clear_wc_singleton_state() never runs for it. The cart context, the
country locale, reset_shipping() and the shipping transients are all
on WC() singletons with nothing else to put them back.
- restore_shipping_session() is not cleanup at all. It has its own
test: test_shipping_topology_restores_existing_session_state calls
this test as a helper and asserts the incoming session values come
back exactly, including a key that was missing staying missing.
Same 92 tests and 603 assertions before and after.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Drop process isolation from the percent-discount coupon test
The @runInSeparateProcess annotation this PR added forks a child that
re-runs tests/legacy/bootstrap.php, whose maybe_initialize_hpos() calls
OrderHelper::toggle_cot_feature_and_usage(). That writes
woocommerce_custom_orders_table_enabled over the child's own connection,
outside the parent's rolled-back transaction, and flips the order store
for every test that follows. #68629 hit the same trap and removed the
same annotation for the same reason.
The constant it was containing stays contained by the defined() guard
that was already there. The docblock now records why, including why not
to reach for isolation again -- written as "process isolation" rather
than the annotation name, since PHPUnit parses an @annotation at the
start of a docblock line and a reflow would re-enable the very thing the
comment warns about.
Also restore the Cart-page coupon apply and remove that the retained
journey had dropped. Applying on the Cart block is covered by
tests/e2e/tests/coupons/cart-block-coupons.spec.ts, but removing an
applied coupon is exercised nowhere else: "Remove coupon" has exactly
one match left in the E2E tree, and it is only asserted visible, never
clicked.
Verified with the whole suite in one process, both dimensions: HPOS on
15112 tests / 58835 assertions / 56 skips, HPOS off 15112 / 58875 / 51,
no failures either way.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(store-api): Stop pinning a coupon message that global state chooses
WooCommerce has three usage-limit wordings and picks between them on
whether the current user has a failed or pending order. The test asserted
the whole message with assertSame, so any earlier test in the process
that commits such an order for user 1 -- a wpSetUpBeforeClass fixture is
the obvious shape, since those sit outside the per-test transaction --
would fail this one with a string mismatch giving no hint that the cause
was another class.
All three variants open with the same sentence, and the coupon code is
the part worth pinning, so assert the message starts with it.
Also drop assertIsArray on the chosen shipping methods: the session read
one line above passes array() as its default, so the value is an array on
every path. The two assertions after it carry the check.
Refs TESTOPS-234
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-234-cart-checkout-shopper-journeys b/plugins/woocommerce/changelog/testops-234-cart-checkout-shopper-journeys
new file mode 100644
index 00000000000..cc1b0125a68
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-cart-checkout-shopper-journeys
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Consolidate the Cart block shopper E2E journeys and move the discount-label, single-use coupon, and shipping-topology checks to Jest and PHPUnit; replace the wall-clock nonce expiry with one deterministic invalid nonce.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx
index ae787afc243..36dccf546d2 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/cart-line-items-table/test/cart-line-item-row.tsx
@@ -60,7 +60,14 @@ const mockGetSettingWithCoercion = getSettingWithCoercion as jest.Mock;
* preview module's transitive imports (which expect `wcBlocksConfig` to be
* populated at module-load time).
*/
-const buildLineItem = (): CartItem =>
+type RawPriceOverrides = Partial< {
+ precision: number;
+ price: string;
+ regular_price: string;
+ sale_price: string;
+} >;
+
+const buildLineItem = ( rawPriceOverrides: RawPriceOverrides = {} ): CartItem =>
( {
key: 'test-key',
id: 1,
@@ -102,6 +109,7 @@ const buildLineItem = (): CartItem =>
price: '10000000',
regular_price: '10000000',
sale_price: '10000000',
+ ...rawPriceOverrides,
},
},
totals: {
@@ -163,17 +171,82 @@ const setSettings = ( {
);
};
-const renderRow = () => {
+const renderRow = ( rawPriceOverrides?: RawPriceOverrides ) => {
// Wrap in a table so the row's <td>s render in a valid context.
return render(
<table>
<tbody>
- <CartLineItemRow lineItem={ buildLineItem() } />
+ <CartLineItemRow
+ lineItem={ buildLineItem( rawPriceOverrides ) }
+ />
</tbody>
</table>
);
};
+describe( 'renders cart row price', () => {
+ it.each( [
+ {
+ name: 'sale price with its regular price',
+ rawPrices: {
+ precision: 6,
+ price: '8000000',
+ regular_price: '10000000',
+ },
+ currentPrice: '$8.00',
+ regularPrice: '$10.00',
+ },
+ {
+ name: 'ordinary price without sale markup',
+ rawPrices: {
+ price: '10000000',
+ regular_price: '10000000',
+ },
+ currentPrice: '$10.00',
+ regularPrice: undefined,
+ },
+ {
+ name: 'extension-filtered price above its regular price without sale markup',
+ rawPrices: {
+ price: '50000000',
+ regular_price: '10000000',
+ },
+ currentPrice: '$50.00',
+ regularPrice: undefined,
+ },
+ ] )( '$name', ( { rawPrices, currentPrice, regularPrice } ) => {
+ const { container } = renderRow( rawPrices );
+ const price = container.querySelector( '.wc-block-cart-item__prices' );
+ const currentPriceElement = price?.querySelector(
+ '.wc-block-components-product-price__value'
+ );
+ const regularPriceElement = price?.querySelector(
+ 'del.wc-block-components-product-price__regular'
+ );
+ const discountedPriceElement =
+ price?.querySelector( 'ins.is-discounted' );
+
+ expect( price ).toBeInTheDocument();
+ expect(
+ price?.querySelectorAll(
+ '.wc-block-components-product-price__value'
+ )
+ ).toHaveLength( 1 );
+ expect( currentPriceElement?.textContent ).toBe( currentPrice );
+
+ expect( Boolean( regularPriceElement ) ).toBe(
+ Boolean( regularPrice )
+ );
+ expect( Boolean( discountedPriceElement ) ).toBe(
+ Boolean( regularPrice )
+ );
+ expect( regularPriceElement?.textContent ).toBe( regularPrice );
+ expect( discountedPriceElement?.textContent ).toBe(
+ regularPrice ? currentPrice : undefined
+ );
+ } );
+} );
+
describe( 'CartLineItemRow — Save for later visibility', () => {
beforeEach( () => {
jest.clearAllMocks();
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-cart-checkout-shopper-journeys b/plugins/woocommerce/client/blocks/changelog/testops-234-cart-checkout-shopper-journeys
new file mode 100644
index 00000000000..cc1b0125a68
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-cart-checkout-shopper-journeys
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Consolidate the Cart block shopper E2E journeys and move the discount-label, single-use coupon, and shipping-topology checks to Jest and PHPUnit; replace the wall-clock nonce expiry with one deterministic invalid nonce.
+
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/blocks/short-nonce-life.php b/plugins/woocommerce/tests/e2e/test-plugins/blocks/short-nonce-life.php
deleted file mode 100644
index 0af4a340bcb..00000000000
--- a/plugins/woocommerce/tests/e2e/test-plugins/blocks/short-nonce-life.php
+++ /dev/null
@@ -1,21 +0,0 @@
-<?php
-/**
- * Plugin Name: WooCommerce Blocks Test Short Nonce Life
- * Description: Sets a very short nonce lifetime for testing nonce expiry scenarios.
- * Plugin URI: https://github.com/woocommerce/woocommerce
- * Author: WooCommerce
- *
- * @package woocommerce-blocks-test-short-nonce-life
- */
-
-declare( strict_types=1 );
-
-/**
- * Set nonce lifetime to 2 seconds to simulate cache expiry scenarios.
- */
-add_filter(
- 'nonce_life',
- function () {
- return 2;
- }
-);
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-block.shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-block.shopper.block_theme.spec.ts
index 265a6b1153e..5d56b1d4124 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-block.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-block.shopper.block_theme.spec.ts
@@ -8,8 +8,6 @@ import { test as base, expect } from '@woocommerce/e2e-utils';
*/
import { CartPage } from './cart.page';
import {
- DISCOUNTED_PRODUCT_NAME,
- REGULAR_PRICED_PRODUCT_NAME,
SIMPLE_PHYSICAL_PRODUCT_NAME,
SIMPLE_VIRTUAL_PRODUCT_NAME,
} from '../checkout/constants';
@@ -24,135 +22,76 @@ const test = base.extend< { pageObject: CartPage } >( {
} );
test.describe( 'Shopper → Cart block', () => {
- test( 'The discount label is only visible next to the discounted product', async ( {
- pageObject,
+ test( 'User can add, update, remove, and proceed from the cart', async ( {
frontendUtils,
+ page,
+ pageObject,
} ) => {
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( DISCOUNTED_PRODUCT_NAME );
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
await frontendUtils.goToCart();
- // Check for discount on the discounted product
- const discountedProductRow = await pageObject.findProductRow(
- DISCOUNTED_PRODUCT_NAME
- );
- await expect( discountedProductRow ).toBeVisible();
- const capRegularPriceElement = discountedProductRow.locator(
- '.wc-block-components-product-price__regular'
- );
- const capDiscountedPriceElement = discountedProductRow.locator(
- '.wc-block-components-product-price__value.is-discounted'
- );
- await expect( capRegularPriceElement ).toBeVisible();
- await expect( capDiscountedPriceElement ).toBeVisible();
-
- // Check for absence of discount on the regular priced product
- const regularPricedProductRow = await pageObject.findProductRow(
- REGULAR_PRICED_PRODUCT_NAME
- );
- await expect( regularPricedProductRow ).toBeVisible();
- const hoodieRegularPriceElement = regularPricedProductRow.locator(
- '.wc-block-components-product-price__regular'
- );
- const hoodieDiscountedPriceElement = regularPricedProductRow.locator(
- '.wc-block-components-product-price__value.is-discounted'
- );
- await expect( hoodieRegularPriceElement ).toBeHidden();
- await expect( hoodieDiscountedPriceElement ).toBeHidden();
- } );
- test( 'Products with updated prices should not display a discount label', async ( {
- pageObject,
- requestUtils,
- frontendUtils,
- } ) => {
- await requestUtils.activatePlugin(
- 'woocommerce-blocks-test-update-price'
- );
+ await expect(
+ page.getByRole( 'heading', {
+ name: 'Your cart is currently empty!',
+ } )
+ ).toBeVisible();
await frontendUtils.goToShop();
- await frontendUtils.addToCart( DISCOUNTED_PRODUCT_NAME );
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await frontendUtils.addToCart( SIMPLE_PHYSICAL_PRODUCT_NAME );
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( SIMPLE_VIRTUAL_PRODUCT_NAME );
await frontendUtils.goToCart();
- // Ensure no discount label is present on the products in the cart
- const capRow = await pageObject.findProductRow(
- DISCOUNTED_PRODUCT_NAME
- );
- await expect( capRow ).toBeVisible();
- const capRegularPriceElement = capRow.locator(
- '.wc-block-components-product-price__regular'
+ const physicalRow = await pageObject.findProductRow(
+ SIMPLE_PHYSICAL_PRODUCT_NAME
);
- const capDiscountedPriceElement = capRow.locator(
- '.wc-block-components-product-price__value.is-discounted'
+ const virtualRow = await pageObject.findProductRow(
+ SIMPLE_VIRTUAL_PRODUCT_NAME
);
- await expect( capRegularPriceElement ).toBeHidden();
- await expect( capDiscountedPriceElement ).toBeHidden();
-
- // Locate the price element within the Cap product row
- const capPriceElement = capRow
- .locator( '.wc-block-components-product-price__value' )
- .first();
- await expect( capPriceElement ).toBeVisible();
-
- // Get the text content of the price element and check the price
- const capPriceText = await capPriceElement.textContent();
- expect( capPriceText ).toBe( '$50.00' );
+ await expect( physicalRow ).toHaveCount( 1 );
+ await expect( physicalRow ).toBeVisible();
+ await expect( virtualRow ).toHaveCount( 1 );
+ await expect( virtualRow ).toBeVisible();
- const hoodieRow = await pageObject.findProductRow(
- REGULAR_PRICED_PRODUCT_NAME
+ const quantityInput = page.getByLabel(
+ `Quantity of ${ SIMPLE_VIRTUAL_PRODUCT_NAME } in your cart.`
);
- await expect( hoodieRow ).toBeVisible();
- const hoodieRegularPriceElement = hoodieRow.locator(
- '.wc-block-components-product-price__regular'
- );
- const hoodieDiscountedPriceElement = hoodieRow.locator(
- '.wc-block-components-product-price__value.is-discounted'
- );
- await expect( hoodieRegularPriceElement ).toBeHidden();
- await expect( hoodieDiscountedPriceElement ).toBeHidden();
-
- // Locate the price element within the Hoodie with Logo product row
- const hoodiePriceElement = hoodieRow
- .locator( '.wc-block-components-product-price__value' )
- .first();
- await expect( hoodiePriceElement ).toBeVisible();
+ const proceedToCheckout = page.getByRole( 'link', {
+ name: 'Proceed to Checkout',
+ } );
- // Get the text content of the price element and check the price
- const hoodiePriceText = await hoodiePriceElement.textContent();
- expect( hoodiePriceText ).toBe( '$50.00' );
- } );
+ await quantityInput.fill( '4' );
- test( 'User can view empty cart message', async ( {
- frontendUtils,
- page,
- } ) => {
- await frontendUtils.goToCart();
+ await expect( proceedToCheckout ).toBeDisabled();
+ await expect( proceedToCheckout ).toBeEnabled();
+ await expect( quantityInput ).toHaveValue( '4' );
- // Verify cart is empty
- await expect(
- page.getByRole( 'heading', {
- name: 'Your cart is currently empty!',
- } )
- ).toBeVisible();
- } );
+ await page
+ .getByLabel(
+ `Increase quantity of ${ SIMPLE_VIRTUAL_PRODUCT_NAME }`
+ )
+ .click();
+ await expect( proceedToCheckout ).toBeDisabled();
+ await expect( proceedToCheckout ).toBeEnabled();
+ await expect( quantityInput ).toHaveValue( '5' );
- test( 'User can remove a product from cart', async ( {
- frontendUtils,
- page,
- } ) => {
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( SIMPLE_PHYSICAL_PRODUCT_NAME );
- await frontendUtils.goToCart();
await page
+ .getByLabel( `Reduce quantity of ${ SIMPLE_VIRTUAL_PRODUCT_NAME }` )
+ .click();
+ await expect( proceedToCheckout ).toBeDisabled();
+ await expect( proceedToCheckout ).toBeEnabled();
+ await expect( quantityInput ).toHaveValue( '4' );
+
+ await physicalRow
.getByLabel( `Remove ${ SIMPLE_PHYSICAL_PRODUCT_NAME } from cart` )
.click();
+ await expect( physicalRow ).toBeHidden();
+ await expect( virtualRow ).toBeVisible();
+ await expect( quantityInput ).toHaveValue( '4' );
+
+ await proceedToCheckout.click();
- // Verify product is removed from the cart'
await expect(
- page.getByRole( 'heading', {
- name: 'Your cart is currently empty!',
- } )
+ page.getByRole( 'button', { name: 'Place Order' } )
).toBeVisible();
} );
@@ -230,20 +169,6 @@ test.describe( 'Shopper → Cart block', () => {
).toHaveValue( '4' );
} );
- test( 'User can proceed to checkout', async ( { frontendUtils, page } ) => {
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( SIMPLE_VIRTUAL_PRODUCT_NAME );
- await frontendUtils.goToCart();
-
- // Click on "Proceed to Checkout" button
- await page.getByRole( 'link', { name: 'Proceed to Checkout' } ).click();
-
- // Verify that the "Place Order" button is visible, then we're on the checkout page
- await expect(
- page.getByRole( 'button', { name: 'Place Order' } )
- ).toBeVisible();
- } );
-
test( 'User can see Cross-Sells products block', async ( {
frontendUtils,
page,
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-coupons.shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-coupons.shopper.block_theme.spec.ts
index 641ef4cb9c2..f8120490fe8 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-coupons.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-coupons.shopper.block_theme.spec.ts
@@ -34,6 +34,10 @@ test.describe( 'Shopper → Coupon', () => {
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
await frontendUtils.goToCart();
+ // Removing an applied coupon is exercised nowhere else in the E2E suite, on
+ // either block. Applying on the Cart block is covered by
+ // tests/coupons/cart-block-coupons.spec.ts, so these two clicks are here for
+ // the removal that follows them.
await page.getByRole( 'button', { name: 'Add coupons' } ).click();
await page.getByLabel( 'Enter code' ).fill( 'single-use-coupon' );
await page.getByRole( 'button', { name: 'Apply' } ).click();
@@ -72,40 +76,4 @@ test.describe( 'Shopper → Coupon', () => {
page.getByRole( 'cell', { name: '–$2.00' } )
).toBeVisible();
} );
-
- test( 'Logged in user cannot apply single-use coupon twice', async ( {
- checkoutPageObject,
- frontendUtils,
- page,
- } ) => {
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
-
- await page.getByRole( 'button', { name: 'Add coupons' } ).click();
- await page.getByLabel( 'Enter code' ).fill( 'single-use-coupon' );
- await page.getByRole( 'button', { name: 'Apply' } ).click();
-
- await expect(
- page.getByLabel( 'Remove coupon "single-use-coupon"' )
- ).toBeVisible();
-
- await checkoutPageObject.fillInCheckoutWithTestData();
- await checkoutPageObject.placeOrder();
-
- await frontendUtils.emptyCart();
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
-
- await page.getByRole( 'button', { name: 'Add coupons' } ).click();
- await page.getByLabel( 'Enter code' ).fill( 'single-use-coupon' );
- await page.getByRole( 'button', { name: 'Apply' } ).click();
-
- await expect(
- page.getByText(
- 'Usage limit for coupon "SINGLE-USE-COUPON" has been reached.'
- )
- ).toBeVisible();
- } );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-shipping.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-shipping.block_theme.spec.ts
index 8fb7c442948..ad615e14424 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-shipping.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-shipping.block_theme.spec.ts
@@ -1,8 +1,7 @@
/**
* External dependencies
*/
-import { expect, test as base, FrontendUtils } from '@woocommerce/e2e-utils';
-import { Dialog } from '@playwright/test';
+import { expect, test as base, wpCLI } from '@woocommerce/e2e-utils';
/**
* Internal dependencies
@@ -10,244 +9,186 @@ import { Dialog } from '@playwright/test';
import { CheckoutPage } from '../checkout/checkout.page';
import { REGULAR_PRICED_PRODUCT_NAME } from '../checkout/constants';
+type ShippingTopology = {
+ defaultRate?: boolean;
+ gbRate?: boolean;
+ pickup?: boolean;
+ requiresAddress: boolean;
+};
+
const test = base.extend< { checkoutPageObject: CheckoutPage } >( {
- checkoutPageObject: async ( { page }, use ) => {
- const pageObject = new CheckoutPage( {
- page,
- } );
+ checkoutPageObject: async ( { page, requestUtils }, use ) => {
+ const pageObject = new CheckoutPage( { page, requestUtils } );
await use( pageObject );
},
} );
-test.describe( 'Merchant → Shipping', () => {
- test( 'Merchant can hide shipping costs before address is entered', async ( {
- page,
- shippingUtils,
- localPickupUtils,
- } ) => {
- await localPickupUtils.disableLocalPickup();
- await shippingUtils.enableShippingCostsRequireAddress();
-
- await expect(
- page.getByLabel( 'Hide shipping costs until an address is entered' )
- ).toBeChecked();
- } );
-} );
+const configureShippingTopology = async ( topology: ShippingTopology ) => {
+ const encodedTopology = Buffer.from(
+ JSON.stringify( {
+ defaultRate: false,
+ gbRate: false,
+ pickup: false,
+ ...topology,
+ } )
+ ).toString( 'base64' );
+
+ await wpCLI( `eval '
+ $config = json_decode( base64_decode( "${ encodedTopology }" ), true );
+
+ foreach ( WC_Shipping_Zones::get_zones() as $zone_data ) {
+ $zone = new WC_Shipping_Zone( $zone_data["zone_id"] );
+ $zone->delete( true );
+ }
-test.describe( 'Shopper → Shipping', () => {
- test.beforeEach( async ( { shippingUtils } ) => {
- await shippingUtils.enableShippingCostsRequireAddress();
- } );
+ $default_zone = WC_Shipping_Zones::get_zone( 0 );
+ foreach ( $default_zone->get_shipping_methods() as $method ) {
+ $default_zone->delete_shipping_method( $method->instance_id );
+ }
- test.beforeEach( async ( { admin } ) => {
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=general' );
- await admin.page
- .getByLabel( 'Default customer location' )
- .selectOption( 'No location by default' );
- await admin.saveAdminPage();
+ update_option( "woocommerce_flat_rate_settings", array( "enabled" => "no" ) );
+ update_option( "woocommerce_free_shipping_settings", array( "enabled" => "no" ) );
+ update_option( "woocommerce_local_pickup_settings", array( "enabled" => "no" ) );
+ update_option( "woocommerce_default_customer_address", "" );
+ update_option( "woocommerce_shipping_cost_requires_address", $config["requiresAddress"] ? "yes" : "no" );
+
+ if ( $config["defaultRate"] ) {
+ $instance_id = $default_zone->add_shipping_method( "flat_rate" );
+ update_option(
+ "woocommerce_flat_rate_{$instance_id}_settings",
+ array( "enabled" => "yes", "title" => "Home delivery", "tax_status" => "none", "cost" => "10" )
+ );
+ }
- await admin.visitAdminPage(
- 'admin.php?page=wc-settings&tab=shipping&zone_id=new'
- );
- await admin.page.getByLabel( 'Zone name' ).fill( 'UK' );
- await admin.page
- .getByRole( 'combobox', { name: 'Start typing to filter zones' } )
- .fill( 'United Kingdom' );
- await admin.page
- .getByRole( 'checkbox', { name: 'United Kingdom (UK)' } )
- .click(); // .check() won't work here as the input disappears immediately after checking.
- await admin.saveAdminPage();
- await admin.page
- .getByRole( 'button', { name: 'Add shipping method' } )
- .click();
- await admin.page.getByText( 'Flat rate' ).click();
- await admin.page.getByRole( 'button', { name: 'Continue' } ).click();
- await admin.page
- .getByRole( 'button', { name: 'Save zone and method' } )
- .click();
- await expect( admin.page.getByText( 'Flat rate' ) ).toBeVisible();
- if (
- ! ( await admin.page
- .getByRole( 'button', { name: 'Save changes' } )
- .isDisabled() )
- ) {
- await admin.saveAdminPage();
+ if ( $config["gbRate"] ) {
+ $gb_zone = new WC_Shipping_Zone();
+ $gb_zone->set_zone_name( "United Kingdom" );
+ $gb_zone->set_zone_locations( array( (object) array( "code" => "GB", "type" => "country" ) ) );
+ $gb_zone->save();
+ $instance_id = $gb_zone->add_shipping_method( "flat_rate" );
+ update_option(
+ "woocommerce_flat_rate_{$instance_id}_settings",
+ array( "enabled" => "yes", "title" => "UK delivery", "tax_status" => "none", "cost" => "15" )
+ );
}
- await expect(
- admin.page.getByRole( 'button', { name: 'Save changes' } )
- ).toBeDisabled();
- } );
- // Series of tests below to cover the following scenarios: see PR https://github.com/woocommerce/woocommerce/pull/56460 for more details
+ update_option(
+ "woocommerce_pickup_location_settings",
+ array( "enabled" => $config["pickup"] ? "yes" : "no", "title" => "Pickup", "tax_status" => "none", "cost" => "" )
+ );
+ update_option(
+ "pickup_location_pickup_locations",
+ $config["pickup"]
+ ? array(
+ array(
+ "name" => "Automattic, Inc.",
+ "address" => array(
+ "address_1" => "60 29th Street, Suite 343",
+ "city" => "San Francisco",
+ "postcode" => "94110",
+ "state" => "CA",
+ "country" => "US"
+ ),
+ "details" => "American entity",
+ "enabled" => true
+ )
+ )
+ : array()
+ );
- /**
- * Rates enabled for default customer location
- * Rates enabled for _any_ location
- * Local pickup enabled
- * Hide rates until an address is entered (only applicable when Local pickup is DISABLED)
- *
- * 1. Y Y Y #
- * 2. Y Y N N
- * 3. Y N Y # - Skipping because this behaves the same as test 1.
- * 4. Y N N N
- * 5. N Y Y #
- * 6. N Y N N
- * 7. N N Y # - known bug here with the text/UI to enter an address, despite there being no methods.
- * 8. N N N N
- * 9. Y Y N Y
- * 10. Y N N Y
- * 11. N Y N Y
- * 12. N N N Y
- */
+ WC_Cache_Helper::get_transient_version( "shipping", true );
+ delete_transient( "wc_shipping_method_count" );
+ WC()->shipping()->reset_shipping();
+ '` );
+};
- test( '1. With shipping methods for the default location, shipping methods for _any_ location, and local pickup enabled, the shopper sees shipping rates and pickup options - rates are selected default', async ( {
- localPickupUtils,
- frontendUtils,
+test.describe( 'Merchant → Shipping', () => {
+ test( 'Merchant can hide shipping costs before address is entered', async ( {
+ page,
shippingUtils,
} ) => {
- await localPickupUtils.enableLocalPickup();
- await shippingUtils.disableShippingCostsRequireAddress();
- await localPickupUtils.addPickupLocation( {
- location: {
- name: 'Automattic, Inc.',
- address: '60 29th Street, Suite 343',
- city: 'San Francisco',
- postcode: '94110',
- state: 'US:CA',
- details: 'American entity',
- },
- } );
+ await configureShippingTopology( { requiresAddress: false } );
+ await shippingUtils.openShippingSettings();
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Ship',
- exact: true,
- } )
- ).toBeChecked();
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Flat rate shipping $',
- } )
- ).toBeChecked();
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Flat rate shipping $',
- } )
- ).toBeChecked();
+ const hideShippingCosts = page.getByLabel(
+ 'Hide shipping costs until an address is entered'
+ );
+ await expect( hideShippingCosts ).not.toBeChecked();
+ await hideShippingCosts.check();
+ await shippingUtils.saveShippingSettings();
+ await page.reload();
+
+ await expect( hideShippingCosts ).toBeChecked();
} );
+} );
- test( '2. With shipping methods for the default location, shipping methods for _any_ location, local pickup disabled, and shipping costs requires address disabled, the shopper sees shipping rates only', async ( {
- localPickupUtils,
+test.describe( 'Shopper → Shipping', () => {
+ test( 'Shopper can switch between shipping and local pickup while preserving the effective selected rate', async ( {
frontendUtils,
- shippingUtils,
+ page,
} ) => {
- await localPickupUtils.disableLocalPickup();
- await shippingUtils.disableShippingCostsRequireAddress();
+ await configureShippingTopology( {
+ defaultRate: true,
+ pickup: true,
+ requiresAddress: false,
+ } );
await frontendUtils.goToShop();
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
await frontendUtils.goToCheckout();
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Ship',
- exact: true,
- } )
- ).toBeHidden();
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Flat rate shipping $',
- } )
- ).toBeChecked();
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Flat rate shipping $',
- } )
- ).toBeChecked();
- } );
- // 3. With shipping methods for the default location, no shipping methods for _any_ other location, and local pickup enabled, the shopper sees shipping rates and pickup options - skipped as same result as 1.
+ const ship = page.getByRole( 'radio', { name: 'Ship', exact: true } );
+ const pickup = page.getByRole( 'radio', {
+ name: 'Pickup',
+ exact: true,
+ } );
+ const deliveryRate = page.getByRole( 'radio', {
+ name: /Home delivery/,
+ } );
+ const pickupRate = page.getByRole( 'radio', {
+ name: /Automattic, Inc\./,
+ } );
- test( '4. With shipping methods for the default location, no shipping methods for _any_ other location, local pickup disabled, and shipping costs require address disabled, the shopper sees shipping rates only after entering an address', async ( {
- localPickupUtils,
- admin,
- frontendUtils,
- checkoutPageObject,
- shippingUtils,
- } ) => {
- await localPickupUtils.disableLocalPickup();
- await shippingUtils.disableShippingCostsRequireAddress();
+ await expect( ship ).toBeChecked();
+ await expect( pickup ).not.toBeChecked();
+ await expect( deliveryRate ).toBeChecked();
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
- // Accept the delete dialog, then remove the listener;
- const acceptDialog = ( dialog: Dialog ) => dialog.accept();
- admin.page.on( 'dialog', acceptDialog );
- await admin.page.getByRole( 'link', { name: 'Delete' } ).click();
- admin.page.off( 'dialog', acceptDialog );
+ await pickup.click();
+ await expect( pickup ).toBeChecked();
+ await expect( ship ).not.toBeChecked();
+ await expect( pickupRate ).toBeChecked();
+ await expect( deliveryRate ).toBeHidden();
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
-
- await expect(
- frontendUtils.page.getByText(
- 'Enter a shipping address to view shipping options'
- )
- ).toBeHidden();
-
- await expect(
- checkoutPageObject.page.getByRole( 'radio', {
- name: 'Flat rate shipping $',
- } )
- ).toBeChecked();
+ await ship.click();
+ await expect( ship ).toBeChecked();
+ await expect( pickup ).not.toBeChecked();
+ await expect( deliveryRate ).toBeChecked();
} );
- // 5. With no shipping methods for the default location, but shipping methods for _any_ other location, local pickup enabled, the shopper sees pickup rates until entering an address for the zone with rates
- // Not testing because this is a "bug" we are going to fix - see https://github.com/woocommerce/woocommerce/issues/56462
-
- test( '6. With no shipping methods for the default location, but shipping methods for _any_ other location, local pickup disabled, and shipping costs require address disabled, the shopper sees shipping rates only after entering an address', async ( {
- localPickupUtils,
- admin,
- frontendUtils,
+ test( 'Shopper sees no matching rates until a complete address reveals the zone rate', async ( {
checkoutPageObject,
- shippingUtils,
+ frontendUtils,
+ page,
} ) => {
- await localPickupUtils.disableLocalPickup();
- await shippingUtils.disableShippingCostsRequireAddress();
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=general' );
- await admin.page
- .getByLabel( 'Default customer location' )
- .selectOption( 'No location by default' );
- await admin.saveAdminPage();
-
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
-
- await admin.page
- .getByRole( 'row', { name: 'Rest of the world' } )
- .getByRole( 'link' )
- .click();
-
- // There are two shipping rates enabled. Clicking the first one turns it off.
- // Then only one "name: yes" remains, making it the first, even though it's the second rate.
- await admin.page.getByRole( 'link', { name: 'Yes' } ).first().click();
- await admin.page.getByRole( 'link', { name: 'Yes' } ).first().click();
- await admin.saveAdminPage();
+ await configureShippingTopology( {
+ gbRate: true,
+ requiresAddress: true,
+ } );
await frontendUtils.goToShop();
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
await frontendUtils.goToCheckout();
await expect(
- frontendUtils.page.getByText(
- 'Enter a shipping address to view shipping options'
+ page.getByText(
+ /Enter a shipping address to view shipping options/
)
).toBeVisible();
- await checkoutPageObject.fillInCheckoutWithTestData();
+ await checkoutPageObject.fillInCheckoutWithTestData();
+ await checkoutPageObject.waitForCheckoutToFinishUpdating();
await expect(
- checkoutPageObject.page
+ page
.getByLabel( 'Checkout' )
.getByText(
'No shipping options are available for this address. Please verify the address is correct or try a different address.'
@@ -255,275 +196,26 @@ test.describe( 'Shopper → Shipping', () => {
).toBeVisible();
await checkoutPageObject.fillInCheckoutWithTestData( {
- country: 'GB',
- postcode: 'SW19 5AE',
+ country: 'United Kingdom (UK)',
+ countryKey: 'GB',
+ city: 'London',
+ state: '',
+ postcode: 'EC4M 9AF',
} );
- await expect(
- checkoutPageObject.page.getByRole( 'radio', {
- name: 'Flat rate Free',
- } )
- ).toBeChecked();
- } );
-
- test( '7. With no shipping methods for the default location, no shipping methods for _any_ other location, local pickup enabled the shopper sees local pickup rates only', async ( {
- localPickupUtils,
- admin,
- frontendUtils,
- shippingUtils,
- } ) => {
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
- // Accept the delete dialog, then remove the listener;
- const acceptDialog = ( dialog: Dialog ) => dialog.accept();
- admin.page.on( 'dialog', acceptDialog );
- await admin.page.getByRole( 'link', { name: 'Delete' } ).click();
- admin.page.off( 'dialog', acceptDialog );
-
- await localPickupUtils.enableLocalPickup();
- await shippingUtils.disableShippingCostsRequireAddress();
- await localPickupUtils.addPickupLocation( {
- location: {
- name: 'Automattic, Inc.',
- address: '60 29th Street, Suite 343',
- city: 'San Francisco',
- postcode: '94110',
- state: 'US:CA',
- details: 'American entity',
- },
+ const shippingAddress = page.getByRole( 'group', {
+ name: 'Shipping address',
} );
-
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
-
- await admin.page
- .getByRole( 'row', { name: 'Rest of the world' } )
- .getByRole( 'link' )
- .click();
-
- // There are two shipping rates enabled. Clicking the first one turns it off.
- // Then only one "name: yes" remains, making it the first, even though it's the second rate.
- await admin.page.getByRole( 'link', { name: 'Yes' } ).first().click();
- await admin.page.getByRole( 'link', { name: 'Yes' } ).first().click();
- await admin.saveAdminPage();
-
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
-
await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Automattic, Inc. free 60 29th',
- } )
- ).toBeChecked();
-
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Ship',
- exact: true,
- } )
- ).toBeHidden();
- } );
-
- // Skipping test due to a known bug with needs_shipping - see issue https://github.com/woocommerce/woocommerce/issues/56507
- test.skip( '8. With no shipping methods for the default location, no shipping methods for _any_ other location, local pickup disabled the shopper sees no shipping and no pickup rates', async ( {
- localPickupUtils,
- admin,
- frontendUtils,
- shippingUtils,
- } ) => {
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
- // Accept the delete dialog, then remove the listener;
- const acceptDialog = ( dialog: Dialog ) => dialog.accept();
- admin.page.on( 'dialog', acceptDialog );
- await admin.page.getByRole( 'link', { name: 'Delete' } ).click();
- admin.page.off( 'dialog', acceptDialog );
-
- await localPickupUtils.disableLocalPickup();
- await shippingUtils.disableShippingCostsRequireAddress();
-
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
-
- await admin.page
- .getByRole( 'row', { name: 'Rest of the world' } )
- .getByRole( 'link' )
- .click();
-
- // There are two shipping rates enabled. Clicking the first one turns it off.
- // Then only one "name: yes" remains, making it the first, even though it's the second rate.
- await admin.page.getByRole( 'link', { name: 'Yes' } ).first().click();
- await admin.page.getByRole( 'link', { name: 'Yes' } ).first().click();
- await admin.saveAdminPage();
-
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
-
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Ship',
- exact: true,
- } )
- ).toBeHidden();
-
- await expect(
- frontendUtils.page.getByRole( 'heading', {
- name: 'Shipping options',
- } )
- ).toBeHidden();
- } );
-
- test( '9. With shipping methods for the default location, shipping methods for _any_ other location, local pickup disabled and shipping requires address enabled, shopper sees shipping rates immediately.', async ( {
- localPickupUtils,
- frontendUtils,
- shippingUtils,
- checkoutPageObject,
- } ) => {
- await localPickupUtils.disableLocalPickup();
- await shippingUtils.enableShippingCostsRequireAddress();
-
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
-
- await expect(
- frontendUtils.page.getByText(
- 'Enter a shipping address to view shipping options.'
- )
- ).toBeVisible();
-
- await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Ship',
- exact: true,
- } )
- ).toBeHidden();
-
- await expect(
- frontendUtils.page.getByRole( 'heading', {
- name: 'Shipping options',
- } )
- ).toBeVisible();
-
- await checkoutPageObject.fillInCheckoutWithTestData();
-
- await expect(
- checkoutPageObject.page.getByRole( 'radio', {
- name: 'Flat rate shipping $',
- } )
- ).toBeVisible();
- } );
-
- test( '10. With shipping methods for the default location, no shipping methods for _any_ other location, local pickup disabled, and shipping costs require address enabled, the shopper sees shipping rates only after entering an address', async ( {
- localPickupUtils,
- admin,
- frontendUtils,
- checkoutPageObject,
- shippingUtils,
- } ) => {
- await localPickupUtils.disableLocalPickup();
- await shippingUtils.enableShippingCostsRequireAddress();
-
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
- // Accept the delete dialog, then remove the listener;
- const acceptDialog = ( dialog: Dialog ) => dialog.accept();
- admin.page.on( 'dialog', acceptDialog );
- await admin.page.getByRole( 'link', { name: 'Delete' } ).click();
- admin.page.off( 'dialog', acceptDialog );
-
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
-
- await expect(
- frontendUtils.page.getByText(
- 'Enter a shipping address to view shipping options'
- )
- ).toBeVisible();
- await checkoutPageObject.fillInCheckoutWithTestData();
-
- await expect(
- checkoutPageObject.page.getByRole( 'radio', {
- name: 'Flat rate shipping $',
- } )
- ).toBeVisible();
- } );
-
- // 11. With no shipping methods for the default location, but shipping methods for _any_ other location, local pickup disabled, and shipping requires address enabled, the shopper sees no shipping until an address is entered no pickup rates
- // Skipping testing 11 because it is the same as 6.
-
- // Skipping test due to a known bug with needs_shipping - see issue https://github.com/woocommerce/woocommerce/issues/56507
- test.skip( '12. With no shipping methods for the default location, no shipping methods for _any_ other location, local pickup disabled, and shipping requires address enabled the shopper sees no shipping and no pickup rates', async ( {
- localPickupUtils,
- admin,
- frontendUtils,
- shippingUtils,
- } ) => {
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
- // Accept the delete dialog, then remove the listener;
- const acceptDialog = ( dialog: Dialog ) => dialog.accept();
- admin.page.on( 'dialog', acceptDialog );
- await admin.page.getByRole( 'link', { name: 'Delete' } ).click();
- admin.page.off( 'dialog', acceptDialog );
-
- await localPickupUtils.disableLocalPickup();
- await shippingUtils.enableShippingCostsRequireAddress();
-
- await admin.visitAdminPage( 'admin.php?page=wc-settings&tab=shipping' );
-
- await admin.page
- .getByRole( 'row', { name: 'Rest of the world' } )
- .getByRole( 'link' )
- .click();
-
- // There are two shipping rates enabled. Clicking the first one turns it off.
- // Then only one "name: yes" remains, making it the first, even though it's the second rate.
- await admin.page.getByRole( 'link', { name: 'Yes' } ).first().click();
- await admin.page.getByRole( 'link', { name: 'Yes' } ).first().click();
- await admin.saveAdminPage();
-
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCheckout();
-
+ shippingAddress.getByLabel( 'Country/Region' )
+ ).toHaveValue( 'GB' );
await expect(
- frontendUtils.page.getByRole( 'radio', {
- name: 'Ship',
- exact: true,
- } )
- ).toBeHidden();
+ shippingAddress.locator( '#shipping-postcode' )
+ ).toHaveValue( 'EC4M 9AF' );
+ await checkoutPageObject.waitForCheckoutToFinishUpdating();
await expect(
- frontendUtils.page.getByRole( 'heading', {
- name: 'Shipping options',
- } )
- ).toBeHidden();
- } );
-
- test( 'Guest user does not see shipping rates until full address is entered', async ( {
- requestUtils,
- browser,
- } ) => {
- const guestContext = await browser.newContext();
- const userPage = await guestContext.newPage();
-
- const userFrontendUtils = new FrontendUtils( userPage, requestUtils );
- const userCheckoutPageObject = new CheckoutPage( { page: userPage } );
-
- await userFrontendUtils.goToShop();
- await userFrontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await userFrontendUtils.goToCheckout();
-
- await expect(
- userPage.getByText(
- 'Enter a shipping address to view shipping options.'
- )
- ).toBeVisible();
-
- await userCheckoutPageObject.fillInCheckoutWithTestData();
-
- await expect(
- userPage.getByText(
- 'Enter a shipping address to view shipping options.'
- )
- ).toBeHidden();
+ page.getByRole( 'radio', { name: /UK delivery/ } )
+ ).toBeChecked();
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-store.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-store.block_theme.spec.ts
index 83b4f025a9e..eab4ff760bd 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-store.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-store.block_theme.spec.ts
@@ -11,63 +11,97 @@ import { REGULAR_PRICED_PRODUCT_NAME } from '../checkout/constants';
test.describe( 'Cart Store', () => {
test.use( { storageState: guestFile } );
- test.beforeEach( async ( { requestUtils } ) => {
- await requestUtils.activatePlugin(
- 'woocommerce-blocks-test-short-nonce-life'
- );
- } );
-
test( 'should refresh nonce from Store API and use it for cart mutations', async ( {
page,
frontendUtils,
} ) => {
- let refreshNonce: string | null = null;
- let requestNonce: string | null = null;
- let responseNonce: string | null = null;
+ const refreshCartResponse = page.waitForResponse(
+ ( response ) =>
+ response.url().includes( '/wc/store/v1/cart' ) &&
+ response.request().method() === 'GET'
+ );
+ let initialBatchRequestNonce: string | null = null;
+ let invalidateNextBatch = false;
+ let invalidBatchRequestNonce: string | null = null;
+ let invalidResponseNonce: string | null = null;
+ let invalidResponseBody = '';
+ let captureRecoveryBatch = false;
+ let recoveryBatchRequestNonce: string | null = null;
- // Intercept GET /cart (refreshCartItems) to capture the nonce.
- await page.route( '**/wc/store/v1/cart**', async ( route ) => {
- if ( route.request().method() === 'GET' ) {
- const response = await route.fetch();
- refreshNonce = response.headers().nonce || null;
+ // Forward exactly one invalid nonce to the real Store API so its response
+ // supplies the replacement nonce the client must use for the next mutation.
+ await page.route( '**/wc/store/v1/batch**', async ( route ) => {
+ const batch = JSON.parse( route.request().postData() || '{}' );
+ const [ batchRequest ] = batch.requests || [];
+ const isCartAddItemBatch =
+ batch.requests?.length === 1 &&
+ batchRequest?.method === 'POST' &&
+ batchRequest.path === '/wc/store/v1/cart/add-item';
+ const requestNonce = isCartAddItemBatch
+ ? batchRequest.headers?.Nonce || null
+ : null;
+
+ if ( invalidateNextBatch && isCartAddItemBatch ) {
+ invalidateNextBatch = false;
+ invalidBatchRequestNonce = requestNonce;
+ const invalidBatch = {
+ ...batch,
+ requests: [
+ {
+ ...batchRequest,
+ headers: {
+ ...batchRequest.headers,
+ Nonce: 'invalid-test-nonce',
+ },
+ },
+ ],
+ };
+ const response = await route.fetch( {
+ postData: JSON.stringify( invalidBatch ),
+ } );
+ invalidResponseNonce = response.headers().nonce || null;
+ invalidResponseBody = await response.text();
await route.fulfill( { response } );
- } else {
- await route.continue();
+ return;
+ }
+
+ if (
+ isCartAddItemBatch &&
+ captureRecoveryBatch &&
+ ! recoveryBatchRequestNonce
+ ) {
+ recoveryBatchRequestNonce = requestNonce;
+ } else if ( isCartAddItemBatch && ! initialBatchRequestNonce ) {
+ initialBatchRequestNonce = requestNonce;
}
- } );
- // Intercept batch requests to track which nonce the client sends
- // and which nonce the server returns.
- await page.route( '**/wc/store/v1/batch**', async ( route ) => {
- requestNonce = route.request().headers().nonce || null;
const response = await route.fetch();
- responseNonce = response.headers().nonce || null;
await route.fulfill( { response } );
} );
await frontendUtils.goToShop();
-
- // The GET /cart fires during page load, so a waitForResponse set up
- // here can miss it — poll the intercepted nonce instead.
- await expect.poll( () => refreshNonce ).toBeTruthy();
+ const refreshCartNonce =
+ ( await refreshCartResponse ).headers().nonce || null;
+ expect( refreshCartNonce ).toBeTruthy();
// Adding a product should use the nonce from refreshCartItems.
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- expect( requestNonce ).toBe( refreshNonce );
-
- // Wait for the nonce to expire.
- // eslint-disable-next-line playwright/no-wait-for-timeout, no-restricted-syntax
- await page.waitForTimeout( 2000 );
+ expect( initialBatchRequestNonce ).toBe( refreshCartNonce );
- // Adding another product should fail because it is using an expired nonce.
+ // Forward an invalid nonce and let the real Store API return its error and replacement nonce.
+ invalidateNextBatch = true;
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ expect( invalidResponseBody ).toContain(
+ 'woocommerce_rest_invalid_nonce'
+ );
await expect( page.getByText( 'Nonce is invalid.' ) ).toBeVisible();
- const previousResponseNonce = responseNonce;
+ expect( invalidBatchRequestNonce ).toBe( initialBatchRequestNonce );
+ expect( invalidResponseNonce ).toBeTruthy();
- // Nonce should be updated now and the request should succeed.
+ // The next mutation should use the exact nonce returned by the invalid response.
+ captureRecoveryBatch = true;
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- expect( requestNonce ).not.toBe( refreshNonce );
- expect( requestNonce ).toBe( previousResponseNonce );
+ expect( recoveryBatchRequestNonce ).toBe( invalidResponseNonce );
// Verify the product was actually added to the cart properly.
await frontendUtils.goToCart();
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/coupon/coupon.php b/plugins/woocommerce/tests/legacy/unit-tests/coupon/coupon.php
index e6fd2127c68..3cea2928fc0 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/coupon/coupon.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/coupon/coupon.php
@@ -284,6 +284,13 @@ class WC_Tests_Coupon extends WC_Unit_Test_Case {
/**
* Test an item limit for percent discounts.
+ *
+ * Defines WOOCOMMERCE_CHECKOUT, which a process cannot undefine, so it stays set
+ * for every test that runs after this one. Do not reach for process isolation to
+ * contain that: the forked child re-runs tests/legacy/bootstrap.php, which writes
+ * woocommerce_custom_orders_table_enabled over its own connection, outside the
+ * parent's rolled-back transaction, and flips the order store for the rest of the
+ * run.
*/
public function test_percent_discount_item_limit() {
// Create product.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
index 625628034b6..1f8ee4666ab 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
@@ -153,6 +153,45 @@ class Cart extends ControllerTestCase {
wc()->customer->set_shipping_country( 'US' );
}
+ /**
+ * Dispatches a Store API add-item request.
+ *
+ * @param array $body Request body.
+ * @return \WP_REST_Response
+ */
+ private function dispatch_add_item_request( array $body ): \WP_REST_Response {
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params( $body );
+
+ return rest_get_server()->dispatch( $request );
+ }
+
+ /**
+ * Gets a canonical map of the cart lines that participate in identity.
+ *
+ * @return array<string, array{key: string, product_id: int, variation_id: int, variation: array, quantity: int|float}>
+ */
+ private function get_cart_line_identity_map(): array {
+ $cart_line_map = array();
+
+ foreach ( WC()->cart->get_cart() as $key => $cart_item ) {
+ $variation = $cart_item['variation'] ?? array();
+ ksort( $variation );
+
+ $cart_line_map[ $key ] = array(
+ 'key' => $key,
+ 'product_id' => (int) $cart_item['product_id'],
+ 'variation_id' => (int) $cart_item['variation_id'],
+ 'variation' => $variation,
+ 'quantity' => wc_stock_amount( $cart_item['quantity'] ),
+ );
+ }
+
+ ksort( $cart_line_map );
+ return $cart_line_map;
+ }
+
/**
* Test getting cart.
*/
@@ -335,6 +374,420 @@ class Cart extends ControllerTestCase {
);
}
+ /**
+ * Test the shipping topology returned by the Store API.
+ *
+ * @testdox Store API serializes the expected rates and selected method for $scenario.
+ * @dataProvider shipping_topology_provider
+ *
+ * @param string $scenario Scenario name.
+ * @param bool $has_default_rate Whether the Rest of the World zone has a rate.
+ * @param bool $has_gb_rate Whether the GB zone has a rate.
+ * @param bool $has_pickup Whether Local Pickup is enabled.
+ * @param bool $requires_address Whether rates require a complete address.
+ * @param string $address_key Address fixture key.
+ * @param array $expected_rate_keys Expected rate fixture keys.
+ * @param string $expected_selected_rate_key Expected selected rate fixture key.
+ */
+ public function test_shipping_topology(
+ string $scenario,
+ bool $has_default_rate,
+ bool $has_gb_rate,
+ bool $has_pickup,
+ bool $requires_address,
+ string $address_key,
+ array $expected_rate_keys,
+ string $expected_selected_rate_key
+ ): void {
+ unset( $scenario );
+
+ $customer = $this->capture_shipping_address();
+ $session = $this->capture_shipping_session();
+ $cart_context = WC()->cart->cart_context;
+ $country_data = get_object_vars( WC()->countries );
+ $has_locale = array_key_exists( 'locale', $country_data );
+ $locale = $has_locale ? $country_data['locale'] : array();
+ $fixtures = new FixtureData();
+ $topology = array();
+
+ try {
+ unset( WC()->countries->locale );
+ $this->configure_shipping_topology( $fixtures, $has_default_rate, $has_gb_rate, $has_pickup, $topology );
+
+ update_option( 'woocommerce_default_customer_address', 'blank' === $address_key ? '' : 'base' );
+ update_option( 'woocommerce_shipping_cost_requires_address', $requires_address ? 'yes' : 'no' );
+ WC()->cart->cart_context = 'store-api';
+ $this->clear_shipping_session();
+
+ $address = $this->get_shipping_address_fixture( $address_key );
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/update-customer' );
+ $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+ $request->set_body_params( array( 'shipping_address' => (object) $address ) );
+ $response = rest_get_server()->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status(), 'The customer update should succeed.' );
+ $this->assertTrue( $data['needs_shipping'], 'The cart should continue to require shipping.' );
+
+ $expected_package_count = $requires_address && 'blank' === $address_key && ! $has_pickup ? 0 : 1;
+ $this->assertCount( $expected_package_count, $data['shipping_rates'], 'The response should serialize the exact expected package count.' );
+
+ if ( 0 === $expected_package_count ) {
+ $this->assertSame( array(), WC()->session->get( 'chosen_shipping_methods', array() ), 'No hidden package should choose a shipping method.' );
+ return;
+ }
+
+ $package = (array) $data['shipping_rates'][0];
+ $destination = (array) $package['destination'];
+ $rates = array_map(
+ function ( $rate ): array {
+ return (array) $rate;
+ },
+ $package['shipping_rates']
+ );
+ $expected_destination = array_intersect_key(
+ $address,
+ array_flip( array( 'address_1', 'address_2', 'city', 'state', 'postcode', 'country' ) )
+ );
+
+ $this->assertSame( $expected_destination, $destination, 'The serialized package destination should match the submitted address exactly.' );
+
+ $expected_rate_ids = array_map(
+ function ( string $key ) use ( $topology ): string {
+ return $topology['rate_ids'][ $key ];
+ },
+ $expected_rate_keys
+ );
+ $actual_rate_ids = array_column( $rates, 'rate_id' );
+ sort( $expected_rate_ids );
+ sort( $actual_rate_ids );
+
+ $this->assertSame( $expected_rate_ids, $actual_rate_ids, 'The response should contain only the expected concrete rates.' );
+
+ $expected_method_ids = array_map(
+ function ( string $key ) use ( $topology ): string {
+ return $topology['method_ids'][ $key ];
+ },
+ $expected_rate_keys
+ );
+ $actual_method_ids = array_column( $rates, 'method_id' );
+ sort( $expected_method_ids );
+ sort( $actual_method_ids );
+
+ $this->assertSame( $expected_method_ids, $actual_method_ids, 'The response should contain only the expected shipping method types.' );
+
+ $selected_by_rate = array_column( $rates, 'selected', 'rate_id' );
+ foreach ( $expected_rate_ids as $rate_id ) {
+ $this->assertSame(
+ $expected_selected_rate_key && $topology['rate_ids'][ $expected_selected_rate_key ] === $rate_id,
+ $selected_by_rate[ $rate_id ],
+ "The selected flag should be exact for {$rate_id}."
+ );
+ }
+
+ $chosen_methods = WC()->session->get( 'chosen_shipping_methods', array() );
+ $expected_chosen = $expected_selected_rate_key ? $topology['rate_ids'][ $expected_selected_rate_key ] : '';
+ $this->assertArrayHasKey( 0, $chosen_methods, 'A serialized package should persist a choice for package zero.' );
+ $this->assertSame( $expected_chosen, $chosen_methods[0], 'The session should persist the same method selected in the response.' );
+ } finally {
+ // The five shipping options this test writes are rows inside the transaction.
+ // Everything else below is on the WC() singletons, which this class does not
+ // inherit a teardown for: it extends WP_Test_REST_TestCase, not
+ // WC_Unit_Test_Case, so clear_wc_singleton_state() never runs.
+ $this->remove_shipping_topology( $fixtures, $topology );
+ $this->restore_shipping_address( $customer );
+ WC()->shipping()->reset_shipping();
+ $this->restore_shipping_session( $session );
+ WC()->cart->cart_context = $cart_context;
+ if ( $has_locale ) {
+ WC()->countries->locale = $locale;
+ } else {
+ unset( WC()->countries->locale );
+ }
+ \WC_Cache_Helper::get_transient_version( 'shipping', true );
+ delete_transient( 'wc_shipping_method_count' );
+ }
+ }
+
+ /**
+ * The topology owner restores both present and missing incoming session values.
+ *
+ * @testdox Shipping topology restores exact incoming shipping and customer session values.
+ */
+ public function test_shipping_topology_restores_existing_session_state(): void {
+ $keys = array( 'chosen_shipping_methods', 'customer', 'shipping_method_counts' );
+ $original = array();
+
+ foreach ( $keys as $key ) {
+ $original[ $key ] = array(
+ 'exists' => isset( WC()->session->{$key} ),
+ 'value' => WC()->session->get( $key ),
+ );
+ }
+
+ try {
+ $chosen_methods = array( 'external_shipping:7' );
+ $customer = array( 'shipping_country' => 'SE' );
+ WC()->session->set( 'chosen_shipping_methods', $chosen_methods );
+ WC()->session->set( 'customer', $customer );
+ WC()->session->set( 'shipping_method_counts', null );
+
+ $this->test_shipping_topology(
+ 'session restoration',
+ false,
+ true,
+ true,
+ true,
+ 'blank',
+ array( 'pickup' ),
+ ''
+ );
+
+ $this->assertTrue( isset( WC()->session->chosen_shipping_methods ), 'The incoming chosen-method key should remain present.' );
+ $this->assertSame( $chosen_methods, WC()->session->get( 'chosen_shipping_methods' ), 'The incoming chosen methods should be restored exactly.' );
+ $this->assertTrue( isset( WC()->session->customer ), 'The incoming customer key should remain present.' );
+ $this->assertSame( $customer, WC()->session->get( 'customer' ), 'The incoming customer session value should be restored exactly.' );
+ $this->assertFalse( isset( WC()->session->shipping_method_counts ), 'An incoming missing session key should remain missing.' );
+ } finally {
+ foreach ( $original as $key => $session_value ) {
+ WC()->session->set( $key, $session_value['exists'] ? $session_value['value'] : null );
+ }
+ }
+ }
+
+ /**
+ * Shipping topology scenarios.
+ *
+ * @return array<string, array{string, bool, bool, bool, bool, string, string[], string}>
+ */
+ public function shipping_topology_provider(): array {
+ return array(
+ 'default and any rates with pickup' => array( 'default and any rates with pickup', true, true, true, false, 'us', array( 'default', 'pickup' ), 'default' ),
+ 'default and any rates without pickup' => array( 'default and any rates without pickup', true, true, false, false, 'us', array( 'default' ), 'default' ),
+ 'default rate only' => array( 'default rate only', true, false, false, false, 'us', array( 'default' ), 'default' ),
+ 'any rate only before address' => array( 'any rate only before address', false, true, false, false, 'blank', array(), '' ),
+ 'any rate only for a nonmatching address' => array( 'any rate only for a nonmatching address', false, true, false, false, 'us', array(), '' ),
+ 'any rate only for a matching address' => array( 'any rate only for a matching address', false, true, false, false, 'gb', array( 'gb' ), 'gb' ),
+ 'pickup only without required address' => array( 'pickup only without required address', false, false, true, false, 'blank', array( 'pickup' ), 'pickup' ),
+ 'pickup only with required address' => array( 'pickup only with required address', false, true, true, true, 'blank', array( 'pickup' ), '' ),
+ 'no rates without required address' => array( 'no rates without required address', false, false, false, false, 'blank', array(), '' ),
+ 'no rates with required address' => array( 'no rates with required address', false, false, false, true, 'blank', array(), '' ),
+ 'required address before entry' => array( 'required address before entry', true, true, false, true, 'blank', array(), '' ),
+ 'required address with default rate' => array( 'required address with default rate', true, true, false, true, 'us', array( 'default' ), 'default' ),
+ 'required address with any rate' => array( 'required address with any rate', true, true, false, true, 'gb', array( 'gb' ), 'gb' ),
+ );
+ }
+
+ /**
+ * Configure real zones and shipping methods for a topology scenario.
+ *
+ * @param FixtureData $fixtures Fixture helper.
+ * @param bool $has_default_rate Whether the default zone gets a rate.
+ * @param bool $has_gb_rate Whether the GB zone gets a rate.
+ * @param bool $has_pickup Whether pickup is enabled.
+ * @param array $topology Created topology data, updated as records are added.
+ */
+ private function configure_shipping_topology( FixtureData $fixtures, bool $has_default_rate, bool $has_gb_rate, bool $has_pickup, array &$topology ): void {
+ update_option( 'woocommerce_flat_rate_settings', array( 'enabled' => 'no' ) );
+
+ $topology = array(
+ 'rate_ids' => array( 'pickup' => 'pickup_location:0' ),
+ 'method_ids' => array(
+ 'default' => 'flat_rate',
+ 'gb' => 'flat_rate',
+ 'pickup' => 'pickup_location',
+ ),
+ );
+
+ if ( $has_default_rate ) {
+ $default_zone = \WC_Shipping_Zones::get_zone( 0 );
+ if ( ! $default_zone instanceof \WC_Shipping_Zone ) {
+ throw new \RuntimeException( 'The default shipping zone should be available.' );
+ }
+
+ $topology['default_instance_id'] = $default_zone->add_shipping_method( 'flat_rate' );
+ $topology['rate_ids']['default'] = 'flat_rate:' . $topology['default_instance_id'];
+ update_option(
+ 'woocommerce_flat_rate_' . $topology['default_instance_id'] . '_settings',
+ array(
+ 'enabled' => 'yes',
+ 'title' => 'Default delivery',
+ 'tax_status' => 'none',
+ 'cost' => '10',
+ )
+ );
+ }
+
+ if ( $has_gb_rate ) {
+ $gb_zone = new \WC_Shipping_Zone();
+ $gb_zone->set_zone_name( 'United Kingdom' );
+ $gb_zone->set_zone_locations(
+ array(
+ (object) array(
+ 'code' => 'GB',
+ 'type' => 'country',
+ ),
+ )
+ );
+ $gb_zone->save();
+ $topology['gb_zone'] = $gb_zone;
+ $topology['gb_instance_id'] = $gb_zone->add_shipping_method( 'flat_rate' );
+ $topology['rate_ids']['gb'] = 'flat_rate:' . $topology['gb_instance_id'];
+ update_option(
+ 'woocommerce_flat_rate_' . $topology['gb_instance_id'] . '_settings',
+ array(
+ 'enabled' => 'yes',
+ 'title' => 'UK delivery',
+ 'tax_status' => 'none',
+ 'cost' => '15',
+ )
+ );
+ }
+
+ if ( $has_pickup ) {
+ $fixtures->shipping_add_pickup_location();
+ $topology['pickup_registered'] = true;
+ }
+
+ WC()->shipping()->reset_shipping();
+ \WC_Cache_Helper::get_transient_version( 'shipping', true );
+ delete_transient( 'wc_shipping_method_count' );
+ }
+
+ /**
+ * Remove topology records created by a scenario.
+ *
+ * @param FixtureData $fixtures Fixture helper.
+ * @param array $topology Created topology data.
+ */
+ private function remove_shipping_topology( FixtureData $fixtures, array $topology ): void {
+ if ( isset( $topology['default_instance_id'] ) ) {
+ $default_zone = \WC_Shipping_Zones::get_zone( 0 );
+ if ( $default_zone instanceof \WC_Shipping_Zone ) {
+ $default_zone->delete_shipping_method( $topology['default_instance_id'] );
+ }
+ }
+
+ if ( isset( $topology['gb_zone'] ) && $topology['gb_zone'] instanceof \WC_Shipping_Zone ) {
+ $topology['gb_zone']->delete( true );
+ }
+
+ if ( ! empty( $topology['pickup_registered'] ) ) {
+ $fixtures->shipping_remove_pickup_location();
+ }
+ }
+
+ /**
+ * Get an exact address fixture for a topology scenario.
+ *
+ * @param string $key Fixture key.
+ * @return array<string, string>
+ */
+ private function get_shipping_address_fixture( string $key ): array {
+ $addresses = array(
+ 'blank' => array(
+ 'first_name' => '',
+ 'last_name' => '',
+ 'company' => '',
+ 'address_1' => '',
+ 'address_2' => '',
+ 'city' => '',
+ 'state' => '',
+ 'postcode' => '',
+ 'country' => '',
+ ),
+ 'us' => array(
+ 'first_name' => 'Pat',
+ 'last_name' => 'Shopper',
+ 'company' => '',
+ 'address_1' => '60 29th Street',
+ 'address_2' => '',
+ 'city' => 'San Francisco',
+ 'state' => 'CA',
+ 'postcode' => '94110',
+ 'country' => 'US',
+ ),
+ 'gb' => array(
+ 'first_name' => 'Pat',
+ 'last_name' => 'Shopper',
+ 'company' => '',
+ 'address_1' => '1 New Change',
+ 'address_2' => '',
+ 'city' => 'London',
+ 'state' => '',
+ 'postcode' => 'EC4M 9AF',
+ 'country' => 'GB',
+ ),
+ );
+
+ return $addresses[ $key ];
+ }
+
+ /**
+ * Capture the current shipping address.
+ *
+ * @return array<string, string>
+ */
+ private function capture_shipping_address(): array {
+ $address = array();
+ foreach ( array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country' ) as $field ) {
+ $getter = 'get_shipping_' . $field;
+ $address[ $field ] = WC()->customer->$getter();
+ }
+
+ return $address;
+ }
+
+ /**
+ * Restore a shipping address.
+ *
+ * @param array<string, string> $address Shipping address.
+ */
+ private function restore_shipping_address( array $address ): void {
+ foreach ( $address as $field => $value ) {
+ $setter = 'set_shipping_' . $field;
+ WC()->customer->$setter( $value );
+ }
+ }
+
+ /**
+ * Capture session values involved in shipping selection and caching.
+ *
+ * @return array<string, array{exists: bool, value: mixed}>
+ */
+ private function capture_shipping_session(): array {
+ $session = array();
+ foreach ( array( 'chosen_shipping_methods', 'shipping_method_counts', 'previous_shipping_methods', 'shipping_for_package_0', 'customer' ) as $key ) {
+ $session[ $key ] = array(
+ 'exists' => isset( WC()->session->{$key} ),
+ 'value' => WC()->session->get( $key ),
+ );
+ }
+
+ return $session;
+ }
+
+ /**
+ * Clear session values involved in shipping selection and caching.
+ */
+ private function clear_shipping_session(): void {
+ foreach ( array( 'chosen_shipping_methods', 'shipping_method_counts', 'previous_shipping_methods', 'shipping_for_package_0' ) as $key ) {
+ WC()->session->set( $key, null );
+ }
+ WC()->shipping()->reset_shipping();
+ }
+
+ /**
+ * Restore shipping session values.
+ *
+ * @param array<string, array{exists: bool, value: mixed}> $session Session values.
+ */
+ private function restore_shipping_session( array $session ): void {
+ foreach ( $session as $key => $session_value ) {
+ WC()->session->set( $key, $session_value['exists'] ? $session_value['value'] : null );
+ }
+ }
+
/**
* Test shipping address validation.
*/
@@ -1024,6 +1477,288 @@ class Cart extends ControllerTestCase {
);
}
+ /**
+ * @testdox Re-adding a standalone product preserves its cart-line key and increments its quantity.
+ */
+ public function test_add_item_preserves_standalone_cart_line_identity(): void {
+ wc_empty_cart();
+
+ $first_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $this->products[0]->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ $first_data = $first_response->get_data();
+
+ $this->assertSame( 201, $first_response->get_status(), 'The first standalone add should succeed.' );
+ $this->assertCount( 1, $first_data['items'], 'The first add should create exactly one cart line.' );
+ $cart_item_key = $first_data['items'][0]['key'];
+ $this->assertSame(
+ array(
+ $cart_item_key => array(
+ 'key' => $cart_item_key,
+ 'product_id' => $this->products[0]->get_id(),
+ 'variation_id' => 0,
+ 'variation' => array(),
+ 'quantity' => 1,
+ ),
+ ),
+ $this->get_cart_line_identity_map(),
+ 'The first add should create the expected standalone cart line.'
+ );
+
+ $second_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $this->products[0]->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ $second_data = $second_response->get_data();
+
+ $this->assertSame( 201, $second_response->get_status(), 'The standalone re-add should succeed.' );
+ $this->assertCount( 1, $second_data['items'], 'The re-add should not create another cart line.' );
+ $this->assertSame( $cart_item_key, $second_data['items'][0]['key'], 'The re-add should preserve the server cart-line key.' );
+ $this->assertSame(
+ array(
+ $cart_item_key => array(
+ 'key' => $cart_item_key,
+ 'product_id' => $this->products[0]->get_id(),
+ 'variation_id' => 0,
+ 'variation' => array(),
+ 'quantity' => 2,
+ ),
+ ),
+ $this->get_cart_line_identity_map(),
+ 'The re-add should increment only the existing standalone line.'
+ );
+ }
+
+ /**
+ * @testdox Cart item data keeps a same-product meta line distinct from the standalone line.
+ */
+ public function test_add_item_preserves_cart_item_data_identity(): void {
+ wc_empty_cart();
+
+ $add_cart_item_data = function ( $add_to_cart_data, $request ) {
+ if ( 'meta-line' === $request->get_param( 'cart_line_identity_marker' ) ) {
+ $add_to_cart_data['cart_item_data']['_cart_line_identity'] = 'meta-line';
+ }
+ return $add_to_cart_data;
+ };
+ add_filter( 'woocommerce_store_api_add_to_cart_data', $add_cart_item_data, 10, 2 );
+
+ try {
+ $meta_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $this->products[0]->get_id(),
+ 'quantity' => 1,
+ 'cart_line_identity_marker' => 'meta-line',
+ )
+ );
+ $meta_map = $this->get_cart_line_identity_map();
+ $meta_key = array_key_first( $meta_map );
+
+ $this->assertSame( 201, $meta_response->get_status(), 'The marker-scoped meta-line add should succeed.' );
+ $this->assertCount( 1, $meta_map, 'The first request should create exactly the meta line.' );
+
+ $plain_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $this->products[0]->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ $mixed_map = $this->get_cart_line_identity_map();
+ $plain_keys = array_values( array_diff( array_keys( $mixed_map ), array( $meta_key ) ) );
+
+ $this->assertSame( 201, $plain_response->get_status(), 'The first plain add should succeed.' );
+ $this->assertCount( 1, $plain_keys, 'The plain add should create one distinct standalone key.' );
+ $plain_key = $plain_keys[0];
+ $expected = array(
+ $meta_key => array(
+ 'key' => $meta_key,
+ 'product_id' => $this->products[0]->get_id(),
+ 'variation_id' => 0,
+ 'variation' => array(),
+ 'quantity' => 1,
+ ),
+ $plain_key => array(
+ 'key' => $plain_key,
+ 'product_id' => $this->products[0]->get_id(),
+ 'variation_id' => 0,
+ 'variation' => array(),
+ 'quantity' => 1,
+ ),
+ );
+ ksort( $expected );
+ $this->assertSame( $expected, $mixed_map, 'The meta and standalone lines should remain distinct at quantity one.' );
+
+ $second_plain_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $this->products[0]->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ $expected[ $plain_key ]['quantity'] = 2;
+
+ $this->assertSame( 201, $second_plain_response->get_status(), 'The standalone re-add should succeed.' );
+ $this->assertSame( $expected, $this->get_cart_line_identity_map(), 'Only the standalone line should increment.' );
+ } finally {
+ remove_filter( 'woocommerce_store_api_add_to_cart_data', $add_cart_item_data, 10 );
+ }
+ }
+
+ /**
+ * @testdox Re-adding one variation preserves its key while another variation creates a distinct line.
+ */
+ public function test_add_item_preserves_variation_identity(): void {
+ wc_empty_cart();
+
+ $fixtures = new FixtureData();
+ $variable_product = $fixtures->get_variable_product(
+ array(
+ 'name' => 'Cart line identity variable product',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ 'regular_price' => 10,
+ ),
+ array(
+ $fixtures->get_product_attribute( 'color', array( 'red', 'blue' ) ),
+ )
+ );
+ $red_variation = $fixtures->get_variation_product( $variable_product->get_id(), array( 'pa_color' => 'red' ) );
+ $blue_variation = $fixtures->get_variation_product( $variable_product->get_id(), array( 'pa_color' => 'blue' ) );
+
+ $red_body = array(
+ 'id' => $red_variation->get_id(),
+ 'quantity' => 1,
+ 'variation' => array(
+ array(
+ 'attribute' => 'pa_color',
+ 'value' => 'red',
+ ),
+ ),
+ );
+ $first_response = $this->dispatch_add_item_request( $red_body );
+ $first_map = $this->get_cart_line_identity_map();
+ $red_key = array_key_first( $first_map );
+
+ $this->assertSame( 201, $first_response->get_status(), 'The first variation add should succeed.' );
+ $this->assertCount( 1, $first_map, 'The first variation add should create exactly one line.' );
+
+ $second_response = $this->dispatch_add_item_request( $red_body );
+ $second_map = $this->get_cart_line_identity_map();
+
+ $this->assertSame( 201, $second_response->get_status(), 'Re-adding the same variation should succeed.' );
+ $this->assertSame( array( $red_key ), array_keys( $second_map ), 'The same variation should preserve its cart-line key.' );
+ $this->assertSame( 2, $second_map[ $red_key ]['quantity'], 'The same variation should increment its line.' );
+
+ $blue_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $blue_variation->get_id(),
+ 'quantity' => 1,
+ 'variation' => array(
+ array(
+ 'attribute' => 'pa_color',
+ 'value' => 'blue',
+ ),
+ ),
+ )
+ );
+ $final_map = $this->get_cart_line_identity_map();
+ $blue_keys = array_values( array_diff( array_keys( $final_map ), array( $red_key ) ) );
+
+ $this->assertSame( 201, $blue_response->get_status(), 'Adding a different variation should succeed.' );
+ $this->assertCount( 1, $blue_keys, 'The different variation should create one distinct key.' );
+ $blue_key = $blue_keys[0];
+ $this->assertSame( $red_variation->get_id(), $final_map[ $red_key ]['variation_id'], 'The original key should still identify the red variation.' );
+ $this->assertSame( 2, $final_map[ $red_key ]['quantity'], 'The red variation quantity should remain two.' );
+ $this->assertSame( $blue_variation->get_id(), $final_map[ $blue_key ]['variation_id'], 'The new key should identify the blue variation.' );
+ $this->assertSame( 1, $final_map[ $blue_key ]['quantity'], 'The blue variation should start at quantity one.' );
+ }
+
+ /**
+ * @testdox A rejected sold-individually re-add leaves the exact cart-line map unchanged.
+ */
+ public function test_rejected_add_item_leaves_sold_individually_cart_unchanged(): void {
+ wc_empty_cart();
+
+ $product = $this->products[0];
+ $original_sold_individually = $product->get_sold_individually();
+ $product->set_sold_individually( true );
+ $product->save();
+
+ try {
+ $first_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $product->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ $before = $this->get_cart_line_identity_map();
+ $second_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $product->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ $second_data = $second_response->get_data();
+
+ $this->assertSame( 201, $first_response->get_status(), 'The first sold-individually add should succeed.' );
+ $this->assertSame( 400, $second_response->get_status(), 'The sold-individually re-add should be rejected.' );
+ $this->assertSame( 'readonly_quantity', $second_data['code'], 'The rejection should report the Store API quantity-limit code.' );
+ $this->assertSame( $before, $this->get_cart_line_identity_map(), 'The rejected re-add should leave every cart line unchanged.' );
+ } finally {
+ $product->set_sold_individually( $original_sold_individually );
+ $product->save();
+ }
+ }
+
+ /**
+ * @testdox A rejected managed-stock re-add leaves the exact cart-line map unchanged.
+ */
+ public function test_rejected_add_item_leaves_managed_stock_cart_unchanged(): void {
+ wc_empty_cart();
+
+ $product = $this->products[1];
+ $original_manage_stock = $product->get_manage_stock();
+ $original_stock_quantity = $product->get_stock_quantity();
+ $original_backorders = $product->get_backorders();
+ $original_stock_status = $product->get_stock_status();
+ $product->set_manage_stock( true );
+ $product->set_stock_quantity( 1 );
+ $product->set_backorders( 'no' );
+ $product->set_stock_status( ProductStockStatus::IN_STOCK );
+ $product->save();
+
+ try {
+ $first_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $product->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ $before = $this->get_cart_line_identity_map();
+ $second_response = $this->dispatch_add_item_request(
+ array(
+ 'id' => $product->get_id(),
+ 'quantity' => 1,
+ )
+ );
+ $second_data = $second_response->get_data();
+
+ $this->assertSame( 201, $first_response->get_status(), 'The first managed-stock add should succeed.' );
+ $this->assertSame( 400, $second_response->get_status(), 'The managed-stock re-add should be rejected.' );
+ $this->assertSame( 'woocommerce_rest_product_partially_out_of_stock', $second_data['code'], 'The rejection should report the Store API partial-stock code.' );
+ $this->assertSame( $before, $this->get_cart_line_identity_map(), 'The rejected re-add should leave every cart line unchanged.' );
+ } finally {
+ $product->set_manage_stock( $original_manage_stock );
+ $product->set_stock_quantity( $original_stock_quantity );
+ $product->set_backorders( $original_backorders );
+ $product->set_stock_status( $original_stock_status );
+ $product->save();
+ }
+ }
+
/**
* Test adding item to cart with quantity 0 shows an error.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartApplyCoupon.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartApplyCoupon.php
index fec8c48448a..c1ab38bad61 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartApplyCoupon.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartApplyCoupon.php
@@ -217,6 +217,7 @@ class CartApplyCoupon extends ControllerTestCase {
*/
public function test_coupon_usage_limit() {
wc()->cart->remove_coupons();
+ wp_set_current_user( 1 );
$fixtures = new FixtureData();
@@ -244,10 +245,22 @@ class CartApplyCoupon extends ControllerTestCase {
$response = rest_get_server()->dispatch( $request );
$data = $response->get_data();
+ wp_set_current_user( 0 );
$this->assertEquals( 400, $response->get_status() );
$this->assertEquals( 'woocommerce_rest_cart_coupon_error', $data['code'] );
- $this->assertStringContainsString( 'Usage limit', html_entity_decode( $data['message'] ) );
+ // Match the opening sentence rather than the whole message. WooCommerce has
+ // three usage-limit wordings and picks between them on whether the current
+ // user has a failed or pending order -- state this test does not own and any
+ // earlier test in the process can create. All three start the same way, and
+ // the coupon code is the part worth pinning.
+ $this->assertStringStartsWith(
+ sprintf(
+ 'Usage limit for coupon "%s" has been reached.',
+ $limited_coupon->get_code()
+ ),
+ html_entity_decode( $data['message'] )
+ );
}
/**