From 01e33a96e92624d849608285e9ad263e116b6fc1 Mon Sep 17 00:00:00 2001 From: Ajay Yadav Date: Mon, 5 Oct 2026 00:22:13 +0530 Subject: [PATCH 1/2] fix(order-sync): stop pushing after a terminal 401/403, back off on 429, tell the merchant One store's key lacked orders:write. Every order trigger (creation, each status change, each admin save) pushed again with the same key and was refused: 444 HTTP 403s in 50 minutes, about one every 3 seconds, and the merchant saw nothing. Nothing looped on its own (Action Scheduler does not re-run a callback that returns); a refusal was simply forgotten between triggers and treated like a timeout. Tack_Sync_Gate remembers it, using the temporary/terminal split the TackQuote API applies to the vendors it calls: - TERMINAL: 401 or 403 carrying TackQuote's JSON error body (insufficient_scope, SUBSCRIPTION_INACTIVE, an invalid or revoked key). Pushes stop, a wp-admin error notice names the missing scope and the fix, one re-probe is allowed per hour (a renewed subscription heals without a key change), and saving a different key lifts the block at once. - THROTTLED: 429 waits for Retry-After (delta-seconds or HTTP-date), capped at one hour. - TEMPORARY: everything else, including a 401/403 whose body is not JSON (a firewall or challenge page in front of the API). The API client now keeps status, code, requiredScopes and Retry-After on the WP_Error data (third constructor argument, per core). The error code is unchanged. No wire-contract change: it reads fields the API already sends. An order skipped while blocked stays unmarked, so its next trigger pushes it. Only a 16-character sha256 prefix of the key is stored, never the key itself. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/class-tack-api-client.php | 26 ++- includes/class-tack-order-sync.php | 18 ++ includes/class-tack-quotes.php | 1 + includes/class-tack-sync-gate.php | 261 +++++++++++++++++++++++++++++ tests/run.php | 4 + tests/sync-gate-test.php | 194 +++++++++++++++++++++ tests/wp-stubs.php | 84 +++++++++- uninstall.php | 3 + 8 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 includes/class-tack-sync-gate.php create mode 100644 tests/sync-gate-test.php diff --git a/includes/class-tack-api-client.php b/includes/class-tack-api-client.php index 6769c0d..b943008 100755 --- a/includes/class-tack-api-client.php +++ b/includes/class-tack-api-client.php @@ -92,7 +92,31 @@ public function request( $method, $path, $body = null, $timeout = null, $headers $message = is_array( $data ) && isset( $data['message'] ) ? ( is_array( $data['message'] ) ? implode( ', ', $data['message'] ) : $data['message'] ) : sprintf( /* translators: %d: HTTP status code */ __( 'TackQuote API returned HTTP %d.', 'tackquote' ), $code ); - return new WP_Error( 'tack_http_' . $code, $message ); + + /* + * What the API SAID, kept beside the sentence, because the caller has to decide + * whether trying again can ever help (see Tack_Sync_Gate). A JSON 403 naming a + * missing scope will be refused forever; a 403 HTML page from a firewall in front + * of the API will not. Only the status and the API's own machine fields are + * kept, never the request or the key. + */ + $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); + return new WP_Error( + 'tack_http_' . $code, + $message, + array( + 'status' => $code, + 'json' => is_array( $data ), + 'code' => is_array( $data ) && isset( $data['code'] ) && is_string( $data['code'] ) ? $data['code'] : '', + 'requiredScopes' => is_array( $data ) && isset( $data['requiredScopes'] ) && is_array( $data['requiredScopes'] ) + ? array_values( array_filter( $data['requiredScopes'], 'is_string' ) ) + : array(), + 'retryAfterSeconds' => is_array( $data ) && isset( $data['retryAfterSeconds'] ) && is_numeric( $data['retryAfterSeconds'] ) + ? (int) $data['retryAfterSeconds'] + : 0, + 'retryAfterHeader' => is_array( $retry_after ) ? (string) reset( $retry_after ) : (string) $retry_after, + ) + ); } return is_array( $data ) ? $data : array(); diff --git a/includes/class-tack-order-sync.php b/includes/class-tack-order-sync.php index 04a6a0a..dbed11d 100755 --- a/includes/class-tack-order-sync.php +++ b/includes/class-tack-order-sync.php @@ -96,6 +96,9 @@ public function init() { */ public function register_worker() { add_action( self::SYNC_HOOK, array( $this, 'run_sync' ), 10, 1 ); + // Registered with the worker, not init(): a merchant who switches sync off while it + // is refused still deserves to know why orders stopped arriving. + add_action( 'admin_notices', array( 'Tack_Sync_Gate', 'render_admin_notice' ) ); } /** @@ -187,10 +190,24 @@ public function run_sync( $order_id ) { return; } + /* + * TackQuote already refused this key in a way no retry can change (a missing + * `orders:write` scope, a revoked key, a lapsed subscription), or asked us to slow + * down. Do not send: the order is left unmarked, so its next trigger pushes it once + * the merchant has acted. See Tack_Sync_Gate for the classification and why a + * refusal used to turn into a request every few seconds. + */ + $api_key = (string) get_option( 'tack_quotes_api_key', '' ); + $now = time(); + if ( null !== Tack_Sync_Gate::active_block( $api_key, $now ) ) { + return; + } + $payload['idempotencyKey'] = $key; $result = ( new Tack_Api_Client() )->sync_order( $payload, $key ); if ( is_wp_error( $result ) ) { + Tack_Sync_Gate::record_failure( $result, $api_key, $now ); if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->error( sprintf( @@ -210,6 +227,7 @@ public function run_sync( $order_id ) { // update_post_meta(), because with HPOS enabled orders do not live in wp_postmeta. $order->update_meta_data( self::SYNC_KEY_META, $key ); $order->save_meta_data(); + Tack_Sync_Gate::record_success(); } /** diff --git a/includes/class-tack-quotes.php b/includes/class-tack-quotes.php index 6fdb10f..74b177e 100755 --- a/includes/class-tack-quotes.php +++ b/includes/class-tack-quotes.php @@ -12,6 +12,7 @@ require_once TACK_QUOTES_DIR . 'includes/class-tack-settings.php'; require_once TACK_QUOTES_DIR . 'includes/class-tack-api-client.php'; require_once TACK_QUOTES_DIR . 'includes/class-tack-widget.php'; +require_once TACK_QUOTES_DIR . 'includes/class-tack-sync-gate.php'; require_once TACK_QUOTES_DIR . 'includes/class-tack-order-sync.php'; require_once TACK_QUOTES_DIR . 'includes/class-tack-catalog-mode.php'; require_once TACK_QUOTES_DIR . 'includes/class-tack-wholesale-pricing.php'; diff --git a/includes/class-tack-sync-gate.php b/includes/class-tack-sync-gate.php new file mode 100644 index 0000000..bd70b1c --- /dev/null +++ b/includes/class-tack-sync-gate.php @@ -0,0 +1,261 @@ +get_error_data(); + $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; + $block = array( + 'status' => $status, + 'code' => is_array( $data ) && isset( $data['code'] ) ? (string) $data['code'] : '', + 'scopes' => is_array( $data ) && isset( $data['requiredScopes'] ) && is_array( $data['requiredScopes'] ) ? array_values( $data['requiredScopes'] ) : array(), + 'message' => (string) $error->get_error_message(), + 'at' => (int) $now, + ); + + if ( 429 === $status ) { + $block['kind'] = 'throttled'; + $block['until'] = (int) $now + self::wait_seconds( $data, $now ); + return $block; + } + + if ( ( 401 === $status || 403 === $status ) && is_array( $data ) && ! empty( $data['json'] ) ) { + $block['kind'] = 'terminal'; + $block['until'] = (int) $now + self::TERMINAL_REPROBE; + return $block; + } + + return null; + } + + /** + * Remember a failed push, if it is one that must hold back the next. + * + * @param mixed $error The failure. + * @param string $api_key The key the push was made with. Only a hash is stored. + * @param int $now Current Unix time. + * @return array|null The stored block. + */ + public static function record_failure( $error, $api_key, $now ) { + $block = self::classify( $error, $now ); + if ( null === $block ) { + return null; + } + $block['key'] = self::key_fingerprint( $api_key ); + update_option( self::OPTION, $block, false ); + return $block; + } + + /** + * A push succeeded: whatever was blocking has been fixed. + */ + public static function record_success() { + if ( null !== get_option( self::OPTION, null ) ) { + self::clear(); + } + } + + /** + * Forget any block. + */ + public static function clear() { + delete_option( self::OPTION ); + } + + /** + * The block that must hold back a push right now, if any. + * + * @param string $api_key The key the next push would use. + * @param int $now Current Unix time. + * @return array|null + */ + public static function active_block( $api_key, $now ) { + $block = self::stored_for_key( $api_key ); + if ( null === $block ) { + return null; + } + return (int) $now < (int) ( $block['until'] ?? 0 ) ? $block : null; + } + + /** + * Print the wp-admin notice while order sync is refused. + * + * Shown to anyone who can manage WooCommerce, because they are the people whose + * orders are not arriving. It stays until a push succeeds or a different key is saved; + * a throttle on its own is not shown, since it clears itself. + */ + public static function render_admin_notice() { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return; + } + $block = self::stored_for_key( (string) get_option( 'tack_quotes_api_key', '' ) ); + if ( null === $block || 'terminal' !== ( $block['kind'] ?? '' ) ) { + return; + } + + $settings = admin_url( 'admin.php?page=' . ( class_exists( 'Tack_Settings' ) ? Tack_Settings::PAGE_SLUG : 'tackquote-for-woocommerce' ) ); + echo '

' + . esc_html__( 'TackQuote: orders are not being sent.', 'tackquote' ) + . ' ' + . esc_html( self::notice_text( $block ) ) + . ' ' + . esc_html__( 'Open TackQuote settings', 'tackquote' ) + . '

'; + } + + /** + * The merchant-facing explanation for a terminal block. + * + * @param array $block Stored block. + * @return string + */ + public static function notice_text( array $block ) { + $scopes = isset( $block['scopes'] ) && is_array( $block['scopes'] ) ? array_filter( $block['scopes'], 'is_string' ) : array(); + if ( 'insufficient_scope' === ( $block['code'] ?? '' ) || array() !== $scopes ) { + $list = array() !== $scopes ? implode( ', ', $scopes ) : 'orders:write'; + return sprintf( + /* translators: %1$s: comma-separated API key scopes, e.g. orders:write */ + __( 'TackQuote refused order sync because the API key saved here is missing the %1$s scope. In TackQuote, open Settings > API keys, create a key with the %1$s scope, paste it into TackQuote settings on this site, then revoke the old key. Orders placed until then are sent when they next change.', 'tackquote' ), + $list + ); + } + if ( 'SUBSCRIPTION_INACTIVE' === ( $block['code'] ?? '' ) ) { + return __( 'Your TackQuote subscription is not active, so TackQuote refuses new orders. Choose a plan in TackQuote under Settings > Billing; sync resumes within the hour.', 'tackquote' ); + } + if ( 401 === (int) ( $block['status'] ?? 0 ) ) { + return __( 'TackQuote does not accept the API key saved here (it may have been revoked). Create a new key in TackQuote under Settings > API keys with the orders:write scope and save it in TackQuote settings on this site.', 'tackquote' ); + } + return sprintf( + /* translators: %s: the refusal message TackQuote returned */ + __( 'TackQuote refused order sync: %s', 'tackquote' ), + (string) ( $block['message'] ?? '' ) + ); + } + + /** + * The stored block, if it was recorded for this key. + * + * A block recorded for a different key is stale — the merchant has saved a new one — + * and is dropped rather than trusted. + * + * @param string $api_key Current key. + * @return array|null + */ + private static function stored_for_key( $api_key ) { + $block = get_option( self::OPTION, null ); + if ( ! is_array( $block ) ) { + return null; + } + if ( ( $block['key'] ?? '' ) !== self::key_fingerprint( $api_key ) ) { + self::clear(); + return null; + } + return $block; + } + + /** + * A short one-way fingerprint, so the option can tell keys apart without holding one. + * + * @param string $api_key Key. + * @return string + */ + private static function key_fingerprint( $api_key ) { + return substr( hash( 'sha256', (string) $api_key ), 0, 16 ); + } + + /** + * Seconds to wait after a 429: Retry-After (delta-seconds or an HTTP-date, RFC 9110 + * section 10.2.3), else the body's retryAfterSeconds, else DEFAULT_WAIT; at most MAX_WAIT. + * + * @param mixed $data Error data. + * @param int $now Current Unix time. + * @return int + */ + private static function wait_seconds( $data, $now ) { + $wait = 0; + $header = is_array( $data ) && isset( $data['retryAfterHeader'] ) ? trim( (string) $data['retryAfterHeader'] ) : ''; + if ( '' !== $header && ctype_digit( $header ) ) { + $wait = (int) $header; + } elseif ( '' !== $header ) { + $when = strtotime( $header ); + $wait = false === $when ? 0 : $when - (int) $now; + } + if ( $wait <= 0 && is_array( $data ) && ! empty( $data['retryAfterSeconds'] ) ) { + $wait = (int) $data['retryAfterSeconds']; + } + if ( $wait <= 0 ) { + $wait = self::DEFAULT_WAIT; + } + return min( $wait, self::MAX_WAIT ); + } +} diff --git a/tests/run.php b/tests/run.php index a21b9ed..36f52a9 100644 --- a/tests/run.php +++ b/tests/run.php @@ -95,5 +95,9 @@ function check( $label, $condition, $detail = '' ) { echo "\n-- settings page structure + rule editing --\n"; require __DIR__ . '/settings-page-test.php'; +// Order sync stops on a TERMINAL refusal (401/403 from TackQuote) and backs off on 429. +echo "\n-- order-sync gate: terminal 401/403, 429 back-off, admin notice --\n"; +require __DIR__ . '/sync-gate-test.php'; + echo $failures ? "\n$failures failure(s)\n" : "\nAll checks passed\n"; exit( $failures ? 1 : 0 ); diff --git a/tests/sync-gate-test.php b/tests/sync-gate-test.php new file mode 100644 index 0000000..83ea864 --- /dev/null +++ b/tests/sync-gate-test.php @@ -0,0 +1,194 @@ +sync_order( array( 'externalOrderId' => '1' ), 'k1' ); +} + +$scope_body = wp_json_encode( + array( + 'statusCode' => 403, + 'code' => 'insufficient_scope', + 'message' => 'This API key is missing the required scope(s): orders:write. Create a new key with those scopes in Settings -> API keys.', + 'requiredScopes' => array( 'orders:write' ), + ) +); + +// ── The client keeps what the API said, not just a sentence ───────────────── +$err = tack_gate_push( 403, $scope_body ); +$data = is_wp_error( $err ) ? $err->get_error_data() : null; +check( 'a 403 comes back as a WP_Error', is_wp_error( $err ) ); +check( + 'the WP_Error carries the HTTP status, the API code and the scopes it named', + is_array( $data ) && 403 === ( $data['status'] ?? null ) + && 'insufficient_scope' === ( $data['code'] ?? null ) + && array( 'orders:write' ) === ( $data['requiredScopes'] ?? null ) + && true === ( $data['json'] ?? null ), + 'data: ' . var_export( $data, true ) +); + +// ── Classification ────────────────────────────────────────────────────────── +$block = Tack_Sync_Gate::classify( $err, $now ); +check( 'a JSON 403 for a missing scope is TERMINAL', is_array( $block ) && 'terminal' === $block['kind'] ); +check( + 'the terminal block names the missing scope', + is_array( $block ) && array( 'orders:write' ) === $block['scopes'] +); + +$sub = tack_gate_push( 403, wp_json_encode( array( 'statusCode' => 403, 'code' => 'SUBSCRIPTION_INACTIVE', 'error' => 'SUBSCRIPTION_INACTIVE', 'message' => 'Your subscription is inactive.' ) ) ); +$b = Tack_Sync_Gate::classify( $sub, $now ); +check( 'a JSON 403 for a lapsed subscription is TERMINAL', is_array( $b ) && 'terminal' === $b['kind'] && 'SUBSCRIPTION_INACTIVE' === $b['code'] ); + +$bad = tack_gate_push( 401, wp_json_encode( array( 'statusCode' => 401, 'code' => 'UNAUTHORIZED', 'message' => 'Invalid API key' ) ) ); +$b = Tack_Sync_Gate::classify( $bad, $now ); +check( 'a JSON 401 (invalid or revoked key) is TERMINAL', is_array( $b ) && 'terminal' === $b['kind'] ); + +$waf = tack_gate_push( 403, 'Just a moment...' ); +check( + 'a 403 whose body is NOT JSON (a WAF / challenge page) is TEMPORARY, not terminal', + null === Tack_Sync_Gate::classify( $waf, $now ) +); + +$down = tack_gate_push( 502, '' ); +check( 'a 5xx is TEMPORARY', null === Tack_Sync_Gate::classify( $down, $now ) ); + +check( + 'a transport error (no HTTP status) is TEMPORARY', + null === Tack_Sync_Gate::classify( new WP_Error( 'http_request_failed', 'cURL error 28' ), $now ) +); + +$slow = tack_gate_push( 429, wp_json_encode( array( 'statusCode' => 429, 'code' => 'insufficient_scope', 'message' => 'slow down', 'retryAfterSeconds' => 120 ) ), array( 'retry-after' => '120' ) ); +$b = Tack_Sync_Gate::classify( $slow, $now ); +check( + 'a 429 is THROTTLED until Retry-After', + is_array( $b ) && 'throttled' === $b['kind'] && $now + 120 === $b['until'], + var_export( $b, true ) +); + +$huge = tack_gate_push( 429, '{}', array( 'retry-after' => '999999' ) ); +$b = Tack_Sync_Gate::classify( $huge, $now ); +check( + 'a 429 Retry-After is capped at one hour', + is_array( $b ) && $now + Tack_Sync_Gate::MAX_WAIT === $b['until'] +); + +// ── The gate: what stops the storm ────────────────────────────────────────── +Tack_Sync_Gate::clear(); +check( 'no block before any refusal', null === Tack_Sync_Gate::active_block( $key, $now ) ); + +Tack_Sync_Gate::record_failure( $err, $key, $now ); +check( + 'after a terminal refusal the very next push is held', + is_array( Tack_Sync_Gate::active_block( $key, $now + 3 ) ) +); +check( + 'it is still held 59 minutes later (no 3-second retry storm)', + is_array( Tack_Sync_Gate::active_block( $key, $now + 59 * 60 ) ) +); +check( + 'one re-probe is allowed after an hour (a renewed subscription heals without a key change)', + null === Tack_Sync_Gate::active_block( $key, $now + Tack_Sync_Gate::TERMINAL_REPROBE + 1 ) +); +$stored = get_option( Tack_Sync_Gate::OPTION ); +check( + 'the stored block never contains the API key itself', + is_array( $stored ) && false === strpos( wp_json_encode( $stored ), $key ) +); + +check( + 'a NEW API key lifts the block immediately', + null === Tack_Sync_Gate::active_block( 'tk_live_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', $now + 3 ) +); + +Tack_Sync_Gate::clear(); +Tack_Sync_Gate::record_failure( $down, $key, $now ); +check( 'a temporary failure does not block the next push', null === Tack_Sync_Gate::active_block( $key, $now + 1 ) ); + +Tack_Sync_Gate::record_failure( $err, $key, $now ); +Tack_Sync_Gate::record_success(); +check( 'a successful push clears the block', null === get_option( Tack_Sync_Gate::OPTION, null ) ); + +// ── What the merchant is told ─────────────────────────────────────────────── +Tack_Sync_Gate::record_failure( $err, $key, $now ); +$GLOBALS['TACK_CAPS'] = array( 'manage_woocommerce' ); +ob_start(); +Tack_Sync_Gate::render_admin_notice(); +$notice = ob_get_clean(); +check( 'wp-admin shows an error notice while sync is blocked', false !== strpos( $notice, 'notice notice-error' ) ); +check( 'the notice names the missing scope', false !== strpos( $notice, 'orders:write' ) ); +check( 'the notice says orders are not being sent', false !== stripos( $notice, 'not being sent' ) ); + +$GLOBALS['TACK_CAPS'] = array(); +ob_start(); +Tack_Sync_Gate::render_admin_notice(); +check( 'users who cannot manage WooCommerce do not see it', '' === ob_get_clean() ); + +tack_test_set_option( 'tack_quotes_api_key', 'tk_live_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' ); +$GLOBALS['TACK_CAPS'] = array( 'manage_woocommerce' ); +ob_start(); +Tack_Sync_Gate::render_admin_notice(); +check( 'the notice disappears once a different key is saved', '' === ob_get_clean() ); + +// ── The worker consults the gate BEFORE it makes the request ──────────────── +$src = (string) file_get_contents( TACK_QUOTES_DIR . 'includes/class-tack-order-sync.php' ); +$start = strpos( $src, 'public function run_sync(' ); +$end = false === $start ? false : strpos( $src, "\n\t}\n", $start ); +$body = ( false === $start || false === $end ) ? '' : substr( $src, $start, $end - $start ); +$gate = strpos( $body, 'Tack_Sync_Gate::active_block(' ); +$send = strpos( $body, '->sync_order(' ); +check( + 'run_sync() checks Tack_Sync_Gate::active_block() before ->sync_order()', + false !== $gate && false !== $send && $gate < $send +); +check( 'run_sync() records a failed push with the gate', false !== strpos( $body, 'Tack_Sync_Gate::record_failure(' ) ); +check( 'run_sync() clears the gate on success', false !== strpos( $body, 'Tack_Sync_Gate::record_success(' ) ); + +$uninstall = (string) file_get_contents( TACK_QUOTES_DIR . 'uninstall.php' ); +check( 'uninstall.php removes the block option', false !== strpos( $uninstall, "'" . Tack_Sync_Gate::OPTION . "'" ) ); + +Tack_Sync_Gate::clear(); +tack_test_set_option( 'tack_quotes_api_key', '' ); +$GLOBALS['TACK_CAPS'] = array(); diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index e83f28d..5d895e5 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -153,16 +153,29 @@ class WP_Error { private $code; /** @var string */ private $message; + /** @var mixed */ + private $data; /** - * Constructor. + * Constructor. Same three parameters as core's `WP_Error::__construct( $code, $message, $data )`. * * @param string $code Error code. * @param string $message Error message. + * @param mixed $data Error data. */ - public function __construct( $code = '', $message = '' ) { + public function __construct( $code = '', $message = '', $data = '' ) { $this->code = $code; $this->message = $message; + $this->data = $data; + } + + /** + * Core returns the data for the first code, or null when none was added. + * + * @return mixed + */ + public function get_error_data() { + return '' === $this->data ? null : $this->data; } /** @return string */ @@ -589,3 +602,70 @@ function get_userdata( $user_id ) { function tack_test_reset_user_meta() { $GLOBALS['TACK_USER_META'] = array(); } + + +// ── Stubs added for the order-sync gate (terminal 401/403, 429 back-off) ───── +// +// The HTTP functions below replay ONE scripted response and count calls, so a test can +// assert that a blocked push made no request at all. Shapes follow core: a response is an +// array with `response.code`, `body` and `headers`; `wp_remote_retrieve_header()` returns +// '' for an absent header (developer.wordpress.org/reference/functions/wp_remote_retrieve_header/). + +$GLOBALS['TACK_HTTP_CALLS'] = 0; +$GLOBALS['TACK_HTTP_RESPONSE'] = null; + +if ( ! function_exists( 'delete_option' ) ) { + /** + * @param string $key Option name. + * @return bool + */ + function delete_option( $key ) { + unset( $GLOBALS['TACK_OPTIONS'][ $key ] ); + return true; + } +} + +/** + * Script the next HTTP response. + * + * @param int $code Status code. + * @param string $body Raw body. + * @param array $headers Lower-case header name => value. + */ +function tack_test_set_http_response( $code, $body, $headers = array() ) { + $GLOBALS['TACK_HTTP_RESPONSE'] = array( + 'response' => array( 'code' => $code ), + 'body' => $body, + 'headers' => $headers, + ); +} + +if ( ! function_exists( 'wp_remote_request' ) ) { + /** + * @param string $url URL. + * @param array $args Args. + * @return array|WP_Error + */ + function wp_remote_request( $url, $args = array() ) { + $GLOBALS['TACK_HTTP_CALLS']++; + return $GLOBALS['TACK_HTTP_RESPONSE']; + } +} +if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) { + /** @param array $r Response. @return int|string */ + function wp_remote_retrieve_response_code( $r ) { + return is_array( $r ) && isset( $r['response']['code'] ) ? $r['response']['code'] : ''; + } +} +if ( ! function_exists( 'wp_remote_retrieve_body' ) ) { + /** @param array $r Response. @return string */ + function wp_remote_retrieve_body( $r ) { + return is_array( $r ) && isset( $r['body'] ) ? $r['body'] : ''; + } +} +if ( ! function_exists( 'wp_remote_retrieve_header' ) ) { + /** @param array $r Response. @param string $h Header. @return string */ + function wp_remote_retrieve_header( $r, $h ) { + return is_array( $r ) && isset( $r['headers'][ strtolower( $h ) ] ) ? $r['headers'][ strtolower( $h ) ] : ''; + } +} diff --git a/uninstall.php b/uninstall.php index c6111d4..9ec1ccf 100755 --- a/uninstall.php +++ b/uninstall.php @@ -63,6 +63,9 @@ 'tack_quotes_payment_group_map', 'tack_quotes_shipping_group_map', 'tack_quotes_buyer_group_codes', + + // Order-sync circuit breaker (Tack_Sync_Gate::OPTION) — added after 1.8.1. + 'tack_quotes_order_sync_block', ); /** From ddcc0c6721e1f32f61fc3747f884f6a657f3450f Mon Sep 17 00:00:00 2001 From: Ajay Yadav Date: Mon, 5 Oct 2026 00:45:03 +0530 Subject: [PATCH 2/2] fix(order-sync): review follow-ups: 429 scope refusal stays terminal, only TackQuote's own JSON is terminal, re-queue on unblock From Fable's review of 01e33a9 (APPROVE with changes). - A 429 carrying insufficient_scope or requiredScopes is TackQuote throttling the same scope refusal. It stays terminal, with until = max(now + Retry-After, now + TERMINAL_REPROBE), so the wp-admin notice no longer flickers off each throttle window. - Terminal only for TackQuote's own error body: JSON whose statusCode equals the HTTP status and which carries a non-empty code. A proxy or WAF answering 403 with JSON of its own is temporary. The client now carries statusCode. - Re-queue after unblock. A block keeps `since` (when pushes started being held) across re-records. When it lifts (a push succeeds, a different key is saved via update_option_tack_quotes_api_key, or a stale-key block is dropped), Tack_Sync_Gate fires tack_quotes_order_sync_unblocked. Tack_Order_Sync schedules one async job, which runs wc_get_orders date_modified '>' since (the documented form, woocommerce docs/features/orders/wc-get-orders.md) and queues each order through the normal worker. It is bounded to 200 orders and 30 days, logs when it truncates, and does nothing when order sync is off. - Notice copy for a lapsed subscription: "Profile > Billing & Plan". - Retry-After digits are parsed with preg_match, not ctype_digit. - The notice links Tack_Settings::PAGE_SLUG directly. The bootstrap always loads it, so the dead fallback slug is gone. - readme.txt: "Unreleased" changelog entry. The version bump and tag are left to the owner. Co-Authored-By: Claude Opus 5.5 (1M context) --- includes/class-tack-api-client.php | 2 + includes/class-tack-order-sync.php | 79 ++++++++++++++++++ includes/class-tack-sync-gate.php | 95 ++++++++++++++++++--- readme.txt | 5 ++ tests/sync-gate-test.php | 129 ++++++++++++++++++++++++++++- tests/wp-stubs.php | 46 ++++++++++ 6 files changed, 344 insertions(+), 12 deletions(-) diff --git a/includes/class-tack-api-client.php b/includes/class-tack-api-client.php index b943008..ef32aca 100755 --- a/includes/class-tack-api-client.php +++ b/includes/class-tack-api-client.php @@ -107,6 +107,8 @@ public function request( $method, $path, $body = null, $timeout = null, $headers array( 'status' => $code, 'json' => is_array( $data ), + // TackQuote's own errors echo the status in the body; a proxy's JSON does not. + 'statusCode' => is_array( $data ) && isset( $data['statusCode'] ) && is_numeric( $data['statusCode'] ) ? (int) $data['statusCode'] : 0, 'code' => is_array( $data ) && isset( $data['code'] ) && is_string( $data['code'] ) ? $data['code'] : '', 'requiredScopes' => is_array( $data ) && isset( $data['requiredScopes'] ) && is_array( $data['requiredScopes'] ) ? array_values( array_filter( $data['requiredScopes'], 'is_string' ) ) diff --git a/includes/class-tack-order-sync.php b/includes/class-tack-order-sync.php index dbed11d..26f1c94 100755 --- a/includes/class-tack-order-sync.php +++ b/includes/class-tack-order-sync.php @@ -37,6 +37,23 @@ class Tack_Order_Sync { */ const SYNC_KEY_META = '_tack_quotes_sync_key'; + /** + * Action that re-queues the orders skipped while TackQuote refused order sync. + */ + const REQUEUE_HOOK = 'tack_quotes_requeue_unsynced'; + + /** + * Most orders one re-queue will look at. Bounded so a long outage on a busy store + * cannot enqueue thousands of jobs at once; what is left over is logged, and each of + * those orders is still pushed the next time it changes. + */ + const REQUEUE_LIMIT = 200; + + /** + * How far back a re-queue reaches at most, whatever the block's start time says. + */ + const REQUEUE_MAX_AGE = 2592000; // 30 days. + /** * Whether outbound order sync is switched on. * @@ -99,6 +116,68 @@ public function register_worker() { // Registered with the worker, not init(): a merchant who switches sync off while it // is refused still deserves to know why orders stopped arriving. add_action( 'admin_notices', array( 'Tack_Sync_Gate', 'render_admin_notice' ) ); + + // A refusal lifted (a push succeeded, or a different key was saved): re-send what + // was skipped meanwhile, off the request that lifted it. + add_action( Tack_Sync_Gate::UNBLOCKED_ACTION, array( $this, 'schedule_requeue' ), 10, 1 ); + add_action( self::REQUEUE_HOOK, array( $this, 'requeue_unsynced' ), 10, 1 ); + add_action( 'update_option_tack_quotes_api_key', array( 'Tack_Sync_Gate', 'on_api_key_changed' ), 10, 2 ); + } + + /** + * Queue one re-queue job for the orders skipped since `$since`. + * + * @param int $since Unix time pushes started being held. + */ + public function schedule_requeue( $since ) { + $args = array( (int) $since ); + if ( function_exists( 'as_enqueue_async_action' ) ) { + as_enqueue_async_action( self::REQUEUE_HOOK, $args, self::SYNC_GROUP, true ); + return; + } + if ( ! wp_next_scheduled( self::REQUEUE_HOOK, $args ) ) { + wp_schedule_single_event( time() + 1, self::REQUEUE_HOOK, $args ); + } + } + + /** + * Re-queue the orders modified since `$since`. Each goes through the normal worker, + * which skips an order whose current state was already accepted (its sync key), so + * re-queuing an order that did get through costs one meta read and no request. + * + * `date_modified => '>' . timestamp` is the documented wc_get_orders() form + * (woocommerce/docs/features/orders/wc-get-orders.md). + * + * @param int $since Unix time pushes started being held. + */ + public function requeue_unsynced( $since ) { + if ( ! self::is_enabled() || ! function_exists( 'wc_get_orders' ) ) { + return; + } + $floor = max( (int) $since - 60, time() - self::REQUEUE_MAX_AGE ); + $ids = wc_get_orders( + array( + 'type' => 'shop_order', + 'date_modified' => '>' . $floor, + 'orderby' => 'modified', + 'order' => 'ASC', + 'limit' => self::REQUEUE_LIMIT + 1, + 'return' => 'ids', + ) + ); + $ids = is_array( $ids ) ? $ids : array(); + if ( count( $ids ) > self::REQUEUE_LIMIT && function_exists( 'wc_get_logger' ) && wc_get_logger() ) { + wc_get_logger()->warning( + sprintf( + 'TackQuote order sync resumed: re-sending the %d oldest orders changed since it was refused; later ones are sent when they next change.', + self::REQUEUE_LIMIT + ), + array( 'source' => 'tackquote' ) + ); + } + foreach ( array_slice( $ids, 0, self::REQUEUE_LIMIT ) as $order_id ) { + $this->enqueue( (int) $order_id ); + } } /** diff --git a/includes/class-tack-sync-gate.php b/includes/class-tack-sync-gate.php index bd70b1c..6f1ec45 100644 --- a/includes/class-tack-sync-gate.php +++ b/includes/class-tack-sync-gate.php @@ -26,8 +26,13 @@ * firewall or challenge page in front of the API and it goes away on its * own. Calling it terminal would switch sync off for a five-minute blip. * - * An order skipped while blocked is not marked as sent, so its next trigger pushes it — - * the same recovery a failed push has always had. + * An order skipped while blocked is not marked as sent. When the block lifts (a push + * succeeds, or a different key is saved) UNBLOCKED_ACTION fires and Tack_Order_Sync + * re-queues the orders modified since the block began; any order that changes again is + * pushed by its own trigger as before. + * + * Terminal means TackQuote's OWN error body (JSON with a matching `statusCode` and a + * non-empty `code`); a 429 that carries a scope refusal stays terminal. * * @package TackQuotes */ @@ -61,6 +66,13 @@ class Tack_Sync_Gate { */ const MAX_WAIT = 3600; + /** + * Fired when pushes are allowed again (a push succeeded, or a different key was saved), + * with the Unix time pushes started being held. Tack_Order_Sync re-queues the orders + * skipped in between, since nothing else would send an order that does not change again. + */ + const UNBLOCKED_ACTION = 'tack_quotes_order_sync_unblocked'; + /** * Classify a failed push. * @@ -83,13 +95,37 @@ public static function classify( $error, $now ) { 'at' => (int) $now, ); + /* + * Only TackQuote's OWN error body may make a refusal terminal: JSON whose + * `statusCode` equals the HTTP status and which carries a non-empty `code` (the + * shape every TackQuote API error has). A proxy, WAF or load balancer that happens + * to answer 403 with some JSON of its own is not TackQuote saying "never", and + * goes away on its own. + */ + $from_tackquote = is_array( $data ) + && ! empty( $data['json'] ) + && isset( $data['statusCode'] ) && (int) $data['statusCode'] === $status + && '' !== $block['code']; + if ( 429 === $status ) { + $wait = self::wait_seconds( $data, $now ); + /* + * TackQuote answers a key that keeps hitting a missing scope with 429 instead of + * 403 after a few refusals. It is still the same terminal refusal, so it stays + * terminal — otherwise the wp-admin notice would flicker off for each throttle + * window — and it is held at least as long as any terminal block. + */ + if ( $from_tackquote && ( 'insufficient_scope' === $block['code'] || array() !== $block['scopes'] ) ) { + $block['kind'] = 'terminal'; + $block['until'] = max( (int) $now + $wait, (int) $now + self::TERMINAL_REPROBE ); + return $block; + } $block['kind'] = 'throttled'; - $block['until'] = (int) $now + self::wait_seconds( $data, $now ); + $block['until'] = (int) $now + $wait; return $block; } - if ( ( 401 === $status || 403 === $status ) && is_array( $data ) && ! empty( $data['json'] ) ) { + if ( ( 401 === $status || 403 === $status ) && $from_tackquote ) { $block['kind'] = 'terminal'; $block['until'] = (int) $now + self::TERMINAL_REPROBE; return $block; @@ -112,19 +148,55 @@ public static function record_failure( $error, $api_key, $now ) { return null; } $block['key'] = self::key_fingerprint( $api_key ); + // When pushes STARTED being held, kept across re-records, so the re-queue on + // unblock reaches back to the first skipped order rather than the last refusal. + $previous = get_option( self::OPTION, null ); + $block['since'] = is_array( $previous ) && ( $previous['key'] ?? '' ) === $block['key'] + ? (int) ( $previous['since'] ?? $previous['at'] ?? $now ) + : (int) $now; update_option( self::OPTION, $block, false ); return $block; } /** - * A push succeeded: whatever was blocking has been fixed. + * A push succeeded: whatever was blocking has been fixed. Pushes skipped while it + * stood are re-queued. */ public static function record_success() { - if ( null !== get_option( self::OPTION, null ) ) { - self::clear(); + $block = get_option( self::OPTION, null ); + if ( is_array( $block ) ) { + self::lift( $block ); + } + } + + /** + * `update_option_tack_quotes_api_key` handler: a different key was saved, so a block + * recorded against the old one no longer applies. Lifted now, not on the next push, + * so the skipped orders are re-queued immediately. + * + * @param mixed $old_value Previous key. + * @param mixed $new_value New key. + */ + public static function on_api_key_changed( $old_value, $new_value ) { + if ( (string) $old_value === (string) $new_value ) { + return; + } + $block = get_option( self::OPTION, null ); + if ( is_array( $block ) ) { + self::lift( $block ); } } + /** + * Clear a block and announce it, so the skipped orders can be re-queued. + * + * @param array $block The block being lifted. + */ + private static function lift( array $block ) { + self::clear(); + do_action( self::UNBLOCKED_ACTION, (int) ( $block['since'] ?? $block['at'] ?? 0 ) ); + } + /** * Forget any block. */ @@ -163,7 +235,8 @@ public static function render_admin_notice() { return; } - $settings = admin_url( 'admin.php?page=' . ( class_exists( 'Tack_Settings' ) ? Tack_Settings::PAGE_SLUG : 'tackquote-for-woocommerce' ) ); + // Tack_Settings is always loaded by the plugin bootstrap (class-tack-quotes.php). + $settings = admin_url( 'admin.php?page=' . Tack_Settings::PAGE_SLUG ); echo '

' . esc_html__( 'TackQuote: orders are not being sent.', 'tackquote' ) . ' ' @@ -190,7 +263,7 @@ public static function notice_text( array $block ) { ); } if ( 'SUBSCRIPTION_INACTIVE' === ( $block['code'] ?? '' ) ) { - return __( 'Your TackQuote subscription is not active, so TackQuote refuses new orders. Choose a plan in TackQuote under Settings > Billing; sync resumes within the hour.', 'tackquote' ); + return __( 'Your TackQuote subscription is not active, so TackQuote refuses new orders. Choose a plan in TackQuote under Profile > Billing & Plan; sync resumes within the hour.', 'tackquote' ); } if ( 401 === (int) ( $block['status'] ?? 0 ) ) { return __( 'TackQuote does not accept the API key saved here (it may have been revoked). Create a new key in TackQuote under Settings > API keys with the orders:write scope and save it in TackQuote settings on this site.', 'tackquote' ); @@ -217,7 +290,7 @@ private static function stored_for_key( $api_key ) { return null; } if ( ( $block['key'] ?? '' ) !== self::key_fingerprint( $api_key ) ) { - self::clear(); + self::lift( $block ); return null; } return $block; @@ -244,7 +317,7 @@ private static function key_fingerprint( $api_key ) { private static function wait_seconds( $data, $now ) { $wait = 0; $header = is_array( $data ) && isset( $data['retryAfterHeader'] ) ? trim( (string) $data['retryAfterHeader'] ) : ''; - if ( '' !== $header && ctype_digit( $header ) ) { + if ( '' !== $header && preg_match( '/^\d+$/', $header ) ) { $wait = (int) $header; } elseif ( '' !== $header ) { $when = strtotime( $header ); diff --git a/readme.txt b/readme.txt index c6b80b4..5fa3566 100755 --- a/readme.txt +++ b/readme.txt @@ -238,6 +238,11 @@ So shoppers can add multiple products before requesting one combined quote. Use == Changelog == += Unreleased = +* **Order sync stops when TackQuote refuses the API key, and tells you why.** If the key saved in TackQuote settings lacks the `orders:write` scope, has been revoked, or your TackQuote subscription is inactive, TackQuote refuses every order. The plugin used to try again on every order change, which on a busy store meant a refused request every few seconds, and the reason only appeared in WooCommerce > Status > Logs. It now stops sending, shows an error notice in wp-admin that names the missing scope or the billing step, and checks again at most once an hour. Saving a different key resumes at once. +* When order sync resumes, orders changed while it was refused are sent automatically (up to 200 of the oldest at a time; any beyond that are sent when they next change). +* When TackQuote asks the plugin to slow down (HTTP 429), the plugin waits for the time TackQuote names, up to one hour. A firewall or challenge page in front of TackQuote is treated as temporary and does not stop sync. + = 1.8.1 = * **The "awaiting approval" message no longer says more than TackQuote knows.** TackQuote now answers "awaiting approval" for every quote request made on behalf of a company, so that a shopper typing a company name can no longer learn whether that company is already a customer of the store. The message used to read "Your company registration is awaiting approval by the seller", which is now false for a company that needs no approval. It reads "Request received. If your company account needs approval, we'll email you when it is ready." * After a company request the buyer portal is offered as a link instead of not at all. The automatic redirect still happens only for an individual request; a company shopper may not be able to sign in yet, so they are never dropped onto a login they cannot pass. diff --git a/tests/sync-gate-test.php b/tests/sync-gate-test.php index 83ea864..161e7f6 100644 --- a/tests/sync-gate-test.php +++ b/tests/sync-gate-test.php @@ -100,7 +100,7 @@ function tack_gate_push( $code, $body, $headers = array() ) { null === Tack_Sync_Gate::classify( new WP_Error( 'http_request_failed', 'cURL error 28' ), $now ) ); -$slow = tack_gate_push( 429, wp_json_encode( array( 'statusCode' => 429, 'code' => 'insufficient_scope', 'message' => 'slow down', 'retryAfterSeconds' => 120 ) ), array( 'retry-after' => '120' ) ); +$slow = tack_gate_push( 429, wp_json_encode( array( 'statusCode' => 429, 'code' => 'TOO_MANY_REQUESTS', 'message' => 'slow down', 'retryAfterSeconds' => 120 ) ), array( 'retry-after' => '120' ) ); $b = Tack_Sync_Gate::classify( $slow, $now ); check( 'a 429 is THROTTLED until Retry-After', @@ -115,6 +115,37 @@ function tack_gate_push( $code, $body, $headers = array() ) { is_array( $b ) && $now + Tack_Sync_Gate::MAX_WAIT === $b['until'] ); +// A 429 that is TackQuote's throttle of a scope refusal is still terminal: same +// refusal, louder. Held at least an hour so the notice does not flicker. +$scope429 = tack_gate_push( + 429, + wp_json_encode( array( 'statusCode' => 429, 'code' => 'insufficient_scope', 'message' => 'stop retrying', 'requiredScopes' => array( 'orders:write' ), 'retryAfterSeconds' => 300 ) ), + array( 'retry-after' => '300' ) +); +$b = Tack_Sync_Gate::classify( $scope429, $now ); +check( + 'a 429 carrying insufficient_scope stays TERMINAL', + is_array( $b ) && 'terminal' === $b['kind'] && array( 'orders:write' ) === $b['scopes'], + var_export( $b, true ) +); +check( + '... held for max(Retry-After, TERMINAL_REPROBE)', + is_array( $b ) && $now + Tack_Sync_Gate::TERMINAL_REPROBE === $b['until'] +); + +// Only TackQuote's OWN error body is terminal: JSON whose statusCode matches the +// HTTP status and which carries a non-empty code. +$proxy = tack_gate_push( 403, wp_json_encode( array( 'error' => 'forbidden', 'message' => 'Blocked by policy' ) ) ); +check( 'a 403 with a proxy\'s JSON (no statusCode, no code) is TEMPORARY', null === Tack_Sync_Gate::classify( $proxy, $now ) ); +$mismatch = tack_gate_push( 403, wp_json_encode( array( 'statusCode' => 200, 'code' => 'X', 'message' => 'x' ) ) ); +check( 'a 403 whose body statusCode disagrees is TEMPORARY', null === Tack_Sync_Gate::classify( $mismatch, $now ) ); +$nocode = tack_gate_push( 403, wp_json_encode( array( 'statusCode' => 403, 'code' => '', 'message' => 'x' ) ) ); +check( 'a 403 with an empty code is TEMPORARY', null === Tack_Sync_Gate::classify( $nocode, $now ) ); + +$date429 = tack_gate_push( 429, '{}', array( 'retry-after' => gmdate( 'D, d M Y H:i:s', $now + 90 ) . ' GMT' ) ); +$b = Tack_Sync_Gate::classify( $date429, $now ); +check( 'Retry-After as an HTTP-date is honoured', is_array( $b ) && $now + 90 === $b['until'], var_export( $b, true ) ); + // ── The gate: what stops the storm ────────────────────────────────────────── Tack_Sync_Gate::clear(); check( 'no block before any refusal', null === Tack_Sync_Gate::active_block( $key, $now ) ); @@ -148,8 +179,96 @@ function tack_gate_push( $code, $body, $headers = array() ) { check( 'a temporary failure does not block the next push', null === Tack_Sync_Gate::active_block( $key, $now + 1 ) ); Tack_Sync_Gate::record_failure( $err, $key, $now ); +Tack_Sync_Gate::record_failure( $err, $key, $now + 4000 ); +check( + 'a re-recorded block keeps the time pushes STARTED being held', + $now === ( get_option( Tack_Sync_Gate::OPTION )['since'] ?? null ) +); +$GLOBALS['TACK_DONE_ACTIONS'] = array(); Tack_Sync_Gate::record_success(); check( 'a successful push clears the block', null === get_option( Tack_Sync_Gate::OPTION, null ) ); +check( + '... and announces the unblock with that start time (drives the re-queue)', + array( array( Tack_Sync_Gate::UNBLOCKED_ACTION, array( $now ) ) ) === $GLOBALS['TACK_DONE_ACTIONS'], + var_export( $GLOBALS['TACK_DONE_ACTIONS'], true ) +); + +$GLOBALS['TACK_DONE_ACTIONS'] = array(); +Tack_Sync_Gate::record_success(); +check( 'a success with no block announces nothing', array() === $GLOBALS['TACK_DONE_ACTIONS'] ); + +Tack_Sync_Gate::record_failure( $err, $key, $now ); +Tack_Sync_Gate::on_api_key_changed( $key, 'tk_live_cccccccccccccccccccccccccccccccc' ); +check( + 'saving a different key lifts the block at once and announces it', + null === get_option( Tack_Sync_Gate::OPTION, null ) + && array( array( Tack_Sync_Gate::UNBLOCKED_ACTION, array( $now ) ) ) === $GLOBALS['TACK_DONE_ACTIONS'] +); +$GLOBALS['TACK_DONE_ACTIONS'] = array(); +Tack_Sync_Gate::record_failure( $err, $key, $now ); +Tack_Sync_Gate::on_api_key_changed( $key, $key ); +check( 're-saving the SAME key does not lift it', is_array( get_option( Tack_Sync_Gate::OPTION, null ) ) ); +Tack_Sync_Gate::clear(); + +// ── The re-queue that follows an unblock ──────────────────────────────────── +require_once TACK_QUOTES_DIR . 'includes/class-tack-order-sync.php'; +require_once __DIR__ . '/wc-stubs.php'; +tack_test_set_option( 'tack_quotes_enable_order_sync', 'yes' ); +$sync = new Tack_Order_Sync(); +$GLOBALS['TACK_HOOKS'] = array(); +$sync->register_worker(); +$hooked = array_column( $GLOBALS['TACK_HOOKS'], 'hook' ); +check( + 'register_worker wires the unblock, the re-queue job and the key-change hook', + in_array( Tack_Sync_Gate::UNBLOCKED_ACTION, $hooked, true ) + && in_array( Tack_Order_Sync::REQUEUE_HOOK, $hooked, true ) + && in_array( 'update_option_tack_quotes_api_key', $hooked, true ), + implode( ', ', $hooked ) +); +$GLOBALS['TACK_AS_ENQUEUED'] = array(); +$GLOBALS['TACK_WC_ORDERS_Q'] = array(); +$GLOBALS['TACK_WC_ORDER_IDS'] = array( 11, 12 ); +$since = time() - 600; +$sync->schedule_requeue( $since ); +check( + 'an unblock schedules ONE re-queue job off the request', + array( array( Tack_Order_Sync::REQUEUE_HOOK, array( $since ), Tack_Order_Sync::SYNC_GROUP, true ) ) === $GLOBALS['TACK_AS_ENQUEUED'] +); +$GLOBALS['TACK_AS_ENQUEUED'] = array(); +$sync->requeue_unsynced( $since ); +$q = $GLOBALS['TACK_WC_ORDERS_Q'][0] ?? array(); +check( + 'the re-queue asks WooCommerce for orders modified since the block began', + '>' . ( $since - 60 ) === ( $q['date_modified'] ?? null ) && 'ids' === ( $q['return'] ?? null ), + var_export( $q, true ) +); +check( + '... and queues each through the normal worker', + array( array( 11 ), array( 12 ) ) === array_map( + function ( $e ) { + return $e[1]; + }, + array_values( + array_filter( + $GLOBALS['TACK_AS_ENQUEUED'], + function ( $e ) { + return Tack_Order_Sync::SYNC_HOOK === $e[0]; + } + ) + ) + ) +); +$GLOBALS['TACK_WC_ORDERS_Q'] = array(); +$sync->requeue_unsynced( 1 ); +check( + 'it never reaches back more than 30 days', + isset( $GLOBALS['TACK_WC_ORDERS_Q'][0]['date_modified'] ) + && (int) substr( $GLOBALS['TACK_WC_ORDERS_Q'][0]['date_modified'], 1 ) >= time() - Tack_Order_Sync::REQUEUE_MAX_AGE - 5 +); +tack_test_set_option( 'tack_quotes_enable_order_sync', 'no' ); +$GLOBALS['TACK_AS_ENQUEUED'] = array(); +$sync->requeue_unsynced( $since ); +check( 'with order sync switched off, nothing is re-queued', array() === $GLOBALS['TACK_AS_ENQUEUED'] ); // ── What the merchant is told ─────────────────────────────────────────────── Tack_Sync_Gate::record_failure( $err, $key, $now ); @@ -160,6 +279,14 @@ function tack_gate_push( $code, $body, $headers = array() ) { check( 'wp-admin shows an error notice while sync is blocked', false !== strpos( $notice, 'notice notice-error' ) ); check( 'the notice names the missing scope', false !== strpos( $notice, 'orders:write' ) ); check( 'the notice says orders are not being sent', false !== stripos( $notice, 'not being sent' ) ); +check( + 'the notice links the settings page by its registered slug', + false !== strpos( $notice, 'page=' . Tack_Settings::PAGE_SLUG ) +); +check( + 'the lapsed-subscription copy names where to choose a plan (Profile > Billing & Plan)', + false !== strpos( Tack_Sync_Gate::notice_text( array( 'code' => 'SUBSCRIPTION_INACTIVE', 'status' => 403 ) ), 'Profile > Billing & Plan' ) +); $GLOBALS['TACK_CAPS'] = array(); ob_start(); diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 5d895e5..e758d2e 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -669,3 +669,49 @@ function wp_remote_retrieve_header( $r, $h ) { return is_array( $r ) && isset( $r['headers'][ strtolower( $h ) ] ) ? $r['headers'][ strtolower( $h ) ] : ''; } } + +// Recorded so the gate tests can see the unblock event and the re-queue it drives. +$GLOBALS['TACK_DONE_ACTIONS'] = array(); +$GLOBALS['TACK_AS_ENQUEUED'] = array(); +$GLOBALS['TACK_WC_ORDERS_Q'] = array(); +$GLOBALS['TACK_WC_ORDER_IDS'] = array(); + +if ( ! function_exists( 'do_action' ) ) { + /** + * @param string $hook Hook. + * @param mixed ...$args Args. + */ + function do_action( $hook, ...$args ) { + $GLOBALS['TACK_DONE_ACTIONS'][] = array( $hook, $args ); + } +} +if ( ! function_exists( 'as_enqueue_async_action' ) ) { + /** + * Action Scheduler's signature: ( $hook, $args, $group, $unique, $priority ). + * + * @return int + */ + function as_enqueue_async_action( $hook, $args = array(), $group = '', $unique = false, $priority = 10 ) { + $GLOBALS['TACK_AS_ENQUEUED'][] = array( $hook, $args, $group, $unique ); + return count( $GLOBALS['TACK_AS_ENQUEUED'] ); + } +} +if ( ! function_exists( 'wc_get_orders' ) ) { + /** + * @param array $args Query. + * @return array Scripted ids. + */ + function wc_get_orders( $args ) { + $GLOBALS['TACK_WC_ORDERS_Q'][] = $args; + return $GLOBALS['TACK_WC_ORDER_IDS']; + } +} +if ( ! function_exists( 'wc_get_order' ) ) { + /** + * @param int $id Order id. + * @return WC_Order|false + */ + function wc_get_order( $id ) { + return class_exists( 'WC_Order' ) ? new WC_Order( array( 'id' => (int) $id ) ) : false; + } +}