Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.72% covered (danger)
1.72%
2 / 116
7.69% covered (danger)
7.69%
1 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Speed_Score_Request
0.88% covered (danger)
0.88%
1 / 114
7.69% covered (danger)
7.69%
1 / 13
906.52
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 generate_cache_id_from_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_active_modules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 jsonSerialize
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 jsonUnserialize
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 cache_prefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 is_pending
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_error
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_success
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 poll_update
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
72
 restart
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 record_history
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Represents a request to generate a pair of speed scores.
4 *
5 * @package automattic/jetpack-boost-speed-score
6 */
7
8namespace Automattic\Jetpack\Boost_Speed_Score;
9
10use Automattic\Jetpack\Boost_Core\Lib\Boost_API;
11use Automattic\Jetpack\Boost_Core\Lib\Cacheable;
12use Automattic\Jetpack\Boost_Core\Lib\Url;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit( 0 );
16}
17
18/**
19 * Class Speed_Score_Request
20 */
21class Speed_Score_Request extends Cacheable {
22    /**
23     * Algorithm to use when defining a hash for the cache.
24     */
25    const CACHE_KEY_HASH_ALGO = 'md5';
26
27    /**
28     * The URL to get the Speed Scores for.
29     *
30     * @var string $url Url to get the Speed Scores for.
31     */
32    private $url;
33
34    /**
35     * Active Jetpack Boost modules.
36     *
37     * @var string[] $active_modules Active modules.
38     */
39    private $active_modules;
40
41    /**
42     * When the Speed Scores request was created, in seconds since epoch.
43     *
44     * @var float $created Speed Scores request creation timestamp.
45     */
46    private $created;
47
48    /**
49     * Current status of the Speed Score request.
50     *
51     * @var string $status Speed Scores request status.
52     */
53    private $status;
54
55    /**
56     * Number of retries attempted.
57     *
58     * @var int $retry_count Number of times this Speed Score request has been retried.
59     */
60    private $retry_count;
61
62    /**
63     * The error returned
64     *
65     * @var array $error Speed Scores error.
66     */
67    private $error;
68
69    /**
70     * Where the Speed Scores request was made from.
71     *
72     * @var string $client A string passed to Speed_Score to identify where the request was made from.
73     */
74    private $client;
75
76    /**
77     * Constructor.
78     *
79     * @param string $url The URL to get the Speed Scores for.
80     * @param array  $active_modules Active modules.
81     * @param null   $created When the Speed Scores request was created, in seconds since epoch.
82     * @param string $status Status of the Speed Scores request.
83     * @param null   $error The Speed Scores error.
84     * @param string $client A string identifying where the request was made from.
85     */
86    public function __construct( $url, $active_modules = array(), $created = null, $status = 'pending', $error = null, $client = null ) {
87        $this->set_cache_id( self::generate_cache_id_from_url( $url ) );
88
89        $this->url            = $url;
90        $this->active_modules = $active_modules;
91        $this->created        = $created === null ? microtime( true ) : $created;
92        $this->status         = $status;
93        $this->error          = $error;
94        $this->client         = $client;
95        $this->retry_count    = 0;
96    }
97
98    /**
99     * Generate the cache ID from the URL.
100     *
101     * @param string $url The URL to get the Speed Scores for.
102     *
103     * @return string
104     */
105    public static function generate_cache_id_from_url( $url ) {
106        return hash( self::CACHE_KEY_HASH_ALGO, $url );
107    }
108
109    /**
110     * Get the list of active performance modules while this request was created.
111     *
112     * @return string[]
113     */
114    public function get_active_modules() {
115        return $this->active_modules;
116    }
117
118    /**
119     * Convert this object to a plain array for JSON serialization.
120     */
121    #[\ReturnTypeWillChange]
122    public function jsonSerialize() {
123        return array(
124            'id'             => $this->get_cache_id(),
125            'url'            => $this->url,
126            'active_modules' => $this->get_active_modules(),
127            'created'        => $this->created,
128            'status'         => $this->status,
129            'error'          => $this->error,
130            'client'         => $this->client,
131            'retry_count'    => $this->retry_count,
132        );
133    }
134
135    /**
136     * This is intended to be the reverse of JsonSerializable->jsonSerialize.
137     *
138     * @param mixed $data The data to turn into an object.
139     *
140     * @return Speed_Score_Request
141     */
142    public static function jsonUnserialize( $data ) {
143        $object = new Speed_Score_Request(
144            $data['url'],
145            $data['active_modules'],
146            $data['created'],
147            $data['status'],
148            $data['error'],
149            $data['client']
150        );
151
152        if ( ! empty( $data['id'] ) ) {
153            $object->set_cache_id( $data['id'] );
154        }
155
156        if ( ! empty( $data['retry_count'] ) ) {
157            $object->retry_count = intval( $data['retry_count'] );
158        }
159
160        return $object;
161    }
162
163    /**
164     * Return the cache prefix.
165     *
166     * @return string
167     */
168    protected static function cache_prefix() {
169        return 'jetpack_boost_speed_scores_';
170    }
171
172    /**
173     * Send a Speed Scores request to the API.
174     *
175     * @return true|\WP_Error True on success, WP_Error on failure.
176     */
177    public function execute() {
178        $response = Boost_API::post(
179            'speed-scores',
180            array(
181                'request_id'     => $this->get_cache_id(),
182                'url'            => Url::normalize( $this->url ),
183                'active_modules' => $this->active_modules,
184                'client'         => $this->client,
185            )
186        );
187
188        if ( is_wp_error( $response ) ) {
189            $this->status = 'error';
190            $this->error  = $response->get_error_message();
191            $this->store();
192
193            return $response;
194        }
195
196        return true;
197    }
198
199    /**
200     * Is this request pending?
201     */
202    public function is_pending() {
203        return 'pending' === $this->status;
204    }
205
206    /**
207     * Did the request fail?
208     */
209    public function is_error() {
210        return 'error' === $this->status;
211    }
212
213    /**
214     * Did the request succeed?
215     */
216    public function is_success() {
217        return 'success' === $this->status;
218    }
219
220    /**
221     * Poll for updates to this Speed Scores request.
222     *
223     * @return true|\WP_Error True on success, WP_Error on failure.
224     */
225    public function poll_update() {
226        $response = Boost_API::get(
227            sprintf(
228                'speed-scores/%s',
229                $this->get_cache_id()
230            )
231        );
232
233        if ( is_wp_error( $response ) ) {
234            // Special case: If the request is not found, restart it.
235            if ( 'not_found' === $response->get_error_code() ) {
236                return $this->restart();
237            }
238
239            return $response;
240        }
241
242        switch ( $response['status'] ) {
243            case 'pending':
244                // The initial job probably failed, dispatch again if so.
245                if ( $this->created <= strtotime( '-15 mins' ) ) {
246                    return $this->restart();
247                }
248                break;
249
250            case 'error':
251                $this->status = 'error';
252                $this->error  = $response['error'];
253                $this->store();
254                break;
255
256            case 'success':
257                $this->status = 'success';
258                $this->store();
259                $this->record_history( $response );
260
261                break;
262
263            default:
264                return new \WP_Error(
265                    'invalid_response',
266                    __(
267                        'Invalid response from WPCOM API while polling for speed scores',
268                        'jetpack-boost-speed-score'
269                    ),
270                    $response
271                );
272        }
273
274        return true;
275    }
276
277    /**
278     * Restart this request; useful when WPCOM doesn't recognize the request or it times out.
279     */
280    private function restart() {
281        // Enforce a maximum number of restarts.
282        if ( $this->retry_count > 2 ) {
283            $this->status = 'error';
284            $this->error  = 'Maximum number of retries exceeded';
285            $this->store();
286
287            return new \WP_Error(
288                'error',
289                $this->error
290            );
291        }
292
293        $result = $this->execute();
294        if ( is_wp_error( $result ) ) {
295            return $result;
296        }
297
298        $this->created = time();
299        ++$this->retry_count;
300        $this->store();
301
302        return true;
303    }
304
305    /**
306     * Save the speed score record to history.
307     *
308     * @param array $response Response from api.
309     */
310    private function record_history( $response ) {
311        $history       = new Speed_Score_History( $this->url );
312        $last_history  = $history->latest();
313        $last_scores   = $last_history ? $last_history['scores'] : null;
314        $last_theme    = $last_history ? $last_history['theme'] : null;
315        $current_theme = wp_get_theme()->get( 'Name' );
316
317        // Only change if there is a difference from last score or the theme changed.
318        if ( $last_scores !== $response['scores'] || $current_theme !== $last_theme ) {
319            $history->push(
320                array(
321                    'timestamp' => time(),
322                    'scores'    => $response['scores'],
323                    'theme'     => $current_theme,
324                )
325            );
326        }
327    }
328}