3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
19 use MediaWiki\Config\ServiceOptions
;
20 use MediaWiki\Http\HttpRequestFactory
;
21 use MediaWiki\MainConfigNames
;
22 use MediaWiki\Status\Status
;
23 use PHPUnit\Framework\MockObject\MockObject
;
24 use PHPUnit\Framework\TestCase
;
25 use Psr\Http\Message\ResponseInterface
;
26 use Psr\Log\NullLogger
;
27 use Wikimedia\Http\MultiHttpClient
;
30 * Trait for test cases that need to mock HTTP requests.
32 * @stable to use in extensions
37 * @see MediaWikiIntegrationTestCase::setService()
40 * @phpcs:ignore MediaWiki.Commenting.FunctionComment.ObjectTypeHintParam
41 * @param object|callable $service
43 abstract protected function setService( string $name, $service );
46 * Install a mock HttpRequestFactory in MediaWikiServices, for the duration
47 * of the current test case.
49 * @param null|string|array|callable|MWHttpRequest|MultiHttpClient|GuzzleHttp\Client $request
50 * A list of MWHttpRequest to return on consecutive calls to HttpRequestFactory::create().
51 * These MWHttpRequest also represent the desired response.
52 * For convenience, a single MWHttpRequest can be given,
53 * or a callable producing such an MWHttpRequest,
54 * or a string that will be used as the response body of a successful request.
55 * If a MultiHttpClient is given, createMultiClient() is supported.
56 * If a GuzzleHttp\Client is given, createGuzzleClient() is supported.
57 * Array of MultiHttpClient or GuzzleHttp\Client mocks is supported, but not an array
58 * that contains the mix of the two.
59 * If null is given, any call to create(), createMultiClient() or createGuzzleClient()
60 * will cause the test to fail.
62 private function installMockHttp( $request = null ) {
63 $this->setService( 'HttpRequestFactory', function () use ( $request ) {
64 return $this->makeMockHttpRequestFactory( $request );
69 * Return a mock HttpRequestFactory in MediaWikiServices.
71 * @param null|string|array|callable|MWHttpRequest|MultiHttpClient $request A list of
72 * MWHttpRequest to return on consecutive calls to HttpRequestFactory::create().
73 * These MWHttpRequest also represent the desired response.
74 * For convenience, a single MWHttpRequest can be given,
75 * or a callable producing such an MWHttpRequest,
76 * or a string that will be used as the response body of a successful request.
77 * If a MultiHttpClient is given, createMultiClient() is supported.
78 * If a GuzzleHttp\Client is given, createGuzzleClient() is supported.
79 * Array of MultiHttpClient or GuzzleHttp\Client mocks is supported, but not an array
80 * that contains the mix of the two.
81 * If null or a MultiHttpClient is given instead of a MWHttpRequest,
82 * a call to create() will cause the test to fail.
84 * @return HttpRequestFactory
86 private function makeMockHttpRequestFactory( $request = null ) {
87 $options = new ServiceOptions( HttpRequestFactory
::CONSTRUCTOR_OPTIONS
, [
88 MainConfigNames
::HTTPTimeout
=> 1,
89 MainConfigNames
::HTTPConnectTimeout
=> 1,
90 MainConfigNames
::HTTPMaxTimeout
=> 1,
91 MainConfigNames
::HTTPMaxConnectTimeout
=> 1,
92 MainConfigNames
::LocalVirtualHosts
=> [],
93 MainConfigNames
::LocalHTTPProxy
=> false,
96 $failCallback = static function ( /* discard any arguments */ ) {
97 TestCase
::fail( 'method should not be called' );
100 /** @var HttpRequestFactory|MockObject $mockHttpRequestFactory */
101 $mockHttpRequestFactory = $this->getMockBuilder( HttpRequestFactory
::class )
102 ->setConstructorArgs( [ $options, new NullLogger() ] )
103 ->onlyMethods( [ 'create', 'createMultiClient', 'createGuzzleClient' ] )
107 MultiHttpClient
::class => 'createMultiClient',
108 GuzzleHttp\Client
::class => 'createGuzzleClient'
109 ] as $class => $method ) {
110 if ( $request instanceof $class ) {
111 $mockHttpRequestFactory->method( $method )
112 ->willReturn( $request );
113 } elseif ( $this->isArrayOfClass( $class, $request ) ) {
114 $mockHttpRequestFactory->method( $method )
115 ->willReturnOnConsecutiveCalls( ...$request );
117 $mockHttpRequestFactory->method( $method )
118 ->willReturn( $this->createNoOpMock( $class ) );
122 if ( $request === null ) {
123 $mockHttpRequestFactory->method( 'create' )
124 ->willReturnCallback( $failCallback );
125 } elseif ( $request instanceof MultiHttpClient
) {
126 $mockHttpRequestFactory->method( 'create' )
127 ->willReturnCallback( $failCallback );
128 } elseif ( $request instanceof GuzzleHttp\Client
) {
129 $mockHttpRequestFactory->method( 'create' )
130 ->willReturnCallback( $failCallback );
131 } elseif ( $request instanceof MWHttpRequest
) {
132 $mockHttpRequestFactory->method( 'create' )
133 ->willReturn( $request );
134 } elseif ( is_callable( $request ) ) {
135 $mockHttpRequestFactory->method( 'create' )
136 ->willReturnCallback( $request );
137 } elseif ( is_array( $request ) ) {
138 $mockHttpRequestFactory->method( 'create' )
139 ->willReturnOnConsecutiveCalls( ...$request );
140 } elseif ( is_string( $request ) ) {
141 $mockHttpRequestFactory->method( 'create' )
142 ->willReturn( $this->makeFakeHttpRequest( $request ) );
145 return $mockHttpRequestFactory;
149 * Check whether $array is an array where all elements are instances of $class.
151 * @internal to the trait
152 * @param string $class
153 * @param mixed $array
156 private function isArrayOfClass( string $class, $array ): bool {
157 if ( !is_array( $array ) ||
!count( $array ) ) {
160 foreach ( $array as $item ) {
161 if ( !$item instanceof $class ) {
169 * Constructs a fake MWHTTPRequest. The request also represents the desired response.
171 * @note Not all methods on MWHTTPRequest are mocked, calling other methods will
172 * cause the test to fail.
174 * @param string $body The response body.
175 * @param int|StatusValue $responseStatus The response status code. Use 0 to indicate an internal error.
176 * Alternatively, you can provide a configured StatusValue with status code as a value and
177 * whatever warnings or errors you want.
178 * @param string[] $headers Any response headers.
180 * @return MWHttpRequest
182 private function makeFakeHttpRequest(
183 $body = 'Lorem Ipsum',
184 $responseStatus = 200,
187 $mockHttpRequest = $this->createNoOpMock(
188 MWHttpRequest
::class,
189 [ 'execute', 'setCallback', 'isRedirect', 'getFinalUrl',
190 'getResponseHeaders', 'getResponseHeader', 'setHeader',
191 'getStatus', 'getContent'
195 $statusCode = $responseStatus instanceof StatusValue ?
$responseStatus->getValue() : $responseStatus;
196 $mockHttpRequest->method( 'isRedirect' )->willReturn(
197 $statusCode >= 300 && $statusCode < 400
200 $mockHttpRequest->method( 'getFinalUrl' )->willReturn( $headers[ 'Location' ] ??
'' );
202 $mockHttpRequest->method( 'getResponseHeaders' )->willReturn( $headers );
203 $mockHttpRequest->method( 'getResponseHeader' )->willReturnCallback(
204 static function ( $name ) use ( $headers ) {
205 return $headers[$name] ??
null;
209 $dataCallback = null;
210 $mockHttpRequest->method( 'setCallback' )->willReturnCallback(
211 static function ( $callback ) use ( &$dataCallback ) {
212 $dataCallback = $callback;
216 if ( is_int( $responseStatus ) ) {
217 $statusObject = Status
::newGood( $statusCode );
219 if ( $statusCode === 0 ) {
220 $statusObject->fatal( 'http-internal-error' );
221 } elseif ( $statusCode >= 400 ) {
222 $statusObject->fatal( "http-bad-status", $statusCode, $body );
225 $statusObject = Status
::wrap( $responseStatus );
228 $mockHttpRequest->method( 'getContent' )->willReturn( $body );
229 $mockHttpRequest->method( 'getStatus' )->willReturn( $statusCode );
231 $mockHttpRequest->method( 'execute' )->willReturnCallback(
232 function () use ( &$dataCallback, $body, $statusObject ) {
233 if ( $dataCallback ) {
234 $dataCallback( $this, $body );
236 return $statusObject;
240 return $mockHttpRequest;
244 * Construct a fake HTTP request that will result in an HTTP timeout.
246 * @see self::makeFakeHttpRequest
247 * @param string $body
248 * @param string $requestUrl
249 * @return MWHttpRequest
251 private function makeFakeTimeoutRequest(
252 string $body = 'HTTP Timeout',
253 string $requestUrl = 'https://dummy.org'
255 $responseStatus = StatusValue
::newGood( 504 );
256 $responseStatus->fatal( 'http-timed-out', $requestUrl );
257 return $this->makeFakeHttpRequest( $body, $responseStatus, [] );
261 * Constructs a fake MultiHttpClient which will return the given response.
263 * @note Not all methods on MultiHttpClient are mocked, calling other methods will
264 * cause the test to fail.
266 * @param array $responses An array mapping request keys to responses.
267 * Each response may be a string (the response body), or an array with the
268 * following keys (all optional): 'code', 'reason', 'headers', 'body', 'error'.
269 * If the 'response' key is set, the associated value is expected to be the
270 * response array and contain the 'code', 'body', etc fields. This allows
271 * $responses to have the same structure as the return value of runMulti().
273 * @return MultiHttpClient
275 private function makeFakeHttpMultiClient( $responses = [] ) {
276 $mockHttpRequestMulti = $this->createNoOpMock(
277 MultiHttpClient
::class,
278 [ 'run', 'runMulti' ]
281 $mockHttpRequestMulti->method( 'run' )->willReturnCallback(
282 static function ( array $req, array $opts = [] ) use ( $mockHttpRequestMulti ) {
283 return $mockHttpRequestMulti->runMulti( [ $req ], $opts )[0]['response'];
287 $mockHttpRequestMulti->method( 'runMulti' )->willReturnCallback(
288 static function ( array $reqs, array $opts = [] ) use ( $responses ) {
289 foreach ( $reqs as $key => &$req ) {
290 $resp = $responses[$key] ??
[ 'code' => 0, 'error' => 'unknown' ];
292 if ( is_string( $resp ) ) {
293 $resp = [ 'body' => $resp ];
296 if ( isset( $resp['response'] ) ) {
297 // $responses is not just an array of responses,
298 // but a request/response structure.
299 $resp = $resp['response'];
302 $req['response'] = $resp +
[
310 $req['response'][0] = $req['response']['code'];
311 $req['response'][1] = $req['response']['reason'];
312 $req['response'][2] = $req['response']['headers'];
313 $req['response'][3] = $req['response']['body'];
314 $req['response'][4] = $req['response']['error'];
323 return $mockHttpRequestMulti;
327 * Constructs a fake GuzzleHttp\Client which will return the given response.
329 * @note Not all methods on GuzzleHttp\Client are mocked, calling other methods will
330 * cause the test to fail.
332 * @param ResponseInterface|string $response The response to return.
334 * @return GuzzleHttp\Client
336 private function makeFakeGuzzleClient( $response ) {
337 if ( is_string( $response ) ) {
338 $response = new GuzzleHttp\Psr7\
Response( 200, [], $response );
341 $mockHttpClient = $this->createNoOpMock(
342 GuzzleHttp\Client
::class,
343 [ 'request', 'get', 'put', 'post' ]
346 $mockHttpClient->method( 'request' )->willReturn( $response );
347 $mockHttpClient->method( 'get' )->willReturn( $response );
348 $mockHttpClient->method( 'put' )->willReturn( $response );
349 $mockHttpClient->method( 'post' )->willReturn( $response );
351 return $mockHttpClient;