Commit 3724cacfaa6 for woocommerce
commit 3724cacfaa6c2ae3700c79b046cdc4bdc837b6ca
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Tue May 12 14:44:00 2026 +0300
Add AJAX submission handler for the Review Order form (#64527)
* Add accessible star-rating control for Review Order page
Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
visually replaced by SVG stars. Progressive enhancement adds:
- Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
moves focus and dispatches `change`, so any future submission listener
reacts naturally.
- Dynamic caption underneath the stars, populated from the focused/checked
radio's `data-label`. Defaults match the existing WC product-review
labels (Very poor / Not that bad / Average / Good / Perfect) and are
filterable via `woocommerce_review_order_rating_labels`.
- High-contrast focus ring on the wrapping label whenever its input is
`:focus-visible`.
- Hover preview lights stars left of the cursor; selection persists when
the cursor leaves.
Without JavaScript the underlying radio group still works as a plain
form field, so the page stays functional.
Files:
- `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
Builds the markup and exposes `get_labels()` (filter-aware, with safe
fallbacks for buggy filters that drop slot keys).
- `templates/order/star-rating.php` — theme-overridable partial.
- `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
- `client/legacy/css/order-review.scss` — visual states.
- `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
enqueued only when the review-order endpoint is actually rendering.
- `templates/order/customer-review-order.php` — temporary preview render
per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
swaps this preview for the real per-item form row.
Tests:
- `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
override, filter fallback for missing keys.
Closes #64308 (WOOPLUG-6594).
* Add changefile(s) from automation for the following project(s): woocommerce
* Remove duplicate changelog file (workflow generates the canonical one)
* Address Copilot feedback on star-rating control PR
- Route enqueue URLs through woocommerce_get_asset_url filter so
CDN/asset-rewrite setups continue to work.
- Add screen-reader-only 'Required' text alongside the visual asterisk
on the rating label.
- Correct the JS file header comment: only Arrow keys + Home/End are
handled, not Space/Enter.
* Fix StarRating docblock to use flat @param syntax
The PHPDoc shape syntax (array{...}) trips the WC PHPCS sniff, which
expects a single @param line per parameter. Reverted to plain
@param array with the key descriptions in the body.
* Drop the planning comment + minor tidy on star-rating styles
* Load the RTL stylesheet variant on RTL sites
wp_style_add_data( 'wc-order-review', 'rtl', 'replace' ) tells the
classic-assets pipeline to swap order-review.css for order-review-rtl.css
on right-to-left locales.
* Trim defensive fallbacks and dedupe asset enqueue helpers
- StarRating::get_labels(): replace the manual 1-5 fallback loop and
reorder block with array_replace + array_intersect_key.
- StarRating::render(): drop the unused $selected pre-cast; read it
inline only when it's used.
- Endpoint::enqueue_assets(): factor a local closure for the
woocommerce_get_asset_url filter so we don't repeat the same call
for the style and the script.
- order/star-rating.php template: drop the data-caption-id attribute
that duplicated aria-describedby.
- order-review.js: read the caption id from aria-describedby and drop
the empty-input early-out (init() already filters by class).
No behaviour change. All six StarRating tests still pass.
* Address review feedback on the star-rating control
- Add @since 10.8.0 annotations on StarRating::render() and
StarRating::get_labels() docblocks (per ayushpahwa).
- Bound the `selected` argument: values outside 0-5 fall back to
no-selection rather than passing an unrenderable value to the
template (per ayushpahwa).
- Cover the bound with a regression test.
* Address remaining review feedback on the star-rating control
- CSS: drop every `:has()` selector. The template now renders the
inputs in reverse DOM order (5..1) and uses `flex-direction:
row-reverse`; selected/hover/focus states are driven by the `~`
sibling combinator and `:focus-visible + label`. Caption gets
`order: -1` so it stays on the visual right.
- JS: keyboard navigation flips so ArrowRight/Down still moves
visually right (DOM-previous) and Home/End map to the visual
leftmost/rightmost stars.
- Template: pre-compute `$initial_caption` so the caption element no
longer mixes selection logic, escaping, and output in a single
expression.
- Endpoint: enqueue the JS with `strategy => defer` (in addition to
`in_footer => true`) since it only attaches DOM-ready listeners.
* Add accessible star-rating control for Review Order page
Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
visually replaced by SVG stars. Progressive enhancement adds:
- Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
moves focus and dispatches `change`, so any future submission listener
reacts naturally.
- Dynamic caption underneath the stars, populated from the focused/checked
radio's `data-label`. Defaults match the existing WC product-review
labels (Very poor / Not that bad / Average / Good / Perfect) and are
filterable via `woocommerce_review_order_rating_labels`.
- High-contrast focus ring on the wrapping label whenever its input is
`:focus-visible`.
- Hover preview lights stars left of the cursor; selection persists when
the cursor leaves.
Without JavaScript the underlying radio group still works as a plain
form field, so the page stays functional.
Files:
- `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
Builds the markup and exposes `get_labels()` (filter-aware, with safe
fallbacks for buggy filters that drop slot keys).
- `templates/order/star-rating.php` — theme-overridable partial.
- `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
- `client/legacy/css/order-review.scss` — visual states.
- `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
enqueued only when the review-order endpoint is actually rendering.
- `templates/order/customer-review-order.php` — temporary preview render
per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
swaps this preview for the real per-item form row.
Tests:
- `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
override, filter fallback for missing keys.
Closes #64308 (WOOPLUG-6594).
* Add per-item review form row and submit gate
Replaces the temporary preview render from #64525 with the real per-item
form that the Customer Review Request page submits to. Submission handler
itself lands in M4 (WOOPLUG-6596).
`templates/order/customer-review-order-row.php` (new, theme-overridable):
- Linked product title that opens the product page in a new tab.
- 120×120 thumbnail.
- Hidden `product_id` and `order_item_id` so the row is identifiable
on submit.
- The accessible star-rating control from #64525, indexed by `row_index`
for stable POST data.
- Textarea labelled "Your review" with the i18n placeholder
"Share your experience with this product...".
- `do_action( 'woocommerce_review_order_form_fields', $item, $product,
$order, $row_index )` after the textarea so extensions can inject
fields without overriding the whole template.
`templates/order/customer-review-order.php`:
- Wraps the item list in `<form class="woocommerce-review-order__form"
method="post" action="">` with a hidden `order_id`, hidden `key`
(echoed back from the URL the Endpoint already validated), and the
`woocommerce_submit_order_reviews` nonce.
- "Submit reviews" button rendered disabled by default. The JS module
enables it as soon as any row has a rating selected.
`client/legacy/js/frontend/order-review.js` gains `initSubmitGate(form)`,
which listens for `change` events on `.woocommerce-star-rating__input`
inside the form and toggles the button's `disabled` state.
- StarRating's docblock simplified to plain `@param array` so the WC
PHPCS sniffs don't misparse the structured array shorthand.
Closes #64309 (WOOPLUG-6595).
* Add changefile(s) from automation for the following project(s): woocommerce
* Drop M-numbered milestone references from per-item row templates
* Strip non-layout styling from per-item row; let theme drive typography
* Stack the per-item row vertically on viewports under 600px
Folded in from the (now-closed) design-tokens-responsive PR; the only
contribution that survived the simplification was the 12-line media
query, which fits naturally with the per-item layout introduced here.
* Add changefile(s) from automation for the following project(s): woocommerce
* Wire customer-review-order.php to the per-item row partial
The 6595 changes had been dropped during cascading rebases: the form
wrapper, hidden inputs, foreach loop calling
wc_get_template('order/customer-review-order-row.php', ...), and the
submit button were all missing. Restored so each item actually renders
the row partial and the submit gate's selectors exist in the DOM.
* Strengthen the new-tab product link
- rel='noopener noreferrer' (was just 'noopener') to also prevent
referrer leakage in some browsers/configurations.
- Add a .screen-reader-text 'opens in a new tab' suffix so assistive
tech users are warned the link spawns a new window.
* Address per-item-row review feedback
- Collapse the .woocommerce-review-order__item top border to a single
declaration; the previous form layered border-top-color over a
currentColor border, which was redundant.
- Log a console.warn from initSubmitGate when the submit button is
missing so theme overrides that drop it surface the cause.
* Wrap the long console.warn message to satisfy eslint max-len
* Add AJAX submission handler for the Review Order form
`Internal\OrderReviews\SubmissionHandler` registers
`wp_ajax{,_nopriv}_woocommerce_submit_order_reviews`. For each rated row
it inserts a `comment_type='review'` comment against the product, with
`rating` (1-5) and `verified=1` commentmeta. Author fields are pulled
from the order; logged-in customers must own the order, while guests
authenticate via the order key.
Per-row outcome is `ok`, `pending_moderation`, or `error`. One row's
failure does not block the rest. The response shape:
```json
{
"success": true,
"data": {
"results": {
"<row_index>": {
"product_id": 123,
"status": "ok",
"comment_id": 456
}
}
}
}
```
`get_option('comment_moderation')` is honoured: when on, comments are
inserted with `comment_approved=0` and the row reports
`pending_moderation`.
`woocommerce_review_order_submitted` action fires once per submission
with the order and the per-row results array.
`_wc_review_request_completed_at` order meta is set when every line
item on the order has at least one comment by the customer's billing
email — no matter whether those reviews were posted in this submission
or earlier.
JS:
- `client/legacy/js/frontend/order-review.js` intercepts the form's
`submit`, posts via `fetch` to admin-ajax, renders per-row status
messages from the JSON response.
- The form's `action` falls back to admin-ajax.php for the no-JS case
(handler still works server-side).
Wrapper:
- `OrderReviews::init()` now lists `SubmissionHandler` as a third arg,
so the container resolves and auto-init's it. No `class-woocommerce.php`
changes needed.
Tests:
- `Internal\OrderReviews\SubmissionHandlerTest` covers bad nonce, bad
key, valid insert + meta, empty-rating skip, `comment_moderation`,
per-row isolation when one row references a product not on the
order, and the completed-at meta on full vs partial submission.
Closes #64310 (WOOPLUG-6596).
* Add changefile(s) from automation for the following project(s): woocommerce
* Address Copilot feedback on AJAX submission handler PR
- Gate submissions on the same woocommerce_review_order_eligible_statuses
filter the page-load endpoint uses, so a tampered POST with a valid key
cannot bypass status restrictions.
- Stop double-unslashing the row text. handle() already unslashes the
whole reviews array; process_rows() trusted that.
- Make maybe_mark_order_complete() idempotent: skip when the meta is
already set, and replace the per-product N+1 lookup with a single
grouped get_comments() call.
- Localize the JS status messages via wp_localize_script() instead of
hardcoding English strings client-side.
- Test housekeeping: remove the AJAX/JSON filters in tearDown(), and add
a test for the woocommerce_review_order_submitted action plus an
ineligible-status rejection test.
- Sanitize key and nonce inputs explicitly so the WordPress sniffs pass
on the changed lines.
* Drop empty meta_query arg that triggers WC slow-query lint
The empty meta_query => array() option was redundant and the WC sniff
flags the key's presence as a possible slow query.
* Address Copilot feedback (round 2) on AJAX submission handler PR
- Out-of-range ratings (e.g. 6) no longer disappear: only rating==0 is
treated as a deliberate skip; values <1 or >5 now report an
invalid_rating error per row.
- maybe_mark_order_complete() fetches comment objects directly so the
comment_post_ID lookup doesn't need a follow-up get_comment() per
row (was a regression of the original N+1).
- Submit gate is now reused after AJAX completion: expose syncSubmit
on the form, and initAjaxSubmit calls it instead of unconditionally
enabling the button. The button stays disabled when no rows have a
rating selected (e.g. after a failed submit + cleared rating).
* Add per-row status note style; source colors from WC --wc-green / --wc-red
Avoids introducing new --wc-review-order-* tokens. The active theme
controls everything else; we only need ok/error tinting on the inline
status message and we use the WC variables that already exist at :root.
* Point the Review Order form at admin-ajax for the new submission handler
Adds action + data-ajax-url + the action hidden input so the JS submit
gate's fetch() target and the no-JS POST both reach the handler.
* Accept variation_id; exclude spam/trash from completion check
- The Review Order row template posts the variation id for variable
products, but $item->get_product_id() returns the parent. Accept
either match and store the review against the parent so it shows on
the product page regardless of which variation was bought.
- Switch the completion lookup from status=>all to status=>[approve,hold]
so spam/trash reviews can't trip _wc_review_request_completed_at.
- Fix the maybe_mark_order_complete() docblock so its 'verified review'
wording matches the actual behaviour (any non-spam/non-trash review
by the customer counts).
* Filter the network-failure error path to rows with a rating
The catch handler renders an error on every item row, but the earlier
'success: false' branch only flags rows that were actually rated. A
network failure mid-submit now incorrectly tells the customer the rows
they intentionally skipped also failed; mirror the existing 'has a
checked rating' guard so only rated rows surface the error.
* Address review feedback on the AJAX submission handler
- maybe_mark_order_complete(): build the eligible-row set through the
same `woocommerce_review_order_eligible_items` filter the page uses,
count required reviews per parent product (so duplicate or
multi-variation rows need their full count of reviews), and pass
number=>0 to get_comments() so the default 20-row cap doesn't
produce false negatives on large orders.
- process_rows(): only attribute the inserted comment to a WP user
when the current request is authenticated as that user. Guests
reaching the page via the order key now get user_id=0 instead of
the order's customer id.
- customer-review-order.php: wrap the hidden `action` value in
esc_attr() for consistency with adjacent hidden inputs.
- SubmissionHandlerTest: add coverage for the `invalid_rating` and
`product_mismatch` error codes (40 tests, was 38).
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64527-wooplug-6596-ajax-submission-handler b/plugins/woocommerce/changelog/64527-wooplug-6596-ajax-submission-handler
new file mode 100644
index 00000000000..7f08b2c3623
--- /dev/null
+++ b/plugins/woocommerce/changelog/64527-wooplug-6596-ajax-submission-handler
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the AJAX submission handler that turns the Review Order form into product reviews — one verified-buyer comment per rated row, per-row outcome, `comment_moderation` honoured.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/legacy/css/order-review.scss b/plugins/woocommerce/client/legacy/css/order-review.scss
index da3954e638d..42e7051ed52 100644
--- a/plugins/woocommerce/client/legacy/css/order-review.scss
+++ b/plugins/woocommerce/client/legacy/css/order-review.scss
@@ -52,6 +52,20 @@
resize: vertical;
}
+ // Submission status note. Falls back to literal hex if a stylesheet
+ // loads before _variables.scss exposes the WC color tokens.
+ &__item-status {
+ margin: 0.5em 0 0;
+
+ &--ok {
+ color: var(--wc-green, #008a20);
+ }
+
+ &--error {
+ color: var(--wc-red, #a00);
+ }
+ }
+
@media (max-width: 600px) {
&__item-row {
flex-direction: column;
diff --git a/plugins/woocommerce/client/legacy/js/frontend/order-review.js b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
index 9a6810368d0..629e34d1b26 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/order-review.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/order-review.js
@@ -99,8 +99,8 @@
submit.disabled = ! anyChecked;
}
- // Expose so external code (e.g. the AJAX submission handler in #64527)
- // can re-evaluate the gate after async state changes.
+ // Expose so initAjaxSubmit can re-run the gate after the request
+ // completes (instead of unconditionally enabling the button).
form.syncReviewOrderSubmitGate = syncSubmit;
form.addEventListener( 'change', function ( event ) {
@@ -118,6 +118,135 @@
syncSubmit();
}
+ /**
+ * Render per-row outcome inside a row's fields container.
+ *
+ * @param {HTMLElement} row `.woocommerce-review-order__item`
+ * @param {string} status `ok | pending_moderation | error`
+ * @param {string} [text] Optional message override.
+ */
+ function renderRowStatus( row, status, text ) {
+ var fields = row.querySelector(
+ '.woocommerce-review-order__item-fields'
+ );
+ if ( ! fields ) {
+ return;
+ }
+ var existing = fields.querySelector(
+ '.woocommerce-review-order__item-status'
+ );
+ if ( existing ) {
+ existing.parentNode.removeChild( existing );
+ }
+ var i18n =
+ ( window.wcOrderReview && window.wcOrderReview.i18n ) || {};
+ var defaults = {
+ ok: i18n.ok || 'Thanks, your review is live.',
+ pending_moderation:
+ i18n.pending_moderation ||
+ 'Thanks, your review is pending approval.',
+ error:
+ i18n.error || 'Something went wrong, please try again.',
+ };
+ var note = document.createElement( 'p' );
+ note.className =
+ 'woocommerce-review-order__item-status woocommerce-review-order__item-status--' +
+ status;
+ note.setAttribute( 'role', 'status' );
+ note.textContent = text || defaults[ status ] || defaults.error;
+ fields.appendChild( note );
+ }
+
+ /**
+ * Intercept form submit and POST it to admin-ajax.
+ *
+ * @param {HTMLFormElement} form
+ */
+ function initAjaxSubmit( form ) {
+ var ajaxUrl = form.getAttribute( 'data-ajax-url' );
+ if ( ! ajaxUrl ) {
+ return;
+ }
+
+ form.addEventListener( 'submit', function ( event ) {
+ event.preventDefault();
+
+ var submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+ if ( submit ) {
+ submit.disabled = true;
+ }
+
+ window
+ .fetch( ajaxUrl, {
+ method: 'POST',
+ credentials: 'same-origin',
+ body: new window.FormData( form ),
+ } )
+ .then( function ( response ) {
+ return response.json().catch( function () {
+ return { success: false };
+ } );
+ } )
+ .then( function ( payload ) {
+ if ( ! payload || ! payload.success || ! payload.data ) {
+ Array.prototype.forEach.call(
+ form.querySelectorAll(
+ '.woocommerce-review-order__item'
+ ),
+ function ( row ) {
+ if (
+ row.querySelector(
+ '.woocommerce-star-rating__input:checked'
+ )
+ ) {
+ renderRowStatus( row, 'error' );
+ }
+ }
+ );
+ return;
+ }
+
+ var results = payload.data.results || {};
+ Object.keys( results ).forEach( function ( key ) {
+ var entry = results[ key ];
+ var row = form.querySelector(
+ '.woocommerce-review-order__item[data-row-index="' +
+ key +
+ '"]'
+ );
+ if ( row && entry && entry.status ) {
+ renderRowStatus( row, entry.status );
+ }
+ } );
+ } )
+ .catch( function () {
+ Array.prototype.forEach.call(
+ form.querySelectorAll(
+ '.woocommerce-review-order__item'
+ ),
+ function ( row ) {
+ if (
+ row.querySelector(
+ '.woocommerce-star-rating__input:checked'
+ )
+ ) {
+ renderRowStatus( row, 'error' );
+ }
+ }
+ );
+ } )
+ .then( function () {
+ if ( typeof form.syncReviewOrderSubmitGate === 'function' ) {
+ form.syncReviewOrderSubmitGate();
+ } else if ( submit ) {
+ submit.disabled = false;
+ }
+ } );
+ } );
+ }
+
function init() {
var groups = document.querySelectorAll( '.woocommerce-star-rating' );
Array.prototype.forEach.call( groups, initGroup );
@@ -126,6 +255,7 @@
'.woocommerce-review-order__form'
);
Array.prototype.forEach.call( forms, initSubmitGate );
+ Array.prototype.forEach.call( forms, initAjaxSubmit );
}
if ( document.readyState === 'loading' ) {
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 36539c18016..f2aed327111 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -378,6 +378,7 @@ final class WooCommerce {
$container->get( TaxRateVersionStringInvalidator::class );
$container->get( Automattic\WooCommerce\Internal\OrderReviews\Scheduler::class );
$container->get( Automattic\WooCommerce\Internal\OrderReviews\Endpoint::class );
+ $container->get( Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler::class );
// Feature flags.
if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
index 4e336a01871..3d3cadcd73b 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
@@ -433,6 +433,18 @@ class Endpoint {
'in_footer' => true,
)
);
+
+ wp_localize_script(
+ 'wc-order-review',
+ 'wcOrderReview',
+ array(
+ 'i18n' => array(
+ 'ok' => __( 'Thanks, your review is live.', 'woocommerce' ),
+ 'pending_moderation' => __( 'Thanks, your review is pending approval.', 'woocommerce' ),
+ 'error' => __( 'Something went wrong, please try again.', 'woocommerce' ),
+ ),
+ )
+ );
}
/**
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
new file mode 100644
index 00000000000..cd86af8446c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -0,0 +1,303 @@
+<?php
+/**
+ * SubmissionHandler class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use WC_Order;
+
+/**
+ * Handles the AJAX submission of the Review Order form.
+ *
+ * One comment per rated row, with per-row outcome reported back so a single
+ * row's failure cannot block the rest. Guests submit with the order key;
+ * logged-in customers must own the order.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 10.8.0
+ */
+class SubmissionHandler {
+
+ /**
+ * Action name registered with admin-ajax.
+ */
+ public const ACTION = 'woocommerce_submit_order_reviews';
+
+ /**
+ * Order meta set when every eligible item has been reviewed by the
+ * matching author.
+ */
+ public const COMPLETED_META_KEY = '_wc_review_request_completed_at';
+
+ /**
+ * Wire the AJAX endpoints.
+ *
+ * Auto-called by the WC dependency container after instantiation.
+ *
+ * @internal
+ */
+ final public function init(): void {
+ add_action( 'wp_ajax_' . self::ACTION, array( $this, 'handle' ) );
+ add_action( 'wp_ajax_nopriv_' . self::ACTION, array( $this, 'handle' ) );
+ }
+
+ /**
+ * Entry point fired by `admin-ajax.php`.
+ *
+ * Sends a JSON response and exits.
+ */
+ public function handle(): void {
+ // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce is checked below.
+ $order_id = isset( $_POST['order_id'] ) ? absint( $_POST['order_id'] ) : 0;
+ $key = isset( $_POST['key'] ) && is_string( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '';
+ $nonce = isset( $_POST['_wcnonce'] ) && is_string( $_POST['_wcnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wcnonce'] ) ) : '';
+ // Row-level fields are sanitized inside process_rows(); the array as a whole only needs unslashing.
+ $rows_in = isset( $_POST['reviews'] ) && is_array( $_POST['reviews'] ) ? wp_unslash( $_POST['reviews'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ // phpcs:enable WordPress.Security.NonceVerification.Missing
+
+ if ( ! is_string( $nonce ) || ! wp_verify_nonce( $nonce, self::ACTION ) ) {
+ wp_send_json_error( array( 'message' => __( 'Security check failed.', 'woocommerce' ) ), 403 );
+ }
+
+ $order = $order_id ? wc_get_order( $order_id ) : false;
+ if ( ! $order instanceof WC_Order ) {
+ wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+ }
+
+ if ( '' === $key || ! hash_equals( $order->get_order_key(), $key ) ) {
+ wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+ }
+
+ // Logged-in user must own the order. Guests with the right key still pass.
+ if ( $order->get_customer_id() && is_user_logged_in() && get_current_user_id() !== $order->get_customer_id() ) {
+ wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+ }
+
+ // Reuse the same eligibility filter the page-load endpoint uses so the
+ // submit path can never run on an order whose status no longer permits it.
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented on Endpoint::is_authorised().
+ $eligible_statuses = (array) apply_filters(
+ 'woocommerce_review_order_eligible_statuses',
+ array( OrderStatus::COMPLETED ),
+ $order
+ );
+
+ if ( ! in_array( $order->get_status(), $eligible_statuses, true ) ) {
+ wp_send_json_error( array( 'message' => __( 'Order not found.', 'woocommerce' ) ), 404 );
+ }
+
+ $results = $this->process_rows( $order, $rows_in );
+
+ $this->maybe_mark_order_complete( $order );
+
+ /**
+ * Fires after the Review Order form has been processed.
+ *
+ * @since 10.8.0
+ *
+ * @param WC_Order $order The order.
+ * @param array $results Per-row outcomes — see `SubmissionHandler::process_rows()`.
+ */
+ do_action( 'woocommerce_review_order_submitted', $order, $results );
+
+ wp_send_json_success( array( 'results' => $results ) );
+ }
+
+ /**
+ * Process the submitted row payload and return per-row outcomes.
+ *
+ * @param WC_Order $order Order being reviewed.
+ * @param array $rows_in Raw `$_POST['reviews']` value.
+ * @return array<int, array{product_id:int, status:string, comment_id?:int, error?:string}>
+ */
+ private function process_rows( WC_Order $order, array $rows_in ): array {
+ $results = array();
+ $item_index = $this->index_order_items( $order );
+ $author_name = trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() );
+ $author_email = $order->get_billing_email();
+ $author_ip = $order->get_customer_ip_address();
+ $author_agent = $order->get_customer_user_agent();
+ $require_mod = (bool) get_option( 'comment_moderation' );
+
+ foreach ( $rows_in as $row_index => $row ) {
+ $row_index = (int) $row_index;
+ $row = is_array( $row ) ? $row : array();
+
+ $rating = isset( $row['rating'] ) ? (int) $row['rating'] : 0;
+ if ( 0 === $rating ) {
+ // Empty rating means the customer chose to skip this row; allowed.
+ continue;
+ }
+
+ $product_id = isset( $row['product_id'] ) ? absint( $row['product_id'] ) : 0;
+ $order_item_id = isset( $row['order_item_id'] ) ? absint( $row['order_item_id'] ) : 0;
+ // $rows_in was already unslashed in handle(); avoid double-unslashing.
+ $text = isset( $row['text'] ) && is_string( $row['text'] ) ? trim( wp_kses_post( $row['text'] ) ) : '';
+
+ $result = array(
+ 'product_id' => $product_id,
+ 'status' => 'error',
+ );
+
+ if ( $rating < 1 || $rating > 5 ) {
+ $result['error'] = 'invalid_rating';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
+ if ( ! $product_id || ! $order_item_id || ! isset( $item_index[ $order_item_id ] ) ) {
+ $result['error'] = 'invalid_row';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
+ $item = $item_index[ $order_item_id ];
+
+ // Variable products: the row template posts the variation id,
+ // while $item->get_product_id() returns the parent. Accept either.
+ $line_product_id = (int) $item->get_product_id();
+ $line_variation_id = (int) $item->get_variation_id();
+ if ( $product_id !== $line_product_id && $product_id !== $line_variation_id ) {
+ $result['error'] = 'product_mismatch';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
+ // Reviews always attach to the parent product so they show on the
+ // product page regardless of which variation was bought.
+ $review_post_id = $line_product_id;
+
+ // Only attribute the comment to a WP user when the current request is
+ // authenticated as that user. Guests reaching the page via the order
+ // key are not authenticated, so the comment stays unattributed (0).
+ $customer_id = (int) $order->get_customer_id();
+ $current_user_id = get_current_user_id();
+ $comment_user_id = ( $current_user_id > 0 && $current_user_id === $customer_id ) ? $current_user_id : 0;
+
+ $comment_data = array(
+ 'comment_post_ID' => $review_post_id,
+ 'comment_author' => '' !== $author_name ? $author_name : __( 'Anonymous', 'woocommerce' ),
+ 'comment_author_email' => $author_email,
+ 'comment_author_IP' => $author_ip,
+ 'comment_agent' => $author_agent,
+ 'comment_content' => $text,
+ 'comment_type' => 'review',
+ 'comment_approved' => $require_mod ? 0 : 1,
+ 'user_id' => $comment_user_id,
+ );
+
+ $comment_id = wp_insert_comment( wp_slash( $comment_data ) );
+ if ( ! $comment_id ) {
+ $result['error'] = 'insert_failed';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
+ add_comment_meta( $comment_id, 'rating', $rating, true );
+ add_comment_meta( $comment_id, 'verified', 1, true );
+
+ $result['comment_id'] = (int) $comment_id;
+ $result['status'] = $require_mod ? 'pending_moderation' : 'ok';
+ $results[ $row_index ] = $result;
+ }//end foreach
+
+ return $results;
+ }
+
+ /**
+ * Set the completed-at meta when every eligible item has a review by this
+ * customer (approved or pending moderation), whether posted in this
+ * submission or an earlier one. Spam/trash comments are excluded.
+ *
+ * @param WC_Order $order Order being reviewed.
+ */
+ private function maybe_mark_order_complete( WC_Order $order ): void {
+ // Recording the moment the order first became fully reviewed; never overwrite.
+ if ( $order->get_meta( self::COMPLETED_META_KEY ) ) {
+ return;
+ }
+
+ $customer_email = $order->get_billing_email();
+ if ( '' === $customer_email ) {
+ return;
+ }
+
+ // Build the same eligible-row set the page uses, then count required
+ // reviews per parent product. Same product appearing on N rows needs
+ // N reviews, not 1.
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented at the page-template invocation site.
+ $eligible_items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+
+ $required_reviews = array();
+ foreach ( $eligible_items as $item ) {
+ if ( ! $item instanceof \WC_Order_Item_Product ) {
+ continue;
+ }
+ $product_id = (int) $item->get_product_id();
+ if ( $product_id > 0 ) {
+ $required_reviews[ $product_id ] = ( $required_reviews[ $product_id ] ?? 0 ) + 1;
+ }
+ }
+
+ if ( empty( $required_reviews ) ) {
+ return;
+ }
+
+ // Single grouped lookup, fetching the comment objects directly so we
+ // can read comment_post_ID without a follow-up query per row. Limit
+ // to approved + pending-moderation so spam/trash never count as
+ // completion. number=>0 disables the default 20-row cap so this still
+ // works for orders with many reviewable items.
+ $comments = get_comments(
+ array(
+ 'post__in' => array_keys( $required_reviews ),
+ 'author_email' => $customer_email,
+ 'type' => 'review',
+ 'status' => array( 'approve', 'hold' ),
+ 'number' => 0,
+ )
+ );
+
+ if ( ! is_array( $comments ) || empty( $comments ) ) {
+ return;
+ }
+
+ $review_counts = array();
+ foreach ( $comments as $comment ) {
+ if ( $comment instanceof \WP_Comment ) {
+ $post_id = (int) $comment->comment_post_ID;
+ $review_counts[ $post_id ] = ( $review_counts[ $post_id ] ?? 0 ) + 1;
+ }
+ }
+
+ foreach ( $required_reviews as $product_id => $required ) {
+ if ( ( $review_counts[ $product_id ] ?? 0 ) < $required ) {
+ return;
+ }
+ }
+
+ $order->update_meta_data( self::COMPLETED_META_KEY, (string) time() );
+ $order->save();
+ }
+
+ /**
+ * Map order_item_id => `WC_Order_Item_Product` for fast row lookup.
+ *
+ * @param WC_Order $order Order being reviewed.
+ * @return array<int, \WC_Order_Item_Product>
+ */
+ private function index_order_items( WC_Order $order ): array {
+ $index = array();
+ foreach ( $order->get_items() as $item ) {
+ if ( $item instanceof \WC_Order_Item_Product ) {
+ $index[ $item->get_id() ] = $item;
+ }
+ }
+ return $index;
+ }
+}
diff --git a/plugins/woocommerce/templates/order/customer-review-order.php b/plugins/woocommerce/templates/order/customer-review-order.php
index a634755b026..3ac0f4108a4 100644
--- a/plugins/woocommerce/templates/order/customer-review-order.php
+++ b/plugins/woocommerce/templates/order/customer-review-order.php
@@ -111,8 +111,11 @@ $order_key = (string) $order->get_order_key();
<form
class="woocommerce-review-order__form"
method="post"
+ action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
+ data-ajax-url="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
novalidate
>
+ <input type="hidden" name="action" value="<?php echo esc_attr( 'woocommerce_submit_order_reviews' ); ?>" />
<input type="hidden" name="order_id" value="<?php echo esc_attr( (string) $order->get_id() ); ?>" />
<input type="hidden" name="key" value="<?php echo esc_attr( $order_key ); ?>" />
<?php wp_nonce_field( 'woocommerce_submit_order_reviews', '_wcnonce' ); ?>
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
new file mode 100644
index 00000000000..97f4c15ce73
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
@@ -0,0 +1,505 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Helper_Product;
+use WC_Order;
+use WC_Unit_Test_Case;
+use WPAjaxDieContinueException;
+
+/**
+ * Tests for the Review Order submission handler.
+ */
+class SubmissionHandlerTest extends WC_Unit_Test_Case {
+
+ /**
+ * Reset state between tests.
+ */
+ public function tearDown(): void {
+ $_POST = array();
+ update_option( 'comment_moderation', '0' );
+ remove_all_filters( 'woocommerce_review_order_submitted' );
+ remove_all_filters( 'woocommerce_review_order_eligible_statuses' );
+ remove_all_filters( 'wp_die_ajax_handler' );
+ remove_all_filters( 'wp_send_json_handler' );
+ remove_all_filters( 'wp_doing_ajax' );
+ parent::tearDown();
+ }
+
+ /**
+ * Build a completed order with the given number of products.
+ *
+ * @param int $product_count How many products to attach.
+ * @return array{order:WC_Order, product_ids:int[], item_ids:int[]}
+ */
+ private function make_order( int $product_count = 1 ): array {
+ $order = OrderHelper::create_order();
+ // Wipe the default item.
+ foreach ( $order->get_items() as $item ) {
+ $order->remove_item( $item->get_id() );
+ }
+ $order->set_billing_first_name( 'Jane' );
+ $order->set_billing_last_name( 'Doe' );
+ $order->set_billing_email( 'jane@example.test' );
+ $order->set_status( OrderStatus::COMPLETED );
+
+ $product_ids = array();
+ for ( $i = 0; $i < $product_count; $i++ ) {
+ $product = WC_Helper_Product::create_simple_product();
+ $product_ids[] = $product->get_id();
+ $order->add_product( $product, 1 );
+ }
+ $order->save();
+
+ $item_ids = array();
+ foreach ( $order->get_items() as $item ) {
+ $item_ids[] = $item->get_id();
+ }
+
+ return array(
+ 'order' => $order,
+ 'product_ids' => $product_ids,
+ 'item_ids' => $item_ids,
+ );
+ }
+
+ /**
+ * Invoke the handler and capture the JSON it would have sent.
+ *
+ * @return array{success:bool, data:mixed, status:int}
+ */
+ private function dispatch(): array {
+ $response = array(
+ 'success' => false,
+ 'data' => null,
+ 'status' => 200,
+ );
+
+ $capture = static function ( $payload, $status ) use ( &$response ) {
+ $response['success'] = ! empty( $payload['success'] );
+ $response['data'] = $payload['data'] ?? null;
+ $response['status'] = (int) ( $status ?? 200 );
+ };
+
+ add_filter( 'wp_die_ajax_handler', static fn() => static fn() => null );
+
+ add_filter(
+ 'wp_send_json_handler',
+ static function () use ( $capture ) {
+ return $capture;
+ }
+ );
+
+ add_filter(
+ 'wp_doing_ajax',
+ static function () {
+ return true;
+ }
+ );
+
+ // `wp_send_json_*` always calls `wp_die`, but we can short-circuit
+ // the JSON output by hooking the early `wp_die_ajax_handler`.
+ // Easier: just call the handler and trust it sends headers; capture
+ // via output buffering.
+ ob_start();
+ $handler = new SubmissionHandler();
+ try {
+ $handler->handle();
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected: wp_send_json_* calls wp_die().
+ unset( $e );
+ }
+ $body = (string) ob_get_clean();
+
+ $decoded = json_decode( $body, true );
+ if ( is_array( $decoded ) ) {
+ $response['success'] = ! empty( $decoded['success'] );
+ $response['data'] = $decoded['data'] ?? null;
+ }
+ return $response;
+ }
+
+ /**
+ * @testdox Handler rejects requests with a missing or bad nonce.
+ */
+ public function test_rejects_bad_nonce(): void {
+ $built = $this->make_order( 1 );
+ /** @var WC_Order $order */
+ $order = $built['order'];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => 'not-the-right-nonce',
+ );
+
+ $response = $this->dispatch();
+
+ $this->assertFalse( $response['success'] );
+ }
+
+ /**
+ * @testdox Handler rejects mismatched order keys.
+ */
+ public function test_rejects_bad_key(): void {
+ $built = $this->make_order( 1 );
+ /** @var WC_Order $order */
+ $order = $built['order'];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => 'wc_order_NOPE',
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ );
+
+ $response = $this->dispatch();
+
+ $this->assertFalse( $response['success'] );
+ }
+
+ /**
+ * @testdox A valid submission inserts a comment with rating + verified meta.
+ */
+ public function test_inserts_review_with_meta(): void {
+ $built = $this->make_order( 1 );
+ /** @var WC_Order $order */
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $product_id,
+ 'order_item_id' => $item_id,
+ 'rating' => 5,
+ 'text' => 'Excellent product, highly recommended.',
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $this->assertTrue( $response['success'] );
+ $this->assertIsArray( $response['data'] );
+ $this->assertArrayHasKey( 'results', $response['data'] );
+ $results = $response['data']['results'];
+ $this->assertCount( 1, $results );
+ $row = reset( $results );
+ $this->assertSame( 'ok', $row['status'] );
+ $this->assertArrayHasKey( 'comment_id', $row );
+
+ $comment = get_comment( $row['comment_id'] );
+ $this->assertNotNull( $comment );
+ $this->assertSame( (int) $product_id, (int) $comment->comment_post_ID );
+ $this->assertSame( 'review', $comment->comment_type );
+ $this->assertSame( '5', get_comment_meta( $row['comment_id'], 'rating', true ) );
+ $this->assertSame( '1', get_comment_meta( $row['comment_id'], 'verified', true ) );
+ }
+
+ /**
+ * @testdox Rows with no rating are skipped silently.
+ */
+ public function test_skips_rows_without_rating(): void {
+ $built = $this->make_order( 2 );
+ /** @var WC_Order $order */
+ $order = $built['order'];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $built['product_ids'][0],
+ 'order_item_id' => $built['item_ids'][0],
+ 'rating' => 4,
+ 'text' => 'Great.',
+ ),
+ array(
+ 'product_id' => $built['product_ids'][1],
+ 'order_item_id' => $built['item_ids'][1],
+ 'rating' => 0,
+ 'text' => '',
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $this->assertTrue( $response['success'] );
+ $results = $response['data']['results'];
+ $this->assertCount( 1, $results, 'Skipped row should not appear in the results.' );
+ }
+
+ /**
+ * @testdox When comment_moderation is enabled, rows return pending_moderation.
+ */
+ public function test_pending_moderation(): void {
+ update_option( 'comment_moderation', '1' );
+
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $product_id,
+ 'order_item_id' => $item_id,
+ 'rating' => 4,
+ 'text' => 'Pending text.',
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $results = $response['data']['results'];
+ $row = reset( $results );
+ $this->assertSame( 'pending_moderation', $row['status'] );
+
+ $comment = get_comment( $row['comment_id'] );
+ $this->assertSame( '0', $comment->comment_approved );
+ }
+
+ /**
+ * @testdox Rows referencing a product not on the order fail per-row, others succeed.
+ */
+ public function test_per_row_isolation(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $built['product_ids'][0],
+ 'order_item_id' => $built['item_ids'][0],
+ 'rating' => 5,
+ ),
+ array(
+ 'product_id' => 999999,
+ 'order_item_id' => 999999,
+ 'rating' => 5,
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $results = $response['data']['results'];
+
+ $this->assertCount( 2, $results );
+ $ok_count = 0;
+ $error_count = 0;
+ foreach ( $results as $row ) {
+ if ( 'ok' === $row['status'] ) {
+ ++$ok_count;
+ } elseif ( 'error' === $row['status'] ) {
+ ++$error_count;
+ }
+ }
+ $this->assertSame( 1, $ok_count );
+ $this->assertSame( 1, $error_count );
+ }
+
+ /**
+ * @testdox Out-of-range ratings surface as a per-row error (invalid_rating).
+ */
+ public function test_invalid_rating_returns_error(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $built['product_ids'][0],
+ 'order_item_id' => $built['item_ids'][0],
+ 'rating' => 7,
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $row = $response['data']['results'][0];
+
+ $this->assertSame( 'error', $row['status'] );
+ $this->assertSame( 'invalid_rating', $row['error'] );
+ }
+
+ /**
+ * @testdox Submitting a product_id that doesn't match the order item surfaces product_mismatch.
+ */
+ public function test_product_mismatch_returns_error(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $built['product_ids'][0] + 99999,
+ 'order_item_id' => $built['item_ids'][0],
+ 'rating' => 4,
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $row = $response['data']['results'][0];
+
+ $this->assertSame( 'error', $row['status'] );
+ $this->assertSame( 'product_mismatch', $row['error'] );
+ }
+
+ /**
+ * @testdox Order completed-at meta is set when every item has been reviewed.
+ */
+ public function test_marks_order_complete_when_every_item_reviewed(): void {
+ $built = $this->make_order( 2 );
+ $order = $built['order'];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $built['product_ids'][0],
+ 'order_item_id' => $built['item_ids'][0],
+ 'rating' => 5,
+ ),
+ array(
+ 'product_id' => $built['product_ids'][1],
+ 'order_item_id' => $built['item_ids'][1],
+ 'rating' => 4,
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $this->assertTrue( $response['success'] );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty( $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
+ }
+
+ /**
+ * @testdox Order completed-at meta is NOT set when some items are still unreviewed.
+ */
+ public function test_does_not_mark_complete_when_one_item_unreviewed(): void {
+ $built = $this->make_order( 2 );
+ $order = $built['order'];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $built['product_ids'][0],
+ 'order_item_id' => $built['item_ids'][0],
+ 'rating' => 5,
+ ),
+ // Second product intentionally omitted.
+ ),
+ );
+
+ $this->dispatch();
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertEmpty( $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
+ }
+
+ /**
+ * @testdox A successful submission fires the woocommerce_review_order_submitted action with order + per-row results.
+ */
+ public function test_fires_review_order_submitted_action(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ $captured = array(
+ 'order' => null,
+ 'results' => null,
+ 'calls' => 0,
+ );
+
+ add_action(
+ 'woocommerce_review_order_submitted',
+ static function ( $order_arg, $results_arg ) use ( &$captured ) {
+ $captured['order'] = $order_arg;
+ $captured['results'] = $results_arg;
+ ++$captured['calls'];
+ },
+ 10,
+ 2
+ );
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $product_id,
+ 'order_item_id' => $item_id,
+ 'rating' => 4,
+ ),
+ ),
+ );
+
+ $this->dispatch();
+
+ $this->assertSame( 1, $captured['calls'], 'Action should fire exactly once per submission.' );
+ $this->assertInstanceOf( WC_Order::class, $captured['order'] );
+ $this->assertSame( $order->get_id(), $captured['order']->get_id() );
+ $this->assertIsArray( $captured['results'] );
+ $this->assertCount( 1, $captured['results'] );
+ $row = reset( $captured['results'] );
+ $this->assertSame( 'ok', $row['status'] );
+ }
+
+ /**
+ * @testdox Submissions are rejected when the order's status is no longer eligible.
+ */
+ public function test_rejects_when_order_status_ineligible(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $order->set_status( OrderStatus::PROCESSING );
+ $order->save();
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $built['product_ids'][0],
+ 'order_item_id' => $built['item_ids'][0],
+ 'rating' => 5,
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+
+ $this->assertFalse( $response['success'] );
+ }
+}