Commit b75fdeac453 for woocommerce

commit b75fdeac4535423a8ca6a84f98eeca720e4f5fca
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 15 23:26:45 2026 +0300

    [tests] Reduce Product Reviews block E2E tests from 13 to 4 (#68601)

    * test(blocks): Reduce Product Reviews block E2E tests from 13 to 4

    The All Reviews, Reviews by Category, and Reviews by Product blocks
    ran thirteen browser titles. Most of them sorted or offset a review
    list, or published a block with no category or product selected. That
    exercises shared sort, serialization, hydration, and request code
    that runs below the browser.

    Add Jest contracts for the shared review code: the Store API sort
    arguments, the saved data attributes and their hydration, the
    container's empty-selection guard, the Offset control's validation,
    and the HOC's request arguments. Add a PHPUnit matrix for the Store
    API reviews route's ordering and offset; the test file keeps trunk's
    two password-protection tests from #68375. Keep four browser titles:
    All Reviews inserts the block and sorts in the frontend, each
    filtered block inserts and renders a review in the editor and the
    frontend, and the category offset persists into the frontend.

    Consolidates the mega-branch slices:
    - Slice 025: test(blocks): Move Product Reviews matrices below E2E
    - Slice 086: test(blocks): Consolidate All Reviews coverage
    - test(blocks): assert review offset default
    - test(blocks): model hydrated review defaults
    - test(e2e): Expose review category state assertion
    - test(e2e): Expose review product state assertion

    Refs TESTOPS-234
    Refs #68046

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

    * test(blocks): Pair the category filter with rating order in the review matrix

    No route test combined `category_id` with a sort, so the two were only
    ever proven apart. The matrix product now belongs to a category of its
    own and the request scopes by either `product_id` or `category_id`,
    which lets a fifth row ask for rating order within a category.

    The category holds only this product, so the expected order matches the
    product-scoped rows; what the row adds is the pairing, not new ordering
    coverage. Making the route ignore `category_id` fails the new row and
    the existing category test, and leaves the other four rows green.

    Refs #68601

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

    * test(blocks): Match the Load more button by its exact accessible name

    The sort reset test found the button with `/load more/i`. The repo's
    regex rule allows a pattern only when no built-in alternative exists,
    and `findByRole` accepts an exact string.

    The Reviews block passes "Load more reviews" as the screen reader label,
    and `Label` hides the visible "Load more" with `aria-hidden`, so that is
    the accessible name. Matching it exactly also fails the test when the
    label changes, which the substring pattern did not.

    Refs #68601

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

    * test(blocks): Cover the product offset request in the review contracts

    The Reviews by Product offset E2E title was removed on this branch, and
    the Jest flow that replaced it built only a Reviews by Category element.
    A container change that dropped the offset for product blocks would
    still pass every test; the PR description listed that path as knowingly
    dropped coverage.

    Run the positive-offset flow test for both a category and a product
    element. The product case expects the hydrated `product_id` alongside
    the configured offset. Making the container pass a zero offset whenever
    a product is set fails only the new case.

    Refs #68601

    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-product-reviews-blocks b/plugins/woocommerce/changelog/testops-234-product-reviews-blocks
new file mode 100644
index 00000000000..96faf1e4ef3
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-product-reviews-blocks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Product Reviews block E2E tests from 13 to 4; Jest and a Store API PHPUnit matrix own sorting, offset, and empty-selection behavior.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/hocs/test/with-reviews.jsx b/plugins/woocommerce/client/blocks/assets/js/base/hocs/test/with-reviews.jsx
index e931f3aa09e..25df086197c 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/hocs/test/with-reviews.jsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/hocs/test/with-reviews.jsx
@@ -69,6 +69,8 @@ describe( 'withReviews Component', () => {
 		} );

 	afterEach( () => {
+		renderResult?.unmount();
+		renderResult = undefined;
 		mockUtils.getReviews.mockReset();
 		CapturedComponent.mockClear();
 		lastProps = undefined;
@@ -121,6 +123,72 @@ describe( 'withReviews Component', () => {
 		} );
 	} );

+	it.each( [
+		[
+			'category array',
+			{ categoryIds: [ 4, 8 ], productId: undefined },
+			{
+				category_id: '4,8',
+				offset: 0,
+				order: 'desc',
+				orderby: 'date_gmt',
+				per_page: 2,
+			},
+		],
+		[
+			'product',
+			{ productId: 42 },
+			{
+				offset: 0,
+				order: 'desc',
+				orderby: 'date_gmt',
+				per_page: 2,
+				product_id: 42,
+			},
+		],
+	] )( 'requests reviews for a %s filter', async ( _name, props, args ) => {
+		mockUtils.getReviews.mockResolvedValue( {
+			reviews: [],
+			totalReviews: 0,
+		} );
+
+		await renderComponent( props );
+
+		expect( mockUtils.getReviews ).toHaveBeenCalledWith( args );
+	} );
+
+	it( 'combines configured offset with appended review count', async () => {
+		mockUtils.getReviews
+			.mockResolvedValueOnce( {
+				reviews: mockReviews.slice( 0, 2 ),
+				totalReviews: 10,
+			} )
+			.mockResolvedValueOnce( {
+				reviews: mockReviews.slice( 2 ),
+				totalReviews: 10,
+			} );
+		await renderComponent( { offset: 5, reviewsToDisplay: 2 } );
+
+		await settle( () =>
+			renderResult.rerender(
+				<TestComponent
+					attributes={ {} }
+					offset={ 5 }
+					order="desc"
+					orderby="date_gmt"
+					productId={ 1 }
+					reviewsToDisplay={ 4 }
+				/>
+			)
+		);
+
+		expect( mockUtils.getReviews ).toHaveBeenNthCalledWith( 2, {
+			...defaultArgs,
+			offset: 7,
+			per_page: 2,
+		} );
+	} );
+
 	it( 'replaces reviews when the offset changes as the display count increases', async () => {
 		const { getReviews } = mockUtils;
 		getReviews.mockResolvedValue( {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/test/review-contracts.test.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/test/review-contracts.test.tsx
new file mode 100644
index 00000000000..050ed872760
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/reviews/test/review-contracts.test.tsx
@@ -0,0 +1,476 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { getSetting } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import type { ReviewBlockAttributes } from '../attributes';
+import FrontendContainerBlock from '../frontend-container-block';
+import { getSharedReviewListControls } from '../edit-utils';
+import { getDataAttrs, getReviews, getSortArgs } from '../utils';
+
+type FrontendRegistration = {
+	getProps: (
+		element: HTMLElement,
+		index: number
+	) => { attributes: Record< string, unknown > };
+};
+
+let mockFrontendRegistration: FrontendRegistration | undefined;
+
+jest.mock( '@woocommerce/base-utils', () => ( {
+	...jest.requireActual( '@woocommerce/base-utils' ),
+	renderFrontend: jest.fn( ( registration ) => {
+		mockFrontendRegistration = registration;
+	} ),
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+	...jest.requireActual( '@woocommerce/settings' ),
+	getSetting: jest
+		.fn()
+		.mockImplementation( ( setting, defaultValue ) => defaultValue ),
+} ) );
+
+jest.mock( '../utils', () => ( {
+	...jest.requireActual( '../utils' ),
+	getReviews: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/components', () => {
+	const actual = jest.requireActual( '@wordpress/components' );
+	const React = jest.requireActual( 'react' );
+
+	return {
+		...actual,
+		__experimentalInputControl: ( { label, onChange, value } ) =>
+			React.createElement( 'input', {
+				'aria-label': label,
+				onChange: ( event ) => onChange( event.target.value ),
+				value,
+			} ),
+		__experimentalNumberControl: ( {
+			__unstableStateReducer,
+			label,
+			onChange,
+			value,
+		} ) =>
+			React.createElement( 'input', {
+				'aria-label': label,
+				onChange: ( event ) => {
+					const state = __unstableStateReducer
+						? __unstableStateReducer( {
+								value: event.target.value,
+						  } )
+						: { value: event.target.value };
+					onChange( state.value );
+				},
+				type: 'number',
+				value,
+			} ),
+		__experimentalToolsPanelItem: ( { children, label } ) =>
+			React.createElement( 'section', { 'aria-label': label }, children ),
+		SelectControl: ( { label, onChange, options, value } ) =>
+			React.createElement(
+				'select',
+				{
+					'aria-label': label,
+					onChange: ( event ) => onChange( event.target.value ),
+					value,
+				},
+				options.map( ( option ) =>
+					React.createElement(
+						'option',
+						{ key: option.value, value: option.value },
+						option.label
+					)
+				)
+			),
+		ToggleControl: ( { checked, label, onChange } ) =>
+			React.createElement( 'input', {
+				'aria-label': label,
+				checked,
+				onChange,
+				type: 'checkbox',
+			} ),
+	};
+} );
+
+// Importing the frontend entrypoint registers its selector and hydration mapper.
+jest.requireActual( '../frontend' );
+
+const mockGetReviews = getReviews as jest.Mock;
+const mockGetSetting = getSetting as jest.Mock;
+
+const createAttributes = (
+	overrides: Partial< ReviewBlockAttributes > = {}
+): ReviewBlockAttributes => ( {
+	editMode: false,
+	imageType: 'reviewer',
+	orderby: 'most-recent',
+	previewReviews: null as never,
+	reviewsOnLoadMore: 2,
+	reviewsOnPageLoad: 2,
+	showLoadMore: true,
+	showOrderby: true,
+	showReviewContent: true,
+	showReviewDate: false,
+	showReviewerName: false,
+	showReviewImage: false,
+	showReviewRating: false,
+	...overrides,
+} );
+
+const createFrontendElement = (
+	className: string,
+	dataAttributes: Record< string, unknown > = {}
+) => {
+	const element = document.createElement( 'div' );
+	element.className = className;
+	Object.entries( dataAttributes ).forEach( ( [ key, value ] ) => {
+		if ( value !== undefined ) {
+			element.setAttribute( key, String( value ) );
+		}
+	} );
+	return element;
+};
+
+const getHydratedAttributes = ( element: HTMLElement ) => {
+	if ( ! mockFrontendRegistration ) {
+		throw new Error( 'Reviews frontend was not registered.' );
+	}
+
+	return {
+		...element.dataset,
+		...mockFrontendRegistration.getProps( element, 0 ).attributes,
+	};
+};
+
+const reviews = [
+	{ id: 1, review: 'First review' },
+	{ id: 2, review: 'Second review' },
+	{ id: 3, review: 'Third review' },
+	{ id: 4, review: 'Fourth review' },
+];
+
+describe( 'Product Reviews contracts', () => {
+	afterEach( () => {
+		jest.clearAllMocks();
+		mockGetSetting.mockImplementation(
+			( setting, defaultValue ) => defaultValue
+		);
+	} );
+
+	describe( 'sorting', () => {
+		it.each( [
+			[ 'most recent', 'most-recent', true, 'desc', 'date_gmt' ],
+			[ 'highest rating', 'highest-rating', true, 'desc', 'rating' ],
+			[ 'lowest rating', 'lowest-rating', true, 'asc', 'rating' ],
+			[
+				'ratings disabled fallback',
+				'lowest-rating',
+				false,
+				'desc',
+				'date_gmt',
+			],
+		] )(
+			'maps %s to the Store API order',
+			( _name, value, ratingsEnabled, order, orderby ) => {
+				mockGetSetting.mockImplementation( ( setting, defaultValue ) =>
+					setting === 'reviewRatingsEnabled'
+						? ratingsEnabled
+						: defaultValue
+				);
+
+				expect( getSortArgs( value ) ).toEqual( { order, orderby } );
+			}
+		);
+
+		it( 'resets an appended list and requests the lowest ratings first', async () => {
+			const user = userEvent.setup();
+			mockGetReviews
+				.mockResolvedValueOnce( {
+					reviews: reviews.slice( 0, 2 ),
+					totalReviews: 4,
+				} )
+				.mockResolvedValueOnce( {
+					reviews: reviews.slice( 2 ),
+					totalReviews: 4,
+				} )
+				.mockResolvedValueOnce( {
+					reviews: reviews.slice( 0, 2 ),
+					totalReviews: 4,
+				} );
+
+			render(
+				<FrontendContainerBlock
+					attributes={ createAttributes( { categoryIds: [ 7 ] } ) }
+				/>
+			);
+
+			await waitFor( () =>
+				expect( mockGetReviews ).toHaveBeenCalledTimes( 1 )
+			);
+			await user.click(
+				await screen.findByRole( 'button', {
+					name: 'Load more reviews',
+				} )
+			);
+			await waitFor( () =>
+				expect( mockGetReviews ).toHaveBeenCalledTimes( 2 )
+			);
+			expect( mockGetReviews ).toHaveBeenNthCalledWith( 2, {
+				category_id: '7',
+				offset: 2,
+				order: 'desc',
+				orderby: 'date_gmt',
+				per_page: 2,
+			} );
+
+			await user.selectOptions(
+				screen.getByRole( 'combobox', { name: 'Order reviews by' } ),
+				'lowest-rating'
+			);
+			await waitFor( () =>
+				expect( mockGetReviews ).toHaveBeenCalledTimes( 3 )
+			);
+			expect( mockGetReviews ).toHaveBeenNthCalledWith( 3, {
+				category_id: '7',
+				offset: 0,
+				order: 'asc',
+				orderby: 'rating',
+				per_page: 2,
+			} );
+		} );
+	} );
+
+	describe( 'serialization and hydration', () => {
+		it.each( [
+			[
+				'category IDs',
+				{ categoryIds: [ 12, 34 ] },
+				{ 'data-category-ids': '12,34' },
+			],
+			[ 'product ID', { productId: 56 }, { 'data-product-id': 56 } ],
+			[
+				'empty category IDs',
+				{ categoryIds: [] },
+				{ 'data-category-ids': '' },
+			],
+		] )( 'serializes %s', ( _name, selection, expected ) => {
+			expect(
+				getDataAttrs( createAttributes( selection ) )
+			).toMatchObject( expected );
+		} );
+
+		it.each( [
+			[ 'positive integer', 4, 4 ],
+			[ 'numeric positive integer', '6', 6 ],
+			[ 'zero', 0, undefined ],
+			[ 'negative integer', -1, undefined ],
+			[ 'fraction', 1.5, undefined ],
+			[ 'non-numeric', 'invalid', undefined ],
+		] )( 'serializes a %s offset', ( _name, offset, expected ) => {
+			expect(
+				getDataAttrs( createAttributes( { offset } ) )[ 'data-offset' ]
+			).toBe( expected );
+		} );
+
+		it.each( [
+			[
+				'category block',
+				'wp-block-woocommerce-reviews-by-category has-content',
+				{ 'data-category-ids': '7', 'data-offset': '3' },
+				{ categoryIds: '7', isFilteredReviewsBlock: true, offset: 3 },
+			],
+			[
+				'product block',
+				'wp-block-woocommerce-reviews-by-product',
+				{ 'data-product-id': '9', 'data-offset': '-1' },
+				{ productId: '9', isFilteredReviewsBlock: true, offset: 0 },
+			],
+			[
+				'all reviews block',
+				'wp-block-woocommerce-all-reviews',
+				{ 'data-offset': '1.5' },
+				{ isFilteredReviewsBlock: false, offset: 0 },
+			],
+		] )(
+			'hydrates the %s identity and offset',
+			( _name, className, dataAttributes, expected ) => {
+				const element = createFrontendElement(
+					className,
+					dataAttributes
+				);
+
+				expect( getHydratedAttributes( element ) ).toMatchObject(
+					expected
+				);
+			}
+		);
+
+		it.each( [
+			'wp-block-woocommerce-reviews-by-category',
+			'wp-block-woocommerce-reviews-by-product',
+		] )( 'does not request reviews for an empty %s', ( className ) => {
+			mockGetReviews.mockResolvedValue( {
+				reviews: [],
+				totalReviews: 0,
+			} );
+			const element = createFrontendElement( className );
+			const { container } = render(
+				<FrontendContainerBlock
+					attributes={ createAttributes(
+						getHydratedAttributes(
+							element
+						) as Partial< ReviewBlockAttributes >
+					) }
+				/>
+			);
+
+			expect( container ).toBeEmptyDOMElement();
+			expect( mockGetReviews ).not.toHaveBeenCalled();
+		} );
+
+		it( 'does not request reviews for a saved empty category selection', () => {
+			mockGetReviews.mockResolvedValue( {
+				reviews: [],
+				totalReviews: 0,
+			} );
+			const attributes = createAttributes( { categoryIds: [] } );
+			const element = createFrontendElement(
+				'wp-block-woocommerce-reviews-by-category',
+				getDataAttrs( attributes )
+			);
+			const { container } = render(
+				<FrontendContainerBlock
+					attributes={ createAttributes(
+						getHydratedAttributes(
+							element
+						) as Partial< ReviewBlockAttributes >
+					) }
+				/>
+			);
+
+			expect( container ).toBeEmptyDOMElement();
+			expect( mockGetReviews ).not.toHaveBeenCalled();
+		} );
+	} );
+
+	describe( 'Offset control', () => {
+		it( 'renders a zero default offset', () => {
+			render(
+				<>
+					{ getSharedReviewListControls(
+						createAttributes(),
+						jest.fn(),
+						{ showOffset: true }
+					) }
+				</>
+			);
+
+			expect(
+				screen.getByRole( 'spinbutton', { name: 'Offset' } )
+			).toHaveValue( 0 );
+		} );
+
+		it.each( [
+			[ 'zero', '0', { offset: 0 } ],
+			[ 'a positive integer', '8', { offset: 8 } ],
+			[ 'negative zero', '-0', { offset: 0 } ],
+			[ 'an empty value', '', undefined ],
+			[ 'a fractional value', '1.5', undefined ],
+			[ 'a negative integer', '-2', undefined ],
+		] )( 'accepts or rejects %s', ( _name, value, expected ) => {
+			const setAttributes = jest.fn();
+			render(
+				<>
+					{ getSharedReviewListControls(
+						createAttributes( { offset: 1 } ),
+						setAttributes,
+						{ showOffset: true }
+					) }
+				</>
+			);
+
+			fireEvent.change(
+				screen.getByRole( 'spinbutton', { name: 'Offset' } ),
+				{ target: { value } }
+			);
+
+			expect( setAttributes.mock.calls ).toEqual(
+				expected ? [ [ expected ] ] : []
+			);
+		} );
+
+		it.each( [
+			[
+				'category',
+				'wp-block-woocommerce-reviews-by-category',
+				{ categoryIds: [ 9 ] },
+				{ category_id: 9 },
+			],
+			[
+				'product',
+				'wp-block-woocommerce-reviews-by-product',
+				{ productId: 9 },
+				{ product_id: '9' },
+			],
+		] )(
+			'flows a positive value through serialization, hydration, and the %s review request',
+			async ( _name, className, selection, filterArgs ) => {
+				const setAttributes = jest.fn();
+				const attributes = createAttributes( selection );
+				const controls = render(
+					<>
+						{ getSharedReviewListControls(
+							attributes,
+							setAttributes,
+							{
+								showOffset: true,
+							}
+						) }
+					</>
+				);
+
+				fireEvent.change(
+					screen.getByRole( 'spinbutton', { name: 'Offset' } ),
+					{ target: { value: '5' } }
+				);
+				expect( setAttributes ).toHaveBeenCalledWith( { offset: 5 } );
+				controls.unmount();
+
+				const serialized = getDataAttrs( { ...attributes, offset: 5 } );
+				const element = createFrontendElement( className, serialized );
+				mockGetReviews.mockResolvedValue( {
+					reviews: reviews.slice( 0, 2 ),
+					totalReviews: 4,
+				} );
+
+				render(
+					<FrontendContainerBlock
+						attributes={
+							getHydratedAttributes(
+								element
+							) as ReviewBlockAttributes
+						}
+					/>
+				);
+
+				await waitFor( () =>
+					expect( mockGetReviews ).toHaveBeenCalledWith( {
+						...filterArgs,
+						offset: 5,
+						order: 'desc',
+						orderby: 'date_gmt',
+						per_page: 2,
+					} )
+				);
+			}
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-product-reviews-blocks b/plugins/woocommerce/client/blocks/changelog/testops-234-product-reviews-blocks
new file mode 100644
index 00000000000..96faf1e4ef3
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-product-reviews-blocks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Product Reviews block E2E tests from 13 to 4; Jest and a Store API PHPUnit matrix own sorting, offset, and empty-selection behavior.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/all-reviews/all-reviews.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/all-reviews/all-reviews.block_theme.spec.ts
index 8d76c2a5d3d..5538b8c3c68 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/all-reviews/all-reviews.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/all-reviews/all-reviews.block_theme.spec.ts
@@ -26,61 +26,33 @@ test.describe( `${ BLOCK_NAME } Block`, () => {
 		await editor.insertBlock( { name: BLOCK_NAME } );
 	} );

-	test( 'block can be inserted and it sorts reviews by most recent by default', async ( {
+	test( 'block can be inserted and sorts reviews in the frontend', async ( {
 		frontendUtils,
 		editor,
 	} ) => {
 		await expect(
 			editor.canvas.getByText( allReviews[ 0 ].review )
 		).toBeVisible();
-
-		await editor.publishAndVisitPost();
-
-		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-		const reviews = block.locator(
-			'.wc-block-components-review-list-item__text'
-		);
-
-		await expect( reviews.first() ).toHaveText( latestReview.review );
-	} );
-
-	test( 'can sort by highest rating in the frontend', async ( {
-		page,
-		frontendUtils,
-		editor,
-	} ) => {
-		await editor.publishAndVisitPost();
-
-		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-		const reviews = block.locator(
-			'.wc-block-components-review-list-item__text'
-		);
-
-		await expect( reviews.first() ).toHaveText( latestReview.review );
-
-		const select = page.getByLabel( 'Order by' );
-		await select.selectOption( 'Highest rating' );
-
-		await expect( reviews.first() ).toHaveText( highestRating.review );
-	} );
-
-	test( 'can sort by lowest rating in the frontend', async ( {
-		page,
-		frontendUtils,
-		editor,
-	} ) => {
 		await editor.publishAndVisitPost();

 		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
 		const reviews = block.locator(
 			'.wc-block-components-review-list-item__text'
 		);
+		const select = block.getByLabel( 'Order by' );

-		await expect( reviews.first() ).toHaveText( latestReview.review );
+		await test.step( 'Most recent by default', async () => {
+			await expect( reviews.first() ).toHaveText( latestReview.review );
+		} );

-		const select = page.getByLabel( 'Order by' );
-		await select.selectOption( 'Lowest rating' );
+		await test.step( 'Highest rating', async () => {
+			await select.selectOption( 'Highest rating' );
+			await expect( reviews.first() ).toHaveText( highestRating.review );
+		} );

-		await expect( reviews.first() ).toHaveText( lowestRating.review );
+		await test.step( 'Lowest rating', async () => {
+			await select.selectOption( 'Lowest rating' );
+			await expect( reviews.first() ).toHaveText( lowestRating.review );
+		} );
 	} );
 } );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-category/reviews-by-category.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-category/reviews-by-category.block_theme.spec.ts
index 09e686b50bb..77a39850442 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-category/reviews-by-category.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-category/reviews-by-category.block_theme.spec.ts
@@ -6,112 +6,42 @@ import { expect, test } from '@woocommerce/e2e-utils';
 /**
  * Internal dependencies
  */
-import { hoodieReviews, allReviews } from '../../../test-data/blocks/data/data';
-
-const latestReview = hoodieReviews[ hoodieReviews.length - 1 ];
-
-const highestRating = [ ...hoodieReviews ].sort(
-	( a, b ) => b.rating - a.rating
-)[ 0 ];
-
-const lowestRating = [ ...hoodieReviews ].sort(
-	( a, b ) => a.rating - b.rating
-)[ 0 ];
+import { allReviews, hoodieReviews } from '../../../test-data/blocks/data/data';

 const BLOCK_NAME = 'woocommerce/reviews-by-category';

 test.describe( `${ BLOCK_NAME } Block`, () => {
-	test.describe( 'with a category selected', () => {
-		test.beforeEach( async ( { admin, editor } ) => {
-			await admin.createNewPost();
-			await editor.insertBlock( { name: BLOCK_NAME } );
-
-			const blockLocator = await editor.getBlockByName( BLOCK_NAME );
-			const categoryCheckbox = blockLocator.getByRole( 'checkbox', {
-				name: 'Clothing',
-				exact: true,
-			} );
-			await categoryCheckbox.check();
-			await expect( categoryCheckbox ).toBeChecked();
-
-			await blockLocator.getByRole( 'button', { name: 'Done' } ).click();
-		} );
-
-		test( 'block can be inserted and it successfully renders a review in the editor and the frontend', async ( {
-			page,
-			editor,
-		} ) => {
-			await expect(
-				editor.canvas.getByText( hoodieReviews[ 0 ].review )
-			).toBeVisible();
-
-			await editor.publishAndVisitPost();
-
-			await expect(
-				page.getByText( hoodieReviews[ 0 ].review )
-			).toBeVisible();
-		} );
-
-		test( 'sorts by most recent review by default and can sort by highest rating', async ( {
-			page,
-			frontendUtils,
-			editor,
-		} ) => {
-			await editor.publishAndVisitPost();
-
-			const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-
-			const reviews = block.locator(
-				'.wc-block-components-review-list-item__text'
-			);
-
-			await expect( reviews.first() ).toHaveText( latestReview.review );
-
-			const select = page.getByLabel( 'Order by' );
-			await select.selectOption( 'Highest rating' );
-
-			await expect( reviews.first() ).toHaveText( highestRating.review );
-		} );
-
-		test( 'can sort by lowest rating', async ( {
-			page,
-			frontendUtils,
-			editor,
-		} ) => {
-			await editor.publishAndVisitPost();
-
-			const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-
-			const reviews = block.locator(
-				'.wc-block-components-review-list-item__text'
-			);
-
-			await expect( reviews.first() ).toHaveText( latestReview.review );
-
-			const select = page.getByLabel( 'Order by' );
-			await select.selectOption( 'Lowest rating' );
-
-			await expect( reviews.first() ).toHaveText( lowestRating.review );
-		} );
-	} );
-
-	test( 'renders no reviews on the frontend when published without a category selected', async ( {
+	test( 'block can be inserted and it successfully renders a review in the editor and the frontend', async ( {
+		page,
 		admin,
 		editor,
-		frontendUtils,
 	} ) => {
 		await admin.createNewPost();
 		await editor.insertBlock( { name: BLOCK_NAME } );
-		await editor.publishAndVisitPost();

-		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
+		const blockLocator = await editor.getBlockByName( BLOCK_NAME );
+		const categoryCheckbox = blockLocator.getByRole( 'checkbox', {
+			name: 'Clothing',
+			exact: true,
+		} );
+		await categoryCheckbox.click();
+		await expect( categoryCheckbox ).toBeChecked();
+
+		await blockLocator.getByRole( 'button', { name: 'Done' } ).click();
+		await expect(
+			editor.canvas.getByText( hoodieReviews[ 0 ].review )
+		).toBeVisible();
+
+		await editor.publishAndVisitPost();

 		await expect(
-			block.locator( '.wc-block-components-review-list-item__text' )
-		).toHaveCount( 0 );
+			page
+				.locator( '.wp-block-woocommerce-reviews-by-category' )
+				.getByText( hoodieReviews[ 0 ].review )
+		).toBeVisible();
 	} );

-	test( 'can skip reviews with an offset and load subsequent reviews', async ( {
+	test( 'Category offset persists into frontend review results', async ( {
 		page,
 		admin,
 		frontendUtils,
@@ -129,11 +59,7 @@ test.describe( `${ BLOCK_NAME } Block`, () => {
 		await blockLocator
 			.getByRole( 'checkbox', { name: /Accessories/ } )
 			.check();
-		await blockLocator
-			.getByRole( 'button', {
-				name: 'Done',
-			} )
-			.click();
+		await blockLocator.getByRole( 'button', { name: 'Done' } ).click();

 		await editor.openDocumentSettingsSidebar();
 		const sidebarSettings = page.getByRole( 'region', {
@@ -158,7 +84,6 @@ test.describe( `${ BLOCK_NAME } Block`, () => {
 		await expect(
 			editor.canvas.getByText( allReviews[ 3 ].review )
 		).toBeVisible();
-
 		await expect(
 			editor.canvas.getByText( allReviews[ 4 ].review )
 		).toBeHidden();
@@ -166,7 +91,6 @@ test.describe( `${ BLOCK_NAME } Block`, () => {
 		await editor.publishAndVisitPost();

 		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-
 		await expect( block.getByText( allReviews[ 0 ].review ) ).toBeVisible();
 		await expect( block.getByText( allReviews[ 1 ].review ) ).toBeVisible();
 		await expect( block.getByText( allReviews[ 2 ].review ) ).toBeVisible();
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-product/reviews-by-product.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-product/reviews-by-product.block_theme.spec.ts
index 3973bcda605..0f5801cef39 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-product/reviews-by-product.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/reviews-by-product/reviews-by-product.block_theme.spec.ts
@@ -10,152 +10,32 @@ import { hoodieReviews } from '../../../test-data/blocks/data/data';

 const BLOCK_NAME = 'woocommerce/reviews-by-product';

-const latestReview = hoodieReviews[ hoodieReviews.length - 1 ];
-
-const highestRating = [ ...hoodieReviews ].sort(
-	( a, b ) => b.rating - a.rating
-)[ 0 ];
-
-const lowestRating = [ ...hoodieReviews ].sort(
-	( a, b ) => a.rating - b.rating
-)[ 0 ];
-
 test.describe( `${ BLOCK_NAME } Block`, () => {
-	test.describe( 'with a product selected', () => {
-		test.beforeEach( async ( { admin, editor } ) => {
-			await admin.createNewPost();
-			await editor.insertBlock( { name: BLOCK_NAME } );
-
-			const productCheckbox = editor.canvas.getByLabel(
-				'Hoodie, has 3 reviews'
-			);
-			await productCheckbox.check();
-			await expect( productCheckbox ).toBeChecked();
-
-			await editor.canvas.getByRole( 'button', { name: 'Done' } ).click();
-		} );
-
-		test( 'block can be inserted and it successfully renders a review in the editor and the frontend', async ( {
-			page,
-			editor,
-		} ) => {
-			await expect(
-				editor.canvas.getByText( hoodieReviews[ 0 ].review )
-			).toBeVisible();
-
-			await editor.publishAndVisitPost();
-
-			await expect(
-				page.getByText( hoodieReviews[ 0 ].review )
-			).toBeVisible();
-		} );
-
-		test( 'sorts by most recent by default and can sort by highest rating', async ( {
-			page,
-			frontendUtils,
-			editor,
-		} ) => {
-			await editor.publishAndVisitPost();
-			const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-
-			const reviews = block.locator(
-				'.wc-block-components-review-list-item__text'
-			);
-
-			await expect( reviews.first() ).toHaveText( latestReview.review );
-
-			const select = page.getByLabel( 'Order by' );
-			await select.selectOption( 'Highest rating' );
-
-			await expect( reviews.first() ).toHaveText( highestRating.review );
-		} );
-
-		test( 'can sort by lowest rating', async ( {
-			page,
-			frontendUtils,
-			editor,
-		} ) => {
-			await editor.publishAndVisitPost();
-			const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-
-			const reviews = block.locator(
-				'.wc-block-components-review-list-item__text'
-			);
-
-			await expect( reviews.first() ).toHaveText( latestReview.review );
-
-			const select = page.getByLabel( 'Order by' );
-			await select.selectOption( 'Lowest rating' );
-
-			await expect( reviews.first() ).toHaveText( lowestRating.review );
-		} );
-	} );
-
-	test( 'renders no reviews on the frontend when published without a product selected', async ( {
-		admin,
-		editor,
-		frontendUtils,
-	} ) => {
-		await admin.createNewPost();
-		await editor.insertBlock( { name: BLOCK_NAME } );
-		await editor.publishAndVisitPost();
-
-		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-
-		await expect(
-			block.locator( '.wc-block-components-review-list-item__text' )
-		).toHaveCount( 0 );
-	} );
-
-	test( 'can skip reviews with an offset in the editor and frontend', async ( {
+	test( 'block can be inserted and it successfully renders a review in the editor and the frontend', async ( {
 		page,
 		admin,
-		frontendUtils,
 		editor,
 	} ) => {
 		await admin.createNewPost();
 		await editor.insertBlock( { name: BLOCK_NAME } );
+
 		const productCheckbox = editor.canvas.getByLabel(
 			'Hoodie, has 3 reviews'
 		);
-		await productCheckbox.check();
-		await editor.canvas
-			.getByRole( 'button', {
-				name: 'Done',
-			} )
-			.click();
-
-		await editor.openDocumentSettingsSidebar();
-		const sidebarSettings = page.getByRole( 'region', {
-			name: 'Editor settings',
-		} );
-
-		await sidebarSettings
-			.getByRole( 'spinbutton', { name: 'Offset' } )
-			.fill( '1' );
+		await productCheckbox.click();
+		await expect( productCheckbox ).toBeChecked();

+		await editor.canvas.getByRole( 'button', { name: 'Done' } ).click();
 		await expect(
 			editor.canvas.getByText( hoodieReviews[ 0 ].review )
 		).toBeVisible();
-		await expect(
-			editor.canvas.getByText( hoodieReviews[ 1 ].review )
-		).toBeVisible();
-		await expect(
-			editor.canvas.getByText( hoodieReviews[ 2 ].review )
-		).toBeHidden();

 		await editor.publishAndVisitPost();

-		const block = await frontendUtils.getBlockByName( BLOCK_NAME );
-
 		await expect(
-			block.getByText( hoodieReviews[ 0 ].review )
+			page
+				.locator( '.wp-block-woocommerce-reviews-by-product' )
+				.getByText( hoodieReviews[ 0 ].review )
 		).toBeVisible();
-		await expect(
-			block.getByText( hoodieReviews[ 1 ].review )
-		).toBeVisible();
-		await expect(
-			block.getByText( hoodieReviews[ 2 ].review )
-		).toBeHidden();
 	} );
 } );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php
index b566d837ca9..d1e40e86afd 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php
@@ -217,6 +217,124 @@ class ProductReviews extends ControllerTestCase {
 		$this->assertSame( 5, $data[0]['rating'] );
 	}

+	/**
+	 * @testdox Reviews are returned in the requested sort order and respect offset.
+	 * @dataProvider get_items_sort_and_offset_data
+	 *
+	 * @param string $orderby Review field used for sorting.
+	 * @param string $order Sort direction.
+	 * @param int    $offset Number of reviews to skip.
+	 * @param array  $expected_contents Expected review contents in response order.
+	 * @param string $filter_by Whether to scope the request by `product_id` or `category_id`.
+	 */
+	public function test_get_items_sort_and_offset_matrix( string $orderby, string $order, int $offset, array $expected_contents, string $filter_by = 'product' ): void {
+		$fixtures = new FixtureData();
+		$category = $fixtures->get_product_category(
+			array(
+				'name' => 'Review ordering category',
+			)
+		);
+		$product  = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Review ordering product',
+				'regular_price' => 20,
+				'category_ids'  => array( $category['term_id'] ),
+			)
+		);
+
+		$fixtures->add_product_review(
+			$product->get_id(),
+			1,
+			'Oldest one-star review',
+			array(
+				'comment_date'     => '2024-01-01 09:00:00',
+				'comment_date_gmt' => '2024-01-01 09:00:00',
+			)
+		);
+		$fixtures->add_product_review(
+			$product->get_id(),
+			5,
+			'Middle five-star review',
+			array(
+				'comment_date'     => '2024-01-02 09:00:00',
+				'comment_date_gmt' => '2024-01-02 09:00:00',
+			)
+		);
+		$fixtures->add_product_review(
+			$product->get_id(),
+			3,
+			'Newest three-star review',
+			array(
+				'comment_date'     => '2024-01-03 09:00:00',
+				'comment_date_gmt' => '2024-01-03 09:00:00',
+			)
+		);
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/reviews' );
+		if ( 'category' === $filter_by ) {
+			// The category holds only this product, so the expected order is the same
+			// as the product-scoped rows. Sorting and filtering are resolved by
+			// different parts of the query, and nothing else pairs them.
+			$request->set_param( 'category_id', (string) $category['term_id'] );
+		} else {
+			$request->set_param( 'product_id', (string) $product->get_id() );
+		}
+		$request->set_param( 'per_page', 10 );
+		$request->set_param( 'orderby', $orderby );
+		$request->set_param( 'order', $order );
+		$request->set_param( 'offset', $offset );
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status(), 'Unexpected status code.' );
+		$this->assertSame(
+			array_map( 'wpautop', $expected_contents ),
+			wp_list_pluck( $response->get_data(), 'review' ),
+			'Reviews were not returned in the requested order.'
+		);
+		$this->assertSame( 3, $response->get_headers()['X-WP-Total'], 'Unexpected total review count.' );
+	}
+
+	/**
+	 * Data provider for review sort and offset requests.
+	 *
+	 * @return array<string, array{string, string, int, string[], 3?: string}>
+	 */
+	public function get_items_sort_and_offset_data(): array {
+		return array(
+			'recent'                                => array(
+				'date_gmt',
+				'desc',
+				0,
+				array( 'Newest three-star review', 'Middle five-star review', 'Oldest one-star review' ),
+			),
+			'rating ascending'                      => array(
+				'rating',
+				'asc',
+				0,
+				array( 'Oldest one-star review', 'Newest three-star review', 'Middle five-star review' ),
+			),
+			'rating descending'                     => array(
+				'rating',
+				'desc',
+				0,
+				array( 'Middle five-star review', 'Newest three-star review', 'Oldest one-star review' ),
+			),
+			'recent with offset'                    => array(
+				'date_gmt',
+				'desc',
+				1,
+				array( 'Middle five-star review', 'Oldest one-star review' ),
+			),
+			'rating descending, scoped by category' => array(
+				'rating',
+				'desc',
+				0,
+				array( 'Middle five-star review', 'Newest three-star review', 'Oldest one-star review' ),
+				'category',
+			),
+		);
+	}
+
 	/**
 	 * Test getting reviews from a specific product.
 	 */