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());
+ }
+ }
}