Browse Source

Merge pull request #15732 from cakephp/http-client-mock

4.next - Start Http client mock
Mark Story 4 years ago
parent
commit
d4fc4c8b36
3 changed files with 371 additions and 1 deletions
  1. 53 1
      src/Http/Client.php
  2. 129 0
      src/Http/Client/Adapter/Mock.php
  3. 189 0
      tests/TestCase/Http/ClientTest.php

+ 53 - 1
src/Http/Client.php

@@ -19,6 +19,7 @@ use Cake\Core\App;
 use Cake\Core\Exception\CakeException;
 use Cake\Core\InstanceConfigTrait;
 use Cake\Http\Client\Adapter\Curl;
+use Cake\Http\Client\Adapter\Mock as MockAdapter;
 use Cake\Http\Client\Adapter\Stream;
 use Cake\Http\Client\AdapterInterface;
 use Cake\Http\Client\Request;
@@ -135,6 +136,13 @@ class Client implements ClientInterface
     protected $_cookies;
 
     /**
+     * Mock adapter for stubbing requests in tests.
+     *
+     * @var \Cake\Http\Client\Adapter\Mock|null
+     */
+    protected static $_mockAdapter;
+
+    /**
      * Adapter for sending requests.
      *
      * @var \Cake\Http\Client\AdapterInterface
@@ -489,6 +497,45 @@ class Client implements ClientInterface
     }
 
     /**
+     * Clear all mocked responses
+     *
+     * @return void
+     */
+    public static function clearMockResponses(): void
+    {
+        static::$_mockAdapter = null;
+    }
+
+    /**
+     * Add a mocked response.
+     *
+     * Mocked responses are stored in an adapter that is called
+     * _before_ the network adapter is called.
+     *
+     * ### Matching Requests
+     *
+     * TODO finish this.
+     *
+     * ### Options
+     *
+     * - `match` An additional closure to match requests with.
+     *
+     * @param string $method The HTTP method being mocked.
+     * @param string $url The URL being matched. See above for examples.
+     * @param \Cake\Http\Client\Response $response The response that matches the request.
+     * @param array $options See above.
+     * @return void
+     */
+    public static function addMockResponse(string $method, string $url, Response $response, array $options = []): void
+    {
+        if (!static::$_mockAdapter) {
+            static::$_mockAdapter = new MockAdapter();
+        }
+        $request = new Request($url, $method);
+        static::$_mockAdapter->addResponse($request, $response, $options);
+    }
+
+    /**
      * Send a request without redirection.
      *
      * @param \Psr\Http\Message\RequestInterface $request The request to send.
@@ -497,7 +544,12 @@ class Client implements ClientInterface
      */
     protected function _sendRequest(RequestInterface $request, array $options): Response
     {
-        $responses = $this->_adapter->send($request, $options);
+        if (static::$_mockAdapter) {
+            $responses = static::$_mockAdapter->send($request, $options);
+        }
+        if (empty($responses)) {
+            $responses = $this->_adapter->send($request, $options);
+        }
         foreach ($responses as $response) {
             $this->_cookies = $this->_cookies->addFromResponse($response, $request);
         }

+ 129 - 0
src/Http/Client/Adapter/Mock.php

@@ -0,0 +1,129 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.3.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Http\Client\Adapter;
+
+use Cake\Http\Client\AdapterInterface;
+use Cake\Http\Client\Response;
+use Closure;
+use InvalidArgumentException;
+use Psr\Http\Message\RequestInterface;
+
+/**
+ * Implements sending requests to an array of stubbed responses
+ *
+ * This adapter is not intended for production use. Instead
+ * it is the backend used by `Client::addMockResponse()`
+ */
+class Mock implements AdapterInterface
+{
+    /**
+     * List of mocked responses.
+     *
+     * @var array
+     */
+    protected $responses = [];
+
+    /**
+     * Add a mocked response.
+     *
+     * ### Options
+     *
+     * - `match` An additional closure to match requests with.
+     *
+     * @param \Psr\Http\Message\RequestInterface $request A partial request to use for matching.
+     * @param \Cake\Http\Client\Response $response The response that matches the request.
+     * @param array $options See above.
+     * @return void
+     */
+    public function addResponse(RequestInterface $request, Response $response, array $options): void
+    {
+        if (isset($options['match']) && !($options['match'] instanceof Closure)) {
+            $type = getTypeName($options['match']);
+            throw new InvalidArgumentException("The `match` option must be a `Closure`. Got `{$type}`.");
+        }
+        $this->responses[] = [
+            'request' => $request,
+            'response' => $response,
+            'options' => $options,
+        ];
+    }
+
+    /**
+     * Find a response if one exists.
+     *
+     * @param \Psr\Http\Message\RequestInterface $request The request to match
+     * @param array $options Unused.
+     * @return \Cake\Http\Client\Response[] The matched response or an empty array for no matches.
+     */
+    public function send(RequestInterface $request, array $options): array
+    {
+        $found = null;
+        foreach ($this->responses as $index => $mock) {
+            if ($request->getMethod() !== $mock['request']->getMethod()) {
+                continue;
+            }
+            if (!$this->urlMatches($request, $mock['request'])) {
+                continue;
+            }
+            if (isset($mock['options']['match'])) {
+                $match = $mock['options']['match']($request);
+                if (!is_bool($match)) {
+                    throw new InvalidArgumentException('Match callback must return a boolean value.');
+                }
+                if (!$match) {
+                    continue;
+                }
+            }
+            $found = $index;
+            break;
+        }
+        if ($found !== null) {
+            // Move the current mock to the end so that when there are multiple
+            // matches for a URL the next match is used on subsequent requests.
+            $mock = $this->responses[$found];
+            unset($this->responses[$found]);
+            $this->responses[] = $mock;
+
+            return [$mock['response']];
+        }
+
+        return [];
+    }
+
+    /**
+     * Check if the request URI matches the mock URI.
+     *
+     * @param \Psr\Http\Message\RequestInterface $request The request being sent.
+     * @param \Psr\Http\Message\RequestInterface $mock The request being mocked.
+     * @return bool
+     */
+    protected function urlMatches(RequestInterface $request, RequestInterface $mock): bool
+    {
+        $requestUri = (string)$request->getUri();
+        $mockUri = (string)$mock->getUri();
+        if ($requestUri === $mockUri) {
+            return true;
+        }
+        $starPosition = strrpos($mockUri, '/%2A');
+        if ($starPosition === strlen($mockUri) - 4) {
+            $mockUri = substr($mockUri, 0, $starPosition);
+
+            return strpos($requestUri, $mockUri) === 0;
+        }
+
+        return false;
+    }
+}

+ 189 - 0
tests/TestCase/Http/ClientTest.php

@@ -29,6 +29,13 @@ use InvalidArgumentException;
  */
 class ClientTest extends TestCase
 {
+    public function tearDown(): void
+    {
+        parent::tearDown();
+
+        Client::clearMockResponses();
+    }
+
     /**
      * Test storing config options and modifying them.
      */
@@ -979,4 +986,186 @@ class ClientTest extends TestCase
         ];
         $this->assertSame($expected, $config);
     }
