Commit 0b5899251c3 for woocommerce
commit 0b5899251c3307c93c13fbbc319c20b92b131212
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Tue May 12 23:13:09 2026 +0300
Pre-fill the Review Order form with the customer's existing review (#64529)
* 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.
* Add already-reviewed locked row state
When the customer has already reviewed a product on the order (matched
by billing email), the row renders a static "Reviewed" summary instead
of the form. Reviews disabled on a product cause the row to be skipped
entirely.
Files:
- `src/Internal/OrderReviews/ItemEligibility.php` — `describe()` returns
one of `STATUS_FORM | STATUS_REVIEWED | STATUS_SKIP` plus the matched
comment when applicable. Wraps the decision in
`woocommerce_review_order_item_already_reviewed` so extensions can
flip either direction.
- `templates/order/customer-review-order-row-reviewed.php` — locked row
partial: linked product title, thumbnail, "Reviewed" badge with
posted-on date, locked star rating + label, truncated review text,
optional "View on product page" link.
- `templates/order/customer-review-order.php` — dispatch on the
`ItemEligibility` decision; calls the existing form-row partial for
pending items and the new reviewed-row partial for matches.
- `client/legacy/css/order-review.scss` — reviewed-row styles
(badge, locked rating layout, blockquote-style review text).
- `tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php` —
defaults, comments-closed, existing-match, different-author, filter
override, filter-args context.
Drive-by: tightened lint warnings in `SubmissionHandler` so branch lint
passes against trunk (alignment, yoda conditions, end-comments).
Closes #64312 (WOOPLUG-6598).
* Add changefile(s) from automation for the following project(s): woocommerce
* Address Copilot feedback on already-reviewed locked row PR
- Eliminate the N+1 query: ItemEligibility::prime() pre-fills a
per-request cache with a single get_comments() call covering every
product on the order, and find_existing_review() now reads that
cache. The template calls prime() once before iterating items.
- Tolerate the filter-only reviewed state: when the
woocommerce_review_order_item_already_reviewed filter forces true
without a backing comment, the locked row template renders the
reviewed state without trying to access comment fields.
- Add tests for both behaviors (prime-caches-results and
filter_forced_reviewed_with_no_comment).
* Remove only this test's comments_pre_query filter, not all of them
remove_all_filters( 'comments_pre_query' ) wiped any other callbacks
on a core hook, which could leak state to neighbouring tests. Hold a
reference to the counter and remove only that one.
* Add quote indent for the locked Reviewed row text
* Close server-side gaps in the Review Order submission handler
Previously the handler validated the row's order_item_id and that the
submitted product_id matched the line item's product_id, but it did
not consult any of the page-side eligibility logic. That left three
attack vectors open to anyone with a valid order key:
1. Submit a duplicate review for a product they already reviewed on
this order (stacks verified-buyer comments).
2. Submit a review for a fully-refunded line item.
3. Submit a review for a product whose owner has disabled comments.
process_rows() now mirrors the page's decision flow:
- index_eligible_order_items() runs the items through the same
woocommerce_review_order_eligible_items filter the page uses, so the
default fully-refunded filter strips refunded items from the index
(rows for them now report invalid_row).
- For each row, ItemEligibility::describe() decides STATUS_FORM /
STATUS_REVIEWED / STATUS_SKIP. REVIEWED returns the new
already_reviewed error, SKIP returns reviews_not_open. Only
STATUS_FORM proceeds to wp_insert_comment().
Three new tests cover the three rejection paths and assert no
duplicate comment is created in any of them. tearDown now also
removes the eligible-items and already_reviewed filters so test
fixtures cannot leak across cases.
* Wire ItemEligibility::describe() dispatch into the Review Order page
Calls prime() before the foreach, then per item:
- STATUS_SKIP -> skip the row entirely (reviews disabled on product)
- STATUS_REVIEWED -> render the locked variant (customer-review-order-row-reviewed.php)
- STATUS_FORM -> render the form row
* Document woocommerce_review_order_eligible_items at the handler call; soften reviewed quote border
- Add a hook docblock at the handler's apply_filters call so the
contract is discoverable from both the template and the API path.
- Drop the hardcoded rgba(0,0,0,0.1) on the reviewed-row left border;
use a plain 'border-left: 2px solid' so currentColor is honoured and
dark themes get a visible accent.
* Tighten already-reviewed lookups and the locked-row link
- ItemEligibility: get_comments() now uses status=>approve plus
include_unapproved=>[customer_email] so spam/trash reviews can't
trip STATUS_REVIEWED and re-block a legitimate retry, while the
customer's own pending review still counts. Both find_existing_review()
and prime() are updated to match.
- customer-review-order-row-reviewed.php: link to the parent product
permalink (via $item->get_product_id()) instead of the variation
permalink. Reviews are stored against the parent, so the link should
match for consistency.
- ItemEligibilityTest::test_prime_caches_results: wrap the describe()
call in try/finally so the comments_pre_query filter is always
removed even if an assertion throws.
* Fix PHPStan parameter.phpDocType: fully-qualify WC_Order_Item
The hook docblock declared @param WC_Order_Item[] without a leading
backslash, so PHPStan resolved it to
Automattic\WooCommerce\Internal\OrderReviews\WC_Order_Item which
doesn't exist. Use \WC_Order_Item[] so PHPStan picks up the global
WC class.
* 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).
* Resolve ItemEligibility from the container
ItemEligibility was introduced as a static utility, but a follow-up PR
(#64531) makes it register a default filter callback on
woocommerce_review_order_eligible_items. The container resolution is
where that registration is wired in, so introducing it alongside the
class itself is the natural home.
* Pre-fill the form row with the existing review instead of locking it
Reshapes WOOPLUG-6598 to match the Figma intent and customer behaviour
we want:
- No separate "locked Reviewed" row template, no badge, no
"Reviewed on X" sentence. Drops
templates/order/customer-review-order-row-reviewed.php.
- The regular form row is pre-filled with the customer's existing
rating + text when they already have a review tied to *this order*.
Customers can edit and resubmit.
- Submissions update the matching comment in place (rating meta +
content) when one exists for this order; otherwise insert as before.
Server-side lookup uses (product_id, author_email, _review_order_id)
so a tampered POST cannot edit someone else's comment.
- A review tied to a *different* order doesn't surface here, so a
repeat purchase of the same product gets a fresh empty form. The
customer's experience may legitimately differ between purchases.
- New commentmeta `_review_order_id` stamps each inserted review with
its source order. Legacy reviews without the meta are treated as
"from a different / unknown order" — they don't pre-fill or block.
- ItemEligibility drops STATUS_REVIEWED (no consumer left) and gains
prefill_for_item(). decide() returns STATUS_FORM | STATUS_SKIP plus
the existing comment (or null) for the row.
- The woocommerce_review_order_item_already_reviewed filter is gone:
the locked-row decision it gated no longer exists.
Tests: 24 across ItemEligibility + SubmissionHandler pass, including
new coverage for same-order update, previous-order non-blocking, and
legacy-meta-missing scenarios.
* Add changefile(s) from automation for the following project(s): woocommerce
* Fix branch-lint warnings: array alignment, docblock params, equals alignment
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64529-wooplug-6598-already-reviewed-row b/plugins/woocommerce/changelog/64529-wooplug-6598-already-reviewed-row
new file mode 100644
index 00000000000..e1b42000d22
--- /dev/null
+++ b/plugins/woocommerce/changelog/64529-wooplug-6598-already-reviewed-row
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Pre-fill the Review Order form with the customer's existing review for this order, scoped per-order so repeat purchases of the same product get a fresh empty row.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index f2aed327111..626071d2a63 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -379,6 +379,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\OrderReviews\Scheduler::class );
$container->get( Automattic\WooCommerce\Internal\OrderReviews\Endpoint::class );
$container->get( Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler::class );
+ $container->get( Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::class );
// Feature flags.
if ( Constants::is_true( 'WOOCOMMERCE_BIS_ALPHA_ENABLED' ) ) {
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
new file mode 100644
index 00000000000..bfa561adac3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
@@ -0,0 +1,325 @@
+<?php
+/**
+ * ItemEligibility class file.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\OrderReviews;
+
+use WC_Order;
+use WC_Order_Item;
+use WC_Order_Item_Product;
+use WP_Comment;
+
+/**
+ * Decides how each Review Order line item should be rendered and supplies
+ * any pre-fill data for the row form.
+ *
+ * Two outcomes for a row:
+ *
+ * - `form` — render the editable form row (`customer-review-order-row.php`),
+ * optionally pre-filled with the rating + text the customer has already
+ * submitted for this product **on this order**.
+ * - `skip` — render nothing (e.g. the product has reviews disabled).
+ *
+ * Reviews left for a *different* order are not surfaced here: a customer who
+ * buys the same product again gets a fresh form row, because their experience
+ * the second time around may be different from the first.
+ *
+ * @internal Just for internal use.
+ *
+ * @since 10.8.0
+ */
+class ItemEligibility {
+
+ /**
+ * Render the editable form row.
+ *
+ * @since 10.8.0
+ */
+ public const STATUS_FORM = 'form';
+
+ /**
+ * Render nothing (e.g. comments closed on the product).
+ *
+ * @since 10.8.0
+ */
+ public const STATUS_SKIP = 'skip';
+
+ /**
+ * Commentmeta key storing the order this review was submitted for.
+ *
+ * @since 10.8.0
+ */
+ public const ORDER_META_KEY = '_review_order_id';
+
+ /**
+ * Per-request cache for the "did this email review this product on this
+ * order" lookup, keyed by `order_id|product_id|email`. Value is a
+ * `WP_Comment` when one matches, or `null` when the slot has been checked
+ * and nothing matches (so a second call doesn't re-query).
+ *
+ * @var array<string, ?WP_Comment>
+ */
+ private static array $review_cache = array();
+
+ /**
+ * Register the default filter callbacks the OrderReviews feature ships with.
+ *
+ * Auto-called by the WC dependency container after instantiation.
+ *
+ * @internal
+ */
+ final public function init(): void {
+ add_filter(
+ 'woocommerce_review_order_eligible_items',
+ array( self::class, 'exclude_fully_refunded_items' ),
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Pre-fill the per-request review cache for a set of items in one query.
+ *
+ * Call this from the template before iterating items so each subsequent
+ * `decide()` / `prefill_for_item()` call hits the cache instead of running
+ * its own `get_comments()` query.
+ *
+ * @since 10.8.0
+ *
+ * @param iterable<WC_Order_Item_Product|mixed> $items Order line items.
+ * @param WC_Order $order Order being reviewed.
+ */
+ public static function preload_for_items( iterable $items, WC_Order $order ): void {
+ $email = $order->get_billing_email();
+ $order_id = $order->get_id();
+ if ( '' === $email || $order_id <= 0 ) {
+ return;
+ }
+
+ $product_ids = array();
+ foreach ( $items as $item ) {
+ if ( $item instanceof WC_Order_Item_Product ) {
+ $pid = (int) $item->get_product_id();
+ if ( $pid > 0 ) {
+ $product_ids[ $pid ] = $pid;
+ }
+ }
+ }
+
+ if ( empty( $product_ids ) ) {
+ return;
+ }
+
+ // Scope to this order's reviews only: a customer who buys the same
+ // product on a later order shouldn't see their old review here.
+ $comments = get_comments(
+ array(
+ 'post__in' => array_values( $product_ids ),
+ 'author_email' => $email,
+ 'type' => 'review',
+ 'status' => 'approve',
+ 'include_unapproved' => array( $email ),
+ 'orderby' => 'comment_date_gmt',
+ 'order' => 'DESC',
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post__in + author_email.
+ array(
+ 'key' => self::ORDER_META_KEY,
+ 'value' => (string) $order_id,
+ ),
+ ),
+ )
+ );
+
+ // Default every product id to null so subsequent reads don't re-query.
+ foreach ( $product_ids as $pid ) {
+ self::$review_cache[ self::cache_key( $order_id, $pid, $email ) ] = null;
+ }
+
+ if ( is_array( $comments ) ) {
+ foreach ( $comments as $comment ) {
+ if ( ! $comment instanceof WP_Comment ) {
+ continue;
+ }
+ $key = self::cache_key( $order_id, (int) $comment->comment_post_ID, $email );
+ if ( null === ( self::$review_cache[ $key ] ?? null ) ) {
+ self::$review_cache[ $key ] = $comment;
+ }
+ }
+ }
+ }
+
+ /**
+ * Reset the per-request cache. Test helper.
+ *
+ * @since 10.8.0
+ * @internal
+ */
+ public static function reset_cache(): void {
+ self::$review_cache = array();
+ }
+
+ /**
+ * Decide how an order line item should render on the Review Order page.
+ *
+ * Returns one of the STATUS_* constants plus the matched comment (when
+ * one exists for this order) and the product id.
+ *
+ * @since 10.8.0
+ *
+ * @param WC_Order_Item_Product $item Order line item.
+ * @param WC_Order $order Order being reviewed.
+ * @return array{status:string, comment:?WP_Comment, product_id:int}
+ */
+ public static function decide( WC_Order_Item_Product $item, WC_Order $order ): array {
+ $product_id = (int) $item->get_product_id();
+ $result = array(
+ 'status' => self::STATUS_FORM,
+ 'comment' => null,
+ 'product_id' => $product_id,
+ );
+
+ if ( $product_id <= 0 || ! comments_open( $product_id ) ) {
+ $result['status'] = self::STATUS_SKIP;
+ return $result;
+ }
+
+ $result['comment'] = self::find_existing_review( $product_id, $order );
+ return $result;
+ }
+
+ /**
+ * Pre-fill payload for a line item: rating, text, and comment id.
+ *
+ * Returns zero/empty values when no review exists for this order's row,
+ * so callers can use it unconditionally.
+ *
+ * @since 10.8.0
+ *
+ * @param WC_Order_Item_Product $item Order line item.
+ * @param WC_Order $order Order being reviewed.
+ * @return array{rating:int, text:string, comment_id:int}
+ */
+ public static function prefill_for_item( WC_Order_Item_Product $item, WC_Order $order ): array {
+ $existing = self::find_existing_review( (int) $item->get_product_id(), $order );
+ if ( ! $existing instanceof WP_Comment ) {
+ return array(
+ 'rating' => 0,
+ 'text' => '',
+ 'comment_id' => 0,
+ );
+ }
+
+ $rating = (int) get_comment_meta( (int) $existing->comment_ID, 'rating', true );
+ if ( $rating < 0 || $rating > 5 ) {
+ $rating = 0;
+ }
+
+ return array(
+ 'rating' => $rating,
+ 'text' => (string) $existing->comment_content,
+ 'comment_id' => (int) $existing->comment_ID,
+ );
+ }
+
+ /**
+ * Drop fully-refunded line items from the eligible-items list.
+ *
+ * Default callback wired onto `woocommerce_review_order_eligible_items`
+ * so the page never shows a row for a product the customer no longer
+ * owns. A line item is considered fully refunded when the absolute
+ * refunded quantity is greater than or equal to the item's ordered
+ * quantity. Fractional quantities are honoured.
+ *
+ * @since 10.8.0
+ *
+ * @param WC_Order_Item[] $items Order line items.
+ * @param WC_Order $order Order being reviewed.
+ * @return WC_Order_Item[]
+ */
+ public static function exclude_fully_refunded_items( array $items, WC_Order $order ): array {
+ $filtered = array();
+ foreach ( $items as $key => $item ) {
+ if ( ! $item instanceof WC_Order_Item_Product ) {
+ $filtered[ $key ] = $item;
+ continue;
+ }
+
+ $refunded_qty = (float) abs( (float) $order->get_qty_refunded_for_item( $item->get_id() ) );
+ $ordered_qty = (float) $item->get_quantity();
+
+ if ( $ordered_qty > 0 && $refunded_qty >= $ordered_qty ) {
+ continue;
+ }
+
+ $filtered[ $key ] = $item;
+ }
+
+ return $filtered;
+ }
+
+ /**
+ * Look up the customer's review for a product on this order.
+ *
+ * @since 10.8.0
+ *
+ * @param int $product_id Product id.
+ * @param WC_Order $order Order being reviewed.
+ * @return WP_Comment|null
+ */
+ private static function find_existing_review( int $product_id, WC_Order $order ): ?WP_Comment {
+ $email = $order->get_billing_email();
+ $order_id = (int) $order->get_id();
+ if ( '' === $email || $order_id <= 0 || $product_id <= 0 ) {
+ return null;
+ }
+
+ $key = self::cache_key( $order_id, $product_id, $email );
+ if ( array_key_exists( $key, self::$review_cache ) ) {
+ return self::$review_cache[ $key ];
+ }
+
+ $comments = get_comments(
+ array(
+ 'post_id' => $product_id,
+ 'author_email' => $email,
+ 'type' => 'review',
+ 'status' => 'approve',
+ 'include_unapproved' => array( $email ),
+ 'number' => 1,
+ 'orderby' => 'comment_date_gmt',
+ 'order' => 'DESC',
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post_id + author_email.
+ array(
+ 'key' => self::ORDER_META_KEY,
+ 'value' => (string) $order_id,
+ ),
+ ),
+ )
+ );
+
+ if ( ! is_array( $comments ) || empty( $comments ) ) {
+ self::$review_cache[ $key ] = null;
+ return null;
+ }
+
+ $first = reset( $comments );
+ $found = $first instanceof WP_Comment ? $first : null;
+
+ self::$review_cache[ $key ] = $found;
+ return $found;
+ }
+
+ /**
+ * Build the per-request cache key.
+ *
+ * @param int $order_id Order id.
+ * @param int $product_id Product id.
+ * @param string $email Customer email.
+ */
+ private static function cache_key( int $order_id, int $product_id, string $email ): string {
+ return $order_id . '|' . $product_id . '|' . $email;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
index cd86af8446c..cd91ca5f859 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -117,13 +117,17 @@ class SubmissionHandler {
*/
private function process_rows( WC_Order $order, array $rows_in ): array {
$results = array();
- $item_index = $this->index_order_items( $order );
+ $item_index = $this->index_eligible_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' );
+ // Preload the eligibility cache so the per-row decide() calls below
+ // don't issue one already-reviewed query each.
+ ItemEligibility::preload_for_items( $item_index, $order );
+
foreach ( $rows_in as $row_index => $row ) {
$row_index = (int) $row_index;
$row = is_array( $row ) ? $row : array();
@@ -150,6 +154,8 @@ class SubmissionHandler {
continue;
}
+ // invalid_row also covers fully-refunded line items: index_eligible_order_items()
+ // runs them through woocommerce_review_order_eligible_items, which strips them.
if ( ! $product_id || ! $order_item_id || ! isset( $item_index[ $order_item_id ] ) ) {
$result['error'] = 'invalid_row';
$results[ $row_index ] = $result;
@@ -172,6 +178,15 @@ class SubmissionHandler {
// product page regardless of which variation was bought.
$review_post_id = $line_product_id;
+ // Reject submissions for products whose review form was never
+ // rendered (comments disabled on the product).
+ $decision = ItemEligibility::decide( $item, $order );
+ if ( ItemEligibility::STATUS_SKIP === $decision['status'] ) {
+ $result['error'] = 'reviews_not_open';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
// 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).
@@ -179,6 +194,36 @@ class SubmissionHandler {
$current_user_id = get_current_user_id();
$comment_user_id = ( $current_user_id > 0 && $current_user_id === $customer_id ) ? $current_user_id : 0;
+ // If the customer already has a review tied to this order for this
+ // product, update it in place instead of stacking duplicates. The
+ // existing comment id comes from the server-side lookup, not the
+ // client, so a tampered POST can't target someone else's review.
+ $existing = $decision['comment'] instanceof \WP_Comment ? $decision['comment'] : null;
+
+ if ( $existing instanceof \WP_Comment ) {
+ $update_ok = wp_update_comment(
+ wp_slash(
+ array(
+ 'comment_ID' => (int) $existing->comment_ID,
+ 'comment_content' => $text,
+ 'comment_approved' => $require_mod ? 0 : 1,
+ )
+ )
+ );
+ if ( false === $update_ok || is_wp_error( $update_ok ) ) {
+ $result['error'] = 'update_failed';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
+ update_comment_meta( (int) $existing->comment_ID, 'rating', $rating );
+
+ $result['comment_id'] = (int) $existing->comment_ID;
+ $result['status'] = $require_mod ? 'pending_moderation' : 'ok';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
$comment_data = array(
'comment_post_ID' => $review_post_id,
'comment_author' => '' !== $author_name ? $author_name : __( 'Anonymous', 'woocommerce' ),
@@ -200,6 +245,7 @@ class SubmissionHandler {
add_comment_meta( $comment_id, 'rating', $rating, true );
add_comment_meta( $comment_id, 'verified', 1, true );
+ add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id(), true );
$result['comment_id'] = (int) $comment_id;
$result['status'] = $require_mod ? 'pending_moderation' : 'ok';
@@ -286,14 +332,31 @@ class SubmissionHandler {
}
/**
- * Map order_item_id => `WC_Order_Item_Product` for fast row lookup.
+ * Map order_item_id => `WC_Order_Item_Product` for fast row lookup,
+ * filtered through `woocommerce_review_order_eligible_items` so the
+ * handler agrees with the page on which items are reviewable. The
+ * default callback excludes fully-refunded items.
*
* @param WC_Order $order Order being reviewed.
* @return array<int, \WC_Order_Item_Product>
*/
- private function index_order_items( WC_Order $order ): array {
+ private function index_eligible_order_items( WC_Order $order ): array {
+ /**
+ * Filter the eligible items considered by the Review Order
+ * submission handler.
+ *
+ * Same hook the page uses; documented in
+ * `templates/order/customer-review-order.php`.
+ *
+ * @since 10.8.0
+ *
+ * @param \WC_Order_Item[] $items Order line items.
+ * @param WC_Order $order The order being reviewed.
+ */
+ $items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+
$index = array();
- foreach ( $order->get_items() as $item ) {
+ foreach ( $items as $item ) {
if ( $item instanceof \WC_Order_Item_Product ) {
$index[ $item->get_id() ] = $item;
}
diff --git a/plugins/woocommerce/templates/order/customer-review-order-row.php b/plugins/woocommerce/templates/order/customer-review-order-row.php
index 31128db5ccf..16125fd7d02 100644
--- a/plugins/woocommerce/templates/order/customer-review-order-row.php
+++ b/plugins/woocommerce/templates/order/customer-review-order-row.php
@@ -12,10 +12,12 @@
* @package WooCommerce\Templates
* @version 10.8.0
*
- * @var WC_Order_Item_Product $item Order line item being rendered.
- * @var WC_Product $product Product attached to the line item.
- * @var WC_Order $order Order being reviewed.
- * @var int $row_index Zero-based row index, used in input names.
+ * @var WC_Order_Item_Product $item Order line item being rendered.
+ * @var WC_Product $product Product attached to the line item.
+ * @var WC_Order $order Order being reviewed.
+ * @var int $row_index Zero-based row index, used in input names.
+ * @var int $existing_rating Pre-fill rating (0 when no prior review for this order).
+ * @var string $existing_text Pre-fill review text (empty when no prior review for this order).
*/
defined( 'ABSPATH' ) || exit;
@@ -24,6 +26,9 @@ if ( ! $item instanceof WC_Order_Item_Product || ! $product instanceof WC_Produc
return;
}
+$existing_rating = isset( $existing_rating ) ? (int) $existing_rating : 0;
+$existing_text = isset( $existing_text ) ? (string) $existing_text : '';
+
$item_id = $item->get_id();
$product_id = $product->get_id();
$product_link = $product->is_visible() ? get_permalink( $product_id ) : '';
@@ -38,6 +43,7 @@ $rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::rend
'name' => 'reviews[' . $row_index . '][rating]',
'id_prefix' => 'woocommerce-review-rating-' . $item_id,
'label_id' => $rating_label_id,
+ 'selected' => $existing_rating,
)
);
?>
@@ -85,7 +91,7 @@ $rating_control = \Automattic\WooCommerce\Internal\OrderReviews\StarRating::rend
name="reviews[<?php echo esc_attr( (string) $row_index ); ?>][text]"
rows="3"
placeholder="<?php esc_attr_e( 'Share your experience with this product...', 'woocommerce' ); ?>"
- ></textarea>
+ ><?php echo esc_textarea( $existing_text ); ?></textarea>
</div>
<?php
diff --git a/plugins/woocommerce/templates/order/customer-review-order.php b/plugins/woocommerce/templates/order/customer-review-order.php
index 3ac0f4108a4..59f9c0a8e9b 100644
--- a/plugins/woocommerce/templates/order/customer-review-order.php
+++ b/plugins/woocommerce/templates/order/customer-review-order.php
@@ -86,6 +86,10 @@ foreach ( $items as $item ) {
);
}
+// Single batched lookup of every existing review by this customer for the
+// items below. Without this each decide() call would issue its own query.
+\Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::preload_for_items( $items, $order );
+
// The Endpoint has already validated the URL key against the order key, so the
// canonical value on the order is the right thing to echo into the form post.
$order_key = (string) $order->get_order_key();
@@ -122,16 +126,28 @@ $order_key = (string) $order->get_order_key();
<ul class="woocommerce-review-order__items">
<?php
- foreach ( $renderable_rows as $row_index => $row ) {
+ $row_index = 0;
+ foreach ( $renderable_rows as $row ) {
+ $decision = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::decide( $row['item'], $order );
+
+ if ( \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::STATUS_SKIP === $decision['status'] ) {
+ continue;
+ }
+
+ $prefill = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::prefill_for_item( $row['item'], $order );
+
wc_get_template(
'order/customer-review-order-row.php',
array(
- 'item' => $row['item'],
- 'product' => $row['product'],
- 'order' => $order,
- 'row_index' => $row_index,
+ 'item' => $row['item'],
+ 'product' => $row['product'],
+ 'order' => $order,
+ 'row_index' => $row_index,
+ 'existing_rating' => $prefill['rating'],
+ 'existing_text' => $prefill['text'],
)
);
+ ++$row_index;
}
?>
</ul>
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
new file mode 100644
index 00000000000..89b3d95ea8e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
@@ -0,0 +1,207 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
+
+use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility;
+use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Helper_Product;
+use WC_Order_Item_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for ItemEligibility.
+ *
+ * @covers \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility
+ */
+class ItemEligibilityTest extends WC_Unit_Test_Case {
+
+ /**
+ * Reset between tests.
+ */
+ public function tearDown(): void {
+ ItemEligibility::reset_cache();
+ parent::tearDown();
+ }
+
+ /**
+ * Build a 1-product completed order.
+ *
+ * @param string $email Billing email to set on the order.
+ * @return array Map with `order`, `item`, and `product_id`.
+ */
+ private function make_order( string $email = 'jane@example.test' ): array {
+ $order = OrderHelper::create_order();
+ foreach ( $order->get_items() as $line ) {
+ $order->remove_item( $line->get_id() );
+ }
+ $order->set_billing_email( $email );
+ $order->set_status( OrderStatus::COMPLETED );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $order->add_product( $product, 1 );
+ $order->save();
+
+ $items = $order->get_items();
+ $item = reset( $items );
+
+ return array(
+ 'order' => $order,
+ 'item' => $item,
+ 'product_id' => $product->get_id(),
+ );
+ }
+
+ /**
+ * Insert a customer review for a product, optionally tagged with the source order id.
+ *
+ * @param int $product_id Product post id.
+ * @param string $email Author email.
+ * @param string $body Comment body.
+ * @param int $rating Rating value 1-5.
+ * @param int|null $order_id Source order id stamped as `_review_order_id` commentmeta. Pass null to skip.
+ * @param int $approved 1 for approved, 0 for pending moderation.
+ * @return int Inserted comment id.
+ */
+ private function insert_review( int $product_id, string $email, string $body, int $rating, ?int $order_id = null, int $approved = 1 ): int {
+ $comment_id = (int) wp_insert_comment(
+ array(
+ 'comment_post_ID' => $product_id,
+ 'comment_author' => 'Reviewer',
+ 'comment_author_email' => $email,
+ 'comment_content' => $body,
+ 'comment_type' => 'review',
+ 'comment_approved' => $approved,
+ )
+ );
+ add_comment_meta( $comment_id, 'rating', $rating, true );
+ if ( null !== $order_id ) {
+ add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, $order_id, true );
+ }
+ return $comment_id;
+ }
+
+ /**
+ * @testdox decide() returns `form` and no comment when no review exists for this order.
+ */
+ public function test_decide_default_returns_form(): void {
+ $built = $this->make_order();
+
+ $decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+ $this->assertSame( ItemEligibility::STATUS_FORM, $decision['status'] );
+ $this->assertNull( $decision['comment'] );
+ }
+
+ /**
+ * @testdox decide() returns `skip` when comments are closed on the product.
+ */
+ public function test_decide_skip_when_comments_closed(): void {
+ $built = $this->make_order();
+ wp_update_post(
+ array(
+ 'ID' => $built['product_id'],
+ 'comment_status' => 'closed',
+ )
+ );
+
+ $decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+ $this->assertSame( ItemEligibility::STATUS_SKIP, $decision['status'] );
+ }
+
+ /**
+ * @testdox decide() returns the matching review when one exists for *this* order.
+ */
+ public function test_decide_surfaces_review_from_same_order(): void {
+ $built = $this->make_order( 'match@example.test' );
+ $comment_id = $this->insert_review( $built['product_id'], 'match@example.test', 'Worked great.', 5, (int) $built['order']->get_id() );
+
+ $decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+ $this->assertSame( ItemEligibility::STATUS_FORM, $decision['status'] );
+ $this->assertNotNull( $decision['comment'] );
+ $this->assertSame( $comment_id, (int) $decision['comment']->comment_ID );
+ }
+
+ /**
+ * @testdox decide() ignores reviews tagged to a different order (re-reviewing is allowed).
+ */
+ public function test_decide_ignores_review_from_different_order(): void {
+ $built = $this->make_order( 'repeat@example.test' );
+ // Same customer + product, but review came from a different (older) order.
+ $this->insert_review( $built['product_id'], 'repeat@example.test', 'First time.', 4, (int) $built['order']->get_id() + 999 );
+
+ $decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+ $this->assertSame( ItemEligibility::STATUS_FORM, $decision['status'] );
+ $this->assertNull( $decision['comment'], 'Reviews from a different order must not pre-fill the current row.' );
+ }
+
+ /**
+ * @testdox decide() ignores reviews without the order meta (default for legacy reviews).
+ */
+ public function test_decide_ignores_review_without_order_meta(): void {
+ $built = $this->make_order( 'legacy@example.test' );
+ $this->insert_review( $built['product_id'], 'legacy@example.test', 'Pre-feature review.', 3, null );
+
+ $decision = ItemEligibility::decide( $built['item'], $built['order'] );
+
+ $this->assertSame( ItemEligibility::STATUS_FORM, $decision['status'] );
+ $this->assertNull( $decision['comment'] );
+ }
+
+ /**
+ * @testdox prefill_for_item() returns rating + text + comment id when this order has a review.
+ */
+ public function test_prefill_returns_existing_review_data(): void {
+ $built = $this->make_order( 'prefill@example.test' );
+ $comment_id = $this->insert_review( $built['product_id'], 'prefill@example.test', 'Solid 4 stars.', 4, (int) $built['order']->get_id() );
+
+ $prefill = ItemEligibility::prefill_for_item( $built['item'], $built['order'] );
+
+ $this->assertSame( 4, $prefill['rating'] );
+ $this->assertSame( 'Solid 4 stars.', $prefill['text'] );
+ $this->assertSame( $comment_id, $prefill['comment_id'] );
+ }
+
+ /**
+ * @testdox prefill_for_item() returns zeros / empty when no review for this order.
+ */
+ public function test_prefill_returns_empty_when_no_review(): void {
+ $built = $this->make_order();
+
+ $prefill = ItemEligibility::prefill_for_item( $built['item'], $built['order'] );
+
+ $this->assertSame( 0, $prefill['rating'] );
+ $this->assertSame( '', $prefill['text'] );
+ $this->assertSame( 0, $prefill['comment_id'] );
+ }
+
+ /**
+ * @testdox preload_for_items() caches per-order so decide() does not requery.
+ */
+ public function test_preload_caches_results(): void {
+ $built = $this->make_order( 'cache@example.test' );
+ $this->insert_review( $built['product_id'], 'cache@example.test', 'Cached.', 5, (int) $built['order']->get_id() );
+
+ ItemEligibility::preload_for_items( $built['order']->get_items(), $built['order'] );
+
+ $call_count = 0;
+ $counter = static function ( $value ) use ( &$call_count ) {
+ ++$call_count;
+ return $value;
+ };
+ add_filter( 'comments_pre_query', $counter );
+
+ try {
+ $decision = ItemEligibility::decide( $built['item'], $built['order'] );
+ } finally {
+ remove_filter( 'comments_pre_query', $counter );
+ }
+
+ $this->assertNotNull( $decision['comment'] );
+ $this->assertSame( 0, $call_count, 'decide() should not query when preload_for_items() has cached the result.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
index 97f4c15ce73..669e2091f2b 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility;
use Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use WC_Helper_Product;
@@ -24,6 +25,7 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
update_option( 'comment_moderation', '0' );
remove_all_filters( 'woocommerce_review_order_submitted' );
remove_all_filters( 'woocommerce_review_order_eligible_statuses' );
+ remove_all_filters( 'woocommerce_review_order_eligible_items' );
remove_all_filters( 'wp_die_ajax_handler' );
remove_all_filters( 'wp_send_json_handler' );
remove_all_filters( 'wp_doing_ajax' );
@@ -502,4 +504,231 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
$this->assertFalse( $response['success'] );
}
+
+ /**
+ * @testdox Resubmitting for the same order updates the existing review in place (no duplicate row).
+ */
+ public function test_resubmit_for_same_order_updates_existing_review(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ // First submission inserts the comment with the order-id meta.
+ $_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' => 3,
+ 'text' => 'First take.',
+ ),
+ ),
+ );
+ $first = $this->dispatch();
+ $first_row = reset( $first['data']['results'] );
+ $comment_id = (int) $first_row['comment_id'];
+ $this->assertGreaterThan( 0, $comment_id );
+
+ // Second submission edits the same row.
+ $_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' => 'On reflection — outstanding.',
+ ),
+ ),
+ );
+ $second = $this->dispatch();
+ $second_row = reset( $second['data']['results'] );
+
+ $this->assertSame( 'ok', $second_row['status'] );
+ $this->assertSame( $comment_id, (int) $second_row['comment_id'], 'Re-submit must update the existing comment, not create a new one.' );
+
+ $updated = get_comment( $comment_id );
+ $this->assertSame( 'On reflection — outstanding.', $updated->comment_content );
+ $this->assertSame( '5', get_comment_meta( $comment_id, 'rating', true ) );
+
+ $total = (int) get_comments(
+ array(
+ 'post_id' => $product_id,
+ 'author_email' => $order->get_billing_email(),
+ 'type' => 'review',
+ 'count' => true,
+ 'status' => 'all',
+ )
+ );
+ $this->assertSame( 1, $total, 'No duplicate comment may exist after an edit-resubmit.' );
+ }
+
+ /**
+ * @testdox A review left for a previous order does not block re-reviewing the same product on a new order.
+ */
+ public function test_review_from_previous_order_does_not_block_new_review(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ // Simulate a review from a different order: same email + product, different
+ // _review_order_id meta so the scoping doesn't surface it for this order.
+ $older_comment_id = (int) wp_insert_comment(
+ array(
+ 'comment_post_ID' => $product_id,
+ 'comment_author' => 'Jane Doe',
+ 'comment_author_email' => $order->get_billing_email(),
+ 'comment_content' => 'First time round.',
+ 'comment_type' => 'review',
+ 'comment_approved' => 1,
+ )
+ );
+ add_comment_meta( $older_comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id() + 999, true );
+
+ $_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' => 'Second purchase, even better.',
+ ),
+ ),
+ );
+ $response = $this->dispatch();
+ $row = reset( $response['data']['results'] );
+
+ $this->assertSame( 'ok', $row['status'] );
+ $this->assertNotSame( $older_comment_id, (int) $row['comment_id'], 'New order must produce a fresh comment, not edit the previous order\'s review.' );
+
+ // Two comments exist now: the legacy one and the new one for this order.
+ $total = (int) get_comments(
+ array(
+ 'post_id' => $product_id,
+ 'author_email' => $order->get_billing_email(),
+ 'type' => 'review',
+ 'count' => true,
+ 'status' => 'all',
+ )
+ );
+ $this->assertSame( 2, $total );
+ }
+
+ /**
+ * @testdox A row whose product has comments closed is rejected with reviews_not_open.
+ */
+ public function test_rejects_row_when_reviews_disabled_on_product(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ wp_update_post(
+ array(
+ 'ID' => $product_id,
+ 'comment_status' => 'closed',
+ )
+ );
+
+ $_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,
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $results = $response['data']['results'];
+ $row = reset( $results );
+ $this->assertSame( 'error', $row['status'] );
+ $this->assertSame( 'reviews_not_open', $row['error'] );
+
+ $total = (int) get_comments(
+ array(
+ 'post_id' => $product_id,
+ 'author_email' => $order->get_billing_email(),
+ 'type' => 'review',
+ 'count' => true,
+ 'status' => 'all',
+ )
+ );
+ $this->assertSame( 0, $total );
+ }
+
+ /**
+ * @testdox A row for a fully-refunded line item is rejected via the eligible-items filter.
+ */
+ public function test_rejects_row_for_fully_refunded_item(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ // Stand in for the round-1 default callback that would normally
+ // drop fully-refunded items. The handler uses the same filter, so
+ // dropping the item here mirrors what the WC default does.
+ add_filter(
+ 'woocommerce_review_order_eligible_items',
+ static function ( $items, $order_arg ) use ( $item_id ) {
+ unset( $order_arg );
+ $filtered = array();
+ foreach ( $items as $key => $item ) {
+ if ( $item->get_id() !== $item_id ) {
+ $filtered[ $key ] = $item;
+ }
+ }
+ return $filtered;
+ },
+ 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' => 5,
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $results = $response['data']['results'];
+ $row = reset( $results );
+ $this->assertSame( 'error', $row['status'] );
+ $this->assertSame( 'invalid_row', $row['error'] );
+
+ remove_all_filters( 'woocommerce_review_order_eligible_items' );
+
+ $total = (int) get_comments(
+ array(
+ 'post_id' => $product_id,
+ 'author_email' => $order->get_billing_email(),
+ 'type' => 'review',
+ 'count' => true,
+ 'status' => 'all',
+ )
+ );
+ $this->assertSame( 0, $total );
+ }
}