Commit 0a859848825 for woocommerce

commit 0a85984882597ff3e1181dd17b9a653dde7ee82f
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 15 23:28:52 2026 +0300

    [tests] Reduce Local Pickup settings E2E tests from 7 to 2 (#68596)

    * test(blocks): Reduce Local Pickup settings E2E tests from 7 to 2

    The Local Pickup merchant spec ran seven browser titles, one per
    setting or location action: the enabled toggle, the title, the price
    toggle, cost and tax status, and adding, editing, and deleting a
    location. Most changed a control and read it back on the same page
    without reloading, so they never proved that anything was saved.

    Add Jest suites for the settings screen's three modules: the general
    settings controls, the location table and its edit dialog, and the
    settings provider that builds and sends the save request. Replace the
    seven titles with two journeys that save and reload: one for the
    general settings, and one for adding, editing, and deleting a
    location.

    Consolidates the mega-branch slices:
    - blocks-flow-14-local-pickup: test(blocks): Consolidate Local Pickup
      settings coverage
    - refactor(e2e): simplify migrated Blocks test contracts (this spec
      only)

    Refs TESTOPS-234
    Refs #68046

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

    * test(blocks): Give the pickup endpoint's two faults a PHP owner

    The removed browser journeys were the only tests that caught the
    endpoint turning `tax_status` `none` into `taxable`, and the endpoint
    ignoring an empty location list. Both now have route tests.

    Tax status is a provider pair rather than a single `none` case. `none`
    is both an accepted value and the fallback for a rejected one, so on its
    own it survives almost any mutation of the allow-list. Next to `taxable`
    it separates "the status was kept" from "everything collapses to one
    status".

    The empty-list test saves a location first, so the assertion measures a
    list being cleared rather than one that was never populated.

    Mutation confirms both. Narrowing the allow-list to `taxable` with a
    matching fallback fails only the `none` row. Changing `is_array` to
    `! empty` for the locations branch fails only the empty-list test.

    Refs #68596

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

    * test(blocks): Use useId for the Local Pickup test control IDs

    The mocked CheckboxControl, SelectControl, and TextControl built their
    input IDs from the label with a regex. The same normalization repeated
    in five places, and two labels that normalized to the same string would
    have shared an ID. No current labels do, so this is a readability
    change rather than a fix.

    `useId` gives each mocked control a unique ID by construction. The
    tests import it from `@wordpress/element` as `mockUseId`, the prefix
    Jest allows inside a `jest.mock` factory, matching how other Blocks
    tests import `previewCart` as `mockPreviewCart`.

    Pointing every label at an unrelated ID fails both general settings
    tests, so the role-and-name queries still depend on the new IDs.

    Refs #68596

    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-local-pickup-settings b/plugins/woocommerce/changelog/testops-234-local-pickup-settings
new file mode 100644
index 00000000000..8f333268a3a
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-local-pickup-settings
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Local Pickup settings E2E tests from 7 to 2; three Jest suites own the settings screen's logic.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/general-settings.tsx b/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/general-settings.tsx
new file mode 100644
index 00000000000..cc33282b836
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/general-settings.tsx
@@ -0,0 +1,231 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode } from 'react';
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useId as mockUseId } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import GeneralSettings from '../general-settings';
+import { useSettingsContext } from '../settings-context';
+import type { SettingsContextType } from '../types';
+
+jest.mock( '@wordpress/components', () => ( {
+	Button: ( { children }: { children: ReactNode } ) => (
+		<button type="button">{ children }</button>
+	),
+	Card: ( { children }: { children: ReactNode } ) => <div>{ children }</div>,
+	CardBody: ( { children }: { children: ReactNode } ) => (
+		<div>{ children }</div>
+	),
+	CheckboxControl: ( {
+		checked,
+		label,
+		onChange,
+	}: {
+		checked: boolean;
+		label: string;
+		onChange: ( checked: boolean ) => void;
+	} ) => {
+		const id = mockUseId();
+		return (
+			<>
+				<label htmlFor={ id }>{ label }</label>
+				<input
+					id={ id }
+					type="checkbox"
+					checked={ checked }
+					onChange={ ( event ) => onChange( event.target.checked ) }
+				/>
+			</>
+		);
+	},
+	ExternalLink: ( {
+		children,
+		href,
+	}: {
+		children: ReactNode;
+		href: string;
+	} ) => <a href={ href }>{ children }</a>,
+	Notice: ( { children }: { children: ReactNode } ) => (
+		<div>{ children }</div>
+	),
+	Modal: ( { children }: { children: ReactNode } ) => <div>{ children }</div>,
+	SelectControl: ( {
+		label,
+		onChange,
+		options,
+		value,
+	}: {
+		label: string;
+		onChange: ( value: string ) => void;
+		options: { label: string; value: string }[];
+		value: string;
+	} ) => {
+		const id = mockUseId();
+		return (
+			<>
+				<label htmlFor={ id }>{ label }</label>
+				<select
+					id={ id }
+					value={ value }
+					onChange={ ( event ) => onChange( event.target.value ) }
+				>
+					{ options.map( ( option ) => (
+						<option key={ option.value } value={ option.value }>
+							{ option.label }
+						</option>
+					) ) }
+				</select>
+			</>
+		);
+	},
+	TextControl: ( {
+		label,
+		onChange,
+		placeholder,
+		type = 'text',
+		value,
+	}: {
+		label: string;
+		onChange: ( value: string ) => void;
+		placeholder?: string;
+		type?: string;
+		value: string;
+	} ) => {
+		const id = mockUseId();
+		return (
+			<>
+				<label htmlFor={ id }>{ label }</label>
+				<input
+					id={ id }
+					type={ type }
+					placeholder={ placeholder }
+					value={ value }
+					onChange={ ( event ) => onChange( event.target.value ) }
+				/>
+			</>
+		);
+	},
+	ToggleControl: () => <input type="checkbox" readOnly />,
+} ) );
+
+jest.mock( '../settings-context', () => ( {
+	useSettingsContext: jest.fn(),
+} ) );
+
+const mockUseSettingsContext = useSettingsContext as jest.MockedFunction<
+	typeof useSettingsContext
+>;
+
+const getContext = (
+	overrides: Partial< SettingsContextType > = {}
+): SettingsContextType => ( {
+	settings: {
+		enabled: true,
+		title: '',
+		tax_status: 'taxable',
+		cost: '',
+	},
+	readOnlySettings: {
+		hasLegacyPickup: false,
+		storeCountry: 'US',
+		storeState: 'CA',
+	},
+	setSettingField: jest.fn( () => jest.fn() ),
+	pickupLocations: [],
+	setPickupLocations: jest.fn(),
+	toggleLocation: jest.fn(),
+	updateLocation: jest.fn(),
+	isSaving: false,
+	save: jest.fn(),
+	isDirty: false,
+	...overrides,
+} );
+
+describe( 'GeneralSettings', () => {
+	it( 'delegates enablement and title changes to their setting fields', async () => {
+		const user = userEvent.setup();
+		const enabledChanged = jest.fn();
+		const titleChanged = jest.fn();
+		const setSettingField = jest.fn( ( field ) => {
+			return field === 'enabled' ? enabledChanged : titleChanged;
+		} );
+		mockUseSettingsContext.mockReturnValue(
+			getContext( { setSettingField } )
+		);
+
+		render( <GeneralSettings /> );
+
+		await user.click(
+			screen.getByRole( 'checkbox', { name: 'Enable local pickup' } )
+		);
+		const title = screen.getByRole( 'textbox', { name: 'Title' } );
+		await user.type( title, 'C' );
+
+		expect( setSettingField ).toHaveBeenCalledWith( 'enabled' );
+		expect( setSettingField ).toHaveBeenCalledWith( 'title' );
+		expect( enabledChanged ).toHaveBeenCalledWith( false );
+		expect( titleChanged ).toHaveBeenLastCalledWith( 'C' );
+	} );
+
+	it( 'shows price controls and clears cost whenever price visibility changes', async () => {
+		const user = userEvent.setup();
+		const costChanged = jest.fn();
+		const taxStatusChanged = jest.fn();
+		const setSettingField = jest.fn( ( field ) => {
+			if ( field === 'cost' ) {
+				return costChanged;
+			}
+			if ( field === 'tax_status' ) {
+				return taxStatusChanged;
+			}
+			return jest.fn();
+		} );
+		mockUseSettingsContext.mockReturnValue(
+			getContext( { setSettingField } )
+		);
+
+		render( <GeneralSettings /> );
+
+		const showPrice = screen.getByRole( 'checkbox', {
+			name: 'Add a price for customers who choose local pickup',
+		} );
+		expect(
+			screen.queryByRole( 'spinbutton', { name: 'Cost' } )
+		).not.toBeInTheDocument();
+		expect(
+			screen.queryByRole( 'combobox', { name: 'Taxes' } )
+		).not.toBeInTheDocument();
+
+		await act( async () => {
+			await user.click( showPrice );
+		} );
+
+		const cost = screen.getByRole( 'spinbutton', { name: 'Cost' } );
+		const taxes = screen.getByRole( 'combobox', { name: 'Taxes' } );
+		await user.type( cost, '7' );
+		await user.selectOptions( taxes, 'none' );
+
+		expect( setSettingField ).toHaveBeenCalledWith( 'cost' );
+		expect( setSettingField ).toHaveBeenCalledWith( 'tax_status' );
+		expect( costChanged ).toHaveBeenCalledWith( '' );
+		expect( costChanged ).toHaveBeenLastCalledWith( '7' );
+		expect( taxStatusChanged ).toHaveBeenCalledWith( 'none' );
+
+		await act( async () => {
+			await user.click( showPrice );
+		} );
+
+		expect( costChanged ).toHaveBeenLastCalledWith( '' );
+		expect(
+			screen.queryByRole( 'spinbutton', { name: 'Cost' } )
+		).not.toBeInTheDocument();
+		expect(
+			screen.queryByRole( 'combobox', { name: 'Taxes' } )
+		).not.toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/location-settings.tsx b/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/location-settings.tsx
new file mode 100644
index 00000000000..a43b77c56df
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/location-settings.tsx
@@ -0,0 +1,328 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useId as mockUseId } from '@wordpress/element';
+import type { ReactNode } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import LocationSettings from '../location-settings';
+import { useSettingsContext } from '../settings-context';
+import type { SettingsContextType, SortablePickupLocation } from '../types';
+
+jest.mock( '@wordpress/components', () => ( {
+	Button: ( {
+		children,
+		onClick,
+	}: {
+		children: ReactNode;
+		onClick: () => void;
+	} ) => (
+		<button type="button" onClick={ onClick }>
+			{ children }
+		</button>
+	),
+	Card: ( { children }: { children: ReactNode } ) => <div>{ children }</div>,
+	CardBody: ( { children }: { children: ReactNode } ) => (
+		<div>{ children }</div>
+	),
+	ExternalLink: ( {
+		children,
+		href,
+	}: {
+		children: ReactNode;
+		href: string;
+	} ) => <a href={ href }>{ children }</a>,
+	Modal: ( { children, title }: { children: ReactNode; title: string } ) => (
+		<div role="dialog" aria-label={ title }>
+			{ children }
+		</div>
+	),
+	SelectControl: ( {
+		children,
+		label,
+		onChange,
+		value,
+	}: {
+		children: ReactNode;
+		label: string;
+		onChange: ( value: string ) => void;
+		value: string;
+	} ) => {
+		const id = mockUseId();
+		return (
+			<>
+				<label htmlFor={ id }>{ label }</label>
+				<select
+					id={ id }
+					value={ value }
+					onChange={ ( event ) => onChange( event.target.value ) }
+				>
+					{ children }
+				</select>
+			</>
+		);
+	},
+	TextControl: ( {
+		label,
+		onChange,
+		placeholder,
+		required,
+		value,
+	}: {
+		label?: string;
+		onChange: ( value: string ) => void;
+		placeholder?: string;
+		required?: boolean;
+		value: string;
+	} ) => {
+		const accessibleLabel = label || placeholder || 'Text field';
+		const id = mockUseId();
+		return (
+			<>
+				<label htmlFor={ id }>{ accessibleLabel }</label>
+				<input
+					id={ id }
+					required={ required }
+					value={ value }
+					onChange={ ( event ) => onChange( event.target.value ) }
+				/>
+			</>
+		);
+	},
+	ToggleControl: ( {
+		checked,
+		onChange,
+	}: {
+		checked: boolean;
+		onChange: () => void;
+	} ) => (
+		<>
+			<label htmlFor="toggle-location">Toggle location</label>
+			<input
+				id="toggle-location"
+				type="checkbox"
+				checked={ checked }
+				onChange={ onChange }
+			/>
+		</>
+	),
+} ) );
+
+jest.mock( '../settings-context', () => ( {
+	useSettingsContext: jest.fn(),
+} ) );
+
+jest.mock( '../utils', () => ( {
+	getUserFriendlyAddress: jest.fn( ( address: Record< string, string > ) =>
+		Object.values( address ).filter( Boolean ).join( ', ' )
+	),
+	states: {
+		US: { CA: 'California' },
+		GB: {},
+	},
+	countryStateOptions: {
+		options: [
+			{
+				label: 'United States',
+				options: [
+					{ value: 'US:CA', label: 'United States — California' },
+				],
+			},
+			{
+				options: [ { value: 'GB', label: 'United Kingdom' } ],
+			},
+		],
+	},
+} ) );
+
+const mockUseSettingsContext = useSettingsContext as jest.MockedFunction<
+	typeof useSettingsContext
+>;
+
+const location: SortablePickupLocation = {
+	id: 'warehouse-0',
+	name: 'Warehouse',
+	details: 'Rear entrance',
+	enabled: true,
+	address: {
+		address_1: '60 29th Street',
+		city: 'San Francisco',
+		state: 'CA',
+		postcode: '94110',
+		country: 'US',
+	},
+};
+
+const getContext = (
+	overrides: Partial< SettingsContextType > = {}
+): SettingsContextType => ( {
+	settings: {
+		enabled: true,
+		title: 'Pickup',
+		tax_status: 'taxable',
+		cost: '',
+	},
+	readOnlySettings: {
+		hasLegacyPickup: false,
+		storeCountry: 'US',
+		storeState: 'CA',
+	},
+	setSettingField: jest.fn( () => jest.fn() ),
+	pickupLocations: [ location ],
+	setPickupLocations: jest.fn(),
+	toggleLocation: jest.fn(),
+	updateLocation: jest.fn(),
+	isSaving: false,
+	save: jest.fn(),
+	isDirty: false,
+	...overrides,
+} );
+
+describe( 'LocationSettings', () => {
+	it( 'adds a location with the dialog field values', async () => {
+		const user = userEvent.setup();
+		const updateLocation = jest.fn();
+		mockUseSettingsContext.mockReturnValue(
+			getContext( { pickupLocations: [], updateLocation } )
+		);
+		render( <LocationSettings /> );
+
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Add pickup location' } )
+			);
+		} );
+		await act( async () => {
+			await user.type(
+				screen.getByRole( 'textbox', { name: /Location name/ } ),
+				'Downtown'
+			);
+		} );
+		await act( async () => {
+			await user.type(
+				screen.getByRole( 'textbox', { name: 'Address' } ),
+				'10 Market Street'
+			);
+		} );
+		await act( async () => {
+			await user.type(
+				screen.getByRole( 'textbox', { name: 'City' } ),
+				'San Francisco'
+			);
+		} );
+		await act( async () => {
+			await user.type(
+				screen.getByRole( 'textbox', { name: 'Postcode / ZIP' } ),
+				'94105'
+			);
+		} );
+		await act( async () => {
+			await user.selectOptions(
+				screen.getByRole( 'combobox', { name: 'Country / State' } ),
+				'US:CA'
+			);
+		} );
+		await act( async () => {
+			await user.type(
+				screen.getByRole( 'textbox', { name: 'Pickup details' } ),
+				'Ask at reception'
+			);
+		} );
+		await act( async () => {
+			await user.click( screen.getByRole( 'button', { name: 'Done' } ) );
+		} );
+
+		expect( updateLocation ).toHaveBeenCalledWith( 'new', {
+			name: 'Downtown',
+			details: 'Ask at reception',
+			enabled: true,
+			address: {
+				address_1: '10 Market Street',
+				city: 'San Francisco',
+				state: 'CA',
+				postcode: '94105',
+				country: 'US',
+			},
+		} );
+	} );
+
+	it( 'edits a location using its existing ID and dialog payload', async () => {
+		const user = userEvent.setup();
+		const updateLocation = jest.fn();
+		mockUseSettingsContext.mockReturnValue(
+			getContext( { updateLocation } )
+		);
+		render( <LocationSettings /> );
+
+		await act( async () => {
+			await user.click( screen.getByRole( 'button', { name: 'Edit' } ) );
+		} );
+		const name = screen.getByRole( 'textbox', {
+			name: /Location name/,
+		} );
+		await act( async () => {
+			await user.clear( name );
+		} );
+		await act( async () => {
+			await user.type( name, 'London office' );
+		} );
+		await act( async () => {
+			await user.selectOptions(
+				screen.getByRole( 'combobox', { name: 'Country / State' } ),
+				'GB'
+			);
+		} );
+		await act( async () => {
+			await user.click( screen.getByRole( 'button', { name: 'Done' } ) );
+		} );
+
+		expect( updateLocation ).toHaveBeenCalledWith( 'warehouse-0', {
+			...location,
+			name: 'London office',
+			address: {
+				...location.address,
+				state: '',
+				country: 'GB',
+			},
+		} );
+	} );
+
+	it( 'deletes a location using its existing ID', async () => {
+		const user = userEvent.setup();
+		const updateLocation = jest.fn();
+		mockUseSettingsContext.mockReturnValue(
+			getContext( { updateLocation } )
+		);
+		render( <LocationSettings /> );
+
+		await act( async () => {
+			await user.click( screen.getByRole( 'button', { name: 'Edit' } ) );
+		} );
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Delete location' } )
+			);
+		} );
+
+		expect( updateLocation ).toHaveBeenCalledWith( 'warehouse-0', null );
+	} );
+
+	it( 'toggles a location using its existing ID', async () => {
+		const user = userEvent.setup();
+		const toggleLocation = jest.fn();
+		mockUseSettingsContext.mockReturnValue(
+			getContext( { toggleLocation } )
+		);
+		render( <LocationSettings /> );
+
+		await user.click(
+			screen.getByRole( 'checkbox', { name: 'Toggle location' } )
+		);
+
+		expect( toggleLocation ).toHaveBeenCalledWith( 'warehouse-0' );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/settings-context.tsx b/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/settings-context.tsx
new file mode 100644
index 00000000000..4fa3f6e71e4
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/extensions/shipping-methods/pickup-location/test/settings-context.tsx
@@ -0,0 +1,362 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { dispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * Internal dependencies
+ */
+import { SettingsProvider, useSettingsContext } from '../settings-context';
+import type { SortablePickupLocation } from '../types';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	dispatch: jest.fn(),
+} ) );
+
+jest.mock( '../utils', () => ( {
+	defaultSettings: {
+		enabled: false,
+		title: 'Pickup',
+		tax_status: 'taxable',
+		cost: '',
+	},
+	defaultReadyOnlySettings: {
+		hasLegacyPickup: false,
+		storeCountry: 'US',
+		storeState: 'CA',
+	},
+	readOnlySettings: {
+		hasLegacyPickup: false,
+		storeCountry: 'US',
+		storeState: 'CA',
+	},
+	getInitialSettings: jest.fn( () => ( {
+		enabled: false,
+		title: 'Pickup',
+		tax_status: 'none',
+		cost: '',
+	} ) ),
+	getInitialPickupLocations: jest.fn( () => [
+		{
+			id: 'warehouse-0',
+			name: 'Warehouse',
+			details: 'Rear entrance',
+			enabled: true,
+			address: {
+				address_1: '60 29th Street',
+				city: 'San Francisco',
+				state: 'CA',
+				postcode: '94110',
+				country: 'US',
+			},
+		},
+	] ),
+} ) );
+
+const mockApiFetch = apiFetch as jest.Mock;
+const mockDispatch = dispatch as jest.Mock;
+const createSuccessNotice = jest.fn();
+const createErrorNotice = jest.fn();
+
+const annex: SortablePickupLocation = {
+	id: '',
+	name: 'Annex',
+	details: 'Front desk',
+	enabled: true,
+	address: {
+		address_1: '10 Market Street',
+		city: 'San Francisco',
+		state: 'CA',
+		postcode: '94105',
+		country: 'US',
+	},
+};
+
+const replacement: SortablePickupLocation = {
+	id: 'warehouse-0',
+	name: 'Main warehouse',
+	details: 'Loading bay',
+	enabled: false,
+	address: {
+		address_1: '100 New Bridge Street',
+		city: 'London',
+		state: '',
+		postcode: 'EC4V 6JA',
+		country: 'GB',
+	},
+};
+
+const initialWarehouse: SortablePickupLocation = {
+	id: 'warehouse-0',
+	name: 'Warehouse',
+	details: 'Rear entrance',
+	enabled: true,
+	address: {
+		address_1: '60 29th Street',
+		city: 'San Francisco',
+		state: 'CA',
+		postcode: '94110',
+		country: 'US',
+	},
+};
+
+const addedAnnex: SortablePickupLocation = {
+	...annex,
+	id: 'annex-1',
+};
+
+const ContextConsumer = () => {
+	const context = useSettingsContext();
+	return (
+		<>
+			<output aria-label="Settings state">
+				{ JSON.stringify( context.settings ) }
+			</output>
+			<output aria-label="Pickup locations state">
+				{ JSON.stringify( context.pickupLocations ) }
+			</output>
+			<output aria-label="Dirty state">
+				{ String( context.isDirty ) }
+			</output>
+			<output aria-label="Saving state">
+				{ String( context.isSaving ) }
+			</output>
+			<button
+				type="button"
+				onClick={ () => context.updateLocation( 'new', annex ) }
+			>
+				Add annex
+			</button>
+			<button
+				type="button"
+				onClick={ () =>
+					context.updateLocation( 'warehouse-0', replacement )
+				}
+			>
+				Replace warehouse
+			</button>
+			<button
+				type="button"
+				onClick={ () => context.toggleLocation( 'warehouse-0' ) }
+			>
+				Toggle warehouse
+			</button>
+			<button
+				type="button"
+				onClick={ () => context.updateLocation( 'annex-1', null ) }
+			>
+				Delete annex
+			</button>
+			<button
+				type="button"
+				onClick={ () => {
+					context.setSettingField( 'enabled' )( true );
+					context.setSettingField( 'title' )( 'Curbside pickup' );
+					context.setSettingField( 'tax_status' )( 'unsupported' );
+					context.setSettingField( 'cost' )( '7.50' );
+				} }
+			>
+				Change settings
+			</button>
+			<button type="button" onClick={ context.save }>
+				Save
+			</button>
+		</>
+	);
+};
+
+const getOutput = ( name: string ) => screen.getByRole( 'status', { name } );
+
+const getPickupLocations = (): SortablePickupLocation[] =>
+	JSON.parse(
+		getOutput( 'Pickup locations state' ).textContent ?? '[]'
+	) as SortablePickupLocation[];
+
+describe( 'SettingsProvider', () => {
+	beforeEach( () => {
+		mockApiFetch.mockReset();
+		mockDispatch.mockReset();
+		createSuccessNotice.mockReset();
+		createErrorNotice.mockReset();
+		mockDispatch.mockReturnValue( {
+			createSuccessNotice,
+			createErrorNotice,
+		} );
+	} );
+
+	it( 'adds, replaces, toggles, and deletes locations while marking changes dirty', async () => {
+		const user = userEvent.setup();
+		render(
+			<SettingsProvider>
+				<ContextConsumer />
+			</SettingsProvider>
+		);
+
+		expect( getOutput( 'Dirty state' ) ).toHaveTextContent( 'false' );
+
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Add annex' } )
+			);
+		} );
+		expect( getPickupLocations() ).toEqual( [
+			initialWarehouse,
+			addedAnnex,
+		] );
+		expect( getOutput( 'Dirty state' ) ).toHaveTextContent( 'true' );
+
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Replace warehouse' } )
+			);
+		} );
+		expect( getPickupLocations() ).toEqual( [ replacement, addedAnnex ] );
+
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Toggle warehouse' } )
+			);
+		} );
+		const enabledReplacement = { ...replacement, enabled: true };
+		expect( getPickupLocations() ).toEqual( [
+			enabledReplacement,
+			addedAnnex,
+		] );
+
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Delete annex' } )
+			);
+		} );
+		expect( getPickupLocations() ).toEqual( [ enabledReplacement ] );
+	} );
+
+	it( 'normalizes settings and locations in a successful save request', async () => {
+		const user = userEvent.setup();
+		let resolveRequest: () => void;
+		mockApiFetch.mockImplementation(
+			() =>
+				new Promise( ( resolve ) => {
+					resolveRequest = () => resolve( {} );
+				} )
+		);
+		render(
+			<SettingsProvider>
+				<ContextConsumer />
+			</SettingsProvider>
+		);
+
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Change settings' } )
+			);
+		} );
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Add annex' } )
+			);
+		} );
+		expect( getOutput( 'Dirty state' ) ).toHaveTextContent( 'true' );
+
+		await act( async () => {
+			await user.click( screen.getByRole( 'button', { name: 'Save' } ) );
+		} );
+
+		expect( mockApiFetch ).toHaveBeenCalledWith( {
+			path: '/wc/v3/pickup-locations',
+			method: 'POST',
+			data: {
+				pickup_location_settings: {
+					enabled: 'yes',
+					title: 'Curbside pickup',
+					tax_status: 'taxable',
+					cost: '7.50',
+				},
+				pickup_locations: [
+					{
+						name: 'Warehouse',
+						address: {
+							address_1: '60 29th Street',
+							city: 'San Francisco',
+							state: 'CA',
+							postcode: '94110',
+							country: 'US',
+						},
+						details: 'Rear entrance',
+						enabled: true,
+					},
+					{
+						name: 'Annex',
+						address: annex.address,
+						details: 'Front desk',
+						enabled: true,
+					},
+				],
+			},
+		} );
+		expect( getOutput( 'Saving state' ) ).toHaveTextContent( 'true' );
+		expect( getOutput( 'Dirty state' ) ).toHaveTextContent( 'false' );
+
+		await act( async () => {
+			resolveRequest();
+		} );
+
+		await waitFor( () => {
+			expect( getOutput( 'Saving state' ) ).toHaveTextContent( 'false' );
+		} );
+		expect( createSuccessNotice ).toHaveBeenCalledWith(
+			'Local Pickup settings have been saved.'
+		);
+		expect( createErrorNotice ).not.toHaveBeenCalled();
+		expect( mockDispatch ).toHaveBeenCalledWith( noticesStore );
+	} );
+
+	it( 'restores dirty state and reports a rejected save', async () => {
+		const user = userEvent.setup();
+		let rejectRequest: ( reason: Error ) => void;
+		mockApiFetch.mockImplementation(
+			() =>
+				new Promise( ( resolve, reject ) => {
+					void resolve;
+					rejectRequest = reject;
+				} )
+		);
+		render(
+			<SettingsProvider>
+				<ContextConsumer />
+			</SettingsProvider>
+		);
+
+		await act( async () => {
+			await user.click(
+				screen.getByRole( 'button', { name: 'Change settings' } )
+			);
+		} );
+		await act( async () => {
+			await user.click( screen.getByRole( 'button', { name: 'Save' } ) );
+		} );
+		expect( getOutput( 'Saving state' ) ).toHaveTextContent( 'true' );
+		expect( getOutput( 'Dirty state' ) ).toHaveTextContent( 'false' );
+
+		await act( async () => {
+			rejectRequest( new Error( 'Request failed' ) );
+		} );
+
+		await waitFor( () => {
+			expect( getOutput( 'Saving state' ) ).toHaveTextContent( 'false' );
+		} );
+		expect( getOutput( 'Dirty state' ) ).toHaveTextContent( 'true' );
+		expect( createErrorNotice ).toHaveBeenCalledWith(
+			'There was an error saving your Local Pickup settings. Please try again.'
+		);
+		expect( createSuccessNotice ).not.toHaveBeenCalled();
+		expect( mockDispatch ).toHaveBeenCalledWith( noticesStore );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-local-pickup-settings b/plugins/woocommerce/client/blocks/changelog/testops-234-local-pickup-settings
new file mode 100644
index 00000000000..8f333268a3a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-local-pickup-settings
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Local Pickup settings E2E tests from 7 to 2; three Jest suites own the settings screen's logic.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/local-pickup/local-pickup.merchant.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/local-pickup/local-pickup.merchant.block_theme.spec.ts
index 2f1b1156879..bcee358ca66 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/local-pickup/local-pickup.merchant.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/local-pickup/local-pickup.merchant.block_theme.spec.ts
@@ -2,180 +2,171 @@
  * External dependencies
  */
 import { test, expect } from '@woocommerce/e2e-utils';
+import type { Page } from '@playwright/test';
+
+const saveLocalPickupSettings = async ( page: Page ) => {
+	const saveButton = page.getByRole( 'button', { name: 'Save changes' } );
+	const [ response ] = await Promise.all( [
+		page.waitForResponse(
+			( candidate ) =>
+				candidate.request().method() === 'POST' &&
+				candidate.url().includes( '/wc/v3/pickup-locations' )
+		),
+		saveButton.click(),
+	] );
+
+	expect( response.ok() ).toBeTruthy();
+	await expect( saveButton ).toBeDisabled();
+};

 test.describe( 'Merchant → Local Pickup Settings', () => {
-	test.beforeEach( async ( { localPickupUtils } ) => {
+	test.beforeEach( async ( { page, localPickupUtils } ) => {
 		await localPickupUtils.disableLocalPickupCosts();
 		await localPickupUtils.enableLocalPickup();
+		if (
+			( await page
+				.getByRole( 'textbox', { name: 'Title' } )
+				.inputValue() ) !== 'Pickup'
+		) {
+			await localPickupUtils.setLocalPickupTitle( 'Pickup' );
+		}
 	} );

-	test( 'user can toggle the enabled state', async ( {
+	test( 'Merchant can configure Local Pickup general settings', async ( {
 		page,
-		localPickupUtils,
 	} ) => {
-		await expect( page.getByLabel( 'Enable local pickup' ) ).toBeChecked();
-
-		await localPickupUtils.disableLocalPickup();
-
-		await expect(
-			page.getByLabel( 'Enable local pickup' )
-		).not.toBeChecked();
-	} );
-
-	test( 'user can change the title', async ( { page, localPickupUtils } ) => {
-		await page.getByPlaceholder( 'Pickup' ).fill( 'Local Pickup Test #1' );
-
-		await localPickupUtils.saveLocalPickupSettings();
-
-		await expect( page.getByPlaceholder( 'Pickup' ) ).toHaveValue(
-			'Local Pickup Test #1'
-		);
-
-		await page.getByPlaceholder( 'Pickup' ).fill( 'Local Pickup Test #2' );
-
-		await localPickupUtils.saveLocalPickupSettings();
-
-		await expect( page.getByPlaceholder( 'Pickup' ) ).toHaveValue(
-			'Local Pickup Test #2'
-		);
-	} );
-
-	test( 'user can toggle the price field state', async ( {
-		page,
-		localPickupUtils,
-	} ) => {
-		await localPickupUtils.enableLocalPickupCosts();
-
-		await expect(
-			page.getByLabel(
-				'Add a price for customers who choose local pickup'
-			)
-		).toBeChecked();
-
-		await localPickupUtils.disableLocalPickupCosts();
-
-		await expect(
-			page.getByLabel(
-				'Add a price for customers who choose local pickup'
-			)
-		).not.toBeChecked();
-	} );
-
-	test( 'user can edit costs and tax status', async ( {
-		page,
-		localPickupUtils,
-	} ) => {
-		await localPickupUtils.enableLocalPickupCosts();
-
-		await expect(
-			page.getByLabel(
-				'Add a price for customers who choose local pickup'
-			)
-		).toBeChecked();
-
-		await page.getByPlaceholder( 'Free' ).fill( '20' );
-		await page.getByLabel( 'Taxes' ).selectOption( 'none' );
-
-		await localPickupUtils.saveLocalPickupSettings();
-
-		await expect( page.getByPlaceholder( 'Free' ) ).toHaveValue( '20' );
-		await expect( page.getByLabel( 'Taxes' ) ).toHaveValue( 'none' );
-
-		await page.getByPlaceholder( 'Free' ).fill( '' );
-		await page.getByLabel( 'Taxes' ).selectOption( 'taxable' );
-
-		await localPickupUtils.saveLocalPickupSettings();
+		await test.step( 'Configure and save the general settings', async () => {
+			const enabled = page.getByRole( 'checkbox', {
+				name: 'Enable local pickup',
+			} );
+			const title = page.getByRole( 'textbox', { name: 'Title' } );
+			const showPrice = page.getByRole( 'checkbox', {
+				name: 'Add a price for customers who choose local pickup',
+			} );
+
+			await expect( enabled ).toBeChecked();
+			await expect( title ).toHaveValue( 'Pickup' );
+			await expect( showPrice ).not.toBeChecked();
+			await expect(
+				page.getByRole( 'spinbutton', { name: 'Cost' } )
+			).toBeHidden();
+
+			await enabled.uncheck();
+			await title.fill( 'Curbside pickup' );
+			await showPrice.check();
+
+			const cost = page.getByRole( 'spinbutton', { name: 'Cost' } );
+			const taxes = page.getByRole( 'combobox', { name: 'Taxes' } );
+			await expect( cost ).toBeVisible();
+			await expect( taxes ).toBeVisible();
+			await cost.fill( '20' );
+			await taxes.selectOption( 'none' );
+
+			await saveLocalPickupSettings( page );
+		} );

-		await expect( page.getByPlaceholder( 'Free' ) ).toHaveValue( '' );
-		await expect( page.getByLabel( 'Taxes' ) ).toHaveValue( 'taxable' );
+		await test.step( 'Reload and confirm the settings persisted', async () => {
+			await page.reload();
+
+			await expect(
+				page.getByRole( 'checkbox', { name: 'Enable local pickup' } )
+			).not.toBeChecked();
+			await expect(
+				page.getByRole( 'textbox', { name: 'Title' } )
+			).toHaveValue( 'Curbside pickup' );
+			await expect(
+				page.getByRole( 'checkbox', {
+					name: 'Add a price for customers who choose local pickup',
+				} )
+			).toBeChecked();
+			await expect(
+				page.getByRole( 'spinbutton', { name: 'Cost' } )
+			).toHaveValue( '20' );
+			await expect(
+				page.getByRole( 'combobox', { name: 'Taxes' } )
+			).toHaveValue( 'none' );
+		} );
 	} );

-	test( 'user can add a new location', async ( {
+	test( 'Merchant can manage a Local Pickup location lifecycle', async ( {
 		page,
-		localPickupUtils,
 	} ) => {
-		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 sanFranciscoLocation = page.getByRole( 'cell', {
+			name: 'Automattic, Inc.60 29th Street, Suite 343, San Francisco, California, 94110, United States (US)',
 		} );
-
-		await expect(
-			page.getByRole( 'cell', {
-				name: 'Automattic, Inc.60 29th Street, Suite 343, San Francisco, California, 94110, United States (US)',
-			} )
-		).toBeVisible();
-	} );
-
-	test( 'user can edit a location', async ( { page, localPickupUtils } ) => {
-		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 londonLocation = page.getByRole( 'cell', {
+			name: 'Ministry of Automattic Limited100 New Bridge Street, London, EC4V 6JA, United Kingdom (UK)',
 		} );
-
-		await expect(
-			page.getByRole( 'cell', {
-				name: 'Automattic, Inc.60 29th Street, Suite 343, San Francisco, California, 94110, United States (US)',
-			} )
-		).toBeVisible();
-
-		await localPickupUtils.editPickupLocation( {
-			location: {
-				name: 'Ministry of Automattic Limited',
-				address: '100 New Bridge Street',
-				city: 'London',
-				postcode: 'EC4V 6JA',
-				state: 'GB',
-				details: 'British entity',
-			},
+		const emptyLocations = page.getByRole( 'cell', {
+			name: 'When you add a pickup location, it will appear here.',
 		} );

-		await expect(
-			page.getByRole( 'cell', {
-				name: 'Ministry of Automattic Limited100 New Bridge Street, London, EC4V 6JA, United Kingdom (UK)',
-			} )
-		).toBeVisible();
-	} );
-
-	test( 'user can delete a location', async ( {
-		page,
-		localPickupUtils,
-	} ) => {
-		await localPickupUtils.addPickupLocation( {
-			location: {
-				name: 'Ausomattic Pty Ltd',
-				address:
-					'c/o Baker And Mckenzie Level 19 Cbw, 181 William Street',
-				city: 'Melbourne',
-				postcode: '300',
-				state: 'AU:VIC',
-				details: 'Australian entity',
-			},
+		await test.step( 'Add, save, and reload a pickup location', async () => {
+			await expect( emptyLocations ).toBeVisible();
+			await page
+				.getByRole( 'button', { name: 'Add pickup location' } )
+				.click();
+			await page.getByLabel( 'Location name' ).fill( 'Automattic, Inc.' );
+			await page
+				.getByRole( 'textbox', { name: 'Address' } )
+				.fill( '60 29th Street, Suite 343' );
+			await page
+				.getByRole( 'textbox', { name: 'City' } )
+				.fill( 'San Francisco' );
+			await page
+				.getByRole( 'textbox', { name: 'Postcode / ZIP' } )
+				.fill( '94110' );
+			await page
+				.getByRole( 'combobox', { name: 'Country / State' } )
+				.selectOption( 'US:CA' );
+			await page
+				.getByRole( 'textbox', { name: 'Pickup details' } )
+				.fill( 'American entity' );
+			await page.getByRole( 'button', { name: 'Done' } ).click();
+
+			await saveLocalPickupSettings( page );
+			await page.reload();
+			await expect( sanFranciscoLocation ).toBeVisible();
 		} );

-		await expect(
-			page.getByRole( 'cell', {
-				name: 'Ausomattic Pty Ltdc/o Baker And Mckenzie Level 19 Cbw, 181 William Street, Melbourne, Victoria, 300, Australia',
-			} )
-		).toBeVisible();
+		await test.step( 'Edit, save, and reload the pickup location', async () => {
+			await page.getByRole( 'button', { name: 'Edit' } ).click();
+			await page
+				.getByLabel( 'Location name' )
+				.fill( 'Ministry of Automattic Limited' );
+			await page
+				.getByRole( 'textbox', { name: 'Address' } )
+				.fill( '100 New Bridge Street' );
+			await page
+				.getByRole( 'textbox', { name: 'City' } )
+				.fill( 'London' );
+			await page
+				.getByRole( 'textbox', { name: 'Postcode / ZIP' } )
+				.fill( 'EC4V 6JA' );
+			await page
+				.getByRole( 'combobox', { name: 'Country / State' } )
+				.selectOption( 'GB' );
+			await page
+				.getByRole( 'textbox', { name: 'Pickup details' } )
+				.fill( 'British entity' );
+			await page.getByRole( 'button', { name: 'Done' } ).click();
+
+			await saveLocalPickupSettings( page );
+			await page.reload();
+			await expect( londonLocation ).toBeVisible();
+			await expect( sanFranciscoLocation ).toBeHidden();
+		} );

-		await localPickupUtils.deletePickupLocation();
+		await test.step( 'Delete, save, and reload the pickup location', async () => {
+			await page.getByRole( 'button', { name: 'Edit' } ).click();
+			await page
+				.getByRole( 'button', { name: 'Delete location' } )
+				.click();

-		await expect(
-			page.getByRole( 'cell', {
-				name: 'When you add a pickup location, it will appear here.',
-			} )
-		).toBeVisible();
+			await saveLocalPickupSettings( page );
+			await page.reload();
+			await expect( emptyLocations ).toBeVisible();
+			await expect( londonLocation ).toBeHidden();
+		} );
 	} );
 } );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
index fa682edda2d..257e8f655d2 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Shipping/PickupLocationsRestControllerTest.php
@@ -140,6 +140,87 @@ class PickupLocationsRestControllerTest extends WC_Unit_Test_Case {
 		$this->assertSame( $locations, get_option( 'pickup_location_pickup_locations' ), 'Locations should be persisted to the database.' );
 	}

+	/**
+	 * Tax statuses the endpoint accepts.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function provider_tax_status(): array {
+		return array(
+			'taxable' => array( 'taxable' ),
+			'none'    => array( 'none' ),
+		);
+	}
+
+	/**
+	 * @testdox Should round-trip every accepted tax status rather than coercing it.
+	 * @dataProvider provider_tax_status
+	 *
+	 * @param string $tax_status Tax status to save.
+	 */
+	public function test_update_settings_round_trips_tax_status( string $tax_status ): void {
+		wp_set_current_user( $this->shop_manager_id );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/v3/pickup-locations' );
+		$request->set_param(
+			'pickup_location_settings',
+			array(
+				'enabled'    => 'yes',
+				'title'      => 'Local Pickup',
+				'tax_status' => $tax_status,
+				'cost'       => '',
+			)
+		);
+
+		$response = $this->sut->update_settings( $request );
+		$data     = $response->get_data();
+
+		// `none` is both an accepted value and the fallback for a rejected one, so
+		// it only proves anything next to `taxable`: together they separate "the
+		// status was kept" from "everything collapses to one status".
+		$this->assertSame( $tax_status, $data['pickup_location_settings']['tax_status'], 'The response should echo back the tax status that was saved.' );
+		$this->assertSame( $tax_status, get_option( 'woocommerce_pickup_location_settings' )['tax_status'], 'The saved tax status should be persisted unchanged.' );
+	}
+
+	/**
+	 * @testdox Should store an empty pickup locations list rather than ignoring it.
+	 */
+	public function test_update_settings_stores_an_empty_pickup_locations_list(): void {
+		wp_set_current_user( $this->shop_manager_id );
+
+		$locations = array(
+			array(
+				'name'    => 'Main Store',
+				'address' => array(
+					'address_1' => '123 Main St',
+					'city'      => 'Anytown',
+					'state'     => 'CA',
+					'postcode'  => '90210',
+					'country'   => 'US',
+				),
+				'details' => '',
+				'enabled' => true,
+			),
+		);
+
+		$request = new \WP_REST_Request( 'POST', '/wc/v3/pickup-locations' );
+		$request->set_param( 'pickup_locations', $locations );
+		$this->sut->update_settings( $request );
+
+		$this->assertCount( 1, get_option( 'pickup_location_pickup_locations' ), 'The starting list should hold the location that was saved.' );
+
+		// Removing the last location sends an empty array. Treating that as "nothing
+		// to do" would leave the merchant with a location they deleted.
+		$request = new \WP_REST_Request( 'POST', '/wc/v3/pickup-locations' );
+		$request->set_param( 'pickup_locations', array() );
+
+		$response = $this->sut->update_settings( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( array(), $data['pickup_locations'], 'The response should echo back the empty list.' );
+		$this->assertSame( array(), get_option( 'pickup_location_pickup_locations' ), 'Saving an empty list should clear the stored locations.' );
+	}
+
 	/**
 	 * @testdox Should drop incomplete locations and default missing keys so admin hydration never hits undefined indexes.
 	 */