diff --git a/includes/class-tack-api-client.php b/includes/class-tack-api-client.php index 6769c0d..ef32aca 100755 --- a/includes/class-tack-api-client.php +++ b/includes/class-tack-api-client.php @@ -92,7 +92,33 @@ 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 ), + // 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' ) ) + : 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..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. * @@ -96,6 +113,71 @@ 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' ) ); + + // 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 ); + } } /** @@ -187,10 +269,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 +306,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..6f1ec45 --- /dev/null +++ b/includes/class-tack-sync-gate.php @@ -0,0 +1,334 @@ +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, + ); + + /* + * 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 + $wait; + return $block; + } + + if ( ( 401 === $status || 403 === $status ) && $from_tackquote ) { + $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 ); + // 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. Pushes skipped while it + * stood are re-queued. + */ + public static function record_success() { + $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. + */ + 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; + } + + // 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' ) + . ' ' + . 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 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' ); + } + 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::lift( $block ); + 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 && preg_match( '/^\d+$/', $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/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/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..161e7f6 --- /dev/null +++ b/tests/sync-gate-test.php @@ -0,0 +1,321 @@ +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' => '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', + 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'] +); + +// 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 ) ); + +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_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 ); +$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' ) ); +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(); +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..e758d2e 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,116 @@ 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 ) ] : ''; + } +} + +// 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; + } +} 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', ); /**