From 21021e5d706dddaedce73dc871211001b4da3d6b Mon Sep 17 00:00:00 2001 From: Neur0toxine Date: Thu, 20 May 2021 20:56:45 +0300 Subject: [PATCH] `at(N)`, `always()` methods, separate PSR-18 exceptions, throw methods for them, roadmap to stable --- README.md | 9 +- phpmd.xml | 10 +- src/Client.php | 8 +- .../AbstractRequestAwareException.php | 45 +++++++++ src/Exception/PockClientException.php | 23 +++++ src/Exception/PockNetworkException.php | 22 +++++ src/Exception/PockRequestException.php | 22 +++++ src/Exception/UniversalMockException.php | 51 ---------- src/Mock.php | 46 +++++++-- src/MockInterface.php | 14 ++- src/PockBuilder.php | 93 ++++++++++++++++++- tests/src/PockBuilderTest.php | 84 ++++++++++++++++- 12 files changed, 352 insertions(+), 75 deletions(-) create mode 100644 src/Exception/AbstractRequestAwareException.php create mode 100644 src/Exception/PockClientException.php create mode 100644 src/Exception/PockNetworkException.php create mode 100644 src/Exception/PockRequestException.php delete mode 100644 src/Exception/UniversalMockException.php diff --git a/README.md b/README.md index 6e1a4c6..84189fd 100644 --- a/README.md +++ b/README.md @@ -112,12 +112,11 @@ In order to use unsupported serializer you should create a decorator which imple # Roadmap to stable -- [ ] `at(N)` - execute mock only at Nth call. -- [ ] `after(N)` - allow mock execution only after Nth call (for using with repeat or always). -- [ ] `always()` - always execute this mock (removes mock expiration). +- [x] `at(N)` - execute mock only at Nth call. +- [x] `always()` - always execute this mock (removes mock expiration). - [ ] Regexp matchers for body, query and path. -- [ ] Separate `UniversalMockException` into several exceptions (`PockClientException`, `PockNetworkException`, etc). -- [ ] Add methods for easier throwing of exceptions listed in previous entry. +- [x] Separate `UniversalMockException` into several exceptions (`PockClientException`, `PockNetworkException`, etc). +- [x] Add methods for easier throwing of exceptions listed in previous entry. - [ ] `replyCallback` - reply using specified callback. - [ ] `replyFactory` - reply using specified response factory (provide corresponding interface). - [ ] Compare XML bodies using `DOMDocument`, fallback to text comparison in case of problems. diff --git a/phpmd.xml b/phpmd.xml index 550b68e..fd51dcb 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -10,7 +10,15 @@ - + + + + + + + + + tests/* diff --git a/src/Client.php b/src/Client.php index edf74f4..b584530 100644 --- a/src/Client.php +++ b/src/Client.php @@ -74,17 +74,19 @@ class Client implements ClientInterface, HttpClient, HttpAsyncClient continue; } - if ($mock->getMatcher()->matches($request)) { + if ($mock->matches($request)) { if (null !== $mock->getResponse()) { $mock->registerHit(); return new HttpFulfilledPromise($mock->getResponse()); } - if (null !== $mock->getThrowable()) { + $throwable = $mock->getThrowable($request); + + if (null !== $throwable) { $mock->registerHit(); - return new HttpRejectedPromise($mock->getThrowable()); + return new HttpRejectedPromise($throwable); } throw new IncompleteMockException($mock); diff --git a/src/Exception/AbstractRequestAwareException.php b/src/Exception/AbstractRequestAwareException.php new file mode 100644 index 0000000..03a014e --- /dev/null +++ b/src/Exception/AbstractRequestAwareException.php @@ -0,0 +1,45 @@ +message, $this->code, $this->getPrevious()); // @phpstan-ignore-line + $instance->request = $request; + return $instance; + } + + /** + * @return \Psr\Http\Message\RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } +} diff --git a/src/Exception/PockClientException.php b/src/Exception/PockClientException.php new file mode 100644 index 0000000..df4e694 --- /dev/null +++ b/src/Exception/PockClientException.php @@ -0,0 +1,23 @@ +request = $request; - } - - /** - * @inheritDoc - */ - public function getRequest(): RequestInterface - { - return $this->request; - } -} diff --git a/src/Mock.php b/src/Mock.php index 7ec5832..e10d863 100644 --- a/src/Mock.php +++ b/src/Mock.php @@ -9,7 +9,12 @@ namespace Pock; +use Pock\Exception\PockNetworkException; +use Pock\Exception\PockRequestException; use Pock\Matchers\RequestMatcherInterface; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Http\Client\RequestExceptionInterface; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Throwable; @@ -36,6 +41,9 @@ class Mock implements MockInterface /** @var int */ private $maxHits; + /** @var int */ + private $matchAt; + /** * Mock constructor. * @@ -43,18 +51,25 @@ class Mock implements MockInterface * @param \Psr\Http\Message\ResponseInterface|null $response * @param \Throwable|null $throwable * @param int $maxHits + * @param int $matchAt */ public function __construct( RequestMatcherInterface $matcher, ?ResponseInterface $response, ?Throwable $throwable, - int $maxHits + int $maxHits, + int $matchAt ) { $this->matcher = $matcher; $this->response = $response; $this->throwable = $throwable; + $this->matchAt = $matchAt; $this->maxHits = $maxHits; $this->hits = 0; + + if ($this->maxHits < ($matchAt + 1) && -1 !== $this->maxHits) { + $this->maxHits = $matchAt + 1; + } } /** @@ -62,7 +77,10 @@ class Mock implements MockInterface */ public function registerHit(): MockInterface { - ++$this->hits; + if (-1 !== $this->maxHits) { + ++$this->hits; + } + return $this; } @@ -71,15 +89,27 @@ class Mock implements MockInterface */ public function available(): bool { - return $this->hits < $this->maxHits; + return -1 === $this->maxHits || $this->hits < $this->maxHits; } /** * @inheritDoc */ - public function getMatcher(): RequestMatcherInterface + public function matches(RequestInterface $request): bool { - return $this->matcher; + if ($this->matcher->matches($request)) { + if ($this->matchAt <= 0) { + return true; + } + + if ($this->matchAt === $this->hits) { + return true; + } + + $this->registerHit(); + } + + return false; } /** @@ -101,8 +131,12 @@ class Mock implements MockInterface /** * @inheritDoc */ - public function getThrowable(): ?Throwable + public function getThrowable(RequestInterface $request): ?Throwable { + if ($this->throwable instanceof PockRequestException || $this->throwable instanceof PockNetworkException) { + return $this->throwable->setRequest($request); + } + return $this->throwable; } } diff --git a/src/MockInterface.php b/src/MockInterface.php index c0cc2ca..f985e7b 100644 --- a/src/MockInterface.php +++ b/src/MockInterface.php @@ -10,6 +10,7 @@ namespace Pock; use Pock\Matchers\RequestMatcherInterface; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Throwable; @@ -36,11 +37,14 @@ interface MockInterface public function available(): bool; /** - * Returns matcher for the request. + * Returns true if underlying matcher has matched provided request. + * It also returns false if matcher has matched request but hits condition is not met yet. * - * @return \Pock\Matchers\RequestMatcherInterface + * @param \Psr\Http\Message\RequestInterface $request + * + * @return bool */ - public function getMatcher(): RequestMatcherInterface; + public function matches(RequestInterface $request): bool; /** * Returns response which should be used as mock data. @@ -52,7 +56,9 @@ interface MockInterface /** * Returns the throwable which will be thrown as mock data. * + * @param \Psr\Http\Message\RequestInterface $request This request may be set into exception if possible + * * @return \Throwable|null */ - public function getThrowable(): ?Throwable; + public function getThrowable(RequestInterface $request): ?Throwable; } diff --git a/src/PockBuilder.php b/src/PockBuilder.php index f6768e4..0ecf0c4 100644 --- a/src/PockBuilder.php +++ b/src/PockBuilder.php @@ -12,6 +12,9 @@ namespace Pock; use Diff\ArrayComparer\StrictArrayComparer; use Pock\Enum\RequestMethod; use Pock\Enum\RequestScheme; +use Pock\Exception\PockClientException; +use Pock\Exception\PockNetworkException; +use Pock\Exception\PockRequestException; use Pock\Matchers\AnyRequestMatcher; use Pock\Matchers\BodyMatcher; use Pock\Matchers\CallbackRequestMatcher; @@ -45,6 +48,7 @@ use Throwable; * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) * @SuppressWarnings(PHPMD.TooManyPublicMethods) + * @SuppressWarnings(PHPMD.TooManyMethods) */ class PockBuilder { @@ -64,6 +68,9 @@ class PockBuilder /** @var int */ private $maxHits; + /** @var int */ + private $matchAt; + /** @var \Pock\MockInterface[] */ private $mocks; @@ -329,6 +336,8 @@ class PockBuilder */ public function repeat(int $hits): self { + $this->closePrevious(); + if ($hits > 0) { $this->maxHits = $hits; } @@ -336,6 +345,49 @@ class PockBuilder return $this; } + /** + * Always execute this mock if matched. Mock with this call will not be expired ever. + * + * @return self + */ + public function always(): self + { + $this->closePrevious(); + $this->maxHits = -1; + + return $this; + } + + /** + * Match request only at Nth hit. Previous matches will not be executed. + * + * **Note:** There IS a catch if you use this with the equal mocks. The test Client will not register hit + * for the second mock and the second mock will be executed at N+1 time. + * + * For example, if you try to send 5 requests with this mocks and log response codes: + * ```php + * $builder = new PockBuilder(); + * + * $builder->matchHost('example.com')->at(2)->reply(200); + * $builder->matchHost('example.com')->at(4)->reply(201); + * $builder->always()->reply(400); + * ``` + * + * You will get this: 400, 400, 200, 400, 400, 201 + * Instead of this: 400, 400, 200, 400, 201, 400 + * + * @param int $hit + * + * @return self + */ + public function at(int $hit): self + { + $this->closePrevious(); + $this->matchAt = $hit - 1; + + return $this; + } + /** * Throw an exception when request is being sent. * @@ -350,6 +402,42 @@ class PockBuilder return $this; } + /** + * Throw an ClientExceptionInterface instance with specified message + * + * @param string $message + * + * @return self + */ + public function throwClientException(string $message = 'Pock ClientException'): self + { + return $this->throwException(new PockClientException($message)); + } + + /** + * Throw an NetworkExceptionInterface instance with specified message + * + * @param string $message + * + * @return self + */ + public function throwNetworkException(string $message = 'Pock NetworkException'): self + { + return $this->throwException(new PockNetworkException($message)); + } + + /** + * Throw an RequestExceptionInterface instance with specified message + * + * @param string $message + * + * @return self + */ + public function throwRequestException(string $message = 'Pock RequestException'): self + { + return $this->throwException(new PockRequestException($message)); + } + /** * @param int $statusCode * @@ -377,6 +465,7 @@ class PockBuilder $this->responseBuilder = null; $this->throwable = null; $this->maxHits = 1; + $this->matchAt = -1; $this->mocks = []; return $this; @@ -421,12 +510,14 @@ class PockBuilder $this->matcher, $response, $this->throwable, - $this->maxHits + $this->maxHits, + $this->matchAt ); $this->matcher = new MultipleMatcher(); $this->responseBuilder = null; $this->throwable = null; $this->maxHits = 1; + $this->matchAt = -1; } } } diff --git a/tests/src/PockBuilderTest.php b/tests/src/PockBuilderTest.php index bd5effc..96800e8 100644 --- a/tests/src/PockBuilderTest.php +++ b/tests/src/PockBuilderTest.php @@ -11,14 +11,14 @@ namespace Pock\Tests; use Pock\Enum\RequestMethod; use Pock\Enum\RequestScheme; -use Pock\Exception\UniversalMockException; use Pock\Exception\UnsupportedRequestException; use Pock\PockBuilder; use Pock\TestUtils\PockTestCase; use Pock\TestUtils\SimpleObject; use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Http\Client\RequestExceptionInterface; use Psr\Http\Message\RequestInterface; -use RuntimeException; /** * Class PockBuilderTest @@ -35,7 +35,7 @@ class PockBuilderTest extends PockTestCase ->createRequest(RequestMethod::GET, self::TEST_URI)); } - public function testThrowException(): void + public function testThrowClientException(): void { $this->expectException(ClientExceptionInterface::class); @@ -43,7 +43,37 @@ class PockBuilderTest extends PockTestCase $builder->matchMethod(RequestMethod::GET) ->matchScheme(RequestScheme::HTTPS) ->matchHost(self::TEST_HOST) - ->throwException(new UniversalMockException('Boom!')); + ->throwClientException(); + + $builder->getClient()->sendRequest( + self::getPsr17Factory()->createRequest(RequestMethod::GET, self::TEST_URI) + ); + } + + public function testThrowNetworkException(): void + { + $this->expectException(NetworkExceptionInterface::class); + + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchScheme(RequestScheme::HTTPS) + ->matchHost(self::TEST_HOST) + ->throwNetworkException(); + + $builder->getClient()->sendRequest( + self::getPsr17Factory()->createRequest(RequestMethod::GET, self::TEST_URI) + ); + } + + public function testThrowRequestException(): void + { + $this->expectException(RequestExceptionInterface::class); + + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchScheme(RequestScheme::HTTPS) + ->matchHost(self::TEST_HOST) + ->throwRequestException(); $builder->getClient()->sendRequest( self::getPsr17Factory()->createRequest(RequestMethod::GET, self::TEST_URI) @@ -511,4 +541,50 @@ EOF; ); self::assertEquals('Second token (post)', $response->getBody()->getContents()); } + + public function testAlways(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchUri(self::TEST_URI) + ->always() + ->reply(200) + ->withHeader('Content-Type', 'text/plain') + ->withBody('Successful'); + + for ($i = 0; $i < 10; $i++) { + $response = $builder->getClient()->sendRequest( + self::getPsr17Factory()->createRequest(RequestMethod::GET, self::TEST_URI) + ); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals(['Content-Type' => ['text/plain']], $response->getHeaders()); + self::assertEquals('Successful', $response->getBody()->getContents()); + } + } + + public function testAt(): void + { + $builder = new PockBuilder(); + $builder->matchMethod(RequestMethod::GET) + ->matchUri(self::TEST_URI) + ->at(2) + ->reply(200); + + $builder->matchMethod(RequestMethod::GET) + ->matchUri(self::TEST_URI) + ->at(4) + ->reply(201); + + $builder->always()->reply(400); + $builder->getClient(); + + for ($i = 0; $i < 5; $i++) { + $response = $builder->getClient()->sendRequest( + self::getPsr17Factory()->createRequest(RequestMethod::GET, self::TEST_URI) + ); + + self::assertEquals(1 === $i ? 200 : (4 === $i ? 201 : 400), $response->getStatusCode()); + } + } }