diff --git a/src/MockeryTools/Http/Exchange/TestOnlyExpectedExchangeHandler.php b/src/MockeryTools/Http/Exchange/TestOnlyExpectedExchangeHandler.php new file mode 100644 index 0000000..b24493b --- /dev/null +++ b/src/MockeryTools/Http/Exchange/TestOnlyExpectedExchangeHandler.php @@ -0,0 +1,242 @@ + $this->handleRequest($actualRequest); + } + + + private function handleRequest(RequestInterface $actualRequest): PromiseInterface + { + foreach ($this->expectedExchanges as $key => $expectedExchange) { + $areRequestsSame = $this->areRequestsSame( + $expectedExchange->getRequest(), + $actualRequest, + ); + + if ($areRequestsSame) { + unset($this->expectedExchanges[$key]); + + if ($expectedExchange->getResponse()->getStatusCode() >= 400) { + return $this->createRejectedPromise($expectedExchange); + } + + return new FulfilledPromise($expectedExchange->getResponse()); + } + } + + throw new Exception( + sprintf( + 'Unexpected request: %s %s %s %s', + $actualRequest->getUri(), + $actualRequest->getMethod(), + Json::encode($actualRequest->getHeaders()), + $actualRequest->getBody(), + ), + ); + } + + + public function expectExchange(TestOnlyExpectedExchange $expectedExchange): void + { + $this->expectedExchanges[] = $expectedExchange; + } + + + public function hasExpectedExchanges(): bool + { + return $this->expectedExchanges !== []; + } + + + /** + * @return TestOnlyExpectedExchange[] + */ + public function getExpectedExchanges(): array + { + return $this->expectedExchanges; + } + + + private function areRequestsHeadersSame(RequestInterface $expectedRequest, RequestInterface $actualRequest): bool + { + $actualRequestHeaders = $actualRequest->getHeaders(); + $expectedRequestHeaders = $expectedRequest->getHeaders(); + + /** + * Content-Type is unset here because it's generated by Guzzle at run-time using random_bytes function. + * We are unable to create the expected request with the same value. + * + * @see GuzzleHttp\Psr7\MultipartStream contrustor. + */ + if ($actualRequest->getBody() instanceof MultipartStream) { + unset($actualRequestHeaders['Content-Type']); + unset($expectedRequestHeaders['Content-Type']); + } + + ksort($actualRequestHeaders); + ksort($expectedRequestHeaders); + + return $actualRequestHeaders === $expectedRequestHeaders; + } + + + private function areRequestsSame(RequestInterface $expectedRequest, RequestInterface $actualRequest): bool + { + $areHeadersSame = $this->areRequestsHeadersSame( + $expectedRequest, + $actualRequest, + ); + + if (!$areHeadersSame) { + return false; + } + + $areBodiesSame = $this->areRequestsBodiesSame( + $expectedRequest, + $actualRequest, + ); + + if (!$areBodiesSame) { + return false; + } + + return $expectedRequest->getMethod() === $actualRequest->getMethod() + && (string)$expectedRequest->getUri() === (string)$actualRequest->getUri(); + } + + + private function areRequestsBodiesSame(RequestInterface $expectedRequest, RequestInterface $actualRequest): bool + { + if ($actualRequest->getBody() instanceof MultipartStream + && $expectedRequest->getBody() instanceof MultipartStream) { + return $this->areRequestsMultipartStreamsSame($expectedRequest->getBody(), $actualRequest->getBody()); + } + + $expectedRequestBody = (string)$expectedRequest->getBody(); + $actualRequestBody = (string)$actualRequest->getBody(); + + if ($actualRequest->getHeaderLine('Content-Type') === 'application/x-www-form-urlencoded') { + return $this->areRequestsFormsBodySame($expectedRequestBody, $actualRequestBody); + } + + if ($this->isRequestBodyJson($actualRequestBody) + && $this->isRequestBodyJson($expectedRequestBody) + ) { + return $this->areRequestsJsonBodiesSame($expectedRequestBody, $actualRequestBody); + } + + return $expectedRequestBody === $actualRequestBody; + } + + + private function areRequestsJsonBodiesSame(string $expectedRequestBody, string $actualRequestBody): bool + { + $expectedRequestBody = Json::decode($expectedRequestBody, Json::FORCE_ARRAY); + $actualRequestBody = Json::decode($actualRequestBody, Json::FORCE_ARRAY); + + $expectedRequestBody = $this->sortRequestBody($expectedRequestBody); + $actualRequestBody = $this->sortRequestBody($actualRequestBody); + + return $expectedRequestBody === $actualRequestBody; + } + + + private function areRequestsMultipartStreamsSame(StreamInterface $expectedRequestStream, StreamInterface $actualRequestStream): bool + { + assert($expectedRequestStream instanceof MultipartStream); + assert($actualRequestStream instanceof MultipartStream); + + if ($expectedRequestStream->getSize() !== $actualRequestStream->getSize()) { + return false; + } + + $expectedRequestContentWithoutBoundary = str_replace($expectedRequestStream->getBoundary(), '', $expectedRequestStream->getContents()); + $actualRequestContentWithoutBoundary = str_replace($actualRequestStream->getBoundary(), '', $actualRequestStream->getContents()); + + return $expectedRequestContentWithoutBoundary === $actualRequestContentWithoutBoundary; + } + + + private function areRequestsFormsBodySame(string $expectedRequestBody, string $actualRequestBody): bool + { + $parsedExpectedRequestBody = []; + parse_str($expectedRequestBody, $parsedExpectedRequestBody); + + $parsedActualRequestBody = []; + parse_str($actualRequestBody, $parsedActualRequestBody); + + return $this->sortRequestBody($parsedExpectedRequestBody) === $this->sortRequestBody($parsedActualRequestBody); + } + + + /** + * @param mixed[] $requestBody + * + * @return mixed[] + */ + private function sortRequestBody(array &$requestBody): array + { + ksort($requestBody); + + foreach ($requestBody as &$value) { + if (is_array($value)) { + $this->sortRequestBody($value); + } + } + unset($value); + + return $requestBody; + } + + + private function isRequestBodyJson(string $requestBody): bool + { + json_decode($requestBody); + + return json_last_error() === JSON_ERROR_NONE; + } + + + private function createRejectedPromise(TestOnlyExpectedExchange $expectedExchange): RejectedPromise + { + $requestException = RequestException::create( + $expectedExchange->getRequest(), + $expectedExchange->getResponse(), + ); + + return new RejectedPromise($requestException); + } +}