Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.26% covered (warning)
82.26%
51 / 62
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Plan
83.33% covered (warning)
83.33%
50 / 60
78.57% covered (warning)
78.57%
11 / 14
44.69
0.00% covered (danger)
0.00%
0 / 1
 init_hooks
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 get_plan_info_from_wpcom
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 get_plan_info
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 lookup_failed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_lookup_blocker
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 has_jetpack_search_product
n/a
0 / 0
n/a
0 / 0
1
 supports_instant_search
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 supports_search
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 must_upgrade
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 supports_only_classic_search
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 ever_supported_search
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_free_plan
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 update_search_plan_info
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 set_plan_options
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 clear_failed_lookup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * The Search Plan class.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Status;
12use Jetpack_Options;
13use WP_Error;
14
15if ( ! defined( 'ABSPATH' ) ) {
16    exit( 0 );
17}
18
19/**
20 * Manage Search plan information.
21 */
22class Plan {
23    const JETPACK_SEARCH_PLAN_INFO_OPTION_KEY  = 'jetpack_search_plan_info';
24    const JETPACK_SEARCH_EVER_SUPPORTED_SEARCH = 'jetpack_search_ever_supported_search';
25
26    // Held during an implicit lookup and kept if it fails, so a missing plan doesn't trigger a WPCOM request on every page load.
27    const PLAN_INFO_FETCH_FAILED_TRANSIENT = 'jetpack_search_plan_info_fetch_failed';
28    const PLAN_INFO_FETCH_FAILED_TTL       = HOUR_IN_SECONDS;
29
30    // The pricing update starting from August 2022.
31    const JETPACK_SEARCH_NEW_PRICING_VERSION = '202208';
32    const JETPACK_SEARCH_FREE_PRODUCT_SLUG   = 'jetpack_search_free';
33
34    /**
35     * Whether we have hooked the actions.
36     *
37     * @var boolean
38     */
39    protected static $update_plan_hook_initialized = false;
40
41    /**
42     * Init hooks for updating plan info
43     */
44    public function init_hooks() {
45        // Update plan info from WPCOM on Jetpack heartbeat.
46        // TODO: implement heartbeart for search.
47        if ( ! static::$update_plan_hook_initialized ) {
48            add_action( 'jetpack_heartbeat', array( $this, 'get_plan_info_from_wpcom' ) );
49            // A new blog token (reconnection, re-registration or a manual fix) may resolve the failed lookup.
50            add_action( 'add_option_jetpack_private_options', array( $this, 'clear_failed_lookup' ) );
51            add_action( 'update_option_jetpack_private_options', array( $this, 'clear_failed_lookup' ) );
52            static::$update_plan_hook_initialized = true;
53        }
54    }
55
56    /**
57     * Refresh stored plan info from WordPress.com when online.
58     *
59     * @return array|WP_Error HTTP response, or an error when offline, unregistered, or the request could not be sent.
60     */
61    public function get_plan_info_from_wpcom() {
62        $error = $this->get_lookup_blocker();
63        if ( $error ) {
64            return $error;
65        }
66        $response = Client::wpcom_json_api_request_as_blog(
67            '/sites/' . Jetpack_Options::get_option( 'id' ) . '/jetpack-search/plan',
68            '2',
69            array(),
70            null,
71            'wpcom'
72        );
73
74        // store plan in options.
75        $this->update_search_plan_info( $response );
76
77        return $response;
78    }
79
80    /**
81     * Get plan info.
82     *
83     * A missing plan is fetched implicitly, unless a lookup failed within the last PLAN_INFO_FETCH_FAILED_TTL.
84     *
85     * @param bool $force_refresh - Default to false. Set true to attempt a refresh via get_plan_info_from_wpcom().
86     */
87    public function get_plan_info( $force_refresh = false ) {
88        if ( $force_refresh ) {
89            $this->get_plan_info_from_wpcom();
90        }
91        $plan_info = get_option( self::JETPACK_SEARCH_PLAN_INFO_OPTION_KEY );
92        if ( false === $plan_info && ! $force_refresh && ! $this->get_lookup_blocker() && ! $this->lookup_failed() ) {
93            // Set before the request so concurrent page loads don't each send one; a stored plan clears it.
94            set_transient( self::PLAN_INFO_FETCH_FAILED_TRANSIENT, true, self::PLAN_INFO_FETCH_FAILED_TTL );
95            $plan_info = $this->get_plan_info( true );
96        }
97        return $plan_info;
98    }
99
100    /**
101     * Whether an implicit lookup failed (or is in flight) within the last PLAN_INFO_FETCH_FAILED_TTL.
102     *
103     * @return bool
104     */
105    public function lookup_failed() {
106        return (bool) get_transient( self::PLAN_INFO_FETCH_FAILED_TRANSIENT );
107    }
108
109    /**
110     * Why no plan request can be sent, if any.
111     *
112     * @return WP_Error|null Error when offline or unregistered, null otherwise.
113     */
114    private function get_lookup_blocker() {
115        if ( ( new Status() )->is_offline_mode() ) {
116            return new WP_Error( 'site_offline', 'Site is in offline mode.' );
117        }
118        // An unregistered site (or a stale cache view hiding the registration) has no
119        // blog ID: bail rather than requesting the malformed `/sites//…` path.
120        if ( ! Jetpack_Options::get_option( 'id' ) ) {
121            return new WP_Error( 'site_not_registered', 'Site not registered.' );
122        }
123        return null;
124    }
125
126    /**
127     * Please use `supports_instant_search` instead.
128     *
129     * @deprecated
130     */
131    public function has_jetpack_search_product() {
132        return (bool) get_option( 'has_jetpack_search_product' );
133    }
134
135    /**
136     * Returns true if plan supports Instant Search.
137     */
138    public function supports_instant_search() {
139        $plan_info = $this->get_plan_info();
140        return ( isset( $plan_info['supports_instant_search'] ) && $plan_info['supports_instant_search'] );
141    }
142
143    /**
144     * Returns true if the plan support either Instant Search or Classic Search.
145     */
146    public function supports_search() {
147        $plan_info = $this->get_plan_info();
148        return ( isset( $plan_info['supports_search'] ) && $plan_info['supports_search'] );
149    }
150
151    /**
152     * Returns true if the plan usage is exceeded and search should no longer work.
153     */
154    public function must_upgrade() {
155        $plan_info = $this->get_plan_info();
156        return isset( $plan_info['plan_usage']['must_upgrade'] ) && $plan_info['plan_usage']['must_upgrade'];
157    }
158
159    /**
160     * Returns true if the plan only supports Classic Search.
161     */
162    public function supports_only_classic_search() {
163        $plan_info = $this->get_plan_info();
164        return isset( $plan_info['supports_only_classic_search'] ) && $plan_info['supports_only_classic_search'];
165    }
166
167    /**
168     * Whether the plan(s) ever supported search.
169     */
170    public function ever_supported_search() {
171        return (bool) get_option( self::JETPACK_SEARCH_EVER_SUPPORTED_SEARCH ) || $this->supports_search();
172    }
173
174    /**
175     * Returns true if the site is on free plan.
176     */
177    public function is_free_plan() {
178        $plan_info = $this->get_plan_info();
179        return Helper::is_forced_free_plan() || ( isset( $plan_info['effective_subscription']['product_slug'] ) && $plan_info['effective_subscription']['product_slug'] === self::JETPACK_SEARCH_FREE_PRODUCT_SLUG );
180    }
181
182    /**
183     * Update `has_jetpack_search_product` regarding the plan information
184     *
185     * @param array|WP_Error $response - Resopnse from WPCOM.
186     * @return bool - true on success, false on failure.
187     */
188    public function update_search_plan_info( $response ) {
189        if ( is_wp_error( $response ) ) {
190            return false;
191        }
192        $body        = json_decode( wp_remote_retrieve_body( $response ), true );
193        $status_code = wp_remote_retrieve_response_code( $response );
194
195        if ( 200 !== $status_code ) {
196            return false;
197        }
198
199        return $this->set_plan_options( $body );
200    }
201
202    /**
203     * Set plan info to options table
204     *
205     * @param array $plan_info - the decoded plan info array.
206     */
207    public function set_plan_options( $plan_info ) {
208        if ( isset( $plan_info['swap_classic_to_inline_search'] ) && is_bool( $plan_info['swap_classic_to_inline_search'] ) ) {
209            update_option( Module_Control::SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY, (bool) $plan_info['swap_classic_to_inline_search'] );
210        }
211        if ( ! isset( $plan_info['supports_instant_search'] ) ) {
212            return false;
213        }
214        // set option whether has Jetpack Search plan for capability reason.
215        if ( get_option( 'has_jetpack_search_product' ) !== (bool) $plan_info['supports_instant_search'] ) {
216            update_option( 'has_jetpack_search_product', (bool) $plan_info['supports_instant_search'] );
217        }
218        // We use this option to determine the visibility of search submenu.
219        // If the site ever had search subscription, then we record it and show the menu after.
220        if ( $plan_info['supports_instant_search'] ) {
221            update_option( self::JETPACK_SEARCH_EVER_SUPPORTED_SEARCH, true, false );
222        }
223        update_option( self::JETPACK_SEARCH_PLAN_INFO_OPTION_KEY, $plan_info );
224        $this->clear_failed_lookup();
225        return true;
226    }
227
228    /**
229     * Let the next implicit lookup reach WPCOM again.
230     */
231    public function clear_failed_lookup() {
232        delete_transient( self::PLAN_INFO_FETCH_FAILED_TRANSIENT );
233    }
234}