Commit 32cce7585de for woocommerce
commit 32cce7585de1e409d76af1387f9b8aee4d9b154e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Sep 10 15:14:01 2026 +0300
Pass product image IDs to WordPress attachment functions as integers (#68447)
* fix(products): normalize image IDs to integers
Product image IDs could remain numeric strings or empty strings depending on whether an object was new, assigned directly, or hydrated from post metadata. That inconsistent contract leaked into WordPress attachment helpers and raw CRUD data.
Normalize IDs at the existing product and variation parent-data boundaries, align defaults and getter declarations, and serialize the admin hidden-input value at its string boundary. This preserves existing filters and call signatures while making core state consistently integer-shaped.
Refs #45168
* chore: add product image ID changelog
Record the product and variation image ID normalization as a patch-level bug fix.
Refs #45168
* fix(products): keep no-image thumbnail meta absent on save
Product image IDs are now normalized to integers, so a product
without an image holds integer 0 instead of an empty string.
WC_Product_Data_Store_CPT::update_or_delete_post_meta() only deletes
a meta row for '' or an empty array. Passing 0 through it wrote a
persistent `_thumbnail_id = '0'` row for every product or variation
saved without an image, where WooCommerce and WordPress core had
always left no row. That breaks NOT EXISTS meta queries,
metadata_exists() checks, and raw SQL "products without image"
reports, and adds one postmeta row per image-less product.
Map integer 0 back to '' in the data store's per-prop serialization
switch so the existing delete path runs and stored data keeps the
shape earlier versions produced. Add a test that asserts the meta row
is absent after creating and after clearing, using metadata_exists()
because get_post_meta() cannot tell an absent row from ''.
Refs #45168
* test(products): consolidate variation image ID tests
The persisted-inheritance test passed with every production change
reverted: the variation data store already hydrates parent images as
integers through get_post_thumbnail_id(), and the in-memory
set_parent_data() normalization it would exercise is covered by the
parent-data test. Remove it.
The two parent-data tests exercised the same absint() line with two
inputs, each paying the class's variable-product fixture and creating
an attachment that set_parent_data() never validates. Merge them into
one in-memory test that asserts both a numeric string and false.
Every remaining image ID test now fails on a single-line revert of
the production change it protects.
Refs #45168
* docs(products): describe the zero image ID in getter docblocks
The getters now return integer 0 where they used to return an empty
string. Name that sentinel in the @return line so readers of
WC_Product::get_image_id() and WC_Product_Variation::get_image_id()
see the change without reading the setter.
Refs #45168
* chore(phpstan): drop three more obsolete baseline entries
The V3 product variations controller passes the variation image ID
to get_post(), get_the_title(), and get_post_meta() as well as to
wp_get_attachment_image_src(). With get_image_id() now declared int,
PHPStan no longer reports those three "string given" errors, so the
full run fails on their unmatched baseline entries. Remove them.
The earlier baseline cleanup only removed the wp_get_attachment_*
entries because the check ran on a subset of files, and PHPStan
skips unmatched-ignore reporting in that mode. The full run
(`pnpm --filter=@woocommerce/plugin-woocommerce phpstan`) is clean.
Refs #45168
* fix(products): cast image IDs after the getter filters
get_image_id() is documented as returning an integer, but the value
came straight from the woocommerce_product_get_image_id and
woocommerce_product_variation_get_image_id filters. A callback
returning a numeric string put the string back into the WordPress
attachment helpers, which is the failure #45168 reports.
Run absint() on the final value in both getters so the contract
holds regardless of filter output. For an attachment ID nothing
meaningful is lost: numeric strings map to the same integer and
empty values map to 0. Add a test per getter with a filter returning
a numeric string; both fail without the cast.
Refs #45168
* docs(products): document absint() remapping in set_image_id()
absint() changes malformed values, not only their type: negative
numbers become positive and '12abc' becomes 12. Say so in the
setter's @since line so the normalization is explicit.
Refs #45168
* chore: name the empty-string to zero change in the changelog
The shipped changelog should state that a product without an image
now reports 0 instead of an empty string.
Refs #45168
* test(products): assert the filtered image ID, not the stored one
Both image ID filter tests returned a numeric-string copy of the value
the product already held, so the assertion could be satisfied by the
stored property alone. Making the getters skip the filter entirely left
both tests passing, which means they covered the absint() cast but not
the fact that the cast is applied to what the filter returned.
Have each callback return a different ID and assert that value instead.
The variation callback rewrites only the inherited parent ID, so the
variation's own empty value still falls through to the parent and that
path stays covered.
Both tests now fail if the absint() cast is dropped and if the getters
stop consulting the filter.
Refs #45168
* fix(products): cast image IDs where core hands them to WordPress
`WC_Product::get_image_id()` returns a numeric string for a product
loaded from the database and '' when there is no image, and core passed
that value straight into wp_get_attachment_image_src() and friends,
which forward it uncast to their filters. A callback typed
`int $attachment_id` receives a string, and '' fails even in coercive
mode. Ten PHPStan baseline entries were hiding the mismatch.
An earlier revision of this branch normalized the getters to integers.
Review feedback pointed out that this changes a documented public
return type and can fatal strict-typed theme or merchant code that works
today, and that the reported bug does not need it.
Cast to int at every core call that passes the ID to WordPress instead:
get_image(), structured data, the V3 variations controller, the product
grid block, the V4 order item schema, the variation admin template, and
the two stock-notification templates that passed '' unguarded. The
getters, their docblocks, and the CRUD defaults go back to trunk.
The data store still maps an integer 0 image ID to '' when writing
_thumbnail_id, so set_image_id( 0 ) deletes the meta row instead of
persisting a '0' row. update_or_delete_post_meta() only deletes on a
strict ''.
The tests observe what WordPress receives through the
wp_get_attachment_image_src filter, so they fail on trunk's code and
pass with the casts, and they no longer assert the getter's type.
Refs #45168
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* chore: reword the changelog for the caller-side casts
The entry described normalizing the getters to integers, which this
branch no longer does.
Refs #45168
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/45168-product-image-id-casts b/plugins/woocommerce/changelog/45168-product-image-id-casts
new file mode 100644
index 00000000000..11a727b3dc7
--- /dev/null
+++ b/plugins/woocommerce/changelog/45168-product-image-id-casts
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Pass product image IDs to WordPress attachment functions as integers.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
index 1dccdd369ae..6296c0a1cf5 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-product.php
@@ -2225,16 +2225,17 @@ class WC_Product extends WC_Abstract_Legacy_Product {
* @return string
*/
public function get_image( $size = 'woocommerce_thumbnail', $attr = array(), $placeholder = true ) {
- $image = '';
- if ( $this->get_image_id() ) {
- $image_alt = get_post_meta( $this->get_image_id(), '_wp_attachment_image_alt', true );
+ $image = '';
+ $image_id = (int) $this->get_image_id();
+ if ( $image_id ) {
+ $image_alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
$attr = wp_parse_args(
$attr,
array(
'alt' => $image_alt ? $image_alt : $this->get_name(),
)
);
- $image = wp_get_attachment_image( $this->get_image_id(), $size, false, $attr );
+ $image = wp_get_attachment_image( $image_id, $size, false, $attr );
} elseif ( $this->get_parent_id() ) {
$parent_product = wc_get_product( $this->get_parent_id() );
if ( $parent_product ) {
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 ea0bb0d9c4a..126234d4712 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
@@ -73,7 +73,7 @@ defined( 'ABSPATH' ) || exit;
<div class="form-flex-box">
<div class="form-row upload_image">
<a href="#" class="upload_image_button tips <?php echo $variation_object->get_image_id( 'edit' ) ? 'remove' : ''; ?>" data-tip="<?php echo $variation_object->get_image_id( 'edit' ) ? esc_attr__( 'Remove this image', 'woocommerce' ) : esc_attr__( 'Upload an image', 'woocommerce' ); ?>" rel="<?php echo esc_attr( $variation_id ); ?>">
- <img src="<?php echo $variation_object->get_image_id( 'edit' ) ? esc_url( wp_get_attachment_thumb_url( $variation_object->get_image_id( 'edit' ) ) ) : esc_url( wc_placeholder_img_src() ); ?>" /><input type="hidden" name="upload_image_id[<?php echo esc_attr( $loop ); ?>]" class="upload_image_id" value="<?php echo esc_attr( $variation_object->get_image_id( 'edit' ) ); ?>" />
+ <img src="<?php echo $variation_object->get_image_id( 'edit' ) ? esc_url( wp_get_attachment_thumb_url( (int) $variation_object->get_image_id( 'edit' ) ) ) : esc_url( wc_placeholder_img_src() ); ?>" /><input type="hidden" name="upload_image_id[<?php echo esc_attr( $loop ); ?>]" class="upload_image_id" value="<?php echo esc_attr( $variation_object->get_image_id( 'edit' ) ); ?>" />
</a>
<?php
/**
diff --git a/plugins/woocommerce/includes/class-wc-structured-data.php b/plugins/woocommerce/includes/class-wc-structured-data.php
index f4ecf4321f2..b5aa9644667 100644
--- a/plugins/woocommerce/includes/class-wc-structured-data.php
+++ b/plugins/woocommerce/includes/class-wc-structured-data.php
@@ -204,7 +204,7 @@ class WC_Structured_Data {
$shop_url = home_url();
$currency = get_woocommerce_currency();
$permalink = get_permalink( $product->get_id() );
- $image = wp_get_attachment_url( $product->get_image_id() );
+ $image = wp_get_attachment_url( (int) $product->get_image_id() );
$markup = array(
'@type' => 'Product',
@@ -672,7 +672,7 @@ class WC_Structured_Data {
'@type' => 'Product',
'name' => wp_kses_post( apply_filters( 'woocommerce_order_item_name', $item->get_name(), $item, $is_visible ) ),
'sku' => $product_exists ? $product->get_sku() : '',
- 'image' => $product_exists ? wp_get_attachment_image_url( $product->get_image_id() ) : '',
+ 'image' => $product_exists ? wp_get_attachment_image_url( (int) $product->get_image_id() ) : '',
'url' => $is_visible ? get_permalink( $product->get_id() ) : get_home_url(),
),
'seller' => array(
diff --git a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
index 92d1db73937..a054a78e43c 100644
--- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
+++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php
@@ -790,6 +790,10 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da
case 'gallery_image_ids':
$value = implode( ',', $value );
break;
+ case 'image_id':
+ // An empty string makes update_or_delete_post_meta() remove the meta, as earlier versions did; integer zero would persist a "0" row.
+ $value = $value ? $value : '';
+ break;
case 'date_on_sale_from':
case 'date_on_sale_to':
$value = $value ? $value->getTimestamp() : '';
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 43eb9f67859..cab38607503 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -463,7 +463,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
$image_size = $this->request['image_size'] ?? 'full';
$image_size = is_string( $image_size ) && '' !== $image_size ? sanitize_text_field( $image_size ) : 'full';
- $attachment_id = $variation->get_image_id();
+ $attachment_id = (int) $variation->get_image_id();
$attachment_post = get_post( $attachment_id );
if ( is_null( $attachment_post ) ) {
return;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index f06154a3aa7..8cc2d6da439 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -1062,12 +1062,6 @@ parameters:
count: 1
path: includes/abstracts/abstract-wc-product.php
- -
- message: '#^Parameter \#1 \$attachment_id of function wp_get_attachment_image expects int, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/abstracts/abstract-wc-product.php
-
-
message: '#^Parameter \#1 \$dimensions of function wc_format_dimensions expects array, array\|string given\.$#'
identifier: argument.type
@@ -1080,12 +1074,6 @@ parameters:
count: 1
path: includes/abstracts/abstract-wc-product.php
- -
- message: '#^Parameter \#1 \$post_id of function get_post_meta expects int, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/abstracts/abstract-wc-product.php
-
-
message: '#^Parameter \#1 \$price of function wc_price expects float, float\|string given\.$#'
identifier: argument.type
@@ -6210,12 +6198,6 @@ parameters:
count: 2
path: includes/admin/meta-boxes/views/html-variation-admin.php
- -
- message: '#^Parameter \#1 \$post_id of function wp_get_attachment_thumb_url expects int, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/admin/meta-boxes/views/html-variation-admin.php
-
-
message: '#^Parameter \#1 \$text of function esc_attr expects string, float\|int given\.$#'
identifier: argument.type
@@ -15288,12 +15270,6 @@ parameters:
count: 1
path: includes/class-wc-structured-data.php
- -
- message: '#^Parameter \#1 \$attachment_id of function wp_get_attachment_url expects int, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/class-wc-structured-data.php
-
-
message: '#^Parameter \#1 \$comment of function get_comment_link expects int\|WP_Comment\|null, string given\.$#'
identifier: argument.type
@@ -29523,12 +29499,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
- -
- message: '#^Parameter \#1 \$attachment_id of function wp_get_attachment_image_src expects int, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
-
-
message: '#^Parameter \#1 \$date of function wc_rest_prepare_date_response expects string\|WC_DateTime\|null, int\|false given\.$#'
identifier: argument.type
@@ -29547,24 +29517,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
- -
- message: '#^Parameter \#1 \$post of function get_post expects int\|WP_Post\|null, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
-
- -
- message: '#^Parameter \#1 \$post of function get_the_title expects int\|WP_Post, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
-
- -
- message: '#^Parameter \#1 \$post_id of function get_post_meta expects int, string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
-
-
message: '#^Parameter \#1 \$product of method WC_REST_Product_Variations_Controller\:\:delete_unmatched_product_variations\(\) expects WC_Product, WC_Product\|false\|null given\.$#'
identifier: argument.type
@@ -49149,12 +49101,6 @@ parameters:
count: 5
path: src/Blocks/BlockTypes/AbstractProductGrid.php
- -
- message: '#^Parameter \#1 \$post_id of function get_post_meta expects int, string given\.$#'
- identifier: argument.type
- count: 1
- path: src/Blocks/BlockTypes/AbstractProductGrid.php
-
-
message: '#^Parameter \#1 \$product of method Automattic\\WooCommerce\\Blocks\\BlockTypes\\AbstractProductGrid\:\:get_rating_html\(\) expects Automattic\\WooCommerce\\Blocks\\BlockTypes\\WC_Product, WC_Product given\.$#'
identifier: argument.type
@@ -63936,12 +63882,6 @@ parameters:
count: 1
path: src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
- -
- message: '#^Parameter \#1 \$attachment_id of function wp_get_attachment_image_url expects int, string given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
-
-
message: '#^Parameter \#1 \$order_item of method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema\\AbstractLineItemSchema\:\:prepare_taxes\(\) expects Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema\\WC_Order_Item_Fee\|Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Orders\\Schema\\WC_Order_Item_Product, WC_Order_Item_Product given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php b/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
index c192a5eb3e8..80ef051ba68 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
@@ -546,7 +546,7 @@ abstract class AbstractProductGrid extends AbstractDynamicBlock {
);
if ( $product->get_image_id() ) {
- $image_alt = get_post_meta( $product->get_image_id(), '_wp_attachment_image_alt', true );
+ $image_alt = get_post_meta( (int) $product->get_image_id(), '_wp_attachment_image_alt', true );
$attr = array(
'alt' => ( $image_alt ? $image_alt : $product->get_name() ),
);
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
index 707f90a805e..e73c31e55f7 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchema.php
@@ -290,7 +290,7 @@ class OrderItemSchema extends AbstractLineItemSchema {
return '';
}
- $image_id = $product->get_image_id() ? $product->get_image_id() : 0;
+ $image_id = (int) $product->get_image_id();
return $image_id ? wp_get_attachment_image_url( $image_id, 'full' ) : '';
}
}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Admin/Templates/html-product-data-admin.php b/plugins/woocommerce/src/Internal/StockNotifications/Admin/Templates/html-product-data-admin.php
index cb95199b95c..345b689540a 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Admin/Templates/html-product-data-admin.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Admin/Templates/html-product-data-admin.php
@@ -11,7 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
-$image = wp_get_attachment_image_src( $product->get_image_id(), 'woocommerce_thumbnail' );
+$image = wp_get_attachment_image_src( (int) $product->get_image_id(), 'woocommerce_thumbnail' );
$image_src = is_array( $image ) && isset( $image[0] ) ? $image[0] : '';
$stock_availability = $product->get_availability();
$identifier = '#' . $product->get_id();
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailTemplatesController.php b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailTemplatesController.php
index a958081830e..e577a641d0d 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailTemplatesController.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Emails/EmailTemplatesController.php
@@ -46,7 +46,7 @@ class EmailTemplatesController {
return;
}
- $image = wp_get_attachment_image_src( $product->get_image_id(), 'woocommerce_thumbnail' );
+ $image = wp_get_attachment_image_src( (int) $product->get_image_id(), 'woocommerce_thumbnail' );
$image_src = is_array( $image ) && isset( $image[0] ) ? $image[0] : '';
ob_start();
diff --git a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
index e2d7f0d24a2..eb81dc5e3ec 100644
--- a/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
+++ b/plugins/woocommerce/tests/php/includes/abstracts/class-wc-abstract-product-test.php
@@ -69,6 +69,50 @@ class WC_Abstract_Product_Test extends WC_Unit_Test_Case {
parent::set_up();
}
+ /**
+ * @testdox get_image() hands WordPress an integer attachment ID even when the product was loaded from the database.
+ */
+ public function test_get_image_passes_an_integer_attachment_id_to_wordpress() {
+ $image_id = self::factory()->post->create( array( 'post_type' => 'attachment' ) );
+ $product = WC_Helper_Product::create_simple_product( false );
+ $product->set_image_id( $image_id );
+ $product = wc_get_product( $product->save() );
+
+ $received_id = null;
+ add_filter(
+ 'wp_get_attachment_image_src',
+ static function ( $image, $attachment_id ) use ( &$received_id ) {
+ if ( null === $received_id ) {
+ $received_id = $attachment_id;
+ }
+ return $image;
+ },
+ 10,
+ 2
+ );
+
+ $product->get_image( 'woocommerce_thumbnail', array(), false );
+
+ $this->assertSame( $image_id, $received_id, 'The attachment ID passed to WordPress should be an integer.' );
+ }
+
+ /**
+ * @testdox Saving a product with an integer zero image ID leaves no stored thumbnail meta.
+ */
+ public function test_saving_without_an_image_leaves_no_thumbnail_meta() {
+ $image_id = self::factory()->post->create( array( 'post_type' => 'attachment' ) );
+ $product = WC_Helper_Product::create_simple_product( false );
+ $product->set_image_id( $image_id );
+ $product_id = $product->save();
+
+ $this->assertSame( (string) $image_id, get_post_meta( $product_id, '_thumbnail_id', true ), 'A product with an image should store the attachment ID.' );
+
+ $product->set_image_id( 0 );
+ $product->save();
+
+ $this->assertFalse( metadata_exists( 'post', $product_id, '_thumbnail_id' ), 'Clearing the image with integer zero should remove the thumbnail meta row, not store "0".' );
+ }
+
/**
* @testdox Ensure that individual Downloadable Products follow the rules regarding Approved Download Directories.
*/
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
index 48e5828e9f2..29ef21a802d 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-structured-data-test.php
@@ -20,6 +20,34 @@ class WC_Structured_Data_Test extends \WC_Unit_Test_Case {
parent::setUp();
}
+ /**
+ * @testdox Order structured data looks up the product image with an integer attachment ID.
+ */
+ public function test_order_data_passes_an_integer_attachment_id_to_wordpress(): void {
+ $image_id = self::factory()->post->create( array( 'post_type' => 'attachment' ) );
+ $product = WC_Helper_Product::create_simple_product( false );
+ $product->set_image_id( $image_id );
+ $product = wc_get_product( $product->save() );
+ $order = WC_Helper_Order::create_order( 1, $product );
+
+ $received_id = null;
+ add_filter(
+ 'wp_get_attachment_image_src',
+ static function ( $image, $attachment_id ) use ( &$received_id ) {
+ if ( null === $received_id ) {
+ $received_id = $attachment_id;
+ }
+ return $image;
+ },
+ 10,
+ 2
+ );
+
+ $this->structured_data->generate_order_data( $order );
+
+ $this->assertSame( $image_id, $received_id, 'The attachment ID passed to WordPress should be an integer.' );
+ }
+
/**
* Test is_valid_gtin function
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchemaTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchemaTest.php
new file mode 100644
index 00000000000..e19462f872b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderItemSchemaTest.php
@@ -0,0 +1,60 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\RestApi\Routes\V4\Orders\Schema;
+
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\Schema\OrderItemSchema;
+use WC_Helper_Order;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests for the OrderItemSchema class.
+ */
+class OrderItemSchemaTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var OrderItemSchema
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new OrderItemSchema();
+ }
+
+ /**
+ * @testdox The item image is looked up with an integer attachment ID.
+ */
+ public function test_item_image_passes_an_integer_attachment_id_to_wordpress(): void {
+ $image_id = self::factory()->post->create( array( 'post_type' => 'attachment' ) );
+ $product = WC_Helper_Product::create_simple_product( false );
+ $product->set_image_id( $image_id );
+ $product = wc_get_product( $product->save() );
+ $order = WC_Helper_Order::create_order( 1, $product );
+ $item = current( $order->get_items() );
+
+ $received_id = null;
+ add_filter(
+ 'wp_get_attachment_image_src',
+ static function ( $image, $attachment_id ) use ( &$received_id ) {
+ if ( null === $received_id ) {
+ $received_id = $attachment_id;
+ }
+ return $image;
+ },
+ 10,
+ 2
+ );
+
+ $this->sut->get_item_response( $item, new WP_REST_Request( 'GET', '/wc/v4/orders' ) );
+
+ $this->assertSame( $image_id, $received_id, 'The attachment ID passed to WordPress should be an integer.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/Templates/ProductDataAdminTemplateTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/Templates/ProductDataAdminTemplateTests.php
new file mode 100644
index 00000000000..1b4aa20ec29
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Admin/Templates/ProductDataAdminTemplateTests.php
@@ -0,0 +1,39 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Admin\Templates;
+
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the stock notifications product data admin template.
+ */
+class ProductDataAdminTemplateTests extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox The template looks up the product image with an integer attachment ID even when the product has no image.
+ */
+ public function test_template_passes_an_integer_attachment_id_to_wordpress(): void {
+ $product = wc_get_product( WC_Helper_Product::create_simple_product()->get_id() );
+
+ $received_id = null;
+ add_filter(
+ 'wp_get_attachment_image_src',
+ static function ( $image, $attachment_id ) use ( &$received_id ) {
+ if ( null === $received_id ) {
+ $received_id = $attachment_id;
+ }
+ return $image;
+ },
+ 10,
+ 2
+ );
+
+ ob_start();
+ include WC_ABSPATH . 'src/Internal/StockNotifications/Admin/Templates/html-product-data-admin.php';
+ ob_end_clean();
+
+ $this->assertSame( 0, $received_id, 'A product without an image should pass integer zero to WordPress, not an empty string.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailTemplatesControllerTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailTemplatesControllerTests.php
new file mode 100644
index 00000000000..d01663e7ee3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/Emails/EmailTemplatesControllerTests.php
@@ -0,0 +1,55 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\StockNotifications\Emails;
+
+use Automattic\WooCommerce\Internal\StockNotifications\Emails\EmailTemplatesController;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the EmailTemplatesController class.
+ */
+class EmailTemplatesControllerTests extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var EmailTemplatesController
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new EmailTemplatesController();
+ }
+
+ /**
+ * @testdox The product image is looked up with an integer attachment ID even when the product has no image.
+ */
+ public function test_email_product_image_passes_an_integer_attachment_id_to_wordpress(): void {
+ $product = wc_get_product( WC_Helper_Product::create_simple_product()->get_id() );
+
+ $received_id = null;
+ add_filter(
+ 'wp_get_attachment_image_src',
+ static function ( $image, $attachment_id ) use ( &$received_id ) {
+ if ( null === $received_id ) {
+ $received_id = $attachment_id;
+ }
+ return $image;
+ },
+ 10,
+ 2
+ );
+
+ ob_start();
+ $this->sut->email_product_image( $product, null );
+ ob_end_clean();
+
+ $this->assertSame( 0, $received_id, 'A product without an image should pass integer zero to WordPress, not an empty string.' );
+ }
+}