Add HttpTestTrait. Distinguish it from GuzzleTestTrait.
[civicrm-core.git] / Civi / Test / HttpTestTrait.php
CommitLineData
2bee78a3
TO
1<?php
2
3namespace Civi\Test;
4
5use GuzzleHttp\HandlerStack;
6use 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 */
24trait 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}