diff --git a/includes/API/Orders_Controller.php b/includes/API/Orders_Controller.php index 870ada1e..8cae5f34 100644 --- a/includes/API/Orders_Controller.php +++ b/includes/API/Orders_Controller.php @@ -26,7 +26,7 @@ /** * Orders controller class. * - * @NOTE: methods not prefixed with wcpos_ will override WC_REST_Products_Controller methods + * @NOTE: methods not prefixed with wcpos_ will override WC_REST_Orders_Controller methods */ class Orders_Controller extends WC_REST_Orders_Controller { use Traits\Uuid_Handler; @@ -53,11 +53,19 @@ class Orders_Controller extends WC_REST_Orders_Controller { */ private $is_creating = false; + /** + * Whether High Performance Orders is enabled. + * + * @var bool + */ + private $hpos_enabled = false; + /** * Constructor. */ public function __construct() { add_filter( 'woocommerce_pos_rest_dispatch_orders_request', array( $this, 'wcpos_dispatch_request' ), 10, 4 ); + $this->hpos_enabled = class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled(); if ( method_exists( parent::class, '__construct' ) ) { parent::__construct(); @@ -558,8 +566,11 @@ public function wcpos_tax_based_on( $value, $option ) { public function wcpos_shop_order_query( array $args, WP_REST_Request $request ) { // Check for wcpos_include/wcpos_exclude parameter. if ( isset( $request['wcpos_include'] ) || isset( $request['wcpos_exclude'] ) ) { - // Add a custom WHERE clause to the query. - add_filter( 'posts_where', array( $this, 'wcpos_posts_where_order_include_exclude' ), 10, 2 ); + if ( $this->hpos_enabled ) { + add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'wcpos_hpos_orders_table_query_clauses' ), 10, 3 ); + } else { + add_filter( 'posts_where', array( $this, 'wcpos_posts_where_order_include_exclude' ), 10, 2 ); + } } return $args; @@ -576,14 +587,14 @@ public function wcpos_shop_order_query( array $args, WP_REST_Request $request ) public function wcpos_posts_where_order_include_exclude( string $where, $query ) { global $wpdb; - // Handle 'wcpos_include' + // Handle 'wcpos_include'. if ( ! empty( $this->wcpos_request['wcpos_include'] ) ) { $include_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_include'] ); $ids_format = implode( ',', array_fill( 0, count( $include_ids ), '%d' ) ); $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID IN ($ids_format) ", $include_ids ); } - // Handle 'wcpos_exclude' + // Handle 'wcpos_exclude'. if ( ! empty( $this->wcpos_request['wcpos_exclude'] ) ) { $exclude_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_exclude'] ); $ids_format = implode( ',', array_fill( 0, count( $exclude_ids ), '%d' ) ); @@ -593,6 +604,39 @@ public function wcpos_posts_where_order_include_exclude( string $where, $query ) return $where; } + /** + * Filters all query clauses at once. + * Covers the fields (SELECT), JOIN, WHERE, GROUP BY, ORDER BY, and LIMIT clauses. + * + * @param string[] $clauses { + * Associative array of the clauses for the query. + * + * @type string $fields The SELECT clause of the query. + * @type string $join The JOIN clause of the query. + * @type string $where The WHERE clause of the query. + * @type string $groupby The GROUP BY clause of the query. + * @type string $orderby The ORDER BY clause of the query. + * @type string $limits The LIMIT clause of the query. + * } + * @param OrdersTableQuery $query The OrdersTableQuery instance (passed by reference). + * @param array $args Query args. + */ + public function wcpos_hpos_orders_table_query_clauses( array $clauses, $query, array $args ) { + // Handle 'wcpos_include'. + if ( ! empty( $this->wcpos_request['wcpos_include'] ) ) { + $include_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_include'] ); + $clauses['where'] .= ' AND ' . $query->get_table_name( 'orders' ) . '.id IN (' . implode( ',', $include_ids ) . ')'; + } + + // Handle 'wcpos_exclude'. + if ( ! empty( $this->wcpos_request['wcpos_exclude'] ) ) { + $exclude_ids = array_map( 'intval', (array) $this->wcpos_request['wcpos_exclude'] ); + $clauses['where'] .= ' AND ' . $query->get_table_name( 'orders' ) . '.id NOT IN (' . implode( ',', $exclude_ids ) . ')'; + } + + return $clauses; + } + /** * Returns array of all order ids. * @@ -641,6 +685,55 @@ function ( $status ) { } } + /** + * Prepare objects query. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return array|WP_Error + */ + protected function prepare_objects_query( $request ) { + $args = parent::prepare_objects_query( $request ); + + /** + * Extend the orderby parameter to include custom options. + * Legacy order options. + */ + if ( isset( $request['orderby'] ) && ! $this->hpos_enabled ) { + switch ( $request['orderby'] ) { + case 'status': + // NOTE: 'post_status' is not a valid orderby option for WC_Order_Query. + $args['orderby'] = 'post_status'; + + break; + case 'customer_id': + $args['meta_key'] = '_customer_user'; + $args['orderby'] = 'meta_value_num'; + + break; + case 'payment_method': + $args['meta_key'] = '_payment_method_title'; + $args['orderby'] = 'meta_value'; + + break; + case 'total': + $args['meta_key'] = '_order_total'; + $args['orderby'] = 'meta_value'; + + break; + } + } + + /** + * Extend the orderby parameter to include custom options. + * HOPS orders options. + */ + if ( isset( $request['orderby'] ) && $this->hpos_enabled ) { + add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'wcpos_hpos_orderby_query' ), 10, 3 ); + } + + return $args; + } /** * Filters all query clauses at once. @@ -662,58 +755,31 @@ function ( $status ) { * * @return string[] $clauses */ - public function wcpos_hpos_orderby_status_query( array $clauses, $query, $args ) { + public function wcpos_hpos_orderby_query( array $clauses, $query, $args ) { if ( isset( $clauses['orderby'] ) && '' === $clauses['orderby'] ) { - $order = $args['order'] ?? 'ASC'; - $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.status ' . $order; - } + $order = $args['order'] ?? 'ASC'; + $orderby = $this->wcpos_request->get_param( 'orderby' ); - return $clauses; - } - - /** - * Prepare objects query. - * - * @param WP_REST_Request $request Full details about the request. - * - * @return array|WP_Error - */ - protected function prepare_objects_query( $request ) { - $args = parent::prepare_objects_query( $request ); - - // Add custom 'orderby' options - if ( isset( $request['orderby'] ) ) { - switch ( $request['orderby'] ) { + switch ( $orderby ) { case 'status': - // NOTE: 'post_status' is not a valid orderby option for WC_Order_Query - $args['orderby'] = 'post_status'; + $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.status ' . $order; break; case 'customer_id': - $args['meta_key'] = '_customer_user'; - $args['orderby'] = 'meta_value_num'; + $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.customer_id ' . $order; break; case 'payment_method': - $args['meta_key'] = '_payment_method_title'; - $args['orderby'] = 'meta_value'; + $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.payment_method ' . $order; break; case 'total': - $args['meta_key'] = '_order_total'; - $args['orderby'] = 'meta_value'; + $clauses['orderby'] = $query->get_table_name( 'orders' ) . '.total_amount ' . $order; break; } - - // If HPOS is enabled and $args['orderby'] = 'post_status', we need to add a custom query clause - if ( class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() ) { - if ( 'status' === $request['orderby'] ) { - add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'wcpos_hpos_orderby_status_query' ), 10, 3 ); - } - } } - return $args; + return $clauses; } } diff --git a/tests/Helpers/HPOSToggleTrait.php b/tests/Helpers/HPOSToggleTrait.php new file mode 100644 index 00000000..3721bc84 --- /dev/null +++ b/tests/Helpers/HPOSToggleTrait.php @@ -0,0 +1,88 @@ +toggle_cot_feature_and_usage( true ); + } + + /** + * Call in teardown to disable COT/HPOS. + */ + public function clean_up_cot_setup(): void { + $this->toggle_cot_feature_and_usage( false ); + + // Add back removed filter. + add_filter( 'query', array( $this, '_create_temporary_tables' ) ); + add_filter( 'query', array( $this, '_drop_temporary_tables' ) ); + } + + /** + * Enables or disables the custom orders table feature, and sets the orders table as authoritative, across WP temporarily. + * + * @param boolean $enabled TRUE to enable COT or FALSE to disable. + * @return void + */ + private function toggle_cot_feature_and_usage( bool $enabled ): void { + OrderHelper::toggle_cot_feature_and_usage( $enabled ); + } + + /** + * Set the orders table or the posts table as the authoritative table to store orders. + * + * @param bool $cot_authoritative True to set the orders table as authoritative, false to set the posts table as authoritative. + */ + protected function toggle_cot_authoritative( bool $cot_authoritative ) { + OrderHelper::toggle_cot_feature_and_usage( $cot_authoritative ); + } + + /** + * Helper function to enable COT <> Posts sync. + */ + private function enable_cot_sync() { + $hook_name = 'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION; + remove_all_actions( $hook_name ); + add_filter( + $hook_name, + function () { + return 'yes'; + } + ); + } + + /** + * Helper function to disable COT <> Posts sync. + */ + private function disable_cot_sync() { + $hook_name = 'pre_option_' . DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION; + remove_all_actions( $hook_name ); + add_filter( + $hook_name, + function () { + return 'no'; + } + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 680eb287..5abdf6fe 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -20,7 +20,7 @@ public function __construct() { ini_set( 'display_errors', 'on' ); error_reporting( E_ALL ); $this->tests_dir = $this->get_test_dir(); - $this->plugin_dir = \dirname(__FILE__, 2); + $this->plugin_dir = \dirname( __DIR__, 1 ); // Require composer dependencies. require_once $this->plugin_dir . '/vendor/autoload.php'; @@ -35,11 +35,10 @@ public function __construct() { // Do not try to load JavaScript files from an external URL - this takes a // while. - \define('GUTENBERG_LOAD_VENDOR_SCRIPTS', false); + \define( 'GUTENBERG_LOAD_VENDOR_SCRIPTS', false ); - tests_add_filter('muplugins_loaded', array( $this, 'manually_load_plugin' ) ); - tests_add_filter('muplugins_loaded', array( $this, 'install_woocommerce' ) ); - + tests_add_filter( 'muplugins_loaded', array( $this, 'manually_load_plugin' ) ); + tests_add_filter( 'muplugins_loaded', array( $this, 'install_woocommerce' ) ); // Start up the WP testing environment. tests_add_filter( 'wp_die_handler', array( $this, 'fail_if_died' ) ); // handle bootstrap errors @@ -58,22 +57,22 @@ public function __construct() { */ public function get_test_dir(): string { // Try the WP_TESTS_DIR environment variable first. - $_tests_dir = getenv('WP_TESTS_DIR'); + $_tests_dir = getenv( 'WP_TESTS_DIR' ); // Next, try the WP_PHPUNIT composer package. - if ( ! $_tests_dir) { - $_tests_dir = getenv('WP_PHPUNIT__DIR'); + if ( ! $_tests_dir ) { + $_tests_dir = getenv( 'WP_PHPUNIT__DIR' ); } // See if we're installed inside an existing WP dev instance. - if ( ! $_tests_dir) { - $_try_tests_dir = \dirname(__FILE__) . '/../../../../../tests/phpunit'; - if (file_exists($_try_tests_dir . '/includes/functions.php')) { + if ( ! $_tests_dir ) { + $_try_tests_dir = __DIR__ . '/../../../../../tests/phpunit'; + if ( file_exists( $_try_tests_dir . '/includes/functions.php' ) ) { $_tests_dir = $_try_tests_dir; } } // Fallback. - if ( ! $_tests_dir) { + if ( ! $_tests_dir ) { $_tests_dir = '/tmp/wordpress-tests-lib'; } @@ -94,11 +93,11 @@ public function manually_load_plugin(): void { public function install_woocommerce(): void { require $this->plugin_dir . '/../woocommerce/woocommerce.php'; // Clean existing install first. - // define( 'WP_UNINSTALL_PLUGIN', true ); - // define( 'WC_REMOVE_ALL_DATA', true ); - // require dirname( dirname( __FILE__ ) ) . '/../woocommerce/uninstall.php'; - // WC_Install::install(); - // echo esc_html( 'Installing WooCommerce...' . PHP_EOL ); + // define( 'WP_UNINSTALL_PLUGIN', true ); + // define( 'WC_REMOVE_ALL_DATA', true ); + // require dirname( dirname( __FILE__ ) ) . '/../woocommerce/uninstall.php'; + // WC_Install::install(); + // echo esc_html( 'Installing WooCommerce...' . PHP_EOL ); } /** @@ -117,6 +116,7 @@ public function includes(): void { require_once $this->plugin_dir . '/tests/Helpers/CustomerHelper.php'; require_once $this->plugin_dir . '/tests/Helpers/CouponHelper.php'; require_once $this->plugin_dir . '/tests/Helpers/ShippingHelper.php'; + require_once $this->plugin_dir . '/tests/Helpers/HPOSToggleTrait.php'; } /** @@ -131,12 +131,12 @@ public function includes(): void { * * @throws Exception When a `wp_die()` occurs. */ - public function fail_if_died($message): void { - if (is_wp_error($message)) { + public function fail_if_died( $message ): void { + if ( is_wp_error( $message ) ) { $message = $message->get_error_message(); } - throw new \Exception('WordPress died: ' . $message); + throw new \Exception( 'WordPress died: ' . $message ); } public static function instance() { diff --git a/tests/includes/API/Test_HPOS_Orders_Controller.php b/tests/includes/API/Test_HPOS_Orders_Controller.php new file mode 100644 index 00000000..fcb31a79 --- /dev/null +++ b/tests/includes/API/Test_HPOS_Orders_Controller.php @@ -0,0 +1,883 @@ +setup_cot(); + $this->cot_state = OrderUtil::custom_orders_table_usage_is_enabled(); + $this->toggle_cot_feature_and_usage( true ); + + $this->endpoint = new Orders_Controller(); + } + + public function tearDown(): void { + $this->toggle_cot_feature_and_usage( $this->cot_state ); + $this->clean_up_cot_setup(); + + remove_all_filters( 'wc_allow_changing_orders_storage_while_sync_is_pending' ); + + parent::tearDown(); + } + + /** + * Test HPOS is enabled. + */ + public function test_hpos_enabled(): void { + $this->assertTrue( OrderUtil::custom_orders_table_usage_is_enabled() ); + $order = OrderHelper::create_order(); + $this->assert_order_record_existence( $order->get_id(), true, true ); + } + + public function test_namespace_property(): void { + $namespace = $this->get_reflected_property_value( 'namespace' ); + + $this->assertEquals( 'wcpos/v1', $namespace ); + } + + public function test_rest_base(): void { + $rest_base = $this->get_reflected_property_value( 'rest_base' ); + + $this->assertEquals( 'orders', $rest_base ); + } + + /** + * Test route registration. + */ + public function test_register_routes(): void { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wcpos/v1/orders', $routes ); + $this->assertArrayHasKey( '/wcpos/v1/orders/(?P[\d]+)', $routes ); + $this->assertArrayHasKey( '/wcpos/v1/orders/batch', $routes ); + + // added by WooCommerce POS + $this->assertArrayHasKey( '/wcpos/v1/orders/(?P[\d]+)/email', $routes ); + $this->assertArrayHasKey( '/wcpos/v1/orders/statuses', $routes ); + } + + /** + * Get all expected fields. + */ + /** + * Get all expected fields. + */ + public function get_expected_response_fields() { + return array( + 'id', + 'parent_id', + 'number', + 'order_key', + 'created_via', + 'version', + 'status', + 'currency', + 'date_created', + 'date_created_gmt', + 'date_modified', + 'date_modified_gmt', + 'discount_total', + 'discount_tax', + 'shipping_total', + 'shipping_tax', + 'cart_tax', + 'total', + 'total_tax', + 'prices_include_tax', + 'customer_id', + 'customer_ip_address', + 'customer_user_agent', + 'customer_note', + 'billing', + 'shipping', + 'payment_method', + 'payment_method_title', + 'transaction_id', + 'date_paid', + 'date_paid_gmt', + 'date_completed', + 'date_completed_gmt', + 'cart_hash', + 'meta_data', + 'line_items', + 'tax_lines', + 'shipping_lines', + 'fee_lines', + 'coupon_lines', + 'currency_symbol', + 'refunds', + 'payment_url', + 'is_editable', + 'needs_payment', + 'needs_processing', + ); + } + + public function test_order_api_get_all_fields(): void { + $expected_response_fields = $this->get_expected_response_fields(); + + $order = OrderHelper::create_order(); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders/' . $order->get_id() ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $response_fields = array_keys( $response->get_data() ); + + $this->assertEmpty( array_diff( $expected_response_fields, $response_fields ), 'These fields were expected but not present in WCPOS API response: ' . print_r( array_diff( $expected_response_fields, $response_fields ), true ) ); + + $this->assertEmpty( array_diff( $response_fields, $expected_response_fields ), 'These fields were not expected in the WCPOS API response: ' . print_r( array_diff( $response_fields, $expected_response_fields ), true ) ); + } + + /** + * + */ + public function test_order_api_get_all_ids(): void { + $order1 = OrderHelper::create_order(); + $order2 = OrderHelper::create_order(); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'posts_per_page', -1 ); + $request->set_param( 'fields', array( 'id' ) ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $ids = wp_list_pluck( $data, 'id' ); + + $this->assertEquals( array( $order1->get_id(), $order2->get_id() ), $ids ); + } + + /** + * + */ + public function test_order_api_get_all_ids_with_date_modified_gmt(): void { + $order1 = OrderHelper::create_order(); + $order2 = OrderHelper::create_order(); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'posts_per_page', -1 ); + $request->set_param( 'fields', array( 'id', 'date_modified_gmt' ) ); + + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $ids = wp_list_pluck( $data, 'id' ); + + $this->assertEquals( array( $order1->get_id(), $order2->get_id() ), $ids ); + + // Verify that date_modified_gmt is present for all products and correctly formatted. + foreach ( $data as $d ) { + $this->assertArrayHasKey( 'date_modified_gmt', $d, "The 'date_modified_gmt' field is missing for product ID {$d['id']}." ); + $this->assertMatchesRegularExpression( '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|(\+\d{2}:\d{2}))?/', $d['date_modified_gmt'], "The 'date_modified_gmt' field for product ID {$d['id']} is not correctly formatted." ); + } + } + + /** + * Each order needs a UUID. + */ + public function test_order_response_contains_uuid_meta_data(): void { + $order = OrderHelper::create_order(); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders/' . $order->get_id() ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + + $found = false; + $uuid_value = ''; + $count = 0; + + // Look for the _woocommerce_pos_uuid key in meta_data + foreach ( $data['meta_data'] as $meta ) { + if ( '_woocommerce_pos_uuid' === $meta['key'] ) { + $count++; + $uuid_value = $meta['value']; + } + } + + $this->assertEquals( 1, $count, 'There should only be one _woocommerce_pos_uuid.' ); + $this->assertTrue( Uuid::isValid( $uuid_value ), 'The UUID value is not valid.' ); + } + + /** + * This works on the app, but not in the test?? + */ + public function test_orderby_status(): void { + $order1 = OrderHelper::create_order( array( 'status' => 'pending' ) ); + $order2 = OrderHelper::create_order( array( 'status' => 'completed' ) ); + $order3 = OrderHelper::create_order( array( 'status' => 'on-hold' ) ); + $order4 = OrderHelper::create_order( array( 'status' => 'processing' ) ); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_query_params( + array( + 'orderby' => 'status', + 'order' => 'asc', + ) + ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $statuses = wp_list_pluck( $data, 'status' ); + + $this->assertEquals( $statuses, array( 'completed', 'on-hold', 'pending', 'processing' ) ); + + // reverse order + $request->set_query_params( + array( + 'orderby' => 'status', + 'order' => 'desc', + ) + ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $statuses = wp_list_pluck( $data, 'status' ); + + $this->assertEquals( $statuses, array( 'processing', 'pending', 'on-hold', 'completed' ) ); + } + + /** + * + */ + public function test_orderby_customer(): void { + $customer = CustomerHelper::create_customer(); + $order1 = OrderHelper::create_order( array( 'customer_id' => $customer->get_id() ) ); + $order2 = OrderHelper::create_order(); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_query_params( + array( + 'orderby' => 'customer_id', + 'order' => 'asc', + ) + ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $customer_ids = wp_list_pluck( $data, 'customer_id' ); + + $this->assertEquals( $customer_ids, array( 1, $customer->get_id() ) ); + + // reverse order + $request->set_query_params( + array( + 'orderby' => 'customer_id', + 'order' => 'desc', + ) + ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $customer_ids = wp_list_pluck( $data, 'customer_id' ); + + $this->assertEquals( $customer_ids, array( $customer->get_id(), 1 ) ); + } + + public function test_orderby_payment_method(): void { + $order1 = OrderHelper::create_order( array( 'payment_method' => 'pos_cash' ) ); + $order2 = OrderHelper::create_order( array( 'payment_method' => 'pos_card' ) ); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_query_params( + array( + 'orderby' => 'payment_method', + 'order' => 'asc', + ) + ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $payment_methods = wp_list_pluck( $data, 'payment_method_title' ); + + $this->assertEquals( $payment_methods, array( 'Card', 'Cash' ) ); + + // reverse order + $request->set_query_params( + array( + 'orderby' => 'payment_method', + 'order' => 'desc', + ) + ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $payment_methods = wp_list_pluck( $data, 'payment_method_title' ); + + $this->assertEquals( $payment_methods, array( 'Cash', 'Card' ) ); + } + + public function test_orderby_total(): void { + $order1 = OrderHelper::create_order( array( 'total' => 100 ) ); + $order2 = OrderHelper::create_order( array( 'total' => 200 ) ); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_query_params( + array( + 'orderby' => 'total', + 'order' => 'asc', + ) + ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $totals = wp_list_pluck( $data, 'total' ); + + $this->assertEquals( $totals, array( 100, 200 ) ); + + // reverse order + $request->set_query_params( + array( + 'orderby' => 'total', + 'order' => 'desc', + ) + ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $totals = wp_list_pluck( $data, 'total' ); + + $this->assertEquals( $totals, array( 200, 100 ) ); + } + + /** + * Line items. + */ + public function test_line_items_contains_uuid_meta_data(): void { + $order = OrderHelper::create_order(); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders/' . $order->get_id() ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data['line_items'] ) ); + + $found = false; + $uuid_value = ''; + $count = 0; + + // Look for the _woocommerce_pos_uuid key in meta_data + foreach ( $data['line_items'][0]['meta_data'] as $meta ) { + if ( '_woocommerce_pos_uuid' === $meta['key'] ) { + $count++; + $uuid_value = $meta['value']; + } + } + + $this->assertEquals( 1, $count, 'There should only be one _woocommerce_pos_uuid.' ); + $this->assertTrue( Uuid::isValid( $uuid_value ), 'The UUID value is not valid.' ); + } + + /** + * Shipping lines. + */ + public function test_shipping_lines_contains_uuid_meta_data(): void { + $order = OrderHelper::create_order(); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders/' . $order->get_id() ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data['shipping_lines'] ) ); + + $found = false; + $uuid_value = ''; + $count = 0; + + // Look for the _woocommerce_pos_uuid key in meta_data + foreach ( $data['shipping_lines'][0]['meta_data'] as $meta ) { + if ( '_woocommerce_pos_uuid' === $meta['key'] ) { + $count++; + $uuid_value = $meta['value']; + } + } + + $this->assertEquals( 1, $count, 'There should only be one _woocommerce_pos_uuid.' ); + $this->assertTrue( Uuid::isValid( $uuid_value ), 'The UUID value is not valid.' ); + } + + /** + * Fee lines. + */ + public function test_fee_lines_contains_uuid_meta_data(): void { + $order = OrderHelper::create_order(); + $fee = new WC_Order_Item_Fee(); + $order->add_item( $fee ); + $order->save(); + $request = $this->wp_rest_get_request( '/wcpos/v1/orders/' . $order->get_id() ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data['fee_lines'] ) ); + + $found = false; + $uuid_value = ''; + $count = 0; + + // Look for the _woocommerce_pos_uuid key in meta_data + foreach ( $data['fee_lines'][0]['meta_data'] as $meta ) { + if ( '_woocommerce_pos_uuid' === $meta['key'] ) { + $count++; + $uuid_value = $meta['value']; + } + } + + $this->assertEquals( 1, $count, 'There should only be one _woocommerce_pos_uuid.' ); + $this->assertTrue( Uuid::isValid( $uuid_value ), 'The UUID value is not valid.' ); + } + + /** + * Create a new order. + */ + public function test_create_guest_order(): void { + $request = $this->wp_rest_post_request( '/wcpos/v1/orders' ); + $request->set_body_params( + array( + 'payment_method' => 'pos_cash', + 'line_items' => array( + array( + 'product_id' => 1, + 'quantity' => 1, + ), + ), + 'billing' => array( + 'email' => '', + 'first_name' => '', + 'last_name' => '', + 'address_1' => '', + 'address_2' => '', + 'city' => '', + 'state' => '', + 'postcode' => '', + 'country' => '', + 'phone' => '', + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 201, $response->get_status() ); + + $this->assertEquals( 'pending', $data['status'] ); + $this->assertEquals( 'woocommerce-pos', $data['created_via'] ); + $this->assertEquals( 0, $data['customer_id'] ); + } + + /** + * GOTCHA: if there is billing info, we need to allow no email for guest orders. + */ + public function test_create_guest_order_with_billing_info(): void { + $request = $this->wp_rest_post_request( '/wcpos/v1/orders' ); + $request->set_body_params( + array( + 'payment_method' => 'pos_cash', + 'line_items' => array( + array( + 'product_id' => 1, + 'quantity' => 1, + ), + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $cashier = null; + $count = 0; + + // Look for the _pos_user key in meta_data + foreach ( $data['meta_data'] as $meta ) { + if ( '_pos_user' === $meta['key'] ) { + $count++; + $cashier = $meta['value']; + } + } + + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'pending', $data['status'] ); + $this->assertEquals( 'woocommerce-pos', $data['created_via'] ); + $this->assertEquals( 'pos_cash', $data['payment_method'] ); + $this->assertEquals( 0, $data['customer_id'] ); + $this->assertEquals( 1, \count( $data['line_items'] ) ); + $this->assertEquals( 1, $count, 'There should only be one _pos_user.' ); + $this->assertEquals( $this->user, $cashier, 'The cashier ID is not correct.' ); + } + + /** + * Send receipt to customer. + */ + public function test_send_receipt(): void { + $email = 'sendtest@example.com'; + $email_sent = false; + $note_added = false; + $expected_note = sprintf( 'Order details manually sent to %s from WooCommerce POS.', $email ); + + $email_sent_callback = function () use ( &$email_sent ): void { + $email_sent = true; + }; + + $order_note_filter_check = function ( $commentdata, $data ) use ( &$note_added, $expected_note ) { + if ( $commentdata['comment_content'] === $expected_note ) { + $note_added = true; + } + + return $commentdata; + }; + + add_action( 'woocommerce_before_resend_order_emails', $email_sent_callback ); + add_filter( 'woocommerce_new_order_note_data', $order_note_filter_check, 10, 2 ); + + $order = OrderHelper::create_order(); + $request = $this->wp_rest_post_request( '/wcpos/v1/orders/' . $order->get_id() . '/email' ); + $request->set_body_params( + array( + 'email' => $email, + ) + ); + $response = $this->server->dispatch( $request ); + + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, $data['success'] ); + $this->assertTrue( $email_sent, 'Order receipt email was not sent.' ); + $this->assertTrue( $note_added, 'Specific order note was not added.' ); + + // Remove the action hook after the test to clean up + remove_action( 'woocommerce_before_resend_order_emails', $email_sent_callback ); + remove_filter( 'woocommerce_new_order_note_data', $order_note_filter_check ); + } + + /** + * Saving variation attributes. + * + * GOTCHA: saving a variation attributes will cause duplication, eg: + * retrieve order from REST API, send back, now you have duplicate attributes. + * + * @TODO - this is working in the app, but not in the test?? + */ + public function test_order_save_line_item_attributes(): void { + $product = ProductHelper::create_variation_product(); + $variation_ids = $product->get_children(); + $variation = wc_get_product( $variation_ids[0] ); + $order = OrderHelper::create_order( array( 'product' => $variation ) ); + + // just retrieve order, no changes + $request = $this->wp_rest_get_request( '/wcpos/v1/orders/' . $order->get_id() ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data['line_items'] ), 'There should be one line item.' ); + + $attr = ''; + $count = 0; + + // Look for the pa_size key in meta_data + foreach ( $data['line_items'][0]['meta_data'] as $meta ) { + if ( 'pa_size' === $meta['key'] ) { + $count++; + $attr = $meta['value']; + } + } + + $this->assertEquals( 1, $count, 'There should only be one pa_size.' ); + $this->assertEquals( 'small', $attr, 'The pa_size value is not valid.' ); + + // now, save the order back to the API + $request = $this->wp_rest_post_request( '/wcpos/v1/orders/' . $order->get_id() ); + $request->set_body_params( $data ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data['line_items'] ), 'There should be one line item.' ); + + // Look for the pa_size key in meta_data + foreach ( $data['line_items'][0]['meta_data'] as $meta ) { + if ( 'pa_size' === $meta['key'] ) { + $count++; + $attr = $meta['value']; + } + } + + $this->assertEquals( 1, $count, 'There should only be one pa_size.' ); + $this->assertEquals( 'small', $attr, 'The pa_size value is not valid.' ); + } + + /** + * Saving line item with parent_name = null. + * + * GOTCHA: WC REST API can return a line item with parent_name = null, + * but it won't pass validation when saving. + */ + public function test_order_save_line_item_with_null_parent_name(): void { + $request = $this->wp_rest_post_request( '/wcpos/v1/orders' ); + $request->set_body_params( + array( + 'line_items' => array( + array( + 'product_id' => 1, + 'quantity' => 1, + 'parent_name' => null, + ), + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'woocommerce-pos', $data['created_via'] ); + } + + /** + * Retrieve all order statuses. + */ + public function test_get_order_statuses(): void { + $request = $this->wp_rest_get_request( '/wcpos/v1/orders/statuses' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertNotEmpty( $data ); + + // Ensure that the response is an array + $this->assertIsArray( $data ); + + // Check if each element in the array has the required structure + foreach ( $data as $status ) { + $this->assertIsArray( $status ); + $this->assertArrayHasKey( 'id', $status ); + $this->assertArrayHasKey( 'name', $status ); + + // Check if the 'id' and 'name' fields are strings + $this->assertIsString( $status['id'] ); + $this->assertIsString( $status['name'] ); + } + } + + /** + * + */ + public function test_order_search_by_id() { + $order1 = OrderHelper::create_order(); + $order2 = OrderHelper::create_order(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'search', (string) $order1->get_id() ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data ) ); + + $ids = wp_list_pluck( $data, 'id' ); + $this->assertEquals( array( $order1->get_id() ), $ids ); + } + + /** + * + */ + public function test_order_search_by_billing_first_name() { + $order1 = OrderHelper::create_order(); + $order2 = OrderHelper::create_order(); + $order2->set_billing_first_name( 'John' ); + $order2->save(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'search', 'John' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data ) ); + + $ids = wp_list_pluck( $data, 'id' ); + $this->assertEquals( array( $order2->get_id() ), $ids ); + } + + /** + * + */ + public function test_order_search_by_billing_last_name() { + $order1 = OrderHelper::create_order(); + $order2 = OrderHelper::create_order(); + $order1->set_billing_last_name( 'Doe' ); + $order1->save(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'search', 'Doe' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data ) ); + + $ids = wp_list_pluck( $data, 'id' ); + $this->assertEquals( array( $order1->get_id() ), $ids ); + } + + /** + * + */ + public function test_order_search_by_billing_email() { + $order1 = OrderHelper::create_order(); + $order2 = OrderHelper::create_order(); + $order1->set_billing_email( 'posuser@example.com' ); + $order1->save(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'search', 'posuser' ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data ) ); + + $ids = wp_list_pluck( $data, 'id' ); + $this->assertEquals( array( $order1->get_id() ), $ids ); + } + + /** + * + */ + public function test_order_search_by_id_with_includes() { + $order1 = OrderHelper::create_order(); + $order2 = OrderHelper::create_order(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'search', (string) $order1->get_id() ); + $request->set_param( 'include', array( $order2->get_id() ) ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 0, \count( $data ) ); + } + + /** + * + */ + public function test_order_search_by_id_with_excludes() { + $order1 = OrderHelper::create_order(); + $order2 = OrderHelper::create_order(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'search', (string) $order1->get_id() ); + $request->set_param( 'exclude', array( $order1->get_id() ) ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 0, \count( $data ) ); + } + + /** + * + */ + public function test_order_search_by_billing_first_name_with_includes() { + $order1 = OrderHelper::create_order(); + $order1->set_billing_first_name( 'John' ); + $order1->save(); + $order2 = OrderHelper::create_order(); + $order2->set_billing_first_name( 'John' ); + $order2->save(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'search', 'John' ); + $request->set_param( 'include', array( $order2->get_id() ) ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data ) ); + + $ids = wp_list_pluck( $data, 'id' ); + $this->assertEquals( array( $order2->get_id() ), $ids ); + } + + /** + * + */ + public function test_order_search_by_billing_first_name_with_excludes() { + $order1 = OrderHelper::create_order(); + $order1->set_billing_first_name( 'John' ); + $order1->save(); + $order2 = OrderHelper::create_order(); + $order2->set_billing_first_name( 'John' ); + $order2->save(); + + $request = $this->wp_rest_get_request( '/wcpos/v1/orders' ); + $request->set_param( 'search', 'John' ); + $request->set_param( 'exclude', array( $order1->get_id() ) ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 1, \count( $data ) ); + + $ids = wp_list_pluck( $data, 'id' ); + $this->assertEquals( array( $order2->get_id() ), $ids ); + } + + /** + * + */ + public function test_create_order_with_decimal_quantity() { + $this->setup_decimal_quantity_tests(); + + $request = $this->wp_rest_post_request( '/wcpos/v1/orders' ); + $request->set_body_params( + array( + 'payment_method' => 'pos_cash', + 'line_items' => array( + array( + 'product_id' => 1, + 'quantity' => '1.5', + ), + ), + ) + ); + + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 201, $response->get_status() ); + + $this->assertEquals( 'woocommerce-pos', $data['created_via'] ); + } +} diff --git a/tests/includes/API/Test_Taxes_Controller.php b/tests/includes/API/Test_Taxes_Controller.php index eece4d32..00767b8e 100644 --- a/tests/includes/API/Test_Taxes_Controller.php +++ b/tests/includes/API/Test_Taxes_Controller.php @@ -47,6 +47,7 @@ public function test_register_routes(): void { */ public function get_expected_response_fields() { return array( + 'uuid', 'id', 'country', 'state', diff --git a/tests/includes/API/WCPOS_REST_HPOS_Unit_Test_Case.php b/tests/includes/API/WCPOS_REST_HPOS_Unit_Test_Case.php new file mode 100644 index 00000000..8784f2e2 --- /dev/null +++ b/tests/includes/API/WCPOS_REST_HPOS_Unit_Test_Case.php @@ -0,0 +1,127 @@ +posts; + $order_type = $order_type ?? 'shop_order%'; + $sql = $in_cot ? + "SELECT EXISTS (SELECT id FROM $table_name WHERE id = %d)" : + "SELECT EXISTS (SELECT ID FROM $table_name WHERE ID = %d AND post_type LIKE %s)"; + + //phpcs:disable WordPress.DB.PreparedSQL.NotPrepared + $exists = $wpdb->get_var( + $in_cot ? + $wpdb->prepare( $sql, $order_id ) : + $wpdb->prepare( $sql, $order_id, $order_type ) + ); + //phpcs:enable WordPress.DB.PreparedSQL.NotPrepared + + if ( $must_exist ) { + $this->assertTrue( (bool) $exists, "No order found with id $order_id in table $table_name" ); + } else { + $this->assertFalse( (bool) $exists, "Unexpected order found with id $order_id in table $table_name" ); + } + } + + /** + * Assert that an order deletion record exists or doesn't exist in the orders meta table. + * + * @param int $order_id The order id to check. + * @param bool $deleted_from_cot True to assert that the record corresponds to an order deleted from the orders table, or from the posts table otherwise. + * @param bool $must_exist True to assert that the record exists, false to assert that the record doesn't exist. + * @return void + */ + protected function assert_deletion_record_existence( $order_id, $deleted_from_cot, $must_exist = true ) { + global $wpdb; + + $meta_table_name = OrdersTableDataStore::get_meta_table_name(); + //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $record = $wpdb->get_row( + $wpdb->prepare( + "SELECT meta_value FROM $meta_table_name WHERE order_id = %d AND meta_key = %s", + $order_id, + DataSynchronizer::DELETED_RECORD_META_KEY + ), + ARRAY_A + ); + //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( $must_exist ) { + $this->assertNotNull( $record, "No deletion record found for order id {$order_id}" ); + } else { + $this->assertNull( $record, "Unexpected deletion record found for order id {$order_id}" ); + return; + } + + $deleted_from = $deleted_from_cot ? + DataSynchronizer::DELETED_FROM_ORDERS_META_VALUE : + DataSynchronizer::DELETED_FROM_POSTS_META_VALUE; + + $this->assertEquals( $deleted_from, $record['meta_value'], "Deletion record for order {$order_id} has a value of {$record['meta_value']}, expected {$deleted_from}" ); + } + + /** + * Synchronize all the pending unsynchronized orders. + */ + protected function do_cot_sync() { + $sync = wc_get_container()->get( DataSynchronizer::class ); + $batch = $sync->get_next_batch_to_process( 100 ); + $sync->process_batch( $batch ); + } + + /** + * Sets an order as updated by directly modifying its "last updated gmt" field in the database. + * + * @param \WC_Order|int $order_or_id An order or an order id. + * @param string|null $update_date The new value for the "last updated gmt" in the database, defaults to the current date and time. + * @param bool|null $cot_is_authoritative Whether the orders table is authoritative or not. If null, it will be determined using OrderUtil. + * @return void + */ + protected function set_order_as_updated( $order_or_id, ?string $update_date = null, ?bool $cot_is_authoritative = null ) { + global $wpdb; + + $order_id = $order_or_id instanceof \WC_Order ? $order_or_id->get_id() : $order_or_id; + + $update_date = $update_date ?? current_time( 'mysql' ); + $cot_is_authoritative = $cot_is_authoritative ?? OrderUtil::custom_orders_table_usage_is_enabled(); + + if ( $cot_is_authoritative ) { + $wpdb->update( + OrdersTableDataStore::get_orders_table_name(), + array( + 'date_updated_gmt' => $update_date, + ), + array( 'id' => $order_id ) + ); + } else { + $wpdb->update( + $wpdb->posts, + array( + 'post_modified_gmt' => $update_date, + ), + array( 'id' => $order_id ) + ); + } + } +}