diff --git a/css/cleantalk-admin.min.css b/css/cleantalk-admin.min.css index daf049497..c2c7a6d27 100644 --- a/css/cleantalk-admin.min.css +++ b/css/cleantalk-admin.min.css @@ -1 +1 @@ -:disabled{cursor:not-allowed!important}.apbct_color--gray{color:gray}.apbct_display--none{display:none}.apbct_bottom_links--left{margin-right:2pc}.apbct_bottom_links--other{margin-right:2pc;margin-left:2pc}.ct_translate_links{color:#969614}.ct_support_links{color:#961414}.ct_faq_links{color:#149614}.ct_setting_links{color:#141496}.ct_translate_links:hover{color:#d2d214!important}.ct_support_links:hover{color:#fa1414!important}.ct_faq_links:hover{color:#14fa14!important}.ct_setting_links:hover{color:#1414fa!important}.ct_link_new_tab img{float:none!important;margin:0 2px;border:0}#negative_reports_table tr td{padding:7px 5px!important}#wp-admin-bar-cleantalk_admin_bar__parent_node{margin-right:5px}#wp-admin-bar-cleantalk_admin_bar__parent_node span{display:inline-block}#wp-admin-bar-cleantalk_admin_bar__parent_node .apbct-icon-attention-alt{background:#d63638;color:#fff;border-radius:50%;font-size:12px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__spbc_icon{width:14px;height:17px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__apbct_icon{width:18px;height:18px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node div.cleantalk_admin_bar__sum_counter{color:#999;display:inline;padding:2px 5px!important}.cleantalk_admin_bar__blocked div{cursor:not-allowed!important}.cleantalk_admin_bar__blocked div a{color:#777!important}.cleantalk_admin_bar__title{vertical-align:top}.cleantalk_admin_bar__separator{height:0!important}.cleantalk-admin_bar--list_wrapper .ab-sub-wrapper ul:last-child{margin-bottom:5px!important}.apbct-plugin-errors{margin-left:0;margin-bottom:20px}#cleantalk_notice_review .caption{margin:0 0 15px;color:gray}#cleantalk_notice_review .button{margin-bottom:20px}.apbct-details-spam-order-button,.apbct-restore-spam-order-button{cursor:pointer}.apbct-details-spam-order-button{color:gray}.ct-modal-buttons{display:flex;align-items:center;padding:20px 0;justify-content:space-between}.ct-modal-message{font-weight:700;font-size:16px;line-height:2rem}.apbct-popup-fade:before{content:'';background:#000;position:fixed;left:0;top:0;width:100%;height:100%;opacity:.7;z-index:9999}.apbct-popup{position:fixed;top:20%;left:50%;padding:20px;width:360px;margin-left:-200px;background:#fff;border:1px solid;border-radius:4px;z-index:99999;opacity:1}.apbct-table-actions-wrapper{background:#fcfcfc;background:#fcfcfc;border-radius:2px;padding:5px!important;border:1px solid #d3d3d3;margin:0 5px 5px 0!important}.apbct_wc_details__table-container_header{text-align:center}.apbct_wc_details__wrapper{display:flex;flex-direction:column;scroll-behavior:smooth;overflow-y:auto;max-height:400px;min-width:50pc;position:relative}.apbct_wc_details__table-wrapper-inner{margin-bottom:25px;background:#f9f9f9;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,.08);position:relative;padding:0 15px}.apbct_wc_details__table-wrapper-inner h4{margin:0 0 12px 0;color:#333;font-size:16px;font-weight:600;padding:10px 0 8px 0;border-bottom:2px solid #e0e0e0;position:sticky;top:0;background:#f9f9f9;z-index:10}.apbct_wc_details__table{width:100%;border-collapse:collapse;background:#fff;border-radius:6px;overflow:hidden}.apbct_wc_details__table-key-cell{border:1px solid #e8e8e8;padding:10px 12px;font-weight:600;background:#f5f7fa;color:#2c3e50;width:35%;font-size:13px}.apbct_wc_details__table-value-cell{border:1px solid #e8e8e8;padding:10px 12px;color:#34495e;font-size:13px;word-break:break-word}.apbct_wc_details__table tbody tr:hover{background:#fafbfc}.apbct_wc_details__table tbody tr:last-child td{border-bottom:none}.apbct_wc_details__table-value-cell--json{font-family:monospace;font-size:12px;white-space:pre-wrap}@supports (position:sticky){.apbct_wc_details__table th,.apbct_wc_details__table-wrapper-inner h4{position:sticky}}@media screen and (max-width:1120px){.apbct-tablenav{display:flex;flex-direction:column;flex-wrap:nowrap;height:100%;max-width:50%}}.apbct-notice.apbct-banner-success{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#00bb5d!important}.apbct-banner-content{display:flex;flex-wrap:wrap;justify-content:space-between;row-gap:16px}.apbct-banner-content-wrapper{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between}.apbct-banner-text-wrapper{margin-top:16px;margin-left:32px}.apbct-banner-button{display:block;padding:17px 53px;color:#fff!important;border-radius:8px;font-size:16px;font-weight:500;text-decoration:none!important;text-align:center;align-content:center}.apbct-banner-button-wrapper{display:grid;gap:12px;align-content:center;text-align:center;margin-right:24px;margin-left:32px}.apbct-banner-title{font-size:24px;line-height:29px;font-weight:500;color:#000}.apbct-banner-subtitle{font-size:14px;line-height:22px;font-weight:400;color:#444;margin-top:8px}.apbct-banner-big-subtitle{font-size:16px}.apbct-banner-link{font-size:16px;line-height:24px;font-weight:400;color:#026e88!important}.apbct-banner-dismiss-link{font-weight:400;font-size:14px;line-height:22px;color:#646464!important}.apbct-notice.apbct-banner-error{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#bb3323!important}.apbct-notice.apbct-banner-error .notice-dismiss::before{color:#646464}.apbct-banner-button-green{background-color:#00bb5d!important}.apbct-banner-button-green:hover{background-color:#00a34f!important}.apbct-banner-button-red{background-color:#bb3323!important}.apbct-banner-button-red:hover{background-color:#a82e20!important} \ No newline at end of file +:disabled{cursor:not-allowed!important}.apbct_color--gray{color:gray}.apbct_display--none{display:none}.apbct_bottom_links--left{margin-right:2pc}.apbct_bottom_links--other{margin-right:2pc;margin-left:2pc}.ct_translate_links{color:#969614}.ct_support_links{color:#961414}.ct_faq_links{color:#149614}.ct_setting_links{color:#141496}.ct_translate_links:hover{color:#d2d214!important}.ct_support_links:hover{color:#fa1414!important}.ct_faq_links:hover{color:#14fa14!important}.ct_setting_links:hover{color:#1414fa!important}.ct_link_new_tab img{float:none!important;margin:0 2px;border:0}#negative_reports_table tr td{padding:7px 5px!important}#wp-admin-bar-cleantalk_admin_bar__parent_node{margin-right:5px}#wp-admin-bar-cleantalk_admin_bar__parent_node span{display:inline-block}#wp-admin-bar-cleantalk_admin_bar__parent_node .apbct-icon-attention-alt{background:#d63638;color:#fff;border-radius:50%;font-size:12px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__spbc_icon{width:14px;height:17px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__apbct_icon{width:18px;height:18px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node div.cleantalk_admin_bar__sum_counter{color:#999;display:inline;padding:2px 5px!important}.cleantalk_admin_bar__blocked div{cursor:not-allowed!important}.cleantalk_admin_bar__blocked div a{color:#777!important}.cleantalk_admin_bar__title{vertical-align:top}.cleantalk_admin_bar__separator{height:0!important}.cleantalk-admin_bar--list_wrapper .ab-sub-wrapper ul:last-child{margin-bottom:5px!important}.apbct-plugin-errors{margin-left:0;margin-bottom:20px}#cleantalk_notice_review .caption{margin:0 0 15px;color:gray}#cleantalk_notice_review .button{margin-bottom:20px}.apbct-details-spam-order-button,.apbct-restore-spam-order-button{cursor:pointer}.apbct-details-spam-order-button{color:gray}.wp-list-table.wc_spam_orders .column-ct_order_date,.wp-list-table.wc_spam_orders .column-ct_status,.wp-list-table.wc_spam_orders .column-ct_total{width:15%}.wp-list-table.wc_spam_orders td{vertical-align:middle}.wp-list-table.wc_spam_orders .apbct-order-view{color:#2271b1;text-decoration:none}.wp-list-table.wc_spam_orders .apbct-order-view:hover{color:#135e96}.apbct-order-status{display:inline-flex;line-height:2.5em;color:#454545;background:#e5e5e5;border-radius:4px;border-bottom:1px solid rgba(0,0,0,.05);margin:-.25em 0;cursor:inherit!important;white-space:nowrap;max-width:100%}.apbct-order-status>span{margin:0 1em;overflow:hidden;text-overflow:ellipsis}.apbct-order-status--spam{background:#eba3a3;color:#570000}.ct-modal-buttons{display:flex;align-items:center;padding:20px 0;justify-content:space-between}.ct-modal-message{font-weight:700;font-size:16px;line-height:2rem}.apbct-popup-fade:before{content:'';background:#000;position:fixed;left:0;top:0;width:100%;height:100%;opacity:.7;z-index:9999}.apbct-popup{position:fixed;top:20%;left:50%;padding:20px;width:360px;margin-left:-200px;background:#fff;border:1px solid;border-radius:4px;z-index:99999;opacity:1}.apbct-table-actions-wrapper{background:#fcfcfc;background:#fcfcfc;border-radius:2px;padding:5px!important;border:1px solid #d3d3d3;margin:0 5px 5px 0!important}.apbct_wc_details__table-container_header{text-align:center}.apbct_wc_details__wrapper{display:flex;flex-direction:column;scroll-behavior:smooth;overflow-y:auto;max-height:400px;min-width:50pc;position:relative}.apbct_wc_details__table-wrapper-inner{margin-bottom:25px;background:#f9f9f9;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,.08);position:relative;padding:0 15px}.apbct_wc_details__table-wrapper-inner h4{margin:0 0 12px 0;color:#333;font-size:16px;font-weight:600;padding:10px 0 8px 0;border-bottom:2px solid #e0e0e0;position:sticky;top:0;background:#f9f9f9;z-index:10}.apbct_wc_details__table{width:100%;border-collapse:collapse;background:#fff;border-radius:6px;overflow:hidden}.apbct_wc_details__table-key-cell{border:1px solid #e8e8e8;padding:10px 12px;font-weight:600;background:#f5f7fa;color:#2c3e50;width:35%;font-size:13px}.apbct_wc_details__table-value-cell{border:1px solid #e8e8e8;padding:10px 12px;color:#34495e;font-size:13px;word-break:break-word}.apbct_wc_details__table tbody tr:hover{background:#fafbfc}.apbct_wc_details__table tbody tr:last-child td{border-bottom:none}.apbct_wc_details__table-value-cell--json{font-family:monospace;font-size:12px;white-space:pre-wrap}@supports (position:sticky){.apbct_wc_details__table th,.apbct_wc_details__table-wrapper-inner h4{position:sticky}}@media screen and (max-width:1120px){.apbct-tablenav{display:flex;flex-direction:column;flex-wrap:nowrap;height:100%;max-width:50%}}.apbct-notice.apbct-banner-success{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#00bb5d!important}.apbct-banner-content{display:flex;flex-wrap:wrap;justify-content:space-between;row-gap:16px}.apbct-banner-content-wrapper{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between}.apbct-banner-text-wrapper{margin-top:16px;margin-left:32px}.apbct-banner-button{display:block;padding:17px 53px;color:#fff!important;border-radius:8px;font-size:16px;font-weight:500;text-decoration:none!important;text-align:center;align-content:center}.apbct-banner-button-wrapper{display:grid;gap:12px;align-content:center;text-align:center;margin-right:24px;margin-left:32px}.apbct-banner-title{font-size:24px;line-height:29px;font-weight:500;color:#000}.apbct-banner-subtitle{font-size:14px;line-height:22px;font-weight:400;color:#444;margin-top:8px}.apbct-banner-big-subtitle{font-size:16px}.apbct-banner-link{font-size:16px;line-height:24px;font-weight:400;color:#026e88!important}.apbct-banner-dismiss-link{font-weight:400;font-size:14px;line-height:22px;color:#646464!important}.apbct-notice.apbct-banner-error{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#bb3323!important}.apbct-notice.apbct-banner-error .notice-dismiss::before{color:#646464}.apbct-banner-button-green{background-color:#00bb5d!important}.apbct-banner-button-green:hover{background-color:#00a34f!important}.apbct-banner-button-red{background-color:#bb3323!important}.apbct-banner-button-red:hover{background-color:#a82e20!important} \ No newline at end of file diff --git a/css/src/cleantalk-admin.css b/css/src/cleantalk-admin.css index 815b371aa..09ff7c189 100644 --- a/css/src/cleantalk-admin.css +++ b/css/src/cleantalk-admin.css @@ -105,6 +105,51 @@ .apbct-details-spam-order-button { color: gray; } + +/* WooCommerce orders list alike view of the spam orders table. + The order column has no width, so it takes all the space left. */ +.wp-list-table.wc_spam_orders .column-ct_order_date, +.wp-list-table.wc_spam_orders .column-ct_status, +.wp-list-table.wc_spam_orders .column-ct_total { + width: 15%; +} + +.wp-list-table.wc_spam_orders td { + vertical-align: middle; +} + +.wp-list-table.wc_spam_orders .apbct-order-view { + color: #2271b1; + text-decoration: none; +} + +.wp-list-table.wc_spam_orders .apbct-order-view:hover { + color: #135e96; +} + +.apbct-order-status { + display: inline-flex; + line-height: 2.5em; + color: #454545; + background: #e5e5e5; + border-radius: 4px; + border-bottom: 1px solid rgba(0, 0, 0, .05); + margin: -.25em 0; + cursor: inherit !important; + white-space: nowrap; + max-width: 100%; +} + +.apbct-order-status > span { + margin: 0 1em; + overflow: hidden; + text-overflow: ellipsis; +} + +.apbct-order-status--spam { + background: #eba3a3; + color: #570000; +} .ct-modal-buttons { display: flex; align-items: center; diff --git a/inc/cleantalk-admin.php b/inc/cleantalk-admin.php index 2c5695653..ea0f05b6b 100644 --- a/inc/cleantalk-admin.php +++ b/inc/cleantalk-admin.php @@ -1194,11 +1194,17 @@ function apbct_admin__admin_bar__add_child_nodes($wp_admin_bar) // Add a child item to our parent item. Bulk checks. if ( ! is_network_admin() && apbct_is_plugin_active('woocommerce/woocommerce.php') ) { + // HPOS installations render the spam orders view inline on the wc-orders screen; + // legacy (posts table) installations get a separate fallback admin page instead. + $spam_orders_page = function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order' + ? 'wc-orders&status=wc-spamorder' + : 'apbct_wc_spam_orders'; + $wp_admin_bar->add_node( array( 'parent' => 'apbct__parent_node', 'id' => 'ct_settings_bulk_orders', - 'title' => '' + 'title' => '' . __('WooCommerce spam orders', 'cleantalk-spam-protect') . '', ) ); diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 69b0ca5ea..b15c77e55 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -366,7 +366,16 @@ function apbct_settings__set_fields() ), 'data__wc_store_blocked_orders' => array( 'title' => __('Store blocked orders', 'cleantalk-spam-protect'), - 'description' => __('The orders which was blocked by the Anti-Spam will be stored and could be restored manually later if its needed.', 'cleantalk-spam-protect'), + 'description' => __('Orders blocked by Anti-Spam will be stored and can be restored manually later if needed.', 'cleantalk-spam-protect'), + 'class' => 'apbct_settings-field_wrapper--sub', + 'options' => array( + array('val' => 1, 'label' => __('On')), + array('val' => 0, 'label' => __('Off')), + ), + ), + 'forms__wc_show_rejection_message' => array( + 'title' => __('Show rejection message to customers', 'cleantalk-spam-protect'), + 'description' => __('This message tells the customer why their order was filtered, allowing them to fix the issue that caused it. However, this may result in multiple orders from the same customer because all rejected orders are saved in the Spam folder. By default, this option is OFF.', 'cleantalk-spam-protect'), 'class' => 'apbct_settings-field_wrapper--sub', 'options' => array( array('val' => 1, 'label' => __('On')), @@ -2187,8 +2196,14 @@ function apbct_settings__field__action_buttons() if ( apbct_is_plugin_active('woocommerce/woocommerce.php') ) { add_filter('apbct_settings_action_buttons', function ($buttons_array) { + // HPOS installations render the spam orders view inline on the wc-orders screen; + // legacy (posts table) installations get a separate fallback admin page instead. + $spam_orders_page = function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order' + ? 'wc-orders&status=wc-spamorder' + : 'apbct_wc_spam_orders'; + $buttons_array[] = - '' + '' . __('WooCommerce spam orders', 'cleantalk-spam-protect') . ''; return $buttons_array; diff --git a/inc/cleantalk-updater.php b/inc/cleantalk-updater.php index d4c865378..67d547c82 100644 --- a/inc/cleantalk-updater.php +++ b/inc/cleantalk-updater.php @@ -1388,3 +1388,19 @@ function apbct_update_to_6_76_0() $apbct->saveData(); } } + +/** + * Preserve the legacy WooCommerce checkout behavior (rejection message shown to the customer) + * for users who already had "Store blocked orders" enabled before the new option was introduced. + * + * @return void + */ +function apbct_update_to_6_89_0() +{ + global $apbct; + + if ( ! empty($apbct->settings['data__wc_store_blocked_orders']) ) { + $apbct->settings['forms__wc_show_rejection_message'] = 1; + $apbct->saveSettings(); + } +} diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index ab1892eb0..9e9240c5d 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -36,6 +36,11 @@ */ class Woocommerce extends IntegrationByClassBase { + /** + * Prefix of the transient keeping the details of a blocked order. + */ + const BLOCKED_ORDER_TRANSIENT = 'apbct_blocked_order_'; + private $event_token = null; /** @@ -46,6 +51,12 @@ public function __construct() $this->imetric_dto_version = '1.0.0'; } + /** + * Key of the transient holding the details of the order blocked in this request. + * @var string + */ + private $blocked_order_key = ''; + /** * @return void * @psalm-suppress PossiblyUnusedMethod @@ -57,6 +68,15 @@ public function doPublicWork() // honeypot add_filter('woocommerce_checkout_fields', [$this, 'addHoneypotField']); + // The blocked visitor gets a thank you page with no order behind it - fill in the details. + // Only the redirect of a blocked order carries the key, so the rest of the pages are left alone. + if ( Get::getString('key') !== '' ) { + // Classic themes render the confirmation with the checkout/thankyou.php template + add_action('woocommerce_after_template_part', [$this, 'renderBlockedOrderOverview'], 10, 4); + // Block themes render it with the woocommerce/order-confirmation-* blocks instead + add_filter('render_block', [$this, 'appendBlockedOrderOverviewToBlock'], 10, 2); + } + // add to cart hooks if cart works with non-ajax requests $this->addCartActions(); @@ -104,27 +124,36 @@ public function doAjaxWork() public function doAdminWork() { - add_action('admin_menu', function () { - add_submenu_page( - 'woocommerce', - __("WooCommerce spam orders", 'cleantalk-spam-protect'), - __("WooCommerce spam orders", 'cleantalk-spam-protect'), - 'activate_plugins', - 'apbct_wc_spam_orders', - function () { - ?> -
-
- display(); - ?> -
-
- id); if ( $ct_result->allow == 0 ) { - if ( $apbct->settings['data__wc_store_blocked_orders'] ) { - $this->storeBlockedOrder(); + $this->handleBlockedOrder(); + + if ( $apbct->settings['forms__wc_show_rejection_message'] ) { + // Legacy behavior: show the rejection reason directly to the customer. + wp_send_json(array( + 'result' => 'failure', + 'messages' => "", + 'refresh' => 'false', + 'reload' => 'false' + )); } + wp_send_json(array( - 'result' => 'failure', - 'messages' => "", - 'refresh' => 'false', - 'reload' => 'false' + 'result' => 'success', + 'redirect' => $this->getBlockedOrderRedirectUrl(), )); } } @@ -310,11 +346,18 @@ public function checkoutCheckFromRest($order) ct_hash($ct_result->id); if ( $ct_result->allow == 0 ) { - if ( $apbct->settings['data__wc_store_blocked_orders'] ) { - $this->storeBlockedOrder(); - } + // The details must be stored before the response carrying their key is built + $this->handleBlockedOrder($order); + + // The response must be captured before the order gets deleted below - deletion + // clears the in-memory order ID and data, which would break the imitated response. + $store_api_response = $this->getStoreApiPassedResponse($order); if ( $order->get_status() === 'pending' || $order->get_status() === 'checkout-draft' ) { + if ( function_exists('wc_release_stock_for_order') ) { + wc_release_stock_for_order($order); + } + try { $order->delete(true); } catch (\Exception $e) { @@ -322,22 +365,250 @@ public function checkoutCheckFromRest($order) } } - $response = [ - 'code' => 'woocommerce_store_api_checkout_order_processed', + if ( $apbct->settings['forms__wc_show_rejection_message'] ) { + // Legacy behavior: show the rejection reason directly to the customer. + $response = array( + 'code' => 'woocommerce_store_api_checkout_order_processed', 'message' => $ct_result->comment, - 'data' => [ - 'status' => 403 - ] - ]; + 'data' => array( + 'status' => 403 + ) + ); + + if ( ! headers_sent() ) { + http_response_code(403); + } + die(json_encode($response)); + } if ( ! headers_sent() ) { - http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); } - die(json_encode($response)); + die(json_encode($store_api_response)); } } } + /** + * Common actions for an order blocked as spam. + * + * @return void + * @psalm-suppress UndefinedFunction + */ + private function handleBlockedOrder($order = null) + { + global $apbct; + + // The option controls the storage only, the rest of the handling is always done + if ( $apbct->settings['data__wc_store_blocked_orders'] ) { + $this->storeBlockedOrder(); + } + + $this->rememberBlockedOrderOverview($order); + + if ( function_exists('wc') && ! is_null(wc()->cart) ) { + wc()->cart->empty_cart(); + } + } + + /** + * Store the details of the blocked order to show them on the thank you page. + * + * The blocked order never becomes a WooCommerce one, so the thank you page has nothing to + * render and gives the spammer a hint that the order went wrong. The details are kept in a + * transient addressed by the key of the redirect URL and printed by renderBlockedOrderOverview(). + * + * @param \WC_Order|null $order Order created by the Store API checkout, absent for the classic one + * + * @return void + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function rememberBlockedOrderOverview($order = null) + { + $overview = array( + 'date' => date_i18n(get_option('date_format')), + 'total' => $this->getBlockedOrderTotal($order), + 'payment_method' => $this->getBlockedOrderPaymentMethod($order), + ); + + $this->blocked_order_key = 'wc_order_' . wp_generate_password(13, false); + + set_transient(self::BLOCKED_ORDER_TRANSIENT . $this->blocked_order_key, $overview, HOUR_IN_SECONDS); + } + + /** + * @param \WC_Order|null $order + * + * @return string Formatted total, the cart one when there is no order + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function getBlockedOrderTotal($order = null) + { + if ( $order instanceof \WC_Order ) { + return $order->get_formatted_order_total(); + } + + return ( function_exists('wc') && ! is_null(wc()->cart) ) ? wc()->cart->get_total() : ''; + } + + /** + * @param \WC_Order|null $order + * + * @return string Title of the chosen gateway, empty when it can not be resolved + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function getBlockedOrderPaymentMethod($order = null) + { + if ( $order instanceof \WC_Order ) { + return $order->get_payment_method_title(); + } + + $chosen_method = Post::getString('payment_method'); + + if ( $chosen_method === '' || ! function_exists('WC') ) { + return ''; + } + + $gateways = WC()->payment_gateways() ? WC()->payment_gateways()->payment_gateways() : array(); + + return isset($gateways[$chosen_method]) ? $gateways[$chosen_method]->get_title() : ''; + } + + /** + * Print the order details on the thank you page shown to the blocked visitor. + * + * WooCommerce renders the details itself when an order stands behind the page. There is none + * for a blocked order, so the same markup is printed right after the template that says + * the order has been received. + * + * @param string $template_name + * @param string $template_path + * @param string $located + * @param array $args + * + * @return void + * @psalm-suppress PossiblyUnusedMethod, UndefinedFunction, PossiblyUnusedParam + */ + public function renderBlockedOrderOverview($template_name, $template_path, $located, $args) + { + if ( $template_name !== 'checkout/thankyou.php' || ! empty($args['order']) ) { + return; + } + + echo $this->getBlockedOrderOverviewHtml(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Print the order details on the block based order confirmation page. + * + * Block themes route the 'order-received' endpoint to the 'order-confirmation' template + * built of the woocommerce/order-confirmation-* blocks, so checkout/thankyou.php is never + * loaded and renderBlockedOrderOverview() never fires. The details are appended to the + * status block instead, which is the one WooCommerce keeps rendering without an order. + * + * @param string $block_content + * @param array $block + * + * @return string + * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedReturnValue + */ + public function appendBlockedOrderOverviewToBlock($block_content, $block) + { + if ( ! isset($block['blockName']) || $block['blockName'] !== 'woocommerce/order-confirmation-status' ) { + return $block_content; + } + + return $block_content . $this->getBlockedOrderOverviewHtml(); + } + + /** + * Markup of the stored order details, empty when there is nothing to show. + * + * @return string + */ + private function getBlockedOrderOverviewHtml() + { + $blocked_order_key = Get::getString('key'); + + if ( $blocked_order_key === '' ) { + return ''; + } + + $overview = get_transient(self::BLOCKED_ORDER_TRANSIENT . $blocked_order_key); + + if ( ! is_array($overview) || empty($overview['date']) ) { + return ''; + } + + $rows = array( + 'date' => array(__('Date:', 'cleantalk-spam-protect'), $overview['date']), + 'total' => array(__('Total:', 'cleantalk-spam-protect'), isset($overview['total']) ? $overview['total'] : ''), + ); + + if ( ! empty($overview['payment_method']) ) { + $rows['method'] = array( + __('Payment method:', 'cleantalk-spam-protect'), + $overview['payment_method'] + ); + } + + $html = ''; + } + + /** + * URL of the page shown to the visitor whose order was blocked. + * + * @return string + * @psalm-suppress UndefinedFunction + */ + private function getBlockedOrderRedirectUrl() + { + $url = wc_get_endpoint_url('order-received', '', wc_get_checkout_url()); + + // The key makes the page look like the usual one and points at the stored details + return $this->blocked_order_key === '' + ? $url + : add_query_arg('key', $this->blocked_order_key, $url); + } + + /** + * Response for the Store API checkout route imitating a passed checkout. + * + * @param \WC_Order $order + * + * @return array + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function getStoreApiPassedResponse($order) + { + return array( + 'order_id' => $order->get_id(), + 'status' => $order->get_status(), + 'order_key' => $order->get_order_key(), + 'customer_note' => $order->get_customer_note(), + 'customer_id' => $order->get_customer_id(), + 'billing_address' => $order->get_address('billing'), + 'shipping_address' => $order->get_address('shipping'), + 'payment_method' => $order->get_payment_method(), + 'payment_result' => array( + 'payment_status' => 'success', + 'payment_details' => array(), + 'redirect_url' => $this->getBlockedOrderRedirectUrl(), + ), + ); + } + /** * @return void * @psalm-suppress UndefinedFunction @@ -630,6 +901,247 @@ public function addOrdersSpamStatusHideFromList($query) } } + /** + * Enable the always visible status links on the HPOS orders list + * + * @param \WP_Screen $current_screen + * + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public function addOrdersListStatusViews($current_screen) + { + // Keep in sync with the capability required by the row actions and AJAX handlers + // (WcSpamOrdersListTable::row_actions_handler(), AJAXService::checkNonceRestrictingNonAdmins()), + // otherwise a user could see the 'Spam' view but get a 403 trying to use it. + if ( ! current_user_can('manage_options') || ! isset($current_screen->id) || ! function_exists('wc_get_page_screen_id') ) { + return; + } + + $orders_screen_id = wc_get_page_screen_id('shop_order'); + + // The legacy storage renders the orders on the posts list screen, the links there lead to another URL + if ( empty($orders_screen_id) || $orders_screen_id === 'shop_order' || $current_screen->id !== $orders_screen_id ) { + return; + } + + add_filter('views_' . $orders_screen_id, [$this, 'addOrdersListStatusLinks']); + + // WooCommerce replaces the whole list with a notice while the store has no orders of its own. + // The status links go away with it, so the 'Spam' view becomes unreachable - keep the list on screen. + add_filter( + 'woocommerce_shop_order_list_table_should_render_blank_state', + [$this, 'keepOrdersListWhenSpamOrdersExist'] + ); + + // The blocked orders are stored apart from the WooCommerce ones, so the 'Spam' view is rendered by the plugin + if ( Get::getString('status') === 'wc-spamorder' ) { + $this->replaceOrdersListRenderer($orders_screen_id); + } + } + + /** + * Keep the orders list on screen when the store has no orders of its own but spam ones exist. + * + * A brand new store has nothing in the WooCommerce tables, so the list is replaced with the + * "When you receive a new order, it will appear here." notice. The blocked orders live in a table + * of the plugin and are not counted there, so the shop owner loses the only way to reach them. + * + * @param bool|null $should_render_blank_state Null keeps the WooCommerce own decision + * + * @return bool|null + * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedReturnValue + */ + public function keepOrdersListWhenSpamOrdersExist($should_render_blank_state) + { + if ( WcSpamOrdersFunctions::getSpamOrdersCount() > 0 ) { + return false; + } + + return $should_render_blank_state; + } + + /** + * Hand the orders page over to the spam orders table. + * + * The page content is printed by the WooCommerce page controller hooked to the page hook, + * so that callback is taken off and replaced. If it can not be found (the WooCommerce + * internals have changed), nothing is replaced to avoid rendering two tables at once. + * + * @param string $page_hook + * + * @return void + */ + private function replaceOrdersListRenderer($page_hook) + { + global $wp_filter; + + if ( ! isset($wp_filter[$page_hook]) || ! isset($wp_filter[$page_hook]->callbacks) ) { + return; + } + + $removed = false; + + foreach ( $wp_filter[$page_hook]->callbacks as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + if ( ! is_array($callback['function']) || ! isset($callback['function'][0]) || ! is_object($callback['function'][0]) ) { + continue; + } + + if ( strpos(get_class($callback['function'][0]), 'Admin\\Orders\\PageController') === false ) { + continue; + } + + $removed = remove_action($page_hook, $callback['function'], $priority) || $removed; + } + } + + if ( $removed ) { + add_action($page_hook, [$this, 'renderSpamOrdersPage']); + } + } + + /** + * The spam orders table shown in place of the WooCommerce orders list, + * keeping the page markup and the status links of the original page. + * + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public function renderSpamOrdersPage() + { + // This is also the callback for the legacy 'apbct_wc_spam_orders' fallback page (addLegacySpamOrdersMenuPage()). + // getOrdersListViews() builds HPOS-style links (page=wc-orders), so it's only valid to embed on the HPOS screen - + // on legacy installations pass null so the list table builds its own standalone views. + $embedded_views = function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order' + ? $this->getOrdersListViews() + : null; + + $list_table = new \Cleantalk\ApbctWP\WcSpamOrdersListTable($embedded_views); + ?> +
+

+
+ renderPageNotices(); ?> +
+ display(); ?> +
+
+ $label ) { + $count = wc_orders_count($status, 'shop_order'); + + $status_object = get_post_status_object($status); + if ( $status_object && ! empty($status_object->show_in_admin_all_list) ) { + $all_count += $count; + } + + if ( $count > 0 ) { + $views[$status] = $this->getOrdersListStatusLink($status, $label, false); + } + } + + return array_merge( + array('all' => $this->getOrdersListStatusLink('', __('All', 'cleantalk-spam-protect'), false, $all_count)), + $views + ); + } + + /** + * The orders list shows the statuses having orders only, so the spam workflow statuses + * are unreachable until an order gets marked as spam. Both of them are added back: + * 'Spam' itself and 'On hold' the unmarked orders are moved to. + * + * @param array $views + * + * @return array + * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedReturnValue + */ + public function addOrdersListStatusLinks($views) + { + if ( ! is_array($views) || ! function_exists('wc_get_order_statuses') ) { + return $views; + } + + $order_statuses = wc_get_order_statuses(); + $current_status = Get::getString('status'); + + foreach ( array('wc-on-hold', 'wc-spamorder') as $status ) { + if ( isset($views[$status]) || ! isset($order_statuses[$status]) ) { + continue; + } + + $views[$status] = $this->getOrdersListStatusLink( + $status, + $order_statuses[$status], + $current_status === $status + ); + } + + return $views; + } + + /** + * @param string $status Empty for the 'All' link + * @param string $label + * @param bool $is_current + * @param int|null $count Known count, counted by the status when not given + * + * @return string + */ + private function getOrdersListStatusLink($status, $label, $is_current, $count = null) + { + if ( is_null($count) ) { + $count = $this->getOrdersListStatusCount($status); + } + + $url = admin_url('admin.php?page=wc-orders'); + if ( ! empty($status) ) { + $url = add_query_arg('status', $status, $url); + } + + return sprintf( + '%s (%s)', + esc_url($url), + $is_current ? ' class="current"' : '', + esc_html($label), + number_format_i18n($count) + ); + } + + /** + * @param string $status + * + * @return int + */ + private function getOrdersListStatusCount($status) + { + // The blocked orders never become WooCommerce ones, they are counted in the plugin table + if ( $status === 'wc-spamorder' ) { + return WcSpamOrdersFunctions::getSpamOrdersCount(); + } + + return function_exists('wc_orders_count') ? wc_orders_count($status, 'shop_order') : 0; + } + /** * Add bulk actions: 'Mark as spam' and 'Unmark as spam' */ diff --git a/lib/Cleantalk/ApbctWP/State.php b/lib/Cleantalk/ApbctWP/State.php index 92b1cbd0d..9d0cf5005 100644 --- a/lib/Cleantalk/ApbctWP/State.php +++ b/lib/Cleantalk/ApbctWP/State.php @@ -95,7 +95,8 @@ class State extends \Cleantalk\Common\State 'data__email_decoder_encode_phone_numbers' => 0, 'data__email_decoder_encode_email_addresses' => 1, 'data__email_decoder_excluded_strings' => '', - 'data__wc_store_blocked_orders' => 0, + 'data__wc_store_blocked_orders' => 1, + 'forms__wc_show_rejection_message' => 0, // Exclusions // Send to the cloud some excepted requests diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php index 327f28559..b75eabc35 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php @@ -6,6 +6,22 @@ class WcSpamOrdersFunctions { + /** + * Count of the stored blocked orders, used for the 'Spam' status counter of the orders list. + * + * @return int + */ + public static function getSpamOrdersCount() + { + global $wpdb; + + if ( ! defined('APBCT_TBL_WC_SPAM_ORDERS') ) { + return 0; + } + + return (int)$wpdb->get_var('SELECT COUNT(*) FROM ' . APBCT_TBL_WC_SPAM_ORDERS . ';'); + } + public static function restoreOrderAction() { AJAXService::checkNonceRestrictingNonAdmins(); diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index b5afb11d8..25ace553a 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -8,19 +8,51 @@ class WcSpamOrdersListTable extends CleantalkListTable { + /** + * Names of the request parameters carrying the actions of the table. + * + * The table is embedded into the WooCommerce orders screen on HPOS installations, where the + * HPOS page controller inspects $_REQUEST['action'] on the 'load-{page}' hook and runs its own + * 'bulk-orders' nonce check, aborting the whole request with "The link you followed has expired" + * long before the table is built. Dedicated parameter names keep our actions invisible to it. + */ + const BULK_ACTION_PARAM = 'apbct_bulk_action'; + const ROW_ACTION_PARAM = 'apbct_row_action'; + + /** + * Name of the nonce of the row actions. + * + * The HPOS page controller redirects to an URL stripped of '_wpnonce' and '_wp_http_referer' + * (see PageController::strip_http_referer()), which would drop the nonce of our delete link + * before it is ever verified. A name of our own survives that redirect. + */ + const ROW_NONCE_PARAM = 'apbct_row_nonce'; + protected $apbct; protected $wc_active = false; - protected $page_title = ''; protected $wc_spam_orders_count = 0; - public function __construct() + /** + * Status links of the hosting page when the table is embedded into it. + * Null means the table is rendered on its own page and builds the links itself. + * + * @var array|null + */ + protected $embedded_views = null; + + /** + * @param array|null $embedded_views Status links of the hosting page, see $embedded_views + */ + public function __construct($embedded_views = null) { parent::__construct(array( 'singular' => 'wc_spam_orders', 'plural' => 'wc_spam_orders' )); + $this->embedded_views = is_array($embedded_views) ? $embedded_views : null; + $this->bulk_actions_handler(); $this->row_actions_handler(); @@ -32,10 +64,7 @@ public function __construct() $this->prepare_items(); global $apbct; - $this->apbct = $apbct; - $this->page_title = 'WooCommerce spam orders'; - - $this->generatePageHeader(); + $this->apbct = $apbct; } /** @@ -62,8 +91,13 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam $wc_spam_orders = $this->getWcSpamOrders(); $this->wc_spam_orders_count = count($wc_spam_orders); + // Blocked orders are never put on hold, so the view is always empty + if ( $this->getCurrentStatus() === 'on-hold' ) { + $wc_spam_orders = array(); + } + $this->set_pagination_args(array( - 'total_items' => $this->wc_spam_orders_count, + 'total_items' => count($wc_spam_orders), 'per_page' => $per_page, )); @@ -83,29 +117,40 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam continue; } - $delete_url = wp_nonce_url( - admin_url('admin.php?page=' . Get::getString('page') . '&action=delete&spam=' . $wc_spam_order->id), - 'apbct_wc_spam_orders_row', - '_wpnonce' + // The status has to be kept, the hosting page is chosen by it + $current_status = Get::getString('status'); + $delete_url = admin_url('admin.php?page=' . Get::getString('page')); + $delete_url = add_query_arg( + array_filter(array( + 'status' => $current_status, + self::ROW_ACTION_PARAM => 'delete', + 'spam' => $wc_spam_order->id, + )), + $delete_url ); + $delete_url = wp_nonce_url($delete_url, 'apbct_wc_spam_orders_row', self::ROW_NONCE_PARAM); $actions = array( 'restore' => '' . esc_html__('Restore', 'cleantalk-spam-protect') . '', 'delete' => 'Delete', 'details' => '' . esc_html__('See details', 'cleantalk-spam-protect') . '', ); - $order_id_column = sprintf('%1$s %2$s', $wc_spam_order->id, $this->row_actions($actions)); + $order_column = sprintf( + '%1$s %2$s', + $this->renderOrderColumn($wc_spam_order->id, $wc_spam_order->customer_details), + $this->row_actions($actions) + ); - $order_details_column = $this->renderOrderDetailsColumn($wc_spam_order->order_details); - $customer_details_column = $this->renderCustomerDetailsColumn($wc_spam_order->customer_details); - $order_date_column = $this->renderOrderDateColumn($wc_spam_order->order_date); + $order_date_column = $this->renderOrderDateColumn($wc_spam_order->order_date); + $status_column = $this->renderStatusColumn(); + $total_column = $this->renderTotalColumn($wc_spam_order->order_details); $this->items[] = array( - 'cb' => $wc_spam_order->id, - 'ct_order_id' => $order_id_column, - 'ct_order_details' => $order_details_column, - 'ct_customer_details' => $customer_details_column, - 'ct_order_date' => $order_date_column, + 'cb' => $wc_spam_order->id, + 'ct_order' => $order_column, + 'ct_order_date' => $order_date_column, + 'ct_status' => $status_column, + 'ct_total' => $total_column, ); } } @@ -113,11 +158,13 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam public function get_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { $columns = array( - 'cb' => '', - 'ct_order_id' => esc_html__('ID', 'cleantalk-spam-protect'), - 'ct_order_details' => esc_html__('Order details', 'cleantalk-spam-protect'), - 'ct_customer_details' => esc_html__('Customer details', 'cleantalk-spam-protect'), - 'ct_order_date' => esc_html__('Order date', 'cleantalk-spam-protect'), + 'cb' => '', + 'ct_order' => esc_html__('Order', 'cleantalk-spam-protect'), + 'ct_order_date' => esc_html__('Date', 'cleantalk-spam-protect'), + 'ct_status' => esc_html__('Status', 'cleantalk-spam-protect'), + // Sums up only the stored cart line items - shipping, fees and other checkout + // charges are not persisted for a blocked order, so this is an items subtotal. + 'ct_total' => esc_html__('Items total', 'cleantalk-spam-protect'), ); return $columns; @@ -126,10 +173,81 @@ public function get_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.N protected function get_sortable_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { return array( + 'ct_order' => array('id', false), 'ct_order_date' => array('order_date', false), + 'ct_total' => array('total', false), ); } + /** + * Statuses row above the table, the same one as the WooCommerce orders list has. + * Every stored order is a blocked spam one, so the "On hold" view is always empty. + * + * @return array + */ + protected function get_views() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + if ( ! is_null($this->embedded_views) ) { + return $this->embedded_views; + } + + $current_status = $this->getCurrentStatus(); + + $statuses = array( + 'all' => array(esc_html__('All', 'cleantalk-spam-protect'), $this->wc_spam_orders_count), + 'on-hold' => array(esc_html__('On hold', 'cleantalk-spam-protect'), 0), + 'spam' => array(esc_html__('Spam', 'cleantalk-spam-protect'), $this->wc_spam_orders_count), + ); + + $views = array(); + + foreach ( $statuses as $status => $status_data ) { + list($title, $count) = $status_data; + + $url = admin_url('admin.php?page=' . Get::getString('page')); + if ( $status !== 'all' ) { + $url = add_query_arg('status', $status, $url); + } + + $views[$status] = sprintf( + '%3$s (%4$d)', + esc_url($url), + $status === $current_status ? ' class="current" aria-current="page"' : '', + $title, + $count + ); + } + + return $views; + } + + /** + * Currently selected status view. + * + * @return string all|on-hold|spam + */ + private function getCurrentStatus() + { + $status = Get::getString('status'); + + return in_array($status, array('on-hold', 'spam'), true) ? $status : 'all'; + } + + /** + * @inheritDoc + */ + public function display() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + $this->views(); + + parent::display(); + } + + public function no_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + esc_html_e('No orders found.', 'cleantalk-spam-protect'); + } + public function get_bulk_actions() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { return array( @@ -137,6 +255,58 @@ public function get_bulk_actions() // phpcs:ignore PSR1.Methods.CamelCapsMethodN ); } + /** + * The bulk actions select rendered under a name of our own, see BULK_ACTION_PARAM. + * + * Mirrors the markup of the parent, the flat list of actions of this table needs no optgroups. + * + * @param string $which 'top' or 'bottom' + * + * @return void + */ + protected function bulk_actions($which = '') // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + $actions = $this->get_bulk_actions(); + + if ( empty($actions) ) { + return; + } + + $two = $which === 'bottom' ? '2' : ''; + $name = self::BULK_ACTION_PARAM . $two; + + echo ''; + echo ''; + + submit_button(__('Apply'), 'action', '', false, array('id' => 'doaction' . $two)); + } + + /** + * Action chosen in the bulk actions select, read from our own parameter, see BULK_ACTION_PARAM. + * + * @return string|false + */ + public function current_action() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + foreach ( array(self::BULK_ACTION_PARAM, self::BULK_ACTION_PARAM . '2') as $param ) { + $action = Post::getString($param); + + if ( $action !== '' && $action !== '-1' ) { + return $action; + } + } + + return false; + } + public function bulk_actions_handler() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { if ( empty(Post::get('spamorderids')) || empty(Post::get('_wpnonce')) ) { @@ -178,19 +348,22 @@ public function column_default($item, $column_name) // phpcs:ignore PSR1.Methods public function row_actions_handler() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { - if ( empty(Get::get('action')) ) { + if ( empty(Get::get(self::ROW_ACTION_PARAM)) ) { return; } - if ( ! wp_verify_nonce(Get::getString('_wpnonce'), 'apbct_wc_spam_orders_row') ) { + if ( ! wp_verify_nonce(Get::getString(self::ROW_NONCE_PARAM), 'apbct_wc_spam_orders_row') ) { wp_die(esc_html__('Security check failed. Please try again.', 'cleantalk-spam-protect'), 403); } - if ( ! current_user_can('activate_plugins') ) { + // Kept in sync with the capability required by the restore/details AJAX handlers + // (AJAXService::checkNonceRestrictingNonAdmins()) and the view registration + // (Woocommerce::addOrdersListStatusViews()). + if ( ! current_user_can('manage_options') ) { wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'cleantalk-spam-protect'), 403); } - if ( Get::get('action') === 'delete' ) { + if ( Get::get(self::ROW_ACTION_PARAM) === 'delete' ) { $id = filter_input(INPUT_GET, 'spam', FILTER_SANITIZE_ENCODED, FILTER_FLAG_STRIP_HIGH); $this->removeSpam(array($id)); } @@ -256,6 +429,46 @@ private function renderCustomerDetailsColumn($customer_details) return $result; } + /** + * Order number and the customer name, the same way as WooCommerce orders list does it. + * + * @param int|string $spam_order_id + * @param string $customer_details + * + * @return string + */ + private function renderOrderColumn($spam_order_id, $customer_details) + { + $customer_details = json_decode($customer_details, true); + + $customer_name = ''; + + if ( is_array($customer_details) ) { + $customer_name = trim( + TT::getArrayValueAsString($customer_details, 'billing_first_name') + . ' ' + . TT::getArrayValueAsString($customer_details, 'billing_last_name'), + " \n\r\t\v\x00" + ); + + if ( $customer_name === '' ) { + $customer_name = TT::getArrayValueAsString($customer_details, 'billing_email'); + } + } + + if ( $customer_name === '' ) { + $customer_name = esc_html__('Guest', 'cleantalk-spam-protect'); + } else { + $customer_name = esc_html($customer_name); + } + + return sprintf( + '#%1$s %2$s', + esc_attr(TT::toString($spam_order_id)), + $customer_name + ); + } + private function renderOrderDateColumn($order_date) { if ( ! $order_date ) { @@ -268,14 +481,96 @@ private function renderOrderDateColumn($order_date) return '-'; } + // Fresh orders are shown as "5 minutes ago", the older ones as a date. Same as WooCommerce does. + $diff = time() - $timestamp; + if ( $diff >= 0 && $diff < DAY_IN_SECONDS ) { + /* translators: %s: human-readable time difference */ + $show_date = sprintf(__('%s ago', 'cleantalk-spam-protect'), human_time_diff($timestamp, time())); + } else { + $show_date = date_i18n('M j, Y', $timestamp); // Feb 15, 2023 + } + return sprintf( '', esc_attr(date_i18n('c', $timestamp)), // 2023-02-15T20:25:06+00:00 - esc_html(date_i18n('d.m.Y H:i', $timestamp)), // 15.02.2023 20:25 - esc_html(date_i18n('M d, Y', $timestamp)) // Feb 15, 2023 + esc_attr(date_i18n('d.m.Y H:i', $timestamp)), // 15.02.2023 20:25 + esc_html($show_date) ); } + /** + * Every stored order is a blocked spam one, so the status is always the same. + * + * @return string + */ + private function renderStatusColumn() + { + return '' + . esc_html__('Spam', 'cleantalk-spam-protect') + . ''; + } + + /** + * @param string $order_details + * + * @return string Formatted order total or a dash if it can not be calculated. + */ + private function renderTotalColumn($order_details) + { + $total = $this->calcOrderTotal($order_details); + + if ( is_null($total) ) { + return '-'; + } + + /** @psalm-suppress UndefinedFunction */ + return function_exists('wc_price') + ? wc_price($total) + : esc_html(number_format_i18n($total, 2)); + } + + /** + * Sums up the stored cart items. Cart data keeps the calculated line totals, + * the product price is used as a fallback only. + * + * @param string $order_details + * + * @return float|null Null if the order details can not be decoded. + * + * @psalm-suppress UndefinedFunction + */ + private function calcOrderTotal($order_details) + { + $order_details = json_decode($order_details, true); + + if ( ! is_array($order_details) ) { + return null; + } + + $total = 0; + + foreach ( $order_details as $order_detail ) { + if ( ! is_array($order_detail) ) { + continue; + } + + if ( isset($order_detail['line_total']) ) { + $total += (float) $order_detail['line_total'] + (float) ($order_detail['line_tax'] ?? 0); + continue; + } + + if ( isset($order_detail['product_id']) && function_exists('wc_get_product') && class_exists('\WC_Product') ) { + $wc_product = wc_get_product($order_detail['product_id']); + $wc_product_class = '\WC_Product'; + if ( $wc_product instanceof $wc_product_class ) { + $total += (float) $wc_product->get_price() * (float) ($order_detail['quantity'] ?? 1); + } + } + } + + return (float) $total; + } + /** * @return array */ @@ -288,20 +583,69 @@ private function getWcSpamOrders() $sql = 'SELECT * FROM ' . APBCT_TBL_WC_SPAM_ORDERS; - if ($orderby) { - $sql .= ' ORDER BY ' . $orderby . ' ' . $order; - } + // The newest spam orders are shown first by default, the same way as WooCommerce orders list does it. + $sql .= ' ORDER BY ' . ($orderby ? $orderby : 'order_date') . ' ' . $order; $result = $wpdb->get_results($sql, OBJECT); - return is_array($result) ? $result : array(); + $result = is_array($result) ? $result : array(); + + if ( Get::getString('orderby') === 'total' ) { + $result = $this->sortByTotal($result, $order); + } + + return $result; + } + + /** + * The order total is not stored as a column, so it has to be sorted after the fetch. + * + * @param array $wc_spam_orders + * @param string $order ASC|DESC + * + * @return array + */ + private function sortByTotal($wc_spam_orders, $order) + { + $totals = array(); + + foreach ( $wc_spam_orders as $key => $wc_spam_order ) { + $calculated_total = is_string($wc_spam_order->order_details) + ? $this->calcOrderTotal($wc_spam_order->order_details) + : null; + + if ( is_null($calculated_total) ) { + // Keep undecodable totals consistently at the end for both ASC and DESC. + $calculated_total = $order === 'DESC' ? -PHP_FLOAT_MAX : PHP_FLOAT_MAX; + } + + $totals[$key] = (float) $calculated_total; + } + + uasort($totals, static function ($a, $b) { + if ( $a === $b ) { + return 0; + } + return $a < $b ? -1 : 1; + }); + + if ( $order === 'DESC' ) { + $totals = array_reverse($totals, true); + } + + $sorted = array(); + foreach ( array_keys($totals) as $key ) { + $sorted[] = $wc_spam_orders[$key]; + } + + return $sorted; } private function getSqlOrderBy() { $order_by = Get::getString('orderby'); - $allowed_order_by = array_keys($this->get_sortable_columns()); - return in_array('ct_' . $order_by, $allowed_order_by) ? $order_by : ''; + $allowed_order_by = array('id', 'order_date'); + return in_array($order_by, $allowed_order_by, true) ? $order_by : ''; } private function removeSpam($ids) @@ -325,10 +669,16 @@ private function removeSpam($ids) ); } - private function generatePageHeader() + /** + * Notices shown above the table: the stored orders count and the warnings about the data. + * + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public function renderPageNotices() { if ( ! apbct_api_key__is_correct() ) { - if ( 1 == $this->spam_checker->getApbct()->moderate_ip ) { + if ( 1 == $this->apbct->moderate_ip ) { echo '

' . sprintf( __( @@ -346,39 +696,35 @@ private function generatePageHeader() } ?> -
-

- apbct->data["wl_mode_enabled"]) { - echo $this->apbct->data["wl_brandname"]; - } else { - echo 'CleanTalk logo' . $this->apbct->plugin_name; - } - ?> -

- -
-

page_title; ?>

-

Total count of spam orders: wc_spam_orders_count ?>

-

Please do backup of WordPress database before delete any orders!

-

Results are based on the decision of our spam checking system and do not give a complete guarantee that - these orders are spam.

- apbct->settings['data__wc_store_blocked_orders'] != 1) { - echo '

' - . __( - 'To store WooCommerce spam orders, enable the "Store blocked WooCommerce orders" option in CleanTalk settings.', - 'cleantalk-spam-protect' - ) - . '

'; - } - ?> -
+

%2$s', + esc_url(TT::toString($this->apbct->settings_link)), + esc_html__('Anti-Spam by CleanTalk', 'cleantalk-spam-protect') + ); ?>

apbct->settings['data__wc_store_blocked_orders']) ) { + echo '

' + . sprintf( + esc_html__( + 'To store Spam orders, enable the "Store blocked WooCommerce orders" option in %1$sCleanTalk settings%2$s.', + 'cleantalk-spam-protect' + ), + '', + '' + ) + . '

'; + } } private function deleteFromDb($spam_ids) diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php new file mode 100644 index 000000000..48f375812 --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php @@ -0,0 +1,403 @@ +id = $id; + } + + public function update_status($new_status) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + self::$update_status_calls[] = array($this->id, $new_status); + + return true; + } + } + } +} + +namespace Antispam\IntegrationsByClass { + + use Cleantalk\Antispam\IntegrationsByClass\Woocommerce; + use Cleantalk\ApbctWP\State; + use Cleantalk\ApbctWP\UpdatePlugin\DbAnalyzer; + use Cleantalk\ApbctWP\Variables\Get; + use Cleantalk\ApbctWP\Variables\Post; + use PHPUnit\Framework\TestCase; + + /** + * Unit tests for the admin/list-table hooks of the WooCommerce integration: + * the HPOS 'Spam' view wiring, the legacy status filters and the bulk actions. + */ + class TestWoocommerceAdminListTableHooks extends TestCase + { + /** + * @var Woocommerce + */ + private $integration; + + /** + * @var mixed + */ + private $apbct_backup; + + public function setUp(): void + { + global $apbct, $wpdb; + parent::setUp(); + + $this->apbct_backup = $apbct; + $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + Get::getInstance()->variables = array(); + Post::getInstance()->variables = array(); + \WC_Order::$update_status_calls = array(); + + $this->integration = new Woocommerce(); + } + + public static function setUpBeforeClass(): void + { + global $wpdb; + + $creator = new \Cleantalk\ApbctWP\UpdatePlugin\DbTablesCreator(); + $creator->createAllTables(); + $wpdb->query('TRUNCATE TABLE ' . APBCT_TBL_WC_SPAM_ORDERS); + } + + public function tearDown(): void + { + global $apbct, $wpdb; + + $apbct = $this->apbct_backup; + $wpdb->query('TRUNCATE TABLE ' . APBCT_TBL_WC_SPAM_ORDERS); + + Get::getInstance()->variables = array(); + Post::getInstance()->variables = array(); + unset($GLOBALS['post_status'], $_GET['status']); + + parent::tearDown(); + } + + /** + * @return \ReflectionMethod + */ + private function getMethod($name) + { + $method = new \ReflectionMethod(Woocommerce::class, $name); + $method->setAccessible(true); + + return $method; + } + + // ------------------------------------------------------------------- + // addOrdersListStatusViews() + // ------------------------------------------------------------------- + + public function testAddOrdersListStatusViewsReturnsEarlyWithoutAScreenId() + { + $this->assertNull( + $this->integration->addOrdersListStatusViews(new \stdClass()) + ); + } + + public function testAddOrdersListStatusViewsReturnsEarlyWithoutHposFunction() + { + $this->assertFalse(function_exists('wc_get_page_screen_id')); + + $screen = new \stdClass(); + $screen->id = 'edit-shop_order'; + + $this->assertNull($this->integration->addOrdersListStatusViews($screen)); + } + + // ------------------------------------------------------------------- + // addOrdersListStatusLinks() + // ------------------------------------------------------------------- + + public function testAddOrdersListStatusLinksReturnsNonArrayInputUnchanged() + { + $this->assertSame('not-an-array', $this->integration->addOrdersListStatusLinks('not-an-array')); + $this->assertNull($this->integration->addOrdersListStatusLinks(null)); + } + + public function testAddOrdersListStatusLinksLeavesArrayUnchangedWithoutWooCommerce() + { + $this->assertFalse(function_exists('wc_get_order_statuses')); + + $views = array('all' => 'All'); + + $this->assertSame($views, $this->integration->addOrdersListStatusLinks($views)); + } + + // ------------------------------------------------------------------- + // getOrdersListViews() (private) + // ------------------------------------------------------------------- + + public function testGetOrdersListViewsReturnsEmptyArrayWithoutWooCommerce() + { + $this->assertFalse(function_exists('wc_get_order_statuses')); + + $result = $this->getMethod('getOrdersListViews')->invoke($this->integration); + + $this->assertSame(array(), $result); + } + + // ------------------------------------------------------------------- + // keepOrdersListWhenSpamOrdersExist() + // ------------------------------------------------------------------- + + public function testKeepOrdersListWhenSpamOrdersExistPassesThroughWithNoSpamOrders() + { + $this->assertNull($this->integration->keepOrdersListWhenSpamOrdersExist(null)); + $this->assertTrue($this->integration->keepOrdersListWhenSpamOrdersExist(true)); + } + + public function testKeepOrdersListWhenSpamOrdersExistForcesFalseWithStoredSpamOrders() + { + global $wpdb; + + $wpdb->insert( + APBCT_TBL_WC_SPAM_ORDERS, + array( + 'order_details' => '[]', + 'customer_details' => '[]', + 'order_date' => time(), + ) + ); + + $this->assertFalse($this->integration->keepOrdersListWhenSpamOrdersExist(true)); + $this->assertFalse($this->integration->keepOrdersListWhenSpamOrdersExist(null)); + } + + // ------------------------------------------------------------------- + // replaceOrdersListRenderer() (private) + // ------------------------------------------------------------------- + + public function testReplaceOrdersListRendererReturnsEarlyWhenPageHookIsUnknown() + { + $this->getMethod('replaceOrdersListRenderer')->invoke($this->integration, 'no_such_page_hook'); + + $this->assertFalse(has_action('no_such_page_hook', array($this->integration, 'renderSpamOrdersPage'))); + } + + public function testReplaceOrdersListRendererReplacesThePageController() + { + $page_hook = 'apbct_test_page_hook'; + $controller = new WcOrdersPageControllerStub(); + + add_action($page_hook, array($controller, 'output')); + + $this->getMethod('replaceOrdersListRenderer')->invoke($this->integration, $page_hook); + + $this->assertFalse( + has_action($page_hook, array($controller, 'output')), + 'The original WooCommerce page controller must be removed.' + ); + $this->assertNotFalse( + has_action($page_hook, array($this->integration, 'renderSpamOrdersPage')), + 'The spam orders renderer must take over the page hook.' + ); + + remove_action($page_hook, array($this->integration, 'renderSpamOrdersPage')); + } + + public function testReplaceOrdersListRendererLeavesUnrelatedCallbacksAlone() + { + $page_hook = 'apbct_test_page_hook_unrelated'; + $unrelated = new \stdClass(); + $unrelated_callback = function () { + }; + + add_action($page_hook, $unrelated_callback); + + $this->getMethod('replaceOrdersListRenderer')->invoke($this->integration, $page_hook); + + $this->assertNotFalse(has_action($page_hook, $unrelated_callback)); + $this->assertFalse(has_action($page_hook, array($this->integration, 'renderSpamOrdersPage'))); + + remove_action($page_hook, $unrelated_callback); + } + + // ------------------------------------------------------------------- + // addOrdersSpamStatus() / addOrdersSpamStatusSelect() / addOrdersSpamStatusHideFromList() + // ------------------------------------------------------------------- + + public function testAddOrdersSpamStatusRegistersTheSpamStatus() + { + $result = $this->integration->addOrdersSpamStatus(array('wc-processing' => 'Processing')); + + $this->assertArrayHasKey('wc-spamorder', $result); + $this->assertSame('Spam', $result['wc-spamorder']['label']); + $this->assertTrue($result['wc-spamorder']['show_in_admin_all_list']); + } + + public function testAddOrdersSpamStatusSelectRegistersTheSpamOption() + { + $result = $this->integration->addOrdersSpamStatusSelect(array('wc-processing' => 'Processing')); + + $this->assertSame('Spam', $result['wc-spamorder']); + } + + public function testAddOrdersSpamStatusHideFromListRemovesTheSpamStatusOnTheOrdersScreen() + { + global $pagenow; + + $pagenow_backup = $pagenow; + $pagenow = 'edit.php'; + + $query = new \stdClass(); + $query->query_vars = array( + 'post_type' => 'shop_order', + 'post_status' => array('wc-processing', 'wc-spamorder'), + ); + + $this->integration->addOrdersSpamStatusHideFromList($query); + + $this->assertSame(array('wc-processing'), array_values($query->query_vars['post_status'])); + + $pagenow = $pagenow_backup; + } + + public function testAddOrdersSpamStatusHideFromListLeavesOtherScreensAlone() + { + global $pagenow; + + $pagenow_backup = $pagenow; + $pagenow = 'index.php'; + + $query = new \stdClass(); + $query->query_vars = array( + 'post_type' => 'shop_order', + 'post_status' => array('wc-processing', 'wc-spamorder'), + ); + + $this->integration->addOrdersSpamStatusHideFromList($query); + + $this->assertContains('wc-spamorder', $query->query_vars['post_status']); + + $pagenow = $pagenow_backup; + } + + // ------------------------------------------------------------------- + // addSpamActionToBulk() + // ------------------------------------------------------------------- + + public function testAddSpamActionToBulkOffersMarkAsSpamByDefault() + { + $result = $this->integration->addSpamActionToBulk(array()); + + $this->assertArrayHasKey('spamorder', $result); + $this->assertArrayNotHasKey('unspamorder', $result); + } + + public function testAddSpamActionToBulkOffersUnmarkOnTheSpamScreen() + { + set_query_var('post_status', 'wc-spamorder'); + + $result = $this->integration->addSpamActionToBulk(array()); + + $this->assertArrayHasKey('unspamorder', $result); + $this->assertArrayNotHasKey('spamorder', $result); + + set_query_var('post_status', null); + } + + // ------------------------------------------------------------------- + // addSpamActionToBulkHandle() + // ------------------------------------------------------------------- + + public function testAddSpamActionToBulkHandleIgnoresUnrelatedActions() + { + $redirect = 'https://example.test/wp-admin/edit.php'; + + $result = $this->integration->addSpamActionToBulkHandle($redirect, 'trash', array(1, 2)); + + $this->assertSame($redirect, $result); + $this->assertSame(array(), \WC_Order::$update_status_calls); + } + + public function testAddSpamActionToBulkHandleMarksOrdersAsSpam() + { + $redirect = 'https://example.test/wp-admin/edit.php'; + + $result = $this->integration->addSpamActionToBulkHandle($redirect, 'spamorder', array(11, 12)); + + $this->assertSame( + array(array(11, 'wc-spamorder'), array(12, 'wc-spamorder')), + \WC_Order::$update_status_calls + ); + $this->assertStringContainsString('bulk_action=marked_spamorder', $result); + $this->assertStringContainsString('changed=2', $result); + } + + public function testAddSpamActionToBulkHandleUnmarksOrders() + { + $redirect = 'https://example.test/wp-admin/edit.php'; + + $result = $this->integration->addSpamActionToBulkHandle($redirect, 'unspamorder', array(21)); + + $this->assertSame(array(array(21, 'wc-on-hold')), \WC_Order::$update_status_calls); + $this->assertStringContainsString('bulk_action=marked_unspamorder', $result); + $this->assertStringContainsString('changed=1', $result); + } + + // ------------------------------------------------------------------- + // renderSpamOrdersPage() + // ------------------------------------------------------------------- + + public function testRenderSpamOrdersPagePrintsTheWrapperMarkup() + { + ob_start(); + $this->integration->renderSpamOrdersPage(); + $output = ob_get_clean(); + + $this->assertStringContainsString('class="wrap"', $output); + $this->assertStringContainsString('Spam orders', $output); + $this->assertStringContainsString('integration = new Woocommerce(); + + $this->forgetKey(); + } + + public function tearDown(): void + { + delete_transient(self::TRANSIENT_PREFIX . self::KEY); + + $this->forgetKey(); + + parent::tearDown(); + } + + /** + * The key is read through the cached variables storage, so both have to be set. + * + * @param string $key + * + * @return void + */ + private function setKey($key) + { + $_GET['key'] = $key; + + Get::getInstance()->variables = array(); + } + + /** + * @return void + */ + private function forgetKey() + { + unset($_GET['key']); + + Get::getInstance()->variables = array(); + } + + /** + * @return void + */ + private function storeOverview() + { + set_transient( + self::TRANSIENT_PREFIX . self::KEY, + array( + 'date' => 'September 23, 2026', + 'total' => '99.00', + 'payment_method' => 'Cash on delivery', + ), + HOUR_IN_SECONDS + ); + } + + // ------------------------------------------------------------------- + // appendBlockedOrderOverviewToBlock() - the block theme entry point + // ------------------------------------------------------------------- + + public function testOverviewIsAppendedToTheOrderConfirmationStatusBlock() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'STATUS', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertStringStartsWith('STATUS', $result, 'The original block content must be kept.'); + $this->assertStringContainsString('woocommerce-order-overview', $result); + $this->assertStringContainsString('September 23, 2026', $result); + $this->assertStringContainsString('99.00', $result); + $this->assertStringContainsString('Cash on delivery', $result); + } + + public function testUnrelatedBlocksAreLeftUntouched() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'PARAGRAPH', + array('blockName' => 'core/paragraph') + ); + + $this->assertSame('PARAGRAPH', $result); + } + + public function testBlockWithoutNameIsLeftUntouched() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock('RAW', array()); + + $this->assertSame('RAW', $result); + } + + public function testNothingIsAppendedWithoutTheKey() + { + $this->storeOverview(); + $this->forgetKey(); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'STATUS', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertSame('STATUS', $result); + } + + public function testNothingIsAppendedWhenTheDetailsAreGone() + { + // The transient is deliberately not stored - it expires an hour after the block + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'STATUS', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertSame('STATUS', $result); + } + + public function testPaymentMethodRowIsOmittedWhenUnknown() + { + set_transient( + self::TRANSIENT_PREFIX . self::KEY, + array('date' => 'September 23, 2026', 'total' => '10.00'), + HOUR_IN_SECONDS + ); + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'STATUS', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertStringContainsString('woocommerce-order-overview__date', $result); + $this->assertStringContainsString('woocommerce-order-overview__total', $result); + $this->assertStringNotContainsString('woocommerce-order-overview__method', $result); + } + + // ------------------------------------------------------------------- + // renderBlockedOrderOverview() - the classic theme entry point + // ------------------------------------------------------------------- + + public function testOverviewIsPrintedAfterTheThankYouTemplate() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + ob_start(); + $this->integration->renderBlockedOrderOverview('checkout/thankyou.php', '', '', array('order' => false)); + $output = ob_get_clean(); + + $this->assertStringContainsString('woocommerce-order-overview', $output); + $this->assertStringContainsString('September 23, 2026', $output); + } + + public function testNothingIsPrintedForAnotherTemplate() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + ob_start(); + $this->integration->renderBlockedOrderOverview('checkout/form-checkout.php', '', '', array()); + $output = ob_get_clean(); + + $this->assertSame('', $output); + } + + public function testNothingIsPrintedWhenARealOrderStandsBehindThePage() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + ob_start(); + $this->integration->renderBlockedOrderOverview( + 'checkout/thankyou.php', + '', + '', + array('order' => new \stdClass()) + ); + $output = ob_get_clean(); + + $this->assertSame('', $output); + } + + /** + * Both entry points have to show the very same details. + */ + public function testBothEntryPointsProduceTheSameMarkup() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + ob_start(); + $this->integration->renderBlockedOrderOverview('checkout/thankyou.php', '', '', array('order' => false)); + $printed = ob_get_clean(); + + $appended = $this->integration->appendBlockedOrderOverviewToBlock( + '', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertSame($printed, $appended); + } +} diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceCheckoutGuards.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceCheckoutGuards.php new file mode 100644 index 000000000..9bed18a0e --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceCheckoutGuards.php @@ -0,0 +1,126 @@ +apbct_backup = $apbct; + $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $this->integration = new Woocommerce(); + } + + public function tearDown(): void + { + global $apbct; + + $apbct = $this->apbct_backup; + + parent::tearDown(); + } + + /** + * Minimal stand-in for the WP_Error object passed by woocommerce_after_checkout_validation. + * + * @param array $errors + * + * @return object + */ + private function makeErrors($errors = array()) + { + $holder = new \stdClass(); + $holder->errors = $errors; + + return $holder; + } + + /** + * WooCommerce reports its own validation errors first, the anti-spam check is skipped then. + */ + public function testCheckoutCheckReturnsEarlyWhenWooCommerceAlreadyFoundErrors() + { + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 1; + + $this->assertNull( + $this->integration->checkoutCheck( + array(), + $this->makeErrors(array('billing_email' => array('Invalid email'))) + ) + ); + } + + /** + * The storage option must not disable the anti-spam check itself, it only controls + * whether a blocked order is kept in the Spam folder. + */ + public function testCheckoutCheckIsNotGatedByTheStorageOption() + { + $source = file_get_contents( + CLEANTALK_PLUGIN_DIR . 'lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php' + ); + + $this->assertStringNotContainsString( + 'if ( ! $apbct->settings[\'data__wc_store_blocked_orders\'] ) {', + $source + ); + } + + /** + * Anything that is not a WC_Order can not be checked, so the call is dropped. + */ + public function testCheckoutCheckFromRestReturnsEarlyWithoutAnOrder() + { + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 1; + + $this->assertNull($this->integration->checkoutCheckFromRest(null)); + $this->assertNull($this->integration->checkoutCheckFromRest(new \stdClass())); + } + + /** + * The order is not checkable regardless of the storage option value. + */ + public function testCheckoutCheckFromRestReturnsEarlyWithoutAnOrderWhenStorageIsOff() + { + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 0; + + $this->assertNull($this->integration->checkoutCheckFromRest(null)); + $this->assertNull($this->integration->checkoutCheckFromRest(new \stdClass())); + } + + /** + * Both checkout handlers stay attached to their WooCommerce hooks. + */ + public function testCheckoutHandlersAreCallable() + { + $this->assertTrue(is_callable(array($this->integration, 'checkoutCheck'))); + $this->assertTrue(is_callable(array($this->integration, 'checkoutCheckFromRest'))); + } +} diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php new file mode 100644 index 000000000..0f112a706 --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php @@ -0,0 +1,109 @@ +submenu_backup = $submenu; + $submenu = array(); + + // Grant the capability required by add_submenu_page() regardless of the current user. + add_filter('user_has_cap', array($this, 'grantManageOptionsCap')); + + $this->integration = new Woocommerce(); + } + + public function tearDown(): void + { + global $submenu; + + $submenu = $this->submenu_backup; + remove_filter('user_has_cap', array($this, 'grantManageOptionsCap')); + + parent::tearDown(); + } + + /** + * @param array $allcaps + * + * @return array + */ + public function grantManageOptionsCap($allcaps) + { + $allcaps['manage_options'] = true; + + return $allcaps; + } + + /** + * Without wc_get_page_screen_id() (older WooCommerce, or the function unavailable + * as is the case in this test environment) the fallback page must still be registered. + */ + public function testAddsTheFallbackPageWhenTheHposScreenIdCanNotBeResolved() + { + $this->assertFalse(function_exists('wc_get_page_screen_id')); + + $this->integration->addLegacySpamOrdersMenuPage(); + + global $submenu; + + $this->assertArrayHasKey('woocommerce', $submenu); + + $found = false; + foreach ( $submenu['woocommerce'] as $item ) { + if ( $item[2] === 'apbct_wc_spam_orders' ) { + $found = true; + } + } + + $this->assertTrue($found, 'The apbct_wc_spam_orders submenu page was not registered.'); + } + + /** + * Regression guard: the fallback must stay conditional on the HPOS screen id, otherwise + * two competing "Spam" views would be registered on HPOS stores. + */ + public function testFallbackStaysConditionalOnTheHposScreenId() + { + $source = file_get_contents( + CLEANTALK_PLUGIN_DIR . 'lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php' + ); + + $this->assertStringContainsString( + "function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order'", + $source + ); + } + + public function testHandlerIsCallable() + { + $this->assertTrue(is_callable(array($this->integration, 'addLegacySpamOrdersMenuPage'))); + } +} diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceSimpleGetters.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceSimpleGetters.php new file mode 100644 index 000000000..5ab7c39c0 --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceSimpleGetters.php @@ -0,0 +1,262 @@ +formatted_total; + } + + public function get_payment_method_title() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + return $this->payment_method_title; + } + } +} + +namespace Antispam\IntegrationsByClass { + + use Cleantalk\Antispam\IntegrationsByClass\Woocommerce; + use Cleantalk\ApbctWP\State; + use Cleantalk\ApbctWP\Variables\Post; + use PHPUnit\Framework\TestCase; + use WcOrderStub; + + /** + * Unit tests for the small getter methods of the WooCommerce integration. + * + * WooCommerce itself is never loaded in the test environment, so every getter is exercised + * through the "WooCommerce is not active" branch, plus the WC_Order branch using the + * minimal WcOrderStub declared at the top of this file. + */ + class TestWoocommerceSimpleGetters extends TestCase + { + /** + * @var Woocommerce + */ + private $integration; + + /** + * @var mixed + */ + private $apbct_backup; + + /** + * @var array Backup of the 'active_plugins' option to restore after the test. + */ + private $active_plugins_backup; + + public function setUp(): void + { + global $apbct; + parent::setUp(); + + $this->apbct_backup = $apbct; + $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $this->active_plugins_backup = get_option('active_plugins'); + + // Reflected variables cache their values for the process lifetime, drop it + // before every test so $_POST changes actually take effect. + Post::getInstance()->variables = array(); + + $this->integration = new Woocommerce(); + } + + public function tearDown(): void + { + global $apbct; + + $apbct = $this->apbct_backup; + update_option('active_plugins', $this->active_plugins_backup); + Post::getInstance()->variables = array(); + unset($_POST['payment_method']); + + parent::tearDown(); + } + + /** + * @return \ReflectionMethod + */ + private function getMethod($name) + { + $method = new \ReflectionMethod(Woocommerce::class, $name); + $method->setAccessible(true); + + return $method; + } + + // ------------------------------------------------------------------- + // getCompletedOrders() + // ------------------------------------------------------------------- + + public function testGetCompletedOrdersReturnsEmptyStringWhenWooCommerceIsNotActive() + { + update_option('active_plugins', array()); + + $this->assertSame('', Woocommerce::getCompletedOrders()); + } + + public function testGetCompletedOrdersReturnsSqlFragmentWhenWooCommerceIsActive() + { + update_option('active_plugins', array('woocommerce/woocommerce.php')); + + $result = Woocommerce::getCompletedOrders(); + + $this->assertIsString($result); + $this->assertNotSame('', $result); + $this->assertStringContainsString('wc-completed', $result); + } + + // ------------------------------------------------------------------- + // getBlockedOrderTotal() + // ------------------------------------------------------------------- + + public function testGetBlockedOrderTotalReturnsEmptyStringWithoutAnOrderAndWithoutWooCommerce() + { + $result = $this->getMethod('getBlockedOrderTotal')->invoke($this->integration, null); + + $this->assertSame('', $result); + } + + public function testGetBlockedOrderTotalUsesTheOrderWhenGiven() + { + $order = new WcOrderStub(); + $order->formatted_total = '$25.00'; + + $result = $this->getMethod('getBlockedOrderTotal')->invoke($this->integration, $order); + + $this->assertSame('$25.00', $result); + } + + // ------------------------------------------------------------------- + // getBlockedOrderPaymentMethod() + // ------------------------------------------------------------------- + + public function testGetBlockedOrderPaymentMethodReturnsEmptyStringWithoutAnOrderAndWithoutAChosenMethod() + { + $result = $this->getMethod('getBlockedOrderPaymentMethod')->invoke($this->integration, null); + + $this->assertSame('', $result); + } + + public function testGetBlockedOrderPaymentMethodReturnsEmptyStringWithoutAnOrderWhenWooCommerceIsNotActive() + { + $_POST['payment_method'] = 'bacs'; + + $result = $this->getMethod('getBlockedOrderPaymentMethod')->invoke($this->integration, null); + + // WC() is never defined in the test environment, so the gateway title can not be resolved. + $this->assertSame('', $result); + } + + public function testGetBlockedOrderPaymentMethodUsesTheOrderWhenGiven() + { + $order = new WcOrderStub(); + $order->payment_method_title = 'Direct bank transfer'; + + $result = $this->getMethod('getBlockedOrderPaymentMethod')->invoke($this->integration, $order); + + $this->assertSame('Direct bank transfer', $result); + } + + // ------------------------------------------------------------------- + // getOrdersListStatusCount() + // ------------------------------------------------------------------- + + public function testGetOrdersListStatusCountReturnsZeroWhenWooCommerceIsNotActive() + { + $result = $this->getMethod('getOrdersListStatusCount')->invoke($this->integration, 'wc-processing'); + + $this->assertSame(0, $result); + } + + public function testGetOrdersListStatusCountReadsTheSpamTableForTheSpamStatus() + { + $result = $this->getMethod('getOrdersListStatusCount')->invoke($this->integration, 'wc-spamorder'); + + // No WooCommerce/spam table is guaranteed in the test environment, only the type matters here. + $this->assertIsInt($result); + $this->assertGreaterThanOrEqual(0, $result); + } + + // ------------------------------------------------------------------- + // getOrdersListStatusLink() + // ------------------------------------------------------------------- + + public function testGetOrdersListStatusLinkRendersAnAnchorWithTheGivenCount() + { + $result = $this->getMethod('getOrdersListStatusLink')->invoke( + $this->integration, + 'wc-processing', + 'Processing', + false, + 7 + ); + + $this->assertStringContainsString('assertStringContainsString('Processing', $result); + $this->assertStringContainsString('(7)', $result); + $this->assertStringNotContainsString('class="current"', $result); + } + + public function testGetOrdersListStatusLinkMarksTheCurrentStatus() + { + $result = $this->getMethod('getOrdersListStatusLink')->invoke( + $this->integration, + 'wc-spamorder', + 'Spam', + true, + 3 + ); + + $this->assertStringContainsString('class="current"', $result); + } + + public function testGetOrdersListStatusLinkOmitsTheStatusQueryArgForAll() + { + $result = $this->getMethod('getOrdersListStatusLink')->invoke( + $this->integration, + '', + 'All', + true, + 42 + ); + + $this->assertStringNotContainsString('status=', $result); + $this->assertStringContainsString('(42)', $result); + } + + public function testGetOrdersListStatusLinkCountsTheStatusWhenNotGiven() + { + $result = $this->getMethod('getOrdersListStatusLink')->invoke( + $this->integration, + 'wc-processing', + 'Processing', + false, + null + ); + + // Without WooCommerce active the count resolves to 0 via getOrdersListStatusCount(). + $this->assertStringContainsString('(0)', $result); + } + } +} diff --git a/tests/ApbctWP/TestWcShowRejectionMessageOption.php b/tests/ApbctWP/TestWcShowRejectionMessageOption.php new file mode 100644 index 000000000..cee922302 --- /dev/null +++ b/tests/ApbctWP/TestWcShowRejectionMessageOption.php @@ -0,0 +1,140 @@ +apbct_backup = $apbct; + $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + } + + public function tearDown(): void + { + global $apbct; + + $apbct = $this->apbct_backup; + + parent::tearDown(); + } + + /** + * @return array + */ + private function getWcFields() + { + $fields = apbct_settings__set_fields(); + + $this->assertArrayHasKey('wc', $fields); + $this->assertArrayHasKey('fields', $fields['wc']); + + return $fields['wc']['fields']; + } + + public function testOptionIsDisabledByDefault() + { + $state = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $this->assertArrayHasKey(self::OPTION, $state->default_settings); + $this->assertSame(0, $state->default_settings[self::OPTION]); + } + + public function testStoreBlockedOrdersStaysEnabledByDefault() + { + $state = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $this->assertArrayHasKey(self::STORE_BLOCKED_ORDERS_OPTION, $state->default_settings); + $this->assertSame(1, $state->default_settings[self::STORE_BLOCKED_ORDERS_OPTION]); + } + + public function testOptionIsRegisteredInWooCommerceSettingsSection() + { + $wc_fields = $this->getWcFields(); + + $this->assertArrayHasKey(self::OPTION, $wc_fields); + $this->assertSame( + 'Show rejection message to customers', + $wc_fields[self::OPTION]['title'] + ); + } + + public function testOptionHasOnAndOffChoicesOnly() + { + $wc_fields = $this->getWcFields(); + + $this->assertArrayHasKey('options', $wc_fields[self::OPTION]); + + $values = array_column($wc_fields[self::OPTION]['options'], 'val'); + + $this->assertSame(array(1, 0), $values); + } + + public function testOptionIsRenderedAsSubFieldOfTheCheckoutGroup() + { + $wc_fields = $this->getWcFields(); + + $this->assertSame( + 'apbct_settings-field_wrapper--sub', + $wc_fields[self::OPTION]['class'] + ); + } + + public function testOptionDescriptionExplainsTheSideEffects() + { + $wc_fields = $this->getWcFields(); + $description = $wc_fields[self::OPTION]['description']; + + $this->assertStringContainsString( + 'This message tells the customer why their order was filtered', + $description + ); + $this->assertStringContainsString('By default, this option is OFF.', $description); + } + + public function testStoreBlockedOrdersDescriptionIsUpdated() + { + $wc_fields = $this->getWcFields(); + $description = $wc_fields[self::STORE_BLOCKED_ORDERS_OPTION]['description']; + + $this->assertStringContainsString( + 'Orders blocked by Anti-Spam will be stored and can be restored manually later if needed.', + $description + ); + $this->assertStringNotContainsString('could be restored manually later if its needed', $description); + } + + public function testOptionIsPlacedRightAfterStoreBlockedOrders() + { + $wc_fields = $this->getWcFields(); + $keys = array_keys($wc_fields); + + $store_position = array_search(self::STORE_BLOCKED_ORDERS_OPTION, $keys, true); + $message_position = array_search(self::OPTION, $keys, true); + + $this->assertNotFalse($store_position); + $this->assertNotFalse($message_position); + $this->assertSame($store_position + 1, $message_position); + } +} diff --git a/tests/ApbctWP/TestWcSpamOrdersListTable.php b/tests/ApbctWP/TestWcSpamOrdersListTable.php index efdeadda5..605ac4030 100644 --- a/tests/ApbctWP/TestWcSpamOrdersListTable.php +++ b/tests/ApbctWP/TestWcSpamOrdersListTable.php @@ -182,4 +182,91 @@ public function testRenderCustomerDetailsColumnEmptyValues() $expectedCount = substr_count($result, ''); $this->assertEquals(3, $expectedCount); } + + /** + * Resets the memory of the request variables handler so that $_POST is read again. + * + * @return void + */ + private function resetPostMemory() + { + \Cleantalk\ApbctWP\Variables\Post::getInstance()->variables = array(); + } + + /** + * Test the action parameters are named so that the HPOS page controller ignores them. + */ + public function testActionParamsAreNamespaced() + { + $this->assertEquals('apbct_bulk_action', WcSpamOrdersListTable::BULK_ACTION_PARAM); + $this->assertEquals('apbct_row_action', WcSpamOrdersListTable::ROW_ACTION_PARAM); + $this->assertEquals('apbct_row_nonce', WcSpamOrdersListTable::ROW_NONCE_PARAM); + + foreach ( array('action', '_wpnonce', '_wp_http_referer') as $reserved ) { + $this->assertNotEquals($reserved, WcSpamOrdersListTable::BULK_ACTION_PARAM); + $this->assertNotEquals($reserved, WcSpamOrdersListTable::ROW_ACTION_PARAM); + $this->assertNotEquals($reserved, WcSpamOrdersListTable::ROW_NONCE_PARAM); + } + } + + /** + * Test current_action reads the select of the top tablenav. + */ + public function testCurrentActionReadsTopSelect() + { + $_POST = array(WcSpamOrdersListTable::BULK_ACTION_PARAM => 'delete'); + $this->resetPostMemory(); + + $this->assertEquals('delete', $this->instance->current_action()); + } + + /** + * Test current_action falls back to the select of the bottom tablenav. + */ + public function testCurrentActionReadsBottomSelect() + { + $_POST = array( + WcSpamOrdersListTable::BULK_ACTION_PARAM => '-1', + WcSpamOrdersListTable::BULK_ACTION_PARAM . '2' => 'delete', + ); + $this->resetPostMemory(); + + $this->assertEquals('delete', $this->instance->current_action()); + } + + /** + * Test current_action ignores the placeholder option of the selects. + */ + public function testCurrentActionIgnoresPlaceholder() + { + $_POST = array( + WcSpamOrdersListTable::BULK_ACTION_PARAM => '-1', + WcSpamOrdersListTable::BULK_ACTION_PARAM . '2' => '-1', + ); + $this->resetPostMemory(); + + $this->assertFalse($this->instance->current_action()); + } + + /** + * Test current_action reports no action when the selects were not submitted. + */ + public function testCurrentActionWithoutSubmission() + { + $_POST = array(); + $this->resetPostMemory(); + + $this->assertFalse($this->instance->current_action()); + } + + /** + * Test current_action stays blind to the 'action' parameter of the WooCommerce orders screen. + */ + public function testCurrentActionIgnoresWooCommerceActionParam() + { + $_POST = array('action' => 'delete', 'action2' => 'delete'); + $this->resetPostMemory(); + + $this->assertFalse($this->instance->current_action()); + } } diff --git a/tests/Inc/TestCleantalkUpdater.php b/tests/Inc/TestCleantalkUpdater.php index c0b3d2280..c94776e2c 100644 --- a/tests/Inc/TestCleantalkUpdater.php +++ b/tests/Inc/TestCleantalkUpdater.php @@ -16,4 +16,79 @@ public function testApbctUpdateTo_6_76_0() // Assert $this->assertEquals('1', $apbct_rebuilt->data['bot_detector_enabled']); } + + /** + * Users who already stored blocked orders have to keep the legacy checkout behavior, + * so the rejection message stays visible for them after the update. + */ + public function testApbctUpdateTo_6_89_0_EnablesRejectionMessageWhenBlockedOrdersAreStored() + { + // Arrange + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 1; + $apbct->settings['forms__wc_show_rejection_message'] = 0; + $apbct->saveSettings(); + + // Act + apbct_update_to_6_89_0(); + $apbct_rebuilt = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + // Assert + $this->assertEquals(1, $apbct_rebuilt->settings['forms__wc_show_rejection_message']); + } + + /** + * Sites without the stored blocked orders get the new silent behavior, so the option stays off. + */ + public function testApbctUpdateTo_6_89_0_KeepsRejectionMessageOffWhenBlockedOrdersAreNotStored() + { + // Arrange + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 0; + $apbct->settings['forms__wc_show_rejection_message'] = 0; + $apbct->saveSettings(); + + // Act + apbct_update_to_6_89_0(); + $apbct_rebuilt = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + // Assert + $this->assertEquals(0, $apbct_rebuilt->settings['forms__wc_show_rejection_message']); + } + + /** + * The migration must not be triggered by an empty or missing setting value. + */ + public function testApbctUpdateTo_6_89_0_DoesNothingWhenBlockedOrdersSettingIsMissing() + { + // Arrange + global $apbct; + unset($apbct->settings['data__wc_store_blocked_orders']); + $apbct->settings['forms__wc_show_rejection_message'] = 0; + $apbct->saveSettings(); + + // Act + apbct_update_to_6_89_0(); + $apbct_rebuilt = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + // Assert + $this->assertEquals(0, $apbct_rebuilt->settings['forms__wc_show_rejection_message']); + } + + /** + * The migration is registered under a version that the updater loop actually reaches. + */ + public function testApbctUpdateTo_6_89_0_IsReachableByTheUpdaterLoop() + { + $this->assertTrue(function_exists('apbct_update_to_6_89_0')); + + $version_arr = apbct_version_standardization('6.89.0'); + + $this->assertSame(6, $version_arr[0]); + $this->assertSame(89, $version_arr[1]); + $this->assertSame(0, $version_arr[2]); + + // The loop iterates minor versions up to 300, so 6.89.0 is inside the scanned range + $this->assertLessThanOrEqual(300, $version_arr[1]); + } }