Commit | Line | Data |
---|---|---|
2bee78a3 TO |
1 | <?php |
2 | ||
3 | namespace Civi\Test; | |
4 | ||
5 | use GuzzleHttp\HandlerStack; | |
12b478ef | 6 | use GuzzleHttp\MessageFormatter; |
2bee78a3 TO |
7 | use GuzzleHttp\Middleware; |
8 | ||
9 | /** | |
10 | * Class HttpTestTrait | |
11 | * | |
12 | * @package Civi\Test | |
13 | * | |
14 | * This trait provides helpers/assertions for testing Civi's HTTP interfaces, eg | |
15 | * | |
16 | * - createGuzzle() - Create HTTP client for sending requests to Civi. | |
17 | * - callApi4AjaxSuccess() - Use an HTTP client to send an AJAX-style request to APIv4 | |
18 | * - callApi4AjaxError() - Use an HTTP client to send an AJAX-style request to APIv4 | |
19 | * - assertStatusCode() - Check the status code. If it fails, output detailed information. | |
20 | * - assertContentType() - Check the content-type. If it fails, output detailed information. | |
21 | * | |
22 | * Use this in an E2E test for which you need to send inbound HTTP requests to Civi. | |
23 | * Alternatively, for a headless test which mocks outbound HTTP, see GuzzleTestTrait. | |
24 | */ | |
25 | trait HttpTestTrait { | |
26 | ||
27 | /** | |
28 | * List of HTTP requests that have been made by this test. | |
29 | * | |
30 | * @var array | |
31 | */ | |
32 | protected $httpHistory = []; | |
33 | ||
34 | /** | |
35 | * Create an HTTP client suitable for simulating AJAX requests. | |
36 | * | |
ee4d3a2d TO |
37 | * The client may include some mix of these middlewares: |
38 | * | |
39 | * @see \CRM_Utils_GuzzleMiddleware::authx() | |
40 | * @see \CRM_Utils_GuzzleMiddleware::url() | |
41 | * @see \CRM_Utils_GuzzleMiddleware::curlLog() | |
42 | * @see Middleware::history() | |
43 | * @see Middleware::log() | |
44 | * | |
2bee78a3 TO |
45 | * @param array $options |
46 | * @return \GuzzleHttp\Client | |
47 | */ | |
48 | protected function createGuzzle($options = []) { | |
49 | $handler = HandlerStack::create(); | |
ee4d3a2d | 50 | $handler->unshift(\CRM_Utils_GuzzleMiddleware::authx(), 'civi_authx'); |
12b478ef | 51 | $handler->unshift(\CRM_Utils_GuzzleMiddleware::url(), 'civi_url'); |
2bee78a3 TO |
52 | $handler->push(Middleware::history($this->httpHistory), 'history'); |
53 | ||
12b478ef TO |
54 | if (getenv('DEBUG') >= 2) { |
55 | $handler->push(Middleware::log(new \CRM_Utils_EchoLogger(), new MessageFormatter(MessageFormatter::DEBUG)), 'log'); | |
56 | } | |
57 | elseif (getenv('DEBUG') >= 1) { | |
58 | $handler->push(\CRM_Utils_GuzzleMiddleware::curlLog(new \CRM_Utils_EchoLogger()), 'curl_log'); | |
59 | } | |
60 | ||
2bee78a3 TO |
61 | $defaults = [ |
62 | 'handler' => $handler, | |
63 | 'base_uri' => 'auto:', | |
64 | ]; | |
65 | ||
66 | $options = array_merge($defaults, $options); | |
67 | return new \GuzzleHttp\Client($options); | |
68 | } | |
69 | ||
70 | /** | |
71 | * @param string $entity | |
72 | * @param string $action | |
73 | * @param array $params | |
74 | * | |
75 | * @return mixed | |
76 | */ | |
77 | protected function callApi4AjaxSuccess(string $entity, string $action, $params = []) { | |
78 | $method = \CRM_Utils_String::startsWith($action, 'get') ? 'GET' : 'POST'; | |
79 | $response = $this->createGuzzle()->request($method, "civicrm/ajax/api4/$entity/$action", [ | |
80 | 'headers' => ['X-Requested-With' => 'XMLHttpRequest'], | |
81 | // This should probably be 'form_params', but 'query' is more representative of frontend. | |
82 | 'query' => ['params' => json_encode($params)], | |
83 | 'http_errors' => FALSE, | |
84 | ]); | |
85 | $this->assertContentType('application/json', $response); | |
86 | $this->assertStatusCode(200, $response); | |
87 | $result = json_decode((string) $response->getBody(), 1); | |
88 | if (json_last_error() !== JSON_ERROR_NONE) { | |
89 | $this->fail("Failed to decode APIv4 JSON.\n" . $this->formatFailure($response)); | |
90 | } | |
91 | return $result; | |
92 | } | |
93 | ||
94 | /** | |
95 | * @param string $entity | |
96 | * @param string $action | |
97 | * @param array $params | |
98 | * | |
99 | * @return mixed | |
100 | */ | |
101 | protected function callApi4AjaxError(string $entity, string $action, $params = []) { | |
102 | $method = \CRM_Utils_String::startsWith($action, 'get') ? 'GET' : 'POST'; | |
103 | $response = $this->createGuzzle()->request($method, "civicrm/ajax/api4/$entity/$action", [ | |
104 | 'headers' => ['X-Requested-With' => 'XMLHttpRequest'], | |
105 | // This should probably be 'form_params', but 'query' is more representative of frontend. | |
106 | 'query' => ['params' => json_encode($params)], | |
107 | 'http_errors' => FALSE, | |
108 | ]); | |
109 | $this->assertContentType('application/json', $response); | |
110 | $this->assertTrue($response->getStatusCode() >= 400, 'Should return an error' . $this->formatFailure($response)); | |
111 | $result = json_decode((string) $response->getBody(), 1); | |
112 | if (json_last_error() !== JSON_ERROR_NONE) { | |
113 | $this->fail("Failed to decode APIv4 JSON.\n" . $this->formatFailure($response)); | |
114 | } | |
115 | return $result; | |
116 | } | |
117 | ||
118 | /** | |
119 | * @param $expectCode | |
120 | * @param \Psr\Http\Message\ResponseInterface|NULL $response | |
121 | * If NULL, then it uses the last response. | |
122 | * | |
123 | * @return $this | |
124 | */ | |
125 | protected function assertStatusCode($expectCode, $response = NULL) { | |
126 | $response = $this->resolveResponse($response); | |
127 | $actualCode = $response->getStatusCode(); | |
128 | $fmt = $actualCode === $expectCode ? '' : $this->formatFailure($response); | |
129 | $this->assertEquals($expectCode, $actualCode, "Expected HTTP response $expectCode. Received HTTP response $actualCode.\n$fmt"); | |
130 | return $this; | |
131 | } | |
132 | ||
133 | /** | |
134 | * @param $expectType | |
135 | * @param \Psr\Http\Message\ResponseInterface|NULL $response | |
136 | * If NULL, then it uses the last response. | |
137 | * | |
138 | * @return $this | |
139 | */ | |
140 | protected function assertContentType($expectType, $response = NULL) { | |
141 | $response = $this->resolveResponse($response); | |
aa82ca9f | 142 | list($actualType) = explode(';', $response->getHeader('Content-Type')[0]); |
2bee78a3 | 143 | $fmt = $actualType === $expectType ? '' : $this->formatFailure($response); |
88b1511c | 144 | $this->assertEquals($expectType, $actualType, "Expected content-type $expectType. Received content-type $actualType.\n$fmt"); |
2bee78a3 TO |
145 | return $this; |
146 | } | |
147 | ||
148 | /** | |
149 | * @param \Psr\Http\Message\ResponseInterface|NULL $response | |
150 | * @return \Psr\Http\Message\ResponseInterface | |
151 | */ | |
152 | protected function resolveResponse($response) { | |
153 | return $response ?: $this->httpHistory[count($this->httpHistory) - 1]['response']; | |
154 | } | |
155 | ||
156 | /** | |
157 | * Given that an HTTP request has yielded a failed response, format a blurb | |
158 | * to summarize the details of the request+response. | |
159 | * | |
160 | * @param \Psr\Http\Message\ResponseInterface $response | |
161 | * | |
162 | * @return false|string | |
163 | */ | |
164 | protected function formatFailure(\Psr\Http\Message\ResponseInterface $response) { | |
165 | $details = []; | |
166 | ||
167 | $condenseArray = function($v) { | |
168 | if (is_array($v) && count($v) === 1 && isset($v[0])) { | |
169 | return $v[0]; | |
170 | } | |
171 | else { | |
172 | return $v; | |
173 | } | |
174 | }; | |
175 | ||
176 | /** @var \Psr\Http\Message\RequestInterface $request */ | |
177 | $request = NULL; | |
178 | foreach ($this->httpHistory as $item) { | |
179 | if ($item['response'] === $response) { | |
180 | $request = $item['request']; | |
181 | break; | |
182 | } | |
183 | } | |
184 | ||
185 | if ($request) { | |
186 | $details['request'] = [ | |
187 | 'uri' => (string) $request->getUri(), | |
188 | 'method' => $request->getMethod(), | |
189 | // Most headers only have one item. JSON pretty-printer adds several newlines. This output is meant for dev's reading the error-log. | |
190 | 'headers' => array_map($condenseArray, $request->getHeaders()), | |
191 | 'body' => (string) $request->getBody(), | |
192 | ]; | |
193 | } | |
194 | ||
195 | $details['response'] = [ | |
196 | 'status' => $response->getStatusCode() . ' ' . $response->getReasonPhrase(), | |
197 | // Most headers only have one item. JSON pretty-printer adds several newlines. This output is meant for dev's reading the error-log. | |
198 | 'headers' => array_map($condenseArray, $response->getHeaders()), | |
199 | 'body' => (string) $response->getBody(), | |
200 | ]; | |
201 | ||
202 | // If we get a full HTML document, then it'll be hard to read the error messages. Give an alternate rendition. | |
203 | if (preg_match(';\<(!DOCTYPE|HTML);', $details['response']['body'])) { | |
204 | // $details['bodyText'] = strip_tags($details['body']); // too much <style> noise | |
205 | $details['response']['body'] = \CRM_Utils_String::htmlToText($details['response']['body']); | |
206 | } | |
207 | ||
208 | return json_encode($details, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); | |
209 | } | |
210 | ||
211 | } |