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 );