+
+    /**
+     * Test adding and sending to a mocked URL.
+     */
+    public function testAddMockResponseSimpleMatch(): void
+    {
+        $stub = new Response(['HTTP/1.0 200'], 'hello world');
+        Client::addMockResponse('POST', 'http://example.com/path', $stub);
+
+        $client = new Client();
+        $response = $client->post('http://example.com/path');
+        $this->assertSame($stub, $response);
+    }
+
+    /**
+     * When there are multiple matches for a URL the responses should
+     * be used in a cycle.
+     */
+    public function testAddMockResponseMultipleMatches(): void
+    {
+        $one = new Response(['HTTP/1.0 200'], 'one');
+        Client::addMockResponse('GET', 'http://example.com/info', $one);
+
+        $two = new Response(['HTTP/1.0 200'], 'two');
+        Client::addMockResponse('GET', 'http://example.com/info', $two);
+
+        $client = new Client();
+
+        $response = $client->get('http://example.com/info');
+        $this->assertSame($one, $response);
+
+        $response = $client->get('http://example.com/info');
+        $this->assertSame($two, $response);
+
+        $response = $client->get('http://example.com/info');
+        $this->assertSame($one, $response);
+    }
+
+    /**
+     * When there are multiple matches with custom match functions
+     */
+    public function testAddMockResponseMultipleMatchesCustom(): void
+    {
+        $one = new Response(['HTTP/1.0 200'], 'one');
+        Client::addMockResponse('GET', 'http://example.com/info', $one, [
+            'match' => function ($request) {
+                return false;
+            },
+        ]);
+
+        $two = new Response(['HTTP/1.0 200'], 'two');
+        Client::addMockResponse('GET', 'http://example.com/info', $two);
+
+        $client = new Client();
+
+        $response = $client->get('http://example.com/info');
+        $this->assertSame($two, $response);
+
+        $response = $client->get('http://example.com/info');
+        $this->assertSame($two, $response);
+    }
+
+    /**
+     * Mock match failures should result in the request being sent
+     */
+    public function testAddMockResponseMethodMatchFailure(): void
+    {
+        $stub = new Response(['HTTP/1.0 200'], 'hello world');
+        Client::addMockResponse('POST', 'http://example.com/path', $stub);
+
+        $mock = $this->getMockBuilder(Stream::class)
+            ->onlyMethods(['send'])
+            ->getMock();
+        $mock->expects($this->once())
+            ->method('send')
+            ->will($this->throwException(new InvalidArgumentException('No match')));
+
+        $client = new Client(['adapter' => $mock]);
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('No match');
+
+        $client->get('http://example.com/path');
+    }
+
+    /**
+     * Trailing /* patterns should work
+     */
+    public function testAddMockResponseGlobMatch(): void
+    {
+        $stub = new Response(['HTTP/1.0 200'], 'hello world');
+        Client::addMockResponse('POST', 'http://example.com/path/*', $stub);
+
+        $client = new Client();
+        $response = $client->post('http://example.com/path/more/thing');
+        $this->assertSame($stub, $response);
+
+        $client = new Client();
+        $response = $client->post('http://example.com/path/?query=value');
+        $this->assertSame($stub, $response);
+    }
+
+    /**
+     * Custom match methods must be closures
+     */
+    public function testAddMockResponseInvalidMatch(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('The `match` option must be a `Closure`.');
+
+        $stub = new Response(['HTTP/1.0 200'], 'hello world');
+        Client::addMockResponse('POST', 'http://example.com/path', $stub, [
+            'match' => 'oops',
+        ]);
+    }
+
+    /**
+     * Custom matchers should get a request.
+     */
+    public function testAddMockResponseCustomMatch(): void
+    {
+        $stub = new Response(['HTTP/1.0 200'], 'hello world');
+        Client::addMockResponse('POST', 'http://example.com/path', $stub, [
+            'match' => function ($request) {
+                $this->assertInstanceOf(Request::class, $request);
+                $uri = $request->getUri();
+                $this->assertEquals('/path', $uri->getPath());
+                $this->assertEquals('example.com', $uri->getHost());
+
+                return true;
+            },
+        ]);
+
+        $client = new Client();
+        $response = $client->post('http://example.com/path');
+
+        $this->assertSame($stub, $response);
+    }
+
+    /**
+     * Custom matchers can fail the match
+     */
+    public function testAddMockResponseCustomNoMatch(): void
+    {
+        $stub = new Response(['HTTP/1.0 200'], 'hello world');
+        Client::addMockResponse('POST', 'http://example.com/path', $stub, [
+            'match' => function ($request) {
+                return false;
+            },
+        ]);
+
+        $mock = $this->getMockBuilder(Stream::class)
+            ->onlyMethods(['send'])
+            ->getMock();
+        $mock->expects($this->once())
+            ->method('send')
+            ->will($this->throwException(new InvalidArgumentException('No match')));
+
+        $client = new Client(['adapter' => $mock]);
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('No match');
+
+        $client->post('http://example.com/path');
+    }
+
+    /**
+     * Custom matchers must return a boolean
+     */
+    public function testAddMockResponseCustomInvalidDecision(): void
+    {
+        $stub = new Response(['HTTP/1.0 200'], 'hello world');
+        Client::addMockResponse('POST', 'http://example.com/path', $stub, [
+            'match' => function ($request) {
+                return 'invalid';
+            },
+        ]);
+
+        $client = new Client();
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Match callback must');
+
+        $client->post('http://example.com/path');
+    }
 }