Commit 2f3f83a1d97 for woocommerce
commit 2f3f83a1d97d8b01a4858e563c475b990f081dc2
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Fri Oct 9 11:33:31 2026 +0300
Add tests for custom shipping provider AJAX handler and order filter (#69589)
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index c0f5712c61d..b7536ca0986 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -7,6 +7,7 @@
declare( strict_types = 1 );
+use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsController;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\Orders\CouponsController;
use Automattic\WooCommerce\Internal\Orders\TaxesController;
@@ -17,6 +18,11 @@ use Automattic\WooCommerce\Proxies\LegacyProxy;
*/
class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
+ /**
+ * Taxonomy that stores custom shipping providers.
+ */
+ private const PROVIDER_TAXONOMY = 'wc_fulfillment_shipping_provider';
+
/**
* Sets up the test fixture.
*/
@@ -3026,6 +3032,445 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
}
}
+ /**
+ * @testdox Saving shipping providers is rejected when the fulfillments feature is disabled.
+ */
+ public function test_shipping_providers_save_changes_rejects_when_feature_disabled(): void {
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'no' );
+ $this->_setRole( 'administrator' );
+
+ $_POST = array(
+ 'wc_shipping_providers_nonce' => wp_create_nonce( 'wc_shipping_providers_nonce' ),
+ 'changes' => array(),
+ );
+ $_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test installs the real nonce immediately above.
+
+ $response = $this->do_ajax( 'woocommerce_shipping_providers_save_changes' );
+
+ $this->assertFalse( $response['success'] ?? true, 'A disabled feature should reject the request.' );
+ $this->assertSame( 'feature_disabled', $response['data'] ?? null );
+ }
+
+ /**
+ * @testdox Saving shipping providers is rejected when the nonce or changes payload is missing.
+ */
+ public function test_shipping_providers_save_changes_rejects_missing_fields(): void {
+ $this->enable_fulfillments_feature();
+ $this->_setRole( 'administrator' );
+
+ // Nonce present, but the changes payload is absent.
+ $_POST = array(
+ 'wc_shipping_providers_nonce' => wp_create_nonce( 'wc_shipping_providers_nonce' ),
+ );
+ $_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test installs the real nonce immediately above.
+
+ $response = $this->do_ajax( 'woocommerce_shipping_providers_save_changes' );
+
+ $this->assertFalse( $response['success'] ?? true, 'A missing changes payload should reject the request.' );
+ $this->assertSame( 'missing_fields', $response['data'] ?? null );
+ }
+
+ /**
+ * @testdox Saving shipping providers is rejected when the nonce is invalid.
+ */
+ public function test_shipping_providers_save_changes_rejects_bad_nonce(): void {
+ $this->enable_fulfillments_feature();
+ $this->_setRole( 'administrator' );
+
+ $_POST = array(
+ 'wc_shipping_providers_nonce' => 'not-a-valid-nonce',
+ 'changes' => array(),
+ );
+ $_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test asserts the invalid nonce is rejected.
+
+ $response = $this->do_ajax( 'woocommerce_shipping_providers_save_changes' );
+
+ $this->assertFalse( $response['success'] ?? true, 'An invalid nonce should reject the request.' );
+ $this->assertSame( 'bad_nonce', $response['data'] ?? null );
+ }
+
+ /**
+ * @testdox Saving shipping providers is rejected for a user without the manage_woocommerce capability.
+ */
+ public function test_shipping_providers_save_changes_rejects_without_capability(): void {
+ $this->enable_fulfillments_feature();
+
+ // A customer passes the nonce check (it is tied to the current user) but lacks manage_woocommerce.
+ $this->_setRole( 'customer' );
+
+ $_POST = array(
+ 'wc_shipping_providers_nonce' => wp_create_nonce( 'wc_shipping_providers_nonce' ),
+ 'changes' => array(),
+ );
+ $_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test installs the real nonce immediately above.
+
+ $response = $this->do_ajax( 'woocommerce_shipping_providers_save_changes' );
+
+ $this->assertFalse( $response['success'] ?? true, 'A user without manage_woocommerce should be rejected.' );
+ $this->assertSame( 'missing_capabilities', $response['data'] ?? null );
+ }
+
+ /**
+ * @testdox The nonce is checked before the capability, so a bad nonce is rejected even without manage_woocommerce.
+ */
+ public function test_shipping_providers_save_changes_checks_nonce_before_capability(): void {
+ $this->enable_fulfillments_feature();
+ $this->_setRole( 'customer' );
+
+ $_POST = array(
+ 'wc_shipping_providers_nonce' => 'not-a-valid-nonce',
+ 'changes' => array(),
+ );
+ $_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test asserts the invalid nonce is rejected.
+
+ $response = $this->do_ajax( 'woocommerce_shipping_providers_save_changes' );
+
+ $this->assertFalse( $response['success'] ?? true, 'A bad nonce should reject the request.' );
+ $this->assertSame( 'bad_nonce', $response['data'] ?? null, 'The nonce guard runs before the capability guard.' );
+ }
+
+ /**
+ * @testdox Saving shipping providers is rejected when the changes payload is not an array.
+ */
+ public function test_shipping_providers_save_changes_rejects_non_array_changes(): void {
+ $this->enable_fulfillments_feature();
+ $this->_setRole( 'administrator' );
+
+ $_POST = array(
+ 'wc_shipping_providers_nonce' => wp_create_nonce( 'wc_shipping_providers_nonce' ),
+ 'changes' => 'not-an-array',
+ );
+ $_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test installs the real nonce immediately above.
+
+ $response = $this->do_ajax( 'woocommerce_shipping_providers_save_changes' );
+
+ $this->assertFalse( $response['success'] ?? true, 'A non-array changes payload should reject the request.' );
+ $this->assertSame( 'invalid_changes', $response['data'] ?? null );
+ }
+
+ /**
+ * @testdox Creating a new custom provider persists its term, slug, and URL meta, and returns it in the response.
+ */
+ public function test_shipping_providers_save_changes_creates_new_provider(): void {
+ $this->enable_fulfillments_feature();
+ $name = 'Acme Couriers ' . wp_unique_id();
+ $expected_slug = sanitize_title( $name );
+
+ $response = $this->post_provider_changes(
+ array(
+ 'new-row' => array(
+ 'newRow' => true,
+ 'name' => $name,
+ 'tracking_url_template' => 'https://acme.test/track/__PLACEHOLDER__',
+ 'icon' => 'https://acme.test/logo.png',
+ ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'Creating a provider should succeed.' );
+ $this->assertArrayNotHasKey( 'error', $response['data'], 'A valid creation should not return an error.' );
+
+ $term = get_term_by( 'slug', $expected_slug, self::PROVIDER_TAXONOMY );
+ $this->assertInstanceOf( WP_Term::class, $term, 'The provider term should be created.' );
+ if ( ! $term instanceof WP_Term ) {
+ throw new RuntimeException( 'The provider term could not be reloaded.' );
+ }
+
+ $this->assertSame( $name, $term->name );
+ $this->assertSame( 'https://acme.test/track/__PLACEHOLDER__', get_term_meta( $term->term_id, 'tracking_url_template', true ) );
+ $this->assertSame( 'https://acme.test/logo.png', get_term_meta( $term->term_id, 'icon', true ) );
+
+ $matching_rows = array_values(
+ array_filter(
+ $response['data']['shipping_providers'] ?? array(),
+ static fn ( array $row ): bool => (int) ( $row['term_id'] ?? 0 ) === $term->term_id
+ )
+ );
+ $this->assertCount( 1, $matching_rows, 'The response should contain the new provider exactly once.' );
+ $this->assertSame( $expected_slug, $matching_rows[0]['slug'] );
+ $this->assertSame( $name, $matching_rows[0]['name'] );
+ }
+
+ /**
+ * @testdox A new provider whose explicit slug matches a built-in provider key is rejected and not created.
+ */
+ public function test_shipping_providers_save_changes_rejects_explicit_builtin_slug(): void {
+ $this->enable_fulfillments_feature();
+
+ $response = $this->post_provider_changes(
+ array(
+ 'new-row' => array(
+ 'newRow' => true,
+ 'name' => 'Pretend Amazon',
+ 'slug' => 'amazon-logistics',
+ ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'The handler reports validation problems via the error field, not a failed request.' );
+ $this->assertStringContainsString( 'built-in shipping provider', $response['data']['error'] ?? '' );
+ $this->assertFalse( get_term_by( 'name', 'Pretend Amazon', self::PROVIDER_TAXONOMY ), 'No term should be created for a reserved slug.' );
+ }
+
+ /**
+ * @testdox A new provider whose auto-generated slug collides with a built-in key is rolled back and reported.
+ */
+ public function test_shipping_providers_save_changes_rejects_autogenerated_builtin_slug(): void {
+ $this->enable_fulfillments_feature();
+
+ // No explicit slug: sanitize_title( 'Amazon Logistics' ) === 'amazon-logistics', a built-in key.
+ $response = $this->post_provider_changes(
+ array(
+ 'new-row' => array(
+ 'newRow' => true,
+ 'name' => 'Amazon Logistics',
+ ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'The handler reports validation problems via the error field, not a failed request.' );
+ $this->assertStringContainsString( 'auto-generated slug conflicts', $response['data']['error'] ?? '' );
+ $this->assertFalse( get_term_by( 'slug', 'amazon-logistics', self::PROVIDER_TAXONOMY ), 'The colliding term should be deleted after detection.' );
+ }
+
+ /**
+ * @testdox Updating an existing provider changes its name but leaves the slug immutable.
+ */
+ public function test_shipping_providers_save_changes_slug_is_immutable_on_update(): void {
+ $this->enable_fulfillments_feature();
+ $term_id = $this->create_custom_provider_term( 'Original Name', 'original-slug' );
+
+ $response = $this->post_provider_changes(
+ array(
+ (string) $term_id => array(
+ 'name' => 'Renamed Provider',
+ 'slug' => 'hacked-slug',
+ ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'Updating a provider should succeed.' );
+
+ $term = get_term( $term_id, self::PROVIDER_TAXONOMY );
+ $this->assertInstanceOf( WP_Term::class, $term );
+ $this->assertSame( 'Renamed Provider', $term->name, 'The name should be updated.' );
+ $this->assertSame( 'original-slug', $term->slug, 'The slug should be ignored on update.' );
+ }
+
+ /**
+ * @testdox Deleting a provider referenced by a fulfillment is blocked and reported.
+ */
+ public function test_shipping_providers_save_changes_blocks_deleting_provider_in_use(): void {
+ $this->enable_fulfillments_feature();
+ $term_id = $this->create_custom_provider_term( 'In Use Provider', 'in-use-provider' );
+ $this->seed_fulfillment_for_provider( 'in-use-provider' );
+
+ $response = $this->post_provider_changes(
+ array(
+ (string) $term_id => array( 'deleted' => true ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'The handler reports the block via the error field, not a failed request.' );
+ $this->assertStringContainsString( 'used by existing fulfillments', $response['data']['error'] ?? '' );
+ $this->assertInstanceOf( WP_Term::class, get_term( $term_id, self::PROVIDER_TAXONOMY ), 'The in-use provider should not be deleted.' );
+ }
+
+ /**
+ * @testdox Deleting a provider that no fulfillment references removes the term.
+ */
+ public function test_shipping_providers_save_changes_deletes_unused_provider(): void {
+ $this->enable_fulfillments_feature();
+ $term_id = $this->create_custom_provider_term( 'Unused Provider', 'unused-provider' );
+
+ $response = $this->post_provider_changes(
+ array(
+ (string) $term_id => array( 'deleted' => true ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'Deleting an unused provider should succeed.' );
+ $this->assertArrayNotHasKey( 'error', $response['data'], 'Deleting an unused provider should not report an error.' );
+ $this->assertNull( get_term( $term_id, self::PROVIDER_TAXONOMY ), 'The unused provider should be deleted.' );
+ }
+
+ /**
+ * @testdox An invalid tracking URL is rejected and the existing value is preserved.
+ * @testWith ["javascript:alert(document.domain)"]
+ * ["ftp://example.test/track/__PLACEHOLDER__"]
+ * ["not-a-url"]
+ *
+ * @param string $invalid_url The rejected tracking URL template.
+ */
+ public function test_shipping_providers_save_changes_rejects_invalid_tracking_url( string $invalid_url ): void {
+ $this->enable_fulfillments_feature();
+ $term_id = $this->create_custom_provider_term(
+ 'URL Provider',
+ 'url-provider',
+ array( 'tracking_url_template' => 'https://existing.test/track/__PLACEHOLDER__' )
+ );
+
+ $response = $this->post_provider_changes(
+ array(
+ (string) $term_id => array( 'tracking_url_template' => $invalid_url ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'The handler reports URL problems via the error field, not a failed request.' );
+ $this->assertStringContainsString( 'valid HTTP or HTTPS URL', $response['data']['error'] ?? '' );
+ $this->assertSame(
+ 'https://existing.test/track/__PLACEHOLDER__',
+ get_term_meta( $term_id, 'tracking_url_template', true ),
+ 'A rejected URL should leave the stored value untouched.'
+ );
+ }
+
+ /**
+ * @testdox An empty tracking URL clears the stored value.
+ */
+ public function test_shipping_providers_save_changes_clears_tracking_url_with_empty_string(): void {
+ $this->enable_fulfillments_feature();
+ $term_id = $this->create_custom_provider_term(
+ 'Clearable Provider',
+ 'clearable-provider',
+ array( 'tracking_url_template' => 'https://existing.test/track/__PLACEHOLDER__' )
+ );
+
+ $response = $this->post_provider_changes(
+ array(
+ (string) $term_id => array( 'tracking_url_template' => '' ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'Clearing a tracking URL should succeed.' );
+ $this->assertSame( '', get_term_meta( $term_id, 'tracking_url_template', true ), 'An empty string should clear the stored URL.' );
+ }
+
+ /**
+ * @testdox A wp_insert_term failure while creating a provider is surfaced in the response error.
+ */
+ public function test_shipping_providers_save_changes_surfaces_insert_term_error(): void {
+ $this->enable_fulfillments_feature();
+
+ $fail_insert = static function () {
+ return new WP_Error( 'insert_failed', 'Could not insert the term.' );
+ };
+ add_filter( 'pre_insert_term', $fail_insert );
+
+ try {
+ $response = $this->post_provider_changes(
+ array(
+ 'new-row' => array(
+ 'newRow' => true,
+ 'name' => 'Doomed Provider',
+ ),
+ )
+ );
+ } finally {
+ remove_filter( 'pre_insert_term', $fail_insert );
+ }
+
+ $this->assertTrue( $response['success'] ?? false, 'The handler reports insert failures via the error field, not a failed request.' );
+ $this->assertSame( 'Could not insert the term.', $response['data']['error'] ?? '' );
+ $this->assertFalse( get_term_by( 'name', 'Doomed Provider', self::PROVIDER_TAXONOMY ), 'No term should remain after an insert failure.' );
+ }
+
+ /**
+ * @testdox A wp_update_term failure for an unknown term id is surfaced in the response error.
+ */
+ public function test_shipping_providers_save_changes_surfaces_update_term_error(): void {
+ $this->enable_fulfillments_feature();
+
+ // A numeric, non-newRow key for a term that does not exist makes wp_update_term return a WP_Error.
+ $response = $this->post_provider_changes(
+ array(
+ '999999' => array( 'name' => 'Ghost Provider' ),
+ )
+ );
+
+ $this->assertTrue( $response['success'] ?? false, 'The handler reports update failures via the error field, not a failed request.' );
+ $this->assertNotEmpty( $response['data']['error'] ?? '', 'An update failure should be reported in the error field.' );
+ $this->assertFalse( get_term_by( 'name', 'Ghost Provider', self::PROVIDER_TAXONOMY ), 'A failed update should not create a term.' );
+ }
+
+ /**
+ * Enable the fulfillments feature and register its taxonomy and classes.
+ */
+ private function enable_fulfillments_feature(): void {
+ update_option( 'woocommerce_feature_fulfillments_enabled', 'yes' );
+ wc_get_container()->get( FulfillmentsController::class )->initialize_fulfillments();
+ }
+
+ /**
+ * Build the request payload with a valid admin nonce and fire the save-changes action.
+ *
+ * @param array $changes The changes payload to submit.
+ * @return array|null The decoded AJAX response.
+ */
+ private function post_provider_changes( array $changes ) {
+ $this->_setRole( 'administrator' );
+
+ $_POST = array(
+ 'wc_shipping_providers_nonce' => wp_create_nonce( 'wc_shipping_providers_nonce' ),
+ 'changes' => $changes,
+ );
+ $_REQUEST = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test installs the real nonce immediately above.
+
+ return $this->do_ajax( 'woocommerce_shipping_providers_save_changes' );
+ }
+
+ /**
+ * Create a custom shipping provider term with optional meta.
+ *
+ * @param string $name The provider name.
+ * @param string $slug The provider slug.
+ * @param array<string, mixed> $meta Term meta to set after creation.
+ * @return int The created term id.
+ */
+ private function create_custom_provider_term( string $name, string $slug, array $meta = array() ): int {
+ $term = wp_insert_term( $name, self::PROVIDER_TAXONOMY, array( 'slug' => $slug ) );
+ $this->assertIsArray( $term, 'The fixture provider term should be created.' );
+
+ $term_id = (int) $term['term_id'];
+ foreach ( $meta as $key => $value ) {
+ update_term_meta( $term_id, $key, $value );
+ }
+
+ return $term_id;
+ }
+
+ /**
+ * Insert a non-deleted fulfillment that references a provider slug, as the in-use check expects.
+ *
+ * @param string $provider_slug The provider slug the fulfillment references.
+ */
+ private function seed_fulfillment_for_provider( string $provider_slug ): void {
+ global $wpdb;
+ $now = current_time( 'mysql', true );
+
+ $wpdb->insert(
+ $wpdb->prefix . 'wc_order_fulfillments',
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => 1,
+ 'status' => 'unfulfilled',
+ 'is_fulfilled' => 0,
+ 'date_updated' => $now,
+ )
+ );
+
+ $wpdb->insert(
+ $wpdb->prefix . 'wc_order_fulfillment_meta',
+ // Column names of a custom fulfillments table, not a slow postmeta query.
+ array(
+ 'fulfillment_id' => (int) $wpdb->insert_id,
+ 'meta_key' => '_shipment_provider', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => wp_json_encode( $provider_slug ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ 'date_updated' => $now,
+ )
+ );
+ }
+
/**
* Does the 'hard work' of triggering an ajax endpoint and capturing the response.
*
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
index b7e0bdd3492..af19ae074b5 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Fulfillments/FulfillmentsRendererTest.php
@@ -6,10 +6,12 @@ use Automattic\WooCommerce\Admin\Features\Fulfillments\DataStore\FulfillmentsDat
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Admin\Features\Fulfillments\Fulfillment;
use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentsRenderer;
+use Automattic\WooCommerce\Admin\Features\Fulfillments\Providers\AmazonLogisticsShippingProvider;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use WC_Helper_Order;
use WC_Helper_Product;
use WC_Order;
+use WP_Query;
/**
* Tests for Fulfillment object.
@@ -392,4 +394,284 @@ class FulfillmentsRendererTest extends \WC_Unit_Test_Case {
$this->assertStringContainsString( 'wc-admin-fulfillments-js', $output );
$this->assertStringContainsString( 'var wcFulfillmentSettings', $output );
}
+
+ /**
+ * @testdox Filtering by a specific provider returns only the orders whose fulfillment uses it.
+ */
+ public function test_get_order_ids_matches_specific_provider(): void {
+ $this->seed_fulfillment( 101, 'acme-couriers' );
+ $this->seed_fulfillment( 102, 'other-co' );
+
+ $this->assertSame( array( 101 ), $this->get_order_ids( 'acme-couriers' ) );
+ }
+
+ /**
+ * @testdox The filter finds an order whose provider was saved through the fulfillment CRUD path.
+ */
+ public function test_get_order_ids_matches_provider_saved_through_crud(): void {
+ $order = WC_Helper_Order::create_order( get_current_user_id() );
+
+ $fulfillment = new Fulfillment();
+ $fulfillment->set_entity_type( WC_Order::class );
+ $fulfillment->set_entity_id( (string) $order->get_id() );
+ $fulfillment->set_shipment_provider( 'acme-couriers' );
+ $fulfillment->set_items(
+ array(
+ array(
+ 'item_id' => 1,
+ 'qty' => 1,
+ ),
+ )
+ );
+ $fulfillment->set_status( 'unfulfilled' );
+ $fulfillment->save();
+
+ $this->assertSame(
+ array( $order->get_id() ),
+ $this->get_order_ids( 'acme-couriers' ),
+ 'The filter must match the provider meta the data store actually persists.'
+ );
+
+ WC_Helper_Order::delete_order( $order->get_id() );
+ }
+
+ /**
+ * @testdox A soft-deleted fulfillment is excluded from the provider filter.
+ */
+ public function test_get_order_ids_excludes_soft_deleted_fulfillment(): void {
+ $this->seed_fulfillment( 103, 'acme-couriers' );
+ $this->seed_fulfillment( 104, 'acme-couriers', true );
+
+ $this->assertSame( array( 103 ), $this->get_order_ids( 'acme-couriers' ) );
+ }
+
+ /**
+ * @testdox A fulfillment whose provider meta row is soft-deleted is excluded from the filter.
+ */
+ public function test_get_order_ids_excludes_soft_deleted_meta(): void {
+ $this->seed_fulfillment( 105, 'acme-couriers' );
+ $this->seed_fulfillment( 106, 'acme-couriers', false, true );
+
+ $this->assertSame( array( 105 ), $this->get_order_ids( 'acme-couriers' ) );
+ }
+
+ /**
+ * @testdox The __other__ sentinel returns orders whose provider is not a known built-in or custom key.
+ */
+ public function test_get_order_ids_other_excludes_known_providers(): void {
+ // Register a known provider so the query uses the NOT IN branch rather than the
+ // empty-known-keys fallback, which would otherwise return every providered order.
+ $register_known = function ( array $providers ): array {
+ $providers[] = AmazonLogisticsShippingProvider::class;
+ return $providers;
+ };
+ add_filter( 'woocommerce_fulfillment_shipping_providers', $register_known );
+
+ try {
+ $this->seed_fulfillment( 201, 'amazon-logistics' );
+ $this->seed_fulfillment( 202, 'ghost-provider' );
+
+ $this->assertSame( array( 202 ), $this->get_order_ids( '__other__' ) );
+ } finally {
+ remove_filter( 'woocommerce_fulfillment_shipping_providers', $register_known );
+ }
+ }
+
+ /**
+ * @testdox The HPOS orders query is filtered to the matching order ids when no post__in exists yet.
+ */
+ public function test_filter_orders_sets_post_in_when_absent(): void {
+ $this->seed_fulfillment( 301, 'acme-couriers' );
+
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () {
+ $args = $this->renderer->filter_orders_by_shipping_provider( array() );
+ $this->assertSame( array( 301 ), $args['post__in'] );
+ }
+ );
+ }
+
+ /**
+ * @testdox The HPOS filter intersects an existing post__in with the provider matches.
+ */
+ public function test_filter_orders_intersects_existing_post_in(): void {
+ $this->seed_fulfillment( 401, 'acme-couriers' );
+ $this->seed_fulfillment( 402, 'acme-couriers' );
+
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () {
+ $args = $this->renderer->filter_orders_by_shipping_provider( array( 'post__in' => array( 401, 999 ) ) );
+ $this->assertSame( array( 401 ), array_values( $args['post__in'] ), 'Only the id present in both sets should remain.' );
+ }
+ );
+ }
+
+ /**
+ * @testdox The HPOS filter returns a no-match sentinel when the provider has no orders.
+ */
+ public function test_filter_orders_returns_zero_when_no_match(): void {
+ $this->with_provider_param(
+ 'provider-with-no-orders',
+ function () {
+ $args = $this->renderer->filter_orders_by_shipping_provider( array() );
+ $this->assertSame( array( 0 ), $args['post__in'], 'An unmatched provider should force an empty result set.' );
+ }
+ );
+ }
+
+ /**
+ * @testdox The HPOS filter leaves the query arguments untouched when no provider is requested.
+ */
+ public function test_filter_orders_is_noop_without_param(): void {
+ $original = array( 'post__in' => array( 7, 8 ) );
+
+ $this->assertSame( $original, $this->renderer->filter_orders_by_shipping_provider( $original ) );
+ }
+
+ /**
+ * @testdox The legacy orders query is filtered to the matching order ids.
+ */
+ public function test_filter_legacy_orders_sets_post_in(): void {
+ $this->seed_fulfillment( 501, 'acme-couriers' );
+
+ $query = new WP_Query();
+ $query->set( 'post_type', 'shop_order' );
+
+ $this->with_main_query(
+ $query,
+ function () use ( $query ) {
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () use ( $query ) {
+ $this->renderer->filter_legacy_orders_by_shipping_provider( $query );
+ $this->assertSame( array( 501 ), $query->get( 'post__in' ) );
+ }
+ );
+ }
+ );
+ }
+
+ /**
+ * @testdox The legacy filter does nothing when the query is not the admin main orders query.
+ */
+ public function test_filter_legacy_orders_skips_non_main_query(): void {
+ $this->seed_fulfillment( 601, 'acme-couriers' );
+
+ // A secondary query: not registered as the main query, so the guard must bail.
+ $query = new WP_Query();
+ $query->set( 'post_type', 'shop_order' );
+
+ // Set the admin screen so is_admin() passes and the bail is isolated to the is_main_query() guard.
+ $original_screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
+ set_current_screen( 'edit-shop_order' );
+
+ try {
+ $this->with_provider_param(
+ 'acme-couriers',
+ function () use ( $query ) {
+ $this->renderer->filter_legacy_orders_by_shipping_provider( $query );
+ $this->assertEmpty( $query->get( 'post__in' ), 'A non-main query should not be filtered.' );
+ }
+ );
+ } finally {
+ if ( $original_screen ) {
+ $GLOBALS['current_screen'] = $original_screen; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ } else {
+ unset( $GLOBALS['current_screen'] );
+ }
+ }
+ }
+
+ /**
+ * Invoke the private provider-to-order-ids query.
+ *
+ * @param string $provider The provider key or the __other__ sentinel.
+ * @return array<int> The matching order ids.
+ */
+ private function get_order_ids( string $provider ): array {
+ $method = new \ReflectionMethod( FulfillmentsRenderer::class, 'get_order_ids_by_shipping_provider' );
+ $method->setAccessible( true );
+
+ return $method->invoke( $this->renderer, $provider );
+ }
+
+ /**
+ * Run a callback with the shipping_provider request parameter set, then restore it.
+ *
+ * @param string $provider The provider value to place in the request.
+ * @param callable $callback The assertions to run while the parameter is set.
+ */
+ private function with_provider_param( string $provider, callable $callback ): void {
+ $_GET['shipping_provider'] = $provider;
+ try {
+ $callback();
+ } finally {
+ unset( $_GET['shipping_provider'] );
+ }
+ }
+
+ /**
+ * Run a callback with the given query registered as the admin main orders query, then restore state.
+ *
+ * @param WP_Query $query The query to treat as the main query.
+ * @param callable $callback The assertions to run while the query is active.
+ */
+ private function with_main_query( WP_Query $query, callable $callback ): void {
+ $original_main_query = $GLOBALS['wp_the_query'] ?? null;
+ $original_screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
+
+ // is_main_query() compares against $wp_the_query, and is_admin() reads the current screen;
+ // the test restores both in the finally block.
+ $GLOBALS['wp_the_query'] = $query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ set_current_screen( 'edit-shop_order' );
+ try {
+ $callback();
+ } finally {
+ $GLOBALS['wp_the_query'] = $original_main_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ if ( $original_screen ) {
+ $GLOBALS['current_screen'] = $original_screen; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ } else {
+ unset( $GLOBALS['current_screen'] );
+ }
+ }
+ }
+
+ /**
+ * Insert a fulfillment referencing a provider slug, as the filter query expects.
+ *
+ * @param int $entity_id The order id the fulfillment belongs to.
+ * @param string $provider_slug The provider slug the fulfillment references.
+ * @param bool $deleted Whether the fulfillment row is soft-deleted.
+ * @param bool $meta_deleted Whether the provider meta row is soft-deleted.
+ */
+ private function seed_fulfillment( int $entity_id, string $provider_slug, bool $deleted = false, bool $meta_deleted = false ): void {
+ global $wpdb;
+ $now = current_time( 'mysql', true );
+
+ $wpdb->insert(
+ $wpdb->prefix . 'wc_order_fulfillments',
+ array(
+ 'entity_type' => WC_Order::class,
+ 'entity_id' => $entity_id,
+ 'status' => 'unfulfilled',
+ 'is_fulfilled' => 0,
+ 'date_updated' => $now,
+ 'date_deleted' => $deleted ? $now : null,
+ )
+ );
+
+ // Column names of a custom fulfillments table, not a slow postmeta query.
+ $wpdb->insert(
+ $wpdb->prefix . 'wc_order_fulfillment_meta',
+ array(
+ 'fulfillment_id' => (int) $wpdb->insert_id,
+ 'meta_key' => '_shipment_provider', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
+ 'meta_value' => wp_json_encode( $provider_slug ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ 'date_updated' => $now,
+ 'date_deleted' => $meta_deleted ? $now : null,
+ )
+ );
+ }
}