| 1 | <?php |
| 2 | |
| 3 | namespace Civi\Test; |
| 4 | |
| 5 | use GuzzleHttp\HandlerStack; |
| 6 | use GuzzleHttp\MessageFormatter; |
| 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 | * |
| 37 | * @param array $options |
| 38 | * @return \GuzzleHttp\Client |
| 39 | */ |
| 40 | protected function createGuzzle($options = []) { |
| 41 | $handler = HandlerStack::create(); |
| 42 | $handler->unshift(\CRM_Utils_GuzzleMiddleware::url(), 'civi_url'); |
| 43 | $handler->push(Middleware::history($this->httpHistory), 'history'); |
| 44 | |
| 45 | if (getenv('DEBUG') >= 2) { |
| 46 | $handler->push(Middleware::log(new \CRM_Utils_EchoLogger(), new MessageFormatter(MessageFormatter::DEBUG)), 'log'); |
| 47 | } |
| 48 | elseif (getenv('DEBUG') >= 1) { |
| 49 | $handler->push(\CRM_Utils_GuzzleMiddleware::curlLog(new \CRM_Utils_EchoLogger()), 'curl_log'); |
| 50 | } |
| 51 | |
| 52 | $defaults = [ |
| 53 | 'handler' => $handler, |
| 54 | 'base_uri' => 'auto:', |
| 55 | ]; |
| 56 | |
| 57 | $options = array_merge($defaults, $options); |
| 58 | return new \GuzzleHttp\Client($options); |
| 59 | } |
| 60 | |
| 61 | /** |
| 62 | * @param string $entity |
| 63 | * @param string $action |
| 64 | * @param array $params |
| 65 | * |
| 66 | * @return mixed |
| 67 | */ |
| 68 | protected function callApi4AjaxSuccess(string $entity, string $action, $params = []) { |
| 69 | $method = \CRM_Utils_String::startsWith($action, 'get') ? 'GET' : 'POST'; |
| 70 | $response = $this->createGuzzle()->request($method, "civicrm/ajax/api4/$entity/$action", [ |
| 71 | 'headers' => ['X-Requested-With' => 'XMLHttpRequest'], |
| 72 | // This should probably be 'form_params', but 'query' is more representative of frontend. |
| 73 | 'query' => ['params' => json_encode($params)], |
| 74 | 'http_errors' => FALSE, |
| 75 | ]); |
| 76 | $this->assertContentType('application/json', $response); |
| 77 | $this->assertStatusCode(200, $response); |
| 78 | $result = json_decode((string) $response->getBody(), 1); |
| 79 | if (json_last_error() !== JSON_ERROR_NONE) { |
| 80 | $this->fail("Failed to decode APIv4 JSON.\n" . $this->formatFailure($response)); |
| 81 | } |
| 82 | return $result; |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * @param string $entity |
| 87 | * @param string $action |
| 88 | * @param array $params |
| 89 | * |
| 90 | * @return mixed |
| 91 | */ |
| 92 | protected function callApi4AjaxError(string $entity, string $action, $params = []) { |
| 93 | $method = \CRM_Utils_String::startsWith($action, 'get') ? 'GET' : 'POST'; |
| 94 | $response = $this->createGuzzle()->request($method, "civicrm/ajax/api4/$entity/$action", [ |
| 95 | 'headers' => ['X-Requested-With' => 'XMLHttpRequest'], |
| 96 | // This should probably be 'form_params', but 'query' is more representative of frontend. |
| 97 | 'query' => ['params' => json_encode($params)], |
| 98 | 'http_errors' => FALSE, |
| 99 | ]); |
| 100 | $this->assertContentType('application/json', $response); |
| 101 | $this->assertTrue($response->getStatusCode() >= 400, 'Should return an error' . $this->formatFailure($response)); |
| 102 | $result = json_decode((string) $response->getBody(), 1); |
| 103 | if (json_last_error() !== JSON_ERROR_NONE) { |
| 104 | $this->fail("Failed to decode APIv4 JSON.\n" . $this->formatFailure($response)); |
| 105 | } |
| 106 | return $result; |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * @param $expectCode |
| 111 | * @param \Psr\Http\Message\ResponseInterface|NULL $response |
| 112 | * If NULL, then it uses the last response. |
| 113 | * |
| 114 | * @return $this |
| 115 | */ |
| 116 | protected function assertStatusCode($expectCode, $response = NULL) { |
| 117 | $response = $this->resolveResponse($response); |
| 118 | $actualCode = $response->getStatusCode(); |
| 119 | $fmt = $actualCode === $expectCode ? '' : $this->formatFailure($response); |
| 120 | $this->assertEquals($expectCode, $actualCode, "Expected HTTP response $expectCode. Received HTTP response $actualCode.\n$fmt"); |
| 121 | return $this; |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * @param $expectType |
| 126 | * @param \Psr\Http\Message\ResponseInterface|NULL $response |
| 127 | * If NULL, then it uses the last response. |
| 128 | * |
| 129 | * @return $this |
| 130 | */ |
| 131 | protected function assertContentType($expectType, $response = NULL) { |
| 132 | $response = $this->resolveResponse($response); |
| 133 | list($actualType) = explode(';', $response->getHeader('Content-Type')[0]); |
| 134 | $fmt = $actualType === $expectType ? '' : $this->formatFailure($response); |
| 135 | $this->assertEquals($expectType, $actualType, "Expected content-type $expectType. Received content-type $actualType.\n$fmt"); |
| 136 | return $this; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * @param \Psr\Http\Message\ResponseInterface|NULL $response |
| 141 | * @return \Psr\Http\Message\ResponseInterface |
| 142 | */ |
| 143 | protected function resolveResponse($response) { |
| 144 | return $response ?: $this->httpHistory[count($this->httpHistory) - 1]['response']; |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Given that an HTTP request has yielded a failed response, format a blurb |
| 149 | * to summarize the details of the request+response. |
| 150 | * |
| 151 | * @param \Psr\Http\Message\ResponseInterface $response |
| 152 | * |
| 153 | * @return false|string |
| 154 | */ |
| 155 | protected function formatFailure(\Psr\Http\Message\ResponseInterface $response) { |
| 156 | $details = []; |
| 157 | |
| 158 | $condenseArray = function($v) { |
| 159 | if (is_array($v) && count($v) === 1 && isset($v[0])) { |
| 160 | return $v[0]; |
| 161 | } |
| 162 | else { |
| 163 | return $v; |
| 164 | } |
| 165 | }; |
| 166 | |
| 167 | /** @var \Psr\Http\Message\RequestInterface $request */ |
| 168 | $request = NULL; |
| 169 | foreach ($this->httpHistory as $item) { |
| 170 | if ($item['response'] === $response) { |
| 171 | $request = $item['request']; |
| 172 | break; |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | if ($request) { |
| 177 | $details['request'] = [ |
| 178 | 'uri' => (string) $request->getUri(), |
| 179 | 'method' => $request->getMethod(), |
| 180 | // Most headers only have one item. JSON pretty-printer adds several newlines. This output is meant for dev's reading the error-log. |
| 181 | 'headers' => array_map($condenseArray, $request->getHeaders()), |
| 182 | 'body' => (string) $request->getBody(), |
| 183 | ]; |
| 184 | } |
| 185 | |
| 186 | $details['response'] = [ |
| 187 | 'status' => $response->getStatusCode() . ' ' . $response->getReasonPhrase(), |
| 188 | // Most headers only have one item. JSON pretty-printer adds several newlines. This output is meant for dev's reading the error-log. |
| 189 | 'headers' => array_map($condenseArray, $response->getHeaders()), |
| 190 | 'body' => (string) $response->getBody(), |
| 191 | ]; |
| 192 | |
| 193 | // If we get a full HTML document, then it'll be hard to read the error messages. Give an alternate rendition. |
| 194 | if (preg_match(';\<(!DOCTYPE|HTML);', $details['response']['body'])) { |
| 195 | // $details['bodyText'] = strip_tags($details['body']); // too much <style> noise |
| 196 | $details['response']['body'] = \CRM_Utils_String::htmlToText($details['response']['body']); |
| 197 | } |
| 198 | |
| 199 | return json_encode($details, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 200 | } |
| 201 | |
| 202 | } |