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