Commit dc6c80ab111 for woocommerce

commit dc6c80ab111bf87f22c6e630f159538bffdb242a
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Sep 14 17:28:18 2026 +0300

    [tests] Move 4 extension cart update E2E tests to Jest and PHPUnit (#68585)

    * test(blocks): Move 4 extension cart update E2E tests to Jest and PHPUnit

    Two Blocks E2E specs ran six browser titles against the public
    extensionCartUpdate API: three for how extension callback errors
    surface, and three for how the checkout form takes, or refuses,
    cart and customer data pushed by an extension.

    Four of those titles checked decisions made in code that is testable
    without a browser: whether the wrapper rejects with the same error,
    which notice context an error lands in, whether the route passes a
    callback's RouteException through unchanged, and whether the thunk
    strips dirty addresses unless told to overwrite them.

    Cover the extensionCartUpdate wrapper in a new Jest file, strengthen
    the applyExtensionCartUpdate thunk tests to assert the resolved
    response and the rejected error, add the default cart notice context
    to the error-processing tests, and make CartExtensions assert the
    exact invalid-namespace response and a passed-through callback
    exception. Keep one browser title per spec: the extension error
    notice, and a checkout journey that holds back the update-customer
    request to prove the dirty address survives an extension update.

    Consolidates the mega-branch slices:
    - Slice 004: test(blocks): right-size extension cart update coverage

    Refs TESTOPS-234
    Refs #68046

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(blocks): Use an unpushed postcode, not a country, for the overwrite check

    The consolidated extensionCartUpdate test picked United Kingdom, waited
    for the dirty flag, and then asserted that a call with
    overwriteDirtyCustomerData replaced the browser's country with the
    server's. It relied on the dirty country never reaching the server.

    That stopped being true on trunk. #68364, "Recalculate shipping options
    when the country changes at checkout", pushes a country change straight
    away instead of waiting out the debounce, so by the time the overwrite
    runs the server already holds GB and there is nothing to replace. The
    test failed on every Blocks matrix, retries included.

    #68364 hit the same wall in the test it was replacing and solved it by
    switching to a postcode that fails validation, which is therefore never
    pushed and exists only in the browser. This commit carries that
    mechanism into the consolidated test: fill the address, wait for it to
    reach the server, type an invalid postcode, then check that the call
    without the argument keeps it and the call with the argument restores
    90210 from the server.

    The last consolidation step, an extension changing the first name
    through its own data payload, is unchanged.

    Verified locally against the Blocks e2e environment: the test fails on
    the previous version and passes on this one, and both specs this PR
    touches pass three times each.

    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-extensibility b/plugins/woocommerce/changelog/testops-234-cart-checkout-extensibility
new file mode 100644
index 00000000000..7cbcdb3e571
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-cart-checkout-extensibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move 4 extension cart update E2E tests to Jest and PHPUnit, covering the extensionCartUpdate wrapper, the cart thunk, the notice context, and the CartExtensions route; keep one browser title per spec.
+
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-cart-checkout-extensibility b/plugins/woocommerce/client/blocks/changelog/testops-234-cart-checkout-extensibility
new file mode 100644
index 00000000000..7cbcdb3e571
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-cart-checkout-extensibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move 4 extension cart update E2E tests to Jest and PHPUnit, covering the extensionCartUpdate wrapper, the cart thunk, the notice context, and the CartExtensions route; keep one browser title per spec.
+
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/thunks.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/thunks.ts
index 59a92989540..b50e9787307 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/thunks.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/cart/test/thunks.ts
@@ -430,12 +430,13 @@ describe( 'applyExtensionCartUpdate', () => {
 	it( 'should include both addresses when customer data is not dirty', async () => {
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 		} )( { dispatch } as never );

 		expect( dispatch.receiveCart ).toHaveBeenCalledWith( mockResponse );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should set prefersCollection true when the extension response selects local pickup', async () => {
@@ -500,7 +501,7 @@ describe( 'applyExtensionCartUpdate', () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 		} )( { dispatch } as never );
@@ -509,13 +510,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		expect( received ).not.toHaveProperty( 'shipping_address' );
 		expect( received ).not.toHaveProperty( 'billing_address' );
 		expect( received ).toHaveProperty( 'totals' );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should strip both addresses when customer data is dirty and overwriteDirtyCustomerData is false', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: false,
@@ -525,13 +527,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		expect( received ).not.toHaveProperty( 'shipping_address' );
 		expect( received ).not.toHaveProperty( 'billing_address' );
 		expect( received ).toHaveProperty( 'totals' );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should include both addresses when overwriteDirtyCustomerData is true', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: true,
@@ -540,13 +543,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
 		expect( received ).toHaveProperty( 'shipping_address' );
 		expect( received ).toHaveProperty( 'billing_address' );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should overwrite only shipping_address when specified as object', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: { shipping_address: true },
@@ -558,13 +562,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		} );
 		expect( received ).not.toHaveProperty( 'billing_address' );
 		expect( received ).toHaveProperty( 'totals' );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should overwrite only billing_address when specified as object', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: { billing_address: true },
@@ -575,13 +580,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		expect( received.billing_address ).toEqual( {
 			address_1: '456 Bill Ave',
 		} );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should overwrite both addresses when both specified in object', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: {
@@ -597,13 +603,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		expect( received.billing_address ).toEqual( {
 			address_1: '456 Bill Ave',
 		} );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should strip both addresses when object has explicit false flags', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: {
@@ -616,13 +623,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		expect( received ).not.toHaveProperty( 'shipping_address' );
 		expect( received ).not.toHaveProperty( 'billing_address' );
 		expect( received ).toHaveProperty( 'totals' );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should overwrite specified address even when customer data is not dirty', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( false );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: { shipping_address: true },
@@ -637,13 +645,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		expect( received.billing_address ).toEqual( {
 			address_1: '456 Bill Ave',
 		} );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should treat null as false (no overwrite)', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData:
@@ -653,13 +662,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
 		expect( received ).not.toHaveProperty( 'shipping_address' );
 		expect( received ).not.toHaveProperty( 'billing_address' );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should treat an array as false (no overwrite)', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: [
@@ -670,13 +680,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
 		expect( received ).not.toHaveProperty( 'shipping_address' );
 		expect( received ).not.toHaveProperty( 'billing_address' );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should treat non-boolean address fields as false', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: {
@@ -688,13 +699,14 @@ describe( 'applyExtensionCartUpdate', () => {
 		const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
 		expect( received ).not.toHaveProperty( 'shipping_address' );
 		expect( received ).not.toHaveProperty( 'billing_address' );
+		expect( result ).toBe( mockResponse );
 	} );

 	it( 'should default missing address fields to false', async () => {
 		mockGetIsCustomerDataDirty.mockReturnValue( true );
 		const dispatch = createMockDispatch();

-		await applyExtensionCartUpdate( {
+		const result = await applyExtensionCartUpdate( {
 			namespace: 'test',
 			data: {},
 			overwriteDirtyCustomerData: {},
@@ -703,5 +715,25 @@ describe( 'applyExtensionCartUpdate', () => {
 		const received = dispatch.receiveCart.mock.calls[ 0 ][ 0 ];
 		expect( received ).not.toHaveProperty( 'shipping_address' );
 		expect( received ).not.toHaveProperty( 'billing_address' );
+		expect( result ).toBe( mockResponse );
+	} );
+
+	it( 'should dispatch and reject the same API error', async () => {
+		const dispatch = createMockDispatch();
+		const error = {
+			code: 'test_error',
+			message: 'This is an error with cart context.',
+			data: { status: 400, context: 'wc/cart' },
+		};
+		mockApiFetchWithHeaders.mockRejectedValueOnce( error );
+
+		await expect(
+			applyExtensionCartUpdate( {
+				namespace: 'test',
+				data: {},
+			} )( { dispatch } as never )
+		).rejects.toBe( error );
+
+		expect( dispatch.receiveError ).toHaveBeenCalledWith( error );
 	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/block-data/utils/test/process-error-response.ts b/plugins/woocommerce/client/blocks/packages/public-api/block-data/utils/test/process-error-response.ts
index d153fc25e84..994276d0df1 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/block-data/utils/test/process-error-response.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/block-data/utils/test/process-error-response.ts
@@ -49,6 +49,14 @@ const errorResponse: ApiErrorResponse = {
 	},
 };

+const errorResponseWithoutContext: ApiErrorResponse = {
+	code: 'woocommerce_rest_cart_extensions_error',
+	message: 'There is no such namespace registered: test-plugin.',
+	data: {
+		status: 400,
+	},
+};
+
 describe( 'getNoticeContextFromErrorResponse', () => {
 	it( 'should generate notice contexts and ids for the correct fields/errors', () => {
 		const result = getNoticeContextFromErrorResponse( errorResponse );
@@ -73,9 +81,24 @@ describe( 'getNoticeContextFromErrorResponse', () => {
 		);
 		expect( result[ 0 ].context ).toEqual( 'test_context' );
 	} );
+
+	it( 'should use the cart context for a non-parameter error without a server context', () => {
+		expect(
+			getNoticeContextFromErrorResponse( errorResponseWithoutContext )
+		).toEqual( [
+			{
+				id: 'woocommerce_rest_cart_extensions_error',
+				context: 'wc/cart',
+			},
+		] );
+	} );
 } );

 describe( 'processErrorResponse', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
 	it( 'should dismiss old notices and create new ones', () => {
 		processErrorResponse( errorResponse );
 		expect( createNotice ).toHaveBeenCalledTimes( 2 );
@@ -97,4 +120,18 @@ describe( 'processErrorResponse', () => {
 			}
 		);
 	} );
+
+	it( 'should create a non-parameter error notice in the cart context by default', () => {
+		processErrorResponse( errorResponseWithoutContext );
+
+		expect( createNotice ).toHaveBeenCalledTimes( 1 );
+		expect( createNotice ).toHaveBeenCalledWith(
+			'error',
+			'There is no such namespace registered: test-plugin.',
+			{
+				id: 'woocommerce_rest_cart_extensions_error',
+				context: 'wc/cart',
+			}
+		);
+	} );
 } );
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/test/extension-cart-update.ts b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/test/extension-cart-update.ts
new file mode 100644
index 00000000000..3c85acb8609
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/public-api/blocks-checkout/utils/test/extension-cart-update.ts
@@ -0,0 +1,121 @@
+/**
+ * External dependencies
+ */
+import { dispatch } from '@wordpress/data';
+import { processErrorResponse } from '@woocommerce/block-data';
+import type { CartResponse, ExtensionCartUpdateArgs } from '@woocommerce/types';
+
+/**
+ * Internal dependencies
+ */
+import { extensionCartUpdate } from '../extension-cart-update';
+import { STORE_KEY } from '../../../block-data/cart/constants';
+
+jest.mock( '@wordpress/data', () => ( {
+	dispatch: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/block-data', () => ( {
+	processErrorResponse: jest.fn(),
+} ) );
+
+const mockDispatch = dispatch as jest.MockedFunction< typeof dispatch >;
+const mockProcessErrorResponse = processErrorResponse as jest.MockedFunction<
+	typeof processErrorResponse
+>;
+const mockApplyExtensionCartUpdate = jest.fn();
+
+describe( 'extensionCartUpdate', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		mockDispatch.mockReturnValue( {
+			applyExtensionCartUpdate: mockApplyExtensionCartUpdate,
+		} as never );
+	} );
+
+	it.each< [ string, ExtensionCartUpdateArgs ] >( [
+		[
+			'with overwrite omitted',
+			{ namespace: 'test-extension', data: { value: 'omitted' } },
+		],
+		[
+			'with overwrite disabled',
+			{
+				namespace: 'test-extension',
+				data: { value: 'false' },
+				overwriteDirtyCustomerData: false,
+			},
+		],
+		[
+			'with overwrite enabled',
+			{
+				namespace: 'test-extension',
+				data: { value: 'true' },
+				overwriteDirtyCustomerData: true,
+			},
+		],
+		[
+			'with shipping-address overwrite enabled',
+			{
+				namespace: 'test-extension',
+				data: { value: 'shipping' },
+				overwriteDirtyCustomerData: { shipping_address: true },
+			},
+		],
+		[
+			'with billing-address overwrite enabled',
+			{
+				namespace: 'test-extension',
+				data: { value: 'billing' },
+				overwriteDirtyCustomerData: { billing_address: true },
+			},
+		],
+	] )(
+		'forwards arguments and fulfills with the same cart response %s',
+		async ( _, args ) => {
+			const response = { items: [] } as CartResponse;
+			mockApplyExtensionCartUpdate.mockResolvedValueOnce( response );
+
+			const result = await extensionCartUpdate( args );
+
+			expect( mockDispatch ).toHaveBeenCalledWith( STORE_KEY );
+			expect( mockApplyExtensionCartUpdate ).toHaveBeenCalledWith( args );
+			expect( result ).toBe( response );
+		}
+	);
+
+	it( 'rejects the same non-special error without creating a notice', async () => {
+		const error = {
+			code: 'test_error',
+			message: 'This is an extension error.',
+		};
+		mockApplyExtensionCartUpdate.mockRejectedValueOnce( error );
+
+		await expect(
+			extensionCartUpdate( {
+				namespace: 'test-extension',
+				data: {},
+			} )
+		).rejects.toBe( error );
+
+		expect( mockProcessErrorResponse ).not.toHaveBeenCalled();
+	} );
+
+	it( 'processes a special cart-extension error once and rejects that same error', async () => {
+		const error = {
+			code: 'woocommerce_rest_cart_extensions_error',
+			message: 'The cart extension could not be processed.',
+		};
+		mockApplyExtensionCartUpdate.mockRejectedValueOnce( error );
+
+		await expect(
+			extensionCartUpdate( {
+				namespace: 'test-extension',
+				data: {},
+			} )
+		).rejects.toBe( error );
+
+		expect( mockProcessErrorResponse ).toHaveBeenCalledTimes( 1 );
+		expect( mockProcessErrorResponse ).toHaveBeenCalledWith( error );
+	} );
+} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-extension-callbacks.shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-extension-callbacks.shopper.block_theme.spec.ts
index 37cabfe774a..95078213a1b 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-extension-callbacks.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-extension-callbacks.shopper.block_theme.spec.ts
@@ -1,50 +1,14 @@
 /**
  * External dependencies
  */
-import { expect, test as base } from '@woocommerce/e2e-utils';
+import { expect, test } from '@woocommerce/e2e-utils';

 /**
  * Internal dependencies
  */
-import { CheckoutPage } from '../checkout/checkout.page';
 import { REGULAR_PRICED_PRODUCT_NAME } from '../checkout/constants';

-const test = base.extend< { checkoutPageObject: CheckoutPage } >( {
-	checkoutPageObject: async ( { page }, use ) => {
-		const pageObject = new CheckoutPage( {
-			page,
-		} );
-		await use( pageObject );
-	},
-} );
-
 test.describe( 'Shopper → Cart Extension Callbacks', () => {
-	test( 'Custom error code creates exception', async ( {
-		frontendUtils,
-		requestUtils,
-		page,
-	} ) => {
-		await requestUtils.activatePlugin(
-			'woocommerce-blocks-test-cart-extensions'
-		);
-
-		await frontendUtils.goToShop();
-		await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
-		await frontendUtils.goToCart();
-
-		await expect(
-			page.evaluate( () =>
-				window.wc.blocksCheckout
-					.extensionCartUpdate( {
-						namespace: 'cart-extensions-test-helper',
-					} )
-					.catch( ( error ) => {
-						throw new Error( error.message );
-					} )
-			)
-		).rejects.toThrow( 'This is an error with cart context.' );
-	} );
-
 	test( 'Error code `woocommerce_rest_cart_extensions_error` creates notice', async ( {
 		frontendUtils,
 		requestUtils,
@@ -70,27 +34,4 @@ test.describe( 'Shopper → Cart Extension Callbacks', () => {
 				.getByText( 'This is an error with cart context.' )
 		).toBeVisible();
 	} );
-
-	test( 'Invalid callback namespace creates notice', async ( {
-		frontendUtils,
-		page,
-	} ) => {
-		await frontendUtils.goToShop();
-		await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
-		await frontendUtils.goToCart();
-
-		await page.evaluate( () => {
-			window.wc.blocksCheckout.extensionCartUpdate( {
-				namespace: 'invalid-namespace',
-			} );
-		} );
-
-		await expect(
-			page
-				.locator( '.wc-block-components-notice-banner__content' )
-				.getByText(
-					'There is no such namespace registered: invalid-namespace.'
-				)
-		).toBeVisible();
-	} );
 } );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/checkout/checkout-block-extensibility.shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/checkout/checkout-block-extensibility.shopper.block_theme.spec.ts
index e7a05fda2ba..fe772da10ab 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/checkout/checkout-block-extensibility.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/checkout/checkout-block-extensibility.shopper.block_theme.spec.ts
@@ -40,51 +40,13 @@ test.describe( 'Shopper → Extensibility', () => {
 		await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
 		await frontendUtils.goToCheckout();
 	} );
+
 	test.describe( 'extensionCartUpdate', () => {
-		test( 'Response is not undefined in any code path', async ( {
+		test( 'Cart data can be modified by extensions', async ( {
 			checkoutPageObject,
 		} ) => {
-			// With no additional args.
-			let response = await checkoutPageObject.page.evaluate(
-				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update' } ).then( ( response ) => response );"
-			);
-			let resolvedResponse = await Promise.resolve( response );
-			expect( resolvedResponse ).not.toBeUndefined();
-			expect( resolvedResponse ).toHaveProperty( 'billing_address' );
-
-			// With overwriteDirtyCustomerData true.
-			response = await checkoutPageObject.page.evaluate(
-				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update', overwriteDirtyCustomerData: true } ).then( ( response ) => response );"
-			);
-			resolvedResponse = await Promise.resolve( response );
-			expect( resolvedResponse ).not.toBeUndefined();
-			expect( resolvedResponse ).toHaveProperty( 'billing_address' );
-
-			// With overwriteDirtyCustomerData false.
-			response = await checkoutPageObject.page.evaluate(
-				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update', overwriteDirtyCustomerData: false } ).then( ( response ) => response );"
-			);
-			resolvedResponse = await Promise.resolve( response );
-			expect( resolvedResponse ).not.toBeUndefined();
-			expect( resolvedResponse ).toHaveProperty( 'billing_address' );
+			const { page } = checkoutPageObject;

-			// With a dirty customer object.
-			await checkoutPageObject.page
-				.getByLabel( 'Country/Region' )
-				.selectOption( 'United Kingdom (UK)' );
-			await expect(
-				checkoutPageObject.page.getByLabel( 'Country/Region' )
-			).toHaveValue( 'GB' );
-			response = await checkoutPageObject.page.evaluate(
-				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update' } ).then( ( response ) => response );"
-			);
-			resolvedResponse = await Promise.resolve( response );
-			expect( resolvedResponse ).not.toBeUndefined();
-			expect( resolvedResponse ).toHaveProperty( 'billing_address' );
-		} );
-		test( 'Unpushed data is/is not overwritten depending on arg', async ( {
-			checkoutPageObject,
-		} ) => {
 			// Fill in the address, then wait until it has reached the server. The dirty flag is
 			// not enough on its own: it is cleared by whichever push finishes first, which can
 			// be an earlier one that did not carry the address.
@@ -92,7 +54,7 @@ test.describe( 'Shopper → Extensibility', () => {
 			await expect
 				.poll(
 					async () =>
-						checkoutPageObject.page.evaluate( async () => {
+						page.evaluate( async () => {
 							const response = await fetch(
 								'/wp-json/wc/store/v1/cart'
 							);
@@ -110,54 +72,49 @@ test.describe( 'Shopper → Extensibility', () => {
 				.toBe( '90210' );

 			// A postcode that fails validation is never pushed, so it only exists in the browser.
-			const postcode =
-				checkoutPageObject.page.locator( '#shipping-postcode' );
+			// A country change is not usable here: picking one pushes straight away so shipping
+			// can be recalculated, which would leave nothing unpushed to overwrite.
+			const postcode = page.locator( '#shipping-postcode' );
 			await postcode.fill( 'ABCDEF' );
 			await postcode.blur();
-			await checkoutPageObject.page.waitForFunction(
-				() =>
+			await page.waitForFunction( () => {
+				return (
 					window.localStorage.getItem(
 						'WOOCOMMERCE_CHECKOUT_IS_CUSTOMER_DATA_DIRTY'
 					) === 'true'
-			);
+				);
+			} );

 			// Without the arg, the unpushed postcode is kept.
-			await checkoutPageObject.page.evaluate(
-				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update' } )"
+			await page.evaluate( () =>
+				window.wc.blocksCheckout.extensionCartUpdate( {
+					namespace: 'woocommerce-blocks-test-extension-cart-update',
+				} )
 			);
 			await expect( postcode ).toHaveValue( 'ABCDEF' );

 			// With overwriteDirtyCustomerData, the address from the server replaces it.
-			await checkoutPageObject.page.evaluate(
-				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update', overwriteDirtyCustomerData: true } )"
+			const overwriteResponse = await page.evaluate( () =>
+				window.wc.blocksCheckout.extensionCartUpdate( {
+					namespace: 'woocommerce-blocks-test-extension-cart-update',
+					overwriteDirtyCustomerData: true,
+				} )
+			);
+			expect( overwriteResponse.shipping_address.postcode ).toBe(
+				'90210'
 			);
 			await expect( postcode ).toHaveValue( '90210' );

-			await checkoutPageObject.page.evaluate(
-				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update', overwriteDirtyCustomerData: true } )"
+			await page.evaluate( () =>
+				window.wc.blocksCheckout.extensionCartUpdate( {
+					namespace: 'woocommerce-blocks-test-extension-cart-update',
+					data: { 'test-name-change': true },
+					overwriteDirtyCustomerData: true,
+				} )
 			);
-			await expect(
-				checkoutPageObject.page.getByLabel( 'Country/Region' )
-			).toHaveValue( 'US' );
-			await expect( postcode ).toHaveValue( '90210' );
-		} );
-		test( 'Cart data can be modified by extensions', async ( {
-			checkoutPageObject,
-		} ) => {
-			await checkoutPageObject.fillInCheckoutWithTestData();
-			await checkoutPageObject.page.waitForFunction( () => {
-				return (
-					window.localStorage.getItem(
-						'WOOCOMMERCE_CHECKOUT_IS_CUSTOMER_DATA_DIRTY'
-					) === 'false'
-				);
-			} );
-			await checkoutPageObject.page.evaluate(
-				"wc.blocksCheckout.extensionCartUpdate( { namespace: 'woocommerce-blocks-test-extension-cart-update', data: { 'test-name-change': true } } )"
+			await expect( page.getByLabel( 'First name' ) ).toHaveValue(
+				'Mr. Test'
 			);
-			await expect(
-				checkoutPageObject.page.getByLabel( 'First name' )
-			).toHaveValue( 'Mr. Test' );
 		} );
 	} );
 } );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartExtensions.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartExtensions.php
index f5a7bf31142..8220d0fe708 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartExtensions.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/CartExtensions.php
@@ -7,6 +7,9 @@ namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;

 use Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes\ControllerTestCase;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\StoreApi;

 /**
  * Cart Controller Tests.
@@ -35,7 +38,7 @@ class CartExtensions extends ControllerTestCase {
 		woocommerce_store_api_register_update_callback(
 			array(
 				'namespace' => 'valid-test-plugin',
-				'callback'  => function() {
+				'callback'  => function () {
 					add_action(
 						'woocommerce_cart_calculate_fees',
 						function() {
@@ -46,10 +49,11 @@ class CartExtensions extends ControllerTestCase {
 			)
 		);
 	}
+
 	/**
-	 * Test getting cart with invalid namespace.
+	 * @testdox Invalid namespace errors expose the mapped Store API response without a context override.
 	 */
-	public function test_invalid_namespace() {
+	public function test_invalid_namespace(): void {
 		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/extensions' );
 		$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
 		$request->set_body_params(
@@ -57,10 +61,61 @@ class CartExtensions extends ControllerTestCase {
 				'namespace' => 'test-plugin',
 			)
 		);
-		$this->assertAPIResponse(
-			$request,
-			400
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 400, $response->get_status(), 'An invalid extension namespace should return HTTP 400.' );
+		$this->assertSame( 'woocommerce_rest_cart_extensions_error', $data['code'], 'The response should use the mapped cart-extension error code.' );
+		$this->assertSame( 'There is no such namespace registered: test-plugin.', $data['message'], 'The response should identify the missing namespace exactly.' );
+		$this->assertSame( 400, $data['data']['status'], 'The response data should preserve the HTTP status.' );
+		$this->assertArrayNotHasKey( 'context', $data['data'], 'The invalid-namespace response should leave notice context selection to the client.' );
+	}
+
+	/**
+	 * @testdox Extension callback RouteException data passes through the public route unchanged.
+	 */
+	public function test_callback_route_exception_passes_through(): void {
+		$extend            = StoreApi::container()->get( ExtendSchema::class );
+		$original_callback = $extend->get_update_callback( 'valid-test-plugin' );
+
+		woocommerce_store_api_register_update_callback(
+			array(
+				'namespace' => 'valid-test-plugin',
+				'callback'  => function () {
+					throw new RouteException(
+						'test_error',
+						'This is an error with cart context.',
+						400,
+						array( 'context' => 'wc/cart' )
+					);
+				},
+			)
 		);
+
+		try {
+			$request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/extensions' );
+			$request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) );
+			$request->set_body_params(
+				array(
+					'namespace' => 'valid-test-plugin',
+				)
+			);
+			$response = rest_get_server()->dispatch( $request );
+			$data     = $response->get_data();
+
+			$this->assertSame( 400, $response->get_status(), 'The callback RouteException should preserve its HTTP status.' );
+			$this->assertSame( 'test_error', $data['code'], 'The callback RouteException should preserve its error code.' );
+			$this->assertSame( 'This is an error with cart context.', $data['message'], 'The callback RouteException should preserve its message.' );
+			$this->assertSame( 400, $data['data']['status'], 'The callback RouteException response data should preserve its status.' );
+			$this->assertSame( 'wc/cart', $data['data']['context'], 'The callback RouteException should preserve its notice context.' );
+		} finally {
+			woocommerce_store_api_register_update_callback(
+				array(
+					'namespace' => 'valid-test-plugin',
+					'callback'  => $original_callback,
+				)
+			);
+		}
 	}

 	/**