Commit 5938a201bef for woocommerce
commit 5938a201befb81662baa4913614d32758bd25027
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Sep 11 23:48:35 2026 +0300
Fix product responses crashing after a global attribute is deleted (#68567)
* fix(products): guard readers of a just-deleted global attribute
Since #68212, wc_delete_attribute() unregisters the attribute
taxonomy and drops it from $wc_product_attributes before it
returns. Product objects loaded earlier in the same request still
carry the attribute, and several readers assume its taxonomy is
there: the Store API products endpoint throws a TypeError from
array_map() on the null that get_terms() returns, REST v1 fatals
in get_taxonomy_labels(), REST v2 and v3 return a null attribute
name with warnings, and the admin variation views and the CSV
exporter warn. With product instance caching on, any later
wc_get_product() in that request returns such an object.
Guard those readers instead of changing any public return value:
get_taxonomy_object() checks the global entry exists, the four
unguarded get_terms() callers cast to an array, the REST v1 and v2
label helpers and the v2 attribute name fall back to the slug, and
wc_display_product_attributes() and the Product Specifications
block check for null. The post-return state #68212 introduced is
unchanged, and the next request behaves as before.
Refs WOOAIRR-273
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(phpstan): drop baseline entries the attribute guards resolve
The guards added for readers of a just-deleted global attribute fix
three errors that were baselined: the CSV exporter looped over the
array|null that get_terms() returns, and the REST v1 and v2 label
helpers passed get_taxonomy()'s WP_Taxonomy|false straight to
get_taxonomy_labels(). With the errors gone, the full PHPStan run
fails on the unmatched baseline entries, so remove all three. Each
had a count of 1 and nothing is added.
Refs WOOAIRR-273
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(products): cover the v1 default attribute read after an earlier deletion
The existing v1 test loads the product before deleting the attribute, so
it only reaches the crash through the in-memory product. Later requests
crash too, and for a different reason: a fresh read drops the deleted
attribute from the product, but the default attribute stays behind in
the _default_attributes post meta, which nothing cleans up on deletion.
The v1 controller resolves that meta key straight against the taxonomy
registry, so every later request for the product hits the missing
taxonomy again.
Flushing the product cache and the object cache before the request is
what makes the test read the product from the database and reach that
path. Without the flush the request still crashes without the guard, but
through get_attributes(), which the existing test already covers.
Refs WOOAIRR-273
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(changelog): widen the entry to cover later requests
The fix is not limited to the request the attribute was deleted in: REST
v1 kept crashing on every later request for a variable product with a
default attribute set.
Refs WOOAIRR-273
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-wooairr-273-stale-attribute-readers b/plugins/woocommerce/changelog/fix-wooairr-273-stale-attribute-readers
new file mode 100644
index 00000000000..32742598cdd
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooairr-273-stale-attribute-readers
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep product responses, admin variation screens and CSV exports working after one of a product's global attributes is deleted.
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
index cf199fe1381..8466e23400a 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-data-variations.php
@@ -50,7 +50,7 @@ $arrow_img_url = WC_ADMIN_IMAGES_FOLDER_URL . '/product_data/no-variati
<?php /* translators: WooCommerce attribute label */ ?>
<option value=""><?php echo esc_html( sprintf( __( 'No default %s…', 'woocommerce' ), wc_attribute_label( $attribute->get_name() ) ) ); ?></option>
<?php if ( $attribute->is_taxonomy() ) : ?>
- <?php foreach ( $attribute->get_terms() as $option ) : ?>
+ <?php foreach ( (array) $attribute->get_terms() as $option ) : ?>
<?php /* phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment */ ?>
<option <?php selected( $selected_value, $option->slug ); ?> value="<?php echo esc_attr( $option->slug ); ?>"><?php echo esc_html( apply_filters( 'woocommerce_variation_option_name', $option->name, $option, $attribute->get_name(), $product_object ) ); ?></option>
<?php /* phpcs:enable */ ?>
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
index 126234d4712..1f125d2fbd6 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-variation-admin.php
@@ -37,7 +37,7 @@ defined( 'ABSPATH' ) || exit;
?>
</option>
<?php if ( $attribute->is_taxonomy() ) : ?>
- <?php foreach ( $attribute->get_terms() as $option ) : ?>
+ <?php foreach ( (array) $attribute->get_terms() as $option ) : ?>
<?php /* phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment */ ?>
<option <?php selected( $selected_value, $option->slug ); ?> value="<?php echo esc_attr( $option->slug ); ?>"><?php echo esc_html( apply_filters( 'woocommerce_variation_option_name', $option->name, $option, $attribute->get_name(), $product_object ) ); ?></option>
<?php /* phpcs:enable */ ?>
diff --git a/plugins/woocommerce/includes/class-wc-product-attribute.php b/plugins/woocommerce/includes/class-wc-product-attribute.php
index 53e44f43a26..59b927063d1 100644
--- a/plugins/woocommerce/includes/class-wc-product-attribute.php
+++ b/plugins/woocommerce/includes/class-wc-product-attribute.php
@@ -61,16 +61,20 @@ class WC_Product_Attribute implements ArrayAccess {
/**
* Get taxonomy object.
*
+ * Returns null when the attribute is not a taxonomy, or when its global attribute is no longer loaded, for example right after it is deleted.
+ *
* @return array|null
*/
public function get_taxonomy_object() {
global $wc_product_attributes;
- return $this->is_taxonomy() ? $wc_product_attributes[ $this->get_name() ] : null;
+ return $this->is_taxonomy() && isset( $wc_product_attributes[ $this->get_name() ] ) ? $wc_product_attributes[ $this->get_name() ] : null;
}
/**
* Gets terms from the stored options.
*
+ * Returns null when the attribute is not a taxonomy, or when its taxonomy is not registered, for example right after the attribute is deleted.
+ *
* @return array|null
*/
public function get_terms() {
diff --git a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
index 45903b00e55..3622df651cb 100644
--- a/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
+++ b/plugins/woocommerce/includes/export/class-wc-product-csv-exporter.php
@@ -714,7 +714,7 @@ class WC_Product_CSV_Exporter extends WC_CSV_Batch_Exporter {
$row[ 'attributes:name' . $i ] = html_entity_decode( wc_attribute_label( $attribute->get_name(), $product ), ENT_QUOTES );
if ( $attribute->is_taxonomy() ) {
- $terms = $attribute->get_terms();
+ $terms = (array) $attribute->get_terms();
$values = array();
foreach ( $terms as $term ) {
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
index 143ef5dd4d4..c2737181a5b 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
@@ -338,7 +338,10 @@ class WC_REST_Products_V1_Controller extends WC_REST_Posts_Controller {
* @return string
*/
protected function get_attribute_taxonomy_label( $name ) {
- $tax = get_taxonomy( $name );
+ $tax = get_taxonomy( $name );
+ if ( ! $tax ) {
+ return wc_attribute_taxonomy_slug( $name );
+ }
$labels = get_taxonomy_labels( $tax );
return $labels->singular_name;
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index 1022857eaa2..09d384fc02f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -601,7 +601,10 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
* @return string
*/
protected function get_attribute_taxonomy_label( $name ) {
- $tax = get_taxonomy( $name );
+ $tax = get_taxonomy( $name );
+ if ( ! $tax ) {
+ return wc_attribute_taxonomy_slug( $name );
+ }
$labels = get_taxonomy_labels( $tax );
return $labels->singular_name;
@@ -640,7 +643,7 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
// Taxonomy attribute name.
if ( $attribute->is_taxonomy() ) {
$taxonomy = $attribute->get_taxonomy_object();
- return $taxonomy->attribute_label;
+ return $taxonomy ? $taxonomy->attribute_label : $slug;
}
// Custom product attribute name.
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index 766ac694193..61e6918a56a 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -4281,7 +4281,7 @@ function wc_display_product_attributes( $product ) {
foreach ( $attribute_values as $attribute_value ) {
$value_name = esc_html( $attribute_value->name );
- if ( $attribute_taxonomy->attribute_public ) {
+ if ( $attribute_taxonomy && $attribute_taxonomy->attribute_public ) {
$values[] = '<a href="' . esc_url( get_term_link( $attribute_value->term_id, $attribute->get_name() ) ) . '" rel="tag">' . $value_name . '</a>';
} else {
$values[] = $value_name;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 9db06415c6a..cba816698a6 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -19051,12 +19051,6 @@ parameters:
count: 1
path: includes/export/abstract-wc-csv-exporter.php
- -
- message: '#^Argument of an invalid type array\|null supplied for foreach, only iterables are supported\.$#'
- identifier: foreach.nonIterable
- count: 1
- path: includes/export/class-wc-product-csv-exporter.php
-
-
message: '#^Argument of an invalid type array\|stdClass supplied for foreach, only iterables are supported\.$#'
identifier: foreach.nonIterable
@@ -24667,12 +24661,6 @@ parameters:
count: 2
path: includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
- -
- message: '#^Parameter \#1 \$tax of function get_taxonomy_labels expects WP_Taxonomy, WP_Taxonomy\|false given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
-
-
message: '#^Parameter \#1 \$title of function sanitize_title expects string, array\|string given\.$#'
identifier: argument.type
@@ -26839,12 +26827,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
- -
- message: '#^Parameter \#1 \$tax of function get_taxonomy_labels expects WP_Taxonomy, WP_Taxonomy\|false given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-
message: '#^Parameter \#1 \$title of function sanitize_title expects string, array\|string given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSpecifications.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSpecifications.php
index bf9efa8be63..165130532e3 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductSpecifications.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductSpecifications.php
@@ -84,7 +84,7 @@ class ProductSpecifications extends AbstractBlock {
foreach ( $attribute_values as $attribute_value ) {
$value_name = esc_html( $attribute_value->name );
- if ( $attribute_taxonomy->attribute_public ) {
+ if ( $attribute_taxonomy && $attribute_taxonomy->attribute_public ) {
$values[] = '<a href="' . esc_url( get_term_link( $attribute_value->term_id, $attribute->get_name() ) ) . '" rel="tag">' . $value_name . '</a>';
} else {
$values[] = $value_name;
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
index 9062905b778..ca2ae649e41 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
@@ -892,7 +892,7 @@ class ProductSchema extends AbstractSchema {
continue;
}
- $terms = $attribute->is_taxonomy() ? array_map( [ $this, 'prepare_product_attribute_taxonomy_value' ], $attribute->get_terms() ) : array_map( [ $this, 'prepare_product_attribute_value' ], $attribute->get_options() );
+ $terms = $attribute->is_taxonomy() ? array_map( [ $this, 'prepare_product_attribute_taxonomy_value' ], (array) $attribute->get_terms() ) : array_map( [ $this, 'prepare_product_attribute_value' ], $attribute->get_options() );
// Custom attribute names are sanitized to be the array keys.
// So when we do the array_key_exists check below we also need to sanitize the attribute names.
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-product-attribute-test.php b/plugins/woocommerce/tests/php/includes/class-wc-product-attribute-test.php
new file mode 100644
index 00000000000..86d70dea4ec
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/class-wc-product-attribute-test.php
@@ -0,0 +1,52 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for the WC_Product_Attribute class.
+ *
+ * @package WooCommerce\Tests\Includes
+ */
+class WC_Product_Attribute_Test extends \WC_Unit_Test_Case {
+
+ /**
+ * @testdox get_taxonomy_object() returns null without a warning after the global attribute is deleted.
+ */
+ public function test_get_taxonomy_object_returns_null_without_warning_after_attribute_deletion(): void {
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'Stale Finish', array( 'Matte' ) );
+ $this->assertSame( 'Stale Finish', $attribute->get_taxonomy_object()->attribute_label, 'The taxonomy object should be available before the deletion.' );
+
+ wc_delete_attribute( $attribute->get_id() );
+
+ $warnings = array();
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the warning is the assertion; PHPUnit would otherwise convert it to an exception.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ): bool {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- Reads the level only, to skip warnings silenced with @.
+ if ( error_reporting() & $errno ) {
+ $warnings[] = $errstr;
+ }
+ return true;
+ }
+ );
+ try {
+ $taxonomy_object = $attribute->get_taxonomy_object();
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertNull( $taxonomy_object );
+ $this->assertSame( array(), $warnings, 'Reading the taxonomy object of a deleted attribute should not raise a warning.' );
+ }
+
+ /**
+ * @testdox get_terms() still returns null after the attribute taxonomy is unregistered.
+ */
+ public function test_get_terms_returns_null_after_attribute_deletion(): void {
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'Stale Finish', array( 'Matte' ) );
+ $this->assertCount( 1, $attribute->get_terms(), 'The attribute should have its term before the deletion.' );
+
+ wc_delete_attribute( $attribute->get_id() );
+
+ $this->assertNull( $attribute->get_terms(), 'Callers rely on null to detect a taxonomy that is not registered.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
index 4710d45cba0..fd0137bede3 100644
--- a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
+++ b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
@@ -203,4 +203,41 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
}
}
}
+
+ /**
+ * @testdox Exporting a product loaded before its global attribute is deleted leaves the value empty without a warning.
+ */
+ public function test_export_row_for_product_loaded_before_its_global_attribute_is_deleted(): void {
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'Stale Finish', array( 'Matte' ) );
+ $product = new WC_Product_Simple();
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+ $product = wc_get_product( $product->get_id() );
+
+ wc_delete_attribute( $attribute->get_id() );
+
+ $exporter = new WC_Product_CSV_Exporter();
+ $generate_row_data = ( new ReflectionClass( WC_Product_CSV_Exporter::class ) )->getMethod( 'generate_row_data' );
+ $generate_row_data->setAccessible( true );
+ $warnings = array();
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the warning is the assertion; PHPUnit would otherwise convert it to an exception.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ): bool {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- Reads the level only, to skip warnings silenced with @.
+ if ( error_reporting() & $errno ) {
+ $warnings[] = $errstr;
+ }
+ return true;
+ }
+ );
+ try {
+ $row = $generate_row_data->invoke( $exporter, $product );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame( array(), $warnings, 'Exporting the product should not raise warnings.' );
+ $this->assertSame( '', $row['attributes:value1'] );
+ $this->assertSame( 1, $row['attributes:taxonomy1'] );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller-tests.php
new file mode 100644
index 00000000000..a9340e49944
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller-tests.php
@@ -0,0 +1,102 @@
+<?php
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Internal\Caches\ProductCache;
+
+/**
+ * Tests for the REST API v1 products controller.
+ *
+ * @package WooCommerce\Tests\RestApi
+ */
+class WC_REST_Products_V1_Controller_Test extends WC_REST_Unit_Test_Case {
+
+ /**
+ * Set up an administrator for the requests.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+ }
+
+ /**
+ * @testdox Getting a product loaded before its global attribute is deleted uses the attribute slug as its name.
+ */
+ public function test_get_item_for_product_loaded_before_its_global_attribute_is_deleted(): void {
+ update_option( 'woocommerce_feature_product_instance_caching_enabled', 'yes' );
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'Stale Finish', array( 'Matte' ) );
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Stale attribute product' );
+ $product->set_attributes( array( $attribute ) );
+ $product->set_default_attributes( array( $attribute->get_name() => 'matte' ) );
+ $product->save();
+ wc_get_product( $product->get_id() );
+
+ wc_delete_attribute( $attribute->get_id() );
+
+ $warnings = array();
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the warning is the assertion; PHPUnit would otherwise convert it to an exception.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ): bool {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- Reads the level only, to skip warnings silenced with @.
+ if ( error_reporting() & $errno ) {
+ $warnings[] = $errstr;
+ }
+ return true;
+ }
+ );
+ try {
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v1/products/' . $product->get_id() ) );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array(), $warnings, 'Serializing the product should not raise warnings.' );
+ $data = $response->get_data();
+ $this->assertSame( array( 'stale-finish' ), wp_list_pluck( $data['attributes'], 'name' ) );
+ $this->assertSame( array( 'stale-finish' ), wp_list_pluck( $data['default_attributes'], 'name' ) );
+ }
+
+ /**
+ * @testdox Getting a product whose global attribute was deleted in an earlier request uses the attribute slug as its default attribute name.
+ */
+ public function test_get_item_for_product_whose_global_attribute_was_deleted_in_an_earlier_request(): void {
+ update_option( 'woocommerce_feature_product_instance_caching_enabled', 'yes' );
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'Stale Finish', array( 'Matte' ) );
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Stale attribute product' );
+ $product->set_attributes( array( $attribute ) );
+ $product->set_default_attributes( array( $attribute->get_name() => 'matte' ) );
+ $product->save();
+
+ wc_delete_attribute( $attribute->get_id() );
+
+ // Drop every copy of the product so the request below reads it from the database, the way a later request would.
+ wc_get_container()->get( ProductCache::class )->flush();
+ wp_cache_flush();
+
+ $warnings = array();
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the warning is the assertion; PHPUnit would otherwise convert it to an exception.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ): bool {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- Reads the level only, to skip warnings silenced with @.
+ if ( error_reporting() & $errno ) {
+ $warnings[] = $errstr;
+ }
+ return true;
+ }
+ );
+ try {
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v1/products/' . $product->get_id() ) );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array(), $warnings, 'Serializing the product should not raise warnings.' );
+ $data = $response->get_data();
+ // A fresh read drops the deleted attribute, but the default attribute stays in post meta.
+ $this->assertSame( array(), $data['attributes'] );
+ $this->assertSame( array( 'stale-finish' ), wp_list_pluck( $data['default_attributes'], 'name' ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
index 71f15444f97..7a36436500a 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
@@ -466,4 +466,53 @@ class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {
$this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
}
+
+ /**
+ * @testdox Getting a product loaded before its global attribute is deleted uses the attribute slug as its name.
+ */
+ public function test_get_item_for_product_loaded_before_its_global_attribute_is_deleted(): void {
+ update_option( 'woocommerce_feature_product_instance_caching_enabled', 'yes' );
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'Stale Finish', array( 'Matte' ) );
+ $product = new WC_Product_Variable();
+ $product->set_name( 'Stale attribute product' );
+ $product->set_attributes( array( $attribute ) );
+ $product->set_default_attributes( array( $attribute->get_name() => 'matte' ) );
+ $product->save();
+ wc_get_product( $product->get_id() );
+
+ wc_delete_attribute( $attribute->get_id() );
+
+ $warnings = array();
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the warning is the assertion; PHPUnit would otherwise convert it to an exception.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ): bool {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- Reads the level only, to skip warnings silenced with @.
+ if ( error_reporting() & $errno ) {
+ $warnings[] = $errstr;
+ }
+ return true;
+ }
+ );
+ try {
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v2/products/' . $product->get_id() ) );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array(), $warnings, 'Serializing the product should not raise warnings.' );
+ $data = $response->get_data();
+ $this->assertSame( array( 'stale-finish' ), wp_list_pluck( $data['attributes'], 'name' ) );
+ $this->assertSame( array( 'stale-finish' ), wp_list_pluck( $data['default_attributes'], 'name' ) );
+ }
+
+ /**
+ * @testdox The deprecated get_attribute_taxonomy_label() returns the attribute slug for a taxonomy that is not registered.
+ */
+ public function test_deprecated_attribute_taxonomy_label_for_unregistered_taxonomy(): void {
+ $get_label = new ReflectionMethod( WC_REST_Products_V2_Controller::class, 'get_attribute_taxonomy_label' );
+ $get_label->setAccessible( true );
+
+ $this->assertSame( 'not-registered', $get_label->invoke( $this->endpoint, 'pa_not-registered' ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
index 6657301ff7c..10ebdb9cb50 100644
--- a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
@@ -278,4 +278,46 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
$this->assertSame( 'page/%#%/', $pagination_args['format'] );
$this->assertStringContainsString( 'href="https://example.test/shop/"', $markup );
}
+
+ /**
+ * @testdox wc_display_product_attributes() renders term names when the taxonomy is registered but missing from the global attribute list.
+ */
+ public function test_display_product_attributes_without_global_attribute_entry(): void {
+ global $wc_product_attributes;
+
+ $attribute = WC_Helper_Product::create_product_attribute_object( 'Stale Finish', array( 'Matte' ) );
+ $product = new WC_Product_Simple();
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+
+ $taxonomy = $attribute->get_name();
+ $taxonomy_object = $wc_product_attributes[ $taxonomy ];
+ unset( $wc_product_attributes[ $taxonomy ] );
+ $warnings = array();
+ $buffer_level = ob_get_level();
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the warning is the assertion; PHPUnit would otherwise convert it to an exception.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ): bool {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- Reads the level only, to skip warnings silenced with @.
+ if ( error_reporting() & $errno ) {
+ $warnings[] = $errstr;
+ }
+ return true;
+ }
+ );
+ ob_start();
+ try {
+ wc_display_product_attributes( $product );
+ $markup = (string) ob_get_clean();
+ } finally {
+ restore_error_handler();
+ while ( ob_get_level() > $buffer_level ) {
+ ob_end_clean();
+ }
+ $wc_product_attributes[ $taxonomy ] = $taxonomy_object;
+ }
+
+ $this->assertSame( array(), $warnings, 'Rendering the attributes should not raise warnings.' );
+ $this->assertStringContainsString( 'Matte', $markup );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
index 13f9c617228..9b073a566cc 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
@@ -941,4 +941,44 @@ class Products extends ControllerTestCase {
$this->assertEquals( 404, $response->get_status() );
$this->assertFalse( get_transient( 'wc_related_' . $nonexistent_id ), 'No transient should be created for a non-existent product.' );
}
+
+ /**
+ * @testdox Getting a product loaded before its global attribute is deleted returns 200 and the attribute without terms.
+ */
+ public function test_get_item_for_product_loaded_before_its_global_attribute_is_deleted(): void {
+ update_option( 'woocommerce_feature_product_instance_caching_enabled', 'yes' );
+ $attribute = \WC_Helper_Product::create_product_attribute_object( 'Stale Finish', array( 'Matte' ) );
+ $product = new \WC_Product_Simple();
+ $product->set_name( 'Stale attribute product' );
+ $product->set_status( ProductStatus::PUBLISH );
+ $product->set_attributes( array( $attribute ) );
+ $product->save();
+ wc_get_product( $product->get_id() );
+
+ wc_delete_attribute( $attribute->get_id() );
+
+ $warnings = array();
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Capturing the warning is the assertion; PHPUnit would otherwise convert it to an exception.
+ set_error_handler(
+ static function ( int $errno, string $errstr ) use ( &$warnings ): bool {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.prevent_path_disclosure_error_reporting, WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_error_reporting -- Reads the level only, to skip warnings silenced with @.
+ if ( error_reporting() & $errno ) {
+ $warnings[] = $errstr;
+ }
+ return true;
+ }
+ );
+ try {
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $product->get_id() ) );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( array(), $warnings, 'Serializing the product should not raise warnings.' );
+ $attributes = $response->get_data()['attributes'];
+ $this->assertCount( 1, $attributes, 'The cached product still carries the deleted attribute for the rest of the request.' );
+ $this->assertSame( $attribute->get_name(), $attributes[0]->taxonomy );
+ $this->assertSame( array(), $attributes[0]->terms );
+ }
}