Commit 6613a54af0 for woocommerce

commit 6613a54af032ba8f872573b4330dc86078f02679
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Mon Nov 24 14:38:40 2025 +0000

    Prevent fatal errors when getting orders containing deleted products (#62074)

    * Set up fallback values for a product on an order in case it is null

    * Add tests for deleted product on order schema

    * Add changelog

    * Update namespace

diff --git a/plugins/woocommerce/changelog/wooplug-5887-uncaught-error-call-to-a-member-function b/plugins/woocommerce/changelog/wooplug-5887-uncaught-error-call-to-a-member-function
new file mode 100644
index 0000000000..d3e123874a
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-5887-uncaught-error-call-to-a-member-function
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent fatal errors when retrieving orders with deleted products on Store API
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
index f813c7953d..375c7f7610 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/OrderItemSchema.php
@@ -33,6 +33,48 @@ class OrderItemSchema extends ItemSchema {
 		$order   = $order_item->get_order();
 		$product = $order_item->get_product();

+		$product_properties = [
+			'short_description'  => '',
+			'description'        => '',
+			'sku'                => '',
+			'permalink'          => '',
+			'catalog_visibility' => 'hidden',
+			'prices'             => [
+				'price'                       => '',
+				'regular_price'               => '',
+				'sale_price'                  => '',
+				'price_range'                 => null,
+				'currency_code'               => '',
+				'currency_symbol'             => '',
+				'currency_minor_unit'         => 2,
+				'currency_decimal_separator'  => '.',
+				'currency_thousand_separator' => ',',
+				'currency_prefix'             => '',
+				'currency_suffix'             => '',
+				'raw_prices'                  => [
+					'precision'     => 6,
+					'price'         => '',
+					'regular_price' => '',
+					'sale_price'    => '',
+				],
+			],
+			'sold_individually'  => false,
+			'images'             => [],
+			'variation'          => [],
+		];
+
+		if ( is_a( $product, 'WC_Product' ) ) {
+			$product_properties['short_description']  = $product->get_short_description();
+			$product_properties['description']        = $product->get_description();
+			$product_properties['sku']                = $product->get_sku();
+			$product_properties['permalink']          = $product->get_permalink();
+			$product_properties['catalog_visibility'] = $product->get_catalog_visibility();
+			$product_properties['prices']             = $this->prepare_product_price_response( $product, get_option( 'woocommerce_tax_display_cart' ) );
+			$product_properties['sold_individually']  = $product->is_sold_individually();
+			$product_properties['images']             = $this->get_images( $product );
+			$product_properties['variation']          = $this->format_variation_data( $product->get_attributes(), $product );
+		}
+
 		return [
 			'key'                  => $order->get_order_key(),
 			'id'                   => $order_item->get_id(),
@@ -44,20 +86,20 @@ class OrderItemSchema extends ItemSchema {
 				'editable'    => false,
 			),
 			'name'                 => $order_item->get_name(),
-			'short_description'    => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) ),
-			'description'          => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) ),
-			'sku'                  => $this->prepare_html_response( $product->get_sku() ),
+			'short_description'    => $this->prepare_html_response( wc_format_content( wp_kses_post( $product_properties['short_description'] ) ) ),
+			'description'          => $this->prepare_html_response( wc_format_content( wp_kses_post( $product_properties['description'] ) ) ),
+			'sku'                  => $this->prepare_html_response( $product_properties['sku'] ),
 			'low_stock_remaining'  => null,
 			'backorders_allowed'   => false,
 			'show_backorder_badge' => false,
-			'sold_individually'    => $product->is_sold_individually(),
-			'permalink'            => $product->get_permalink(),
-			'images'               => $this->get_images( $product ),
-			'variation'            => $this->format_variation_data( $product->get_attributes(), $product ),
+			'sold_individually'    => $product_properties['sold_individually'] ?? false,
+			'permalink'            => $product_properties['permalink'],
+			'images'               => $product_properties['images'],
+			'variation'            => $product_properties['variation'],
 			'item_data'            => $order_item->get_all_formatted_meta_data(),
-			'prices'               => (object) $this->prepare_product_price_response( $product, get_option( 'woocommerce_tax_display_cart' ) ),
+			'prices'               => (object) $product_properties['prices'],
 			'totals'               => (object) $this->prepare_currency_response( $this->get_totals( $order_item ) ),
-			'catalog_visibility'   => $product->get_catalog_visibility(),
+			'catalog_visibility'   => $product_properties['catalog_visibility'],
 		];
 	}

diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/OrderItemSchemaTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/OrderItemSchemaTest.php
new file mode 100644
index 0000000000..ec04cbff33
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Schemas/OrderItemSchemaTest.php
@@ -0,0 +1,162 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Schemas;
+
+use Automattic\WooCommerce\StoreApi\Schemas\V1\OrderItemSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Formatters;
+use Automattic\WooCommerce\StoreApi\Formatters\MoneyFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\HtmlFormatter;
+use Automattic\WooCommerce\StoreApi\Formatters\CurrencyFormatter;
+use WC_Helper_Order;
+use Yoast\PHPUnitPolyfills\TestCases\TestCase;
+
+/**
+ * OrderItemSchemaTest class.
+ */
+class OrderItemSchemaTest extends TestCase {
+	/**
+	 * The system under test.
+	 *
+	 * @var OrderItemSchema
+	 */
+	private $sut;
+
+	/**
+	 * ExtendSchema instance.
+	 *
+	 * @var ExtendSchema
+	 */
+	private $mock_extend;
+
+	/**
+	 * SchemaController instance.
+	 *
+	 * @var SchemaController
+	 */
+	private $schema_controller;
+
+	/**
+	 * Set up before test.
+	 *
+	 * @return void
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// Set up formatters and extend schema.
+		$formatters = new Formatters();
+		$formatters->register( 'money', MoneyFormatter::class );
+		$formatters->register( 'html', HtmlFormatter::class );
+		$formatters->register( 'currency', CurrencyFormatter::class );
+		$this->mock_extend       = new ExtendSchema( $formatters );
+		$this->schema_controller = new SchemaController( $this->mock_extend );
+		$this->sut               = $this->schema_controller->get( OrderItemSchema::IDENTIFIER );
+	}
+
+	/**
+	 * Tear down after test.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		$this->sut               = null;
+		$this->schema_controller = null;
+		$this->mock_extend       = null;
+	}
+
+	/**
+	 * Test that get_item_response handles deleted products gracefully.
+	 *
+	 * Reproduces the issue: when a product is deleted and an order
+	 * containing that product is fetched via StoreAPI, it should not
+	 * throw an error.
+	 */
+	public function test_get_item_response_with_deleted_product(): void {
+		// Arrange - Create order with a product, then delete the product.
+		$order   = WC_Helper_Order::create_order();
+		$items   = $order->get_items();
+		$item    = reset( $items );
+		$product = $item->get_product();
+
+		// Verify product exists initially.
+		$this->assertInstanceOf( 'WC_Product', $product );
+
+		// Delete the product.
+		$product_id = $product->get_id();
+		wp_delete_post( $product_id, true );
+
+		// Verify product is now deleted (get_product returns false).
+		$item_after_delete = new \WC_Order_Item_Product( $item->get_id() );
+		$this->assertFalse( $item_after_delete->get_product() );
+
+		// Act - Get item response for order item with deleted product.
+		$result = $this->sut->get_item_response( $item_after_delete );
+
+		// Verify key fields exist.
+		$this->assertArrayHasKey( 'id', $result );
+		$this->assertArrayHasKey( 'name', $result );
+		$this->assertArrayHasKey( 'short_description', $result );
+		$this->assertArrayHasKey( 'description', $result );
+		$this->assertArrayHasKey( 'sku', $result );
+		$this->assertArrayHasKey( 'permalink', $result );
+		$this->assertArrayHasKey( 'catalog_visibility', $result );
+		$this->assertArrayHasKey( 'prices', $result );
+		$this->assertArrayHasKey( 'images', $result );
+		$this->assertArrayHasKey( 'variation', $result );
+		$this->assertArrayHasKey( 'sold_individually', $result );
+
+		// Verify product-specific fields have safe defaults.
+		$this->assertEquals( '', $result['sku'] );
+		$this->assertEquals( '', $result['permalink'] );
+		$this->assertEquals( 'hidden', $result['catalog_visibility'] );
+		$this->assertFalse( $result['sold_individually'] );
+		$this->assertIsArray( $result['images'] );
+		$this->assertEmpty( $result['images'] );
+		$this->assertIsArray( $result['variation'] );
+		$this->assertEmpty( $result['variation'] );
+
+		// Verify order-level data is preserved.
+		$this->assertEquals( $item_after_delete->get_id(), $result['id'] );
+		$this->assertEquals( $item_after_delete->get_name(), $result['name'] );
+		$this->assertEquals( $item_after_delete->get_quantity(), $result['quantity'] );
+	}
+
+	/**
+	 * Test that get_item_response works correctly with existing product.
+	 *
+	 * Ensures the fix doesn't break normal behavior.
+	 */
+	public function test_get_item_response_with_existing_product(): void {
+		// Arrange - Create order with a product.
+		$order   = WC_Helper_Order::create_order();
+		$items   = $order->get_items();
+		$item    = reset( $items );
+		$product = $item->get_product();
+
+		// Verify product exists.
+		$this->assertInstanceOf( 'WC_Product', $product );
+
+		// Act - Get item response.
+		$result = $this->sut->get_item_response( $item );
+
+		// Verify key fields exist.
+		$this->assertArrayHasKey( 'id', $result );
+		$this->assertArrayHasKey( 'name', $result );
+		$this->assertArrayHasKey( 'sku', $result );
+		$this->assertArrayHasKey( 'permalink', $result );
+		$this->assertArrayHasKey( 'prices', $result );
+
+		// Verify product data is populated.
+		$this->assertEquals( $product->get_sku(), $result['sku'] );
+		$this->assertEquals( $product->get_permalink(), $result['permalink'] );
+		$this->assertEquals( $product->get_catalog_visibility(), $result['catalog_visibility'] );
+		$this->assertEquals( $product->is_sold_individually(), $result['sold_individually'] );
+
+		// Verify order data.
+		$this->assertEquals( $item->get_id(), $result['id'] );
+		$this->assertEquals( $item->get_name(), $result['name'] );
+		$this->assertEquals( $item->get_quantity(), $result['quantity'] );
+	}
+}