Commit 53381f06b42 for woocommerce

commit 53381f06b421f75650ae36379c018aac6d0ab83d
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Fri Oct 16 16:13:17 2026 -0300

    Add the order-notes-query ability (#69790)

    * Add the order-notes-query ability

    Core has order-add-note but no way to read notes through abilities. The
    new woocommerce/order-notes-query ability takes one order ID and returns
    every note of that order, newest first, with no cap. This matches the
    REST notes endpoint and the Woo AI order-notes-list ability.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Sort order notes by creation date instead of ID

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Assert the note date in the order notes query test

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Create the HPOS tables once before the abilities test class

    setup_cot() drops and creates the HPOS tables in every test that enables HPOS. The DDL guard refuses that inside the per-test transaction. The class now creates the tables in wpSetUpBeforeClass() and only toggles the feature per test, so its entries leave the allowlist.

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * Return the order note date with its timezone offset

    The note date was store-local time without an offset, so a client could
    not tell which timezone it was in. The schema also declares it as
    date-time, which needs an offset. The date now carries the store offset.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/add-order-notes-query-ability b/plugins/woocommerce/changelog/add-order-notes-query-ability
new file mode 100644
index 00000000000..9f6617003d0
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-notes-query-ability
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the woocommerce/order-notes-query ability, which lists every note of an order.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index fa779608aab..f7a09fefb09 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Internal\Abilities;

 use Automattic\WooCommerce\Abilities\AbilityDefinition;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
+use Automattic\WooCommerce\Internal\Abilities\Domain\OrderNotesQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
 use Automattic\WooCommerce\Internal\Abilities\Domain\OrdersQuery;
 use Automattic\WooCommerce\Internal\Abilities\Domain\ProductCreate;
@@ -38,6 +39,7 @@ class AbilitiesLoader {
 	private const CORE_ABILITY_DEFINITION_CLASSES = array(
 		OrdersQuery::class,
 		OrderAddNote::class,
+		OrderNotesQuery::class,
 		OrderUpdateStatus::class,
 		ProductsQuery::class,
 		ProductCreate::class,
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderNotesQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderNotesQuery.php
new file mode 100644
index 00000000000..5a6ec652312
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderNotesQuery.php
@@ -0,0 +1,170 @@
+<?php
+/**
+ * Order notes query ability definition file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Abilities\Domain;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registers the WooCommerce order notes query ability.
+ */
+class OrderNotesQuery extends AbstractDomainAbility implements AbilityDefinition {
+
+	use OrderAbilityTrait;
+
+	/**
+	 * Get the ability name.
+	 *
+	 * @return string
+	 *
+	 * @since 11.3.0
+	 */
+	public static function get_name(): string {
+		return 'woocommerce/order-notes-query';
+	}
+
+	/**
+	 * Get the ability registration arguments.
+	 *
+	 * @return array
+	 *
+	 * @since 11.3.0
+	 */
+	public static function get_registration_args(): array {
+		return array(
+			'label'               => __( 'Query order notes', 'woocommerce' ),
+			'description'         => __(
+				'List every note of an order, newest first.',
+				'woocommerce'
+			),
+			'category'            => 'woocommerce',
+			'input_schema'        => self::get_input_schema(),
+			'output_schema'       => self::get_output_schema(),
+			'execute_callback'    => array( __CLASS__, 'execute' ),
+			'permission_callback' => array( __CLASS__, 'can_view_order' ),
+			'meta'                => array(
+				'show_in_rest' => true,
+				'mcp'          => array(
+					'public' => true,
+					'type'   => 'tool',
+				),
+				'annotations'  => array(
+					'readonly'    => true,
+					'idempotent'  => true,
+					'destructive' => false,
+				),
+			),
+		);
+	}
+
+	/**
+	 * List the notes of an order.
+	 *
+	 * @param array $input Ability input.
+	 * @return array|\WP_Error
+	 *
+	 * @since 11.3.0
+	 */
+	public static function execute( array $input ) {
+		$order = self::get_order_from_input( $input );
+
+		if ( is_wp_error( $order ) ) {
+			return $order;
+		}
+
+		$notes = wc_get_order_notes(
+			array(
+				'order_id' => $order->get_id(),
+				'orderby'  => 'date_created_gmt',
+				'order'    => 'DESC',
+			)
+		);
+
+		return array(
+			'notes' => array_map(
+				static function ( $note ): array {
+					return array(
+						'id'            => (int) $note->id,
+						'author'        => (string) $note->added_by,
+						'date_created'  => $note->date_created ? $note->date_created->format( 'c' ) : null,
+						'note'          => (string) $note->content,
+						'customer_note' => (bool) $note->customer_note,
+					);
+				},
+				$notes
+			),
+		);
+	}
+
+	/**
+	 * Check order read access for an ability input payload.
+	 *
+	 * @param mixed $input Ability input.
+	 * @return bool
+	 *
+	 * @since 11.3.0
+	 */
+	public static function can_view_order( $input = array() ): bool {
+		$order_id = self::get_id_from_input( $input );
+
+		return $order_id > 0 && wc_rest_check_post_permissions( 'shop_order', 'read', $order_id );
+	}
+
+	/**
+	 * Get the ability input schema.
+	 *
+	 * @return array
+	 */
+	private static function get_input_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'id' => array(
+					'type'        => 'integer',
+					'description' => __( 'Order ID.', 'woocommerce' ),
+					'minimum'     => 1,
+				),
+			),
+			'required'             => array( 'id' ),
+			'additionalProperties' => false,
+		);
+	}
+
+	/**
+	 * Get the ability output schema.
+	 *
+	 * @return array
+	 */
+	private static function get_output_schema(): array {
+		return array(
+			'type'                 => 'object',
+			'properties'           => array(
+				'notes' => array(
+					'type'  => 'array',
+					'items' => array(
+						'type'                 => 'object',
+						'properties'           => array(
+							'id'            => array( 'type' => 'integer' ),
+							'author'        => array( 'type' => 'string' ),
+							'date_created'  => array(
+								'type'   => array( 'string', 'null' ),
+								'format' => 'date-time',
+							),
+							'note'          => array( 'type' => 'string' ),
+							'customer_note' => array( 'type' => 'boolean' ),
+						),
+						'additionalProperties' => false,
+					),
+				),
+			),
+			'additionalProperties' => false,
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/legacy/framework/ddl-in-transaction-allowlist.php b/plugins/woocommerce/tests/legacy/framework/ddl-in-transaction-allowlist.php
index cbddfa20705..31df10224dd 100644
--- a/plugins/woocommerce/tests/legacy/framework/ddl-in-transaction-allowlist.php
+++ b/plugins/woocommerce/tests/legacy/framework/ddl-in-transaction-allowlist.php
@@ -15,11 +15,6 @@ declare( strict_types = 1 );
 return array(
 	// Creates the HPOS tables per test through setup_cot(). Move to wpSetUpBeforeClass() + setup_cot_tables(), as #68930 did.
 	'WC_Tests_API_Reports_Sales::test_get_sales_report_with_hpos_enabled_and_sync_off',
-	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_returns_not_found_for_unknown_id',
-	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_excludes_checkout_draft_orders_by_default',
-	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_sorts_by_id_across_order_storage_engines',
-	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_sorts_by_modified_date_across_order_storage_engines',
-	'Automattic\WooCommerce\Tests\Internal\Abilities\AbilitiesLoaderTest::test_orders_query_filters_modified_before_with_time_precision',

 	// Creates the HPOS tables in setUp() through setup_cot(). Move to wpSetUpBeforeClass() + setup_cot_tables(), as #68930 did.
 	'Automattic\WooCommerce\Tests\Internal\Admin\Orders\EditLockTest::*',
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index f4ad9ea0bf2..b791fddae4d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -40,6 +40,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		'woocommerce/orders-query',
 		'woocommerce/order-update-status',
 		'woocommerce/order-add-note',
+		'woocommerce/order-notes-query',
 	);

 	/**
@@ -85,7 +86,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	private $original_cot_state = null;

 	/**
-	 * Whether HPOS/COT tables were set up by the current test.
+	 * Whether the current test uses the permanent HPOS/COT tables.
 	 *
 	 * @var bool
 	 */
@@ -98,6 +99,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 	 */
 	public static function wpSetUpBeforeClass( $factory ): void {
 		self::$administrator_id = $factory->user->create( array( 'role' => 'administrator' ) );
+		self::setup_cot_tables();
 	}

 	/**
@@ -244,6 +246,11 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 				'idempotent'  => false,
 				'destructive' => false,
 			),
+			'woocommerce/order-notes-query'   => array(
+				'readonly'    => true,
+				'idempotent'  => true,
+				'destructive' => false,
+			),
 		);

 		foreach ( $expectations as $ability_id => $annotations ) {
@@ -910,6 +917,12 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 					'note' => 'denied',
 				),
 			),
+			'order-notes-query'   => array(
+				'woocommerce/order-notes-query',
+				array(
+					'id' => 1,
+				),
+			),
 		);
 	}

@@ -1279,6 +1292,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 					'note' => 'Invalid',
 				),
 			),
+			'order-notes-query'   => array(
+				'woocommerce/order-notes-query',
+				array( 'id' => -123 ),
+			),
 		);
 	}

@@ -2088,6 +2105,58 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		$this->assertNotEmpty( $result['orders'][0]['currency_symbol'] );
 	}

+	/**
+	 * @testdox Should list every note of an order, newest first.
+	 *
+	 * @dataProvider provider_order_storage_engines
+	 *
+	 * @param bool $hpos_enabled Whether HPOS/COT should be enabled for the test.
+	 */
+	public function test_order_notes_query_lists_every_note( bool $hpos_enabled ): void {
+		$this->set_order_storage_for_test( $hpos_enabled );
+
+		$order                     = \WC_Helper_Order::create_order();
+		$this->created_order_ids[] = $order->get_id();
+
+		$newer_note_id = $order->add_order_note( 'Internal note.' );
+		$order->add_order_note( 'Customer note.', true );
+
+		// A note with a higher ID but an older date must sort after the newer one.
+		wp_update_comment(
+			array(
+				'comment_ID'       => $newer_note_id,
+				'comment_date'     => '2030-01-01 00:00:00',
+				'comment_date_gmt' => '2030-01-01 00:00:00',
+			)
+		);
+
+		update_option( 'timezone_string', 'America/Sao_Paulo' );
+
+		$result = wp_get_ability( 'woocommerce/order-notes-query' )->execute( array( 'id' => $order->get_id() ) );
+
+		$this->assertNotWPError( $result );
+
+		$by_content = array_column( $result['notes'], 'customer_note', 'note' );
+		$this->assertTrue( $by_content['Customer note.'] );
+		$this->assertFalse( $by_content['Internal note.'] );
+
+		$dates_by_content = array_column( $result['notes'], 'date_created', 'note' );
+		$this->assertSame( '2029-12-31T21:00:00-03:00', $dates_by_content['Internal note.'] );
+
+		$this->assertContainsOnly( 'int', array_column( $result['notes'], 'id' ) );
+		$this->assertSame( $newer_note_id, $result['notes'][0]['id'], 'Notes should be newest first by creation date.' );
+	}
+
+	/**
+	 * @testdox Should return a not-found error when order-notes-query receives an unknown order ID.
+	 */
+	public function test_order_notes_query_returns_not_found_for_unknown_id(): void {
+		$result = wp_get_ability( 'woocommerce/order-notes-query' )->execute( array( 'id' => 999999 ) );
+
+		$this->assertWPError( $result );
+		$this->assertSame( 'woocommerce_order_not_found', $result->get_error_code() );
+	}
+
 	/**
 	 * @testdox Should exclude checkout-draft orders from default order queries.
 	 *
@@ -2636,9 +2705,10 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
 		}

 		if ( $hpos_enabled && ! $this->cot_setup_for_test ) {
-			$this->setup_cot();
+			// Keep the permanent HPOS tables outside the per-test transaction.
+			remove_filter( 'query', array( $this, '_create_temporary_tables' ) );
+			remove_filter( 'query', array( $this, '_drop_temporary_tables' ) );
 			$this->cot_setup_for_test = true;
-			return;
 		}

 		$this->toggle_cot_feature_and_usage( $hpos_enabled );