From b3847529d8a4f643e0e9a59d37e0d6a107a94a36 Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 13:22:07 +0100 Subject: [PATCH 01/11] Add test that Cliens implements HttpClient --- tests/Unit/Client/NativeCurlClientTest.php | 2 ++ tests/Unit/Client/Psr18ClientTest.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/Unit/Client/NativeCurlClientTest.php b/tests/Unit/Client/NativeCurlClientTest.php index caf0ddf1..ec317453 100644 --- a/tests/Unit/Client/NativeCurlClientTest.php +++ b/tests/Unit/Client/NativeCurlClientTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Redmine\Client\Client; use Redmine\Client\NativeCurlClient; +use Redmine\Http\HttpClient; use stdClass; class NativeCurlClientTest extends TestCase @@ -44,6 +45,7 @@ public function shouldPassApiKeyToConstructor() $this->assertInstanceOf(NativeCurlClient::class, $client); $this->assertInstanceOf(Client::class, $client); + $this->assertInstanceOf(HttpClient::class, $client); } /** diff --git a/tests/Unit/Client/Psr18ClientTest.php b/tests/Unit/Client/Psr18ClientTest.php index 8e47eea2..05d4b271 100644 --- a/tests/Unit/Client/Psr18ClientTest.php +++ b/tests/Unit/Client/Psr18ClientTest.php @@ -13,6 +13,7 @@ use Psr\Http\Message\StreamInterface; use Redmine\Client\Client; use Redmine\Client\Psr18Client; +use Redmine\Http\HttpClient; class Psr18ClientTest extends TestCase { @@ -32,6 +33,7 @@ public function shouldPassApiKeyToConstructor() $this->assertInstanceOf(Psr18Client::class, $client); $this->assertInstanceOf(Client::class, $client); + $this->assertInstanceOf(HttpClient::class, $client); } /** From 8cdc46214d6a5001918c3835a24eb64c1ed0b680 Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 13:45:11 +0100 Subject: [PATCH 02/11] Implement HttpClient in Psr18Client --- src/Redmine/Client/Psr18Client.php | 115 ++++++++++++++++++++++++----- 1 file changed, 96 insertions(+), 19 deletions(-) diff --git a/src/Redmine/Client/Psr18Client.php b/src/Redmine/Client/Psr18Client.php index d5b24566..55a17550 100644 --- a/src/Redmine/Client/Psr18Client.php +++ b/src/Redmine/Client/Psr18Client.php @@ -13,11 +13,14 @@ use Psr\Http\Message\ServerRequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Redmine\Exception\ClientException; +use Redmine\Http\HttpClient; +use Redmine\Http\Request; +use Redmine\Http\Response; /** * Psr18 client. */ -final class Psr18Client implements Client +final class Psr18Client implements Client, HttpClient { use ClientApiTrait; @@ -73,6 +76,53 @@ public function __construct( $this->password = $password; } + /** + * Create and send a HTTP request and return the response + * + * @throws ClientException If anything goes wrong on creating or sending the request + */ + public function request(Request $request): Response + { + $response = $this->runRequest( + $request->getMethod(), + $request->getPath(), + $request->getContent(), + $request->getContentType() + ); + + return new class ( + $response->getStatusCode(), + $response->getHeaderLine('Content-Type'), + strval($response->getBody()) + ) implements Response { + private $statusCode; + private $contentType; + private $body; + + public function __construct(int $statusCode, string $contentType, string $body) + { + $this->statusCode = $statusCode; + $this->contentType = $contentType; + $this->body = $body; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getContent(): string + { + return $this->body; + } + }; + } + /** * Sets to an existing username so api calls can be * impersonated to this user. @@ -92,34 +142,58 @@ public function stopImpersonateUser(): void /** * Create and send a GET request. + * + * @throws ClientException If anything goes wrong on the request + * + * @return bool true if status code of the response is not 4xx oder 5xx */ public function requestGet(string $path): bool { - return $this->runRequest('GET', $path); + $response = $this->runRequest('GET', $path); + + return $response->getStatusCode() < 400; } /** * Create and send a POST request. + * + * @throws ClientException If anything goes wrong on the request + * + * @return bool true if status code of the response is not 4xx oder 5xx */ public function requestPost(string $path, string $body): bool { - return $this->runRequest('POST', $path, $body); + $response = $this->runRequest('POST', $path, $body); + + return $response->getStatusCode() < 400; } /** * Create and send a PUT request. + * + * @throws ClientException If anything goes wrong on the request + * + * @return bool true if status code of the response is not 4xx oder 5xx */ public function requestPut(string $path, string $body): bool { - return $this->runRequest('PUT', $path, $body); + $response = $this->runRequest('PUT', $path, $body); + + return $response->getStatusCode() < 400; } /** * Create and send a DELETE request. + * + * @throws ClientException If anything goes wrong on the request + * + * @return bool true if status code of the response is not 4xx oder 5xx */ public function requestDelete(string $path): bool { - return $this->runRequest('DELETE', $path); + $response = $this->runRequest('DELETE', $path); + + return $response->getStatusCode() < 400; } /** @@ -162,14 +236,12 @@ public function getLastResponseBody(): string * Create and run a request. * * @throws ClientException If anything goes wrong on the request - * - * @return bool true if status code of the response is not 4xx oder 5xx */ - private function runRequest(string $method, string $path, string $body = ''): bool + private function runRequest(string $method, string $path, string $body = '', string $contentType = ''): ResponseInterface { $this->lastResponse = null; - $request = $this->createRequest($method, $path, $body); + $request = $this->createRequest($method, $path, $body, $contentType); try { $this->lastResponse = $this->httpClient->sendRequest($request); @@ -177,10 +249,10 @@ private function runRequest(string $method, string $path, string $body = ''): bo throw new ClientException($e->getMessage(), $e->getCode(), $e); } - return $this->lastResponse->getStatusCode() < 400; + return $this->lastResponse; } - private function createRequest(string $method, string $path, string $body = ''): RequestInterface + private function createRequest(string $method, string $path, string $body = '', string $contentType = ''): RequestInterface { $request = $this->requestFactory->createRequest( $method, @@ -228,14 +300,19 @@ private function createRequest(string $method, string $path, string $body = ''): } // set Content-Type header - $tmp = parse_url($this->url . $path); - - if ($this->isUploadCall($path)) { - $request = $request->withHeader('Content-Type', 'application/octet-stream'); - } elseif ('json' === substr($tmp['path'], -4)) { - $request = $request->withHeader('Content-Type', 'application/json'); - } elseif ('xml' === substr($tmp['path'], -3)) { - $request = $request->withHeader('Content-Type', 'text/xml'); + if ($contentType !== '') { + $request = $request->withHeader('Content-Type', $contentType); + } else { + // guess Content-Type from path + $tmp = parse_url($this->url . $path); + + if ($this->isUploadCall($path)) { + $request = $request->withHeader('Content-Type', 'application/octet-stream'); + } elseif ('json' === substr($tmp['path'], -4)) { + $request = $request->withHeader('Content-Type', 'application/json'); + } elseif ('xml' === substr($tmp['path'], -3)) { + $request = $request->withHeader('Content-Type', 'text/xml'); + } } return $request; From 80a4959ba6f4cd20a7c634016377eabc886cbeff Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 13:54:12 +0100 Subject: [PATCH 03/11] Implement HttpClient in NativeCurlClient --- src/Redmine/Client/NativeCurlClient.php | 90 ++++++++++++++++++++----- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/src/Redmine/Client/NativeCurlClient.php b/src/Redmine/Client/NativeCurlClient.php index 40709797..033871ff 100644 --- a/src/Redmine/Client/NativeCurlClient.php +++ b/src/Redmine/Client/NativeCurlClient.php @@ -5,11 +5,14 @@ namespace Redmine\Client; use Redmine\Exception\ClientException; +use Redmine\Http\HttpClient; +use Redmine\Http\Request; +use Redmine\Http\Response; /** * Native cURL client. */ -final class NativeCurlClient implements Client +final class NativeCurlClient implements Client, HttpClient { use ClientApiTrait; @@ -55,6 +58,53 @@ public function __construct( } } + /** + * Create and send a HTTP request and return the response + * + * @throws ClientException If anything goes wrong on creating or sending the request + */ + public function request(Request $request): Response + { + $this->runRequest( + $request->getMethod(), + $request->getPath(), + $request->getContent(), + $request->getContentType() + ); + + return new class ( + $this->lastResponseStatusCode, + $this->lastResponseContentType, + $this->lastResponseBody + ) implements Response { + private $statusCode; + private $contentType; + private $body; + + public function __construct(int $statusCode, string $contentType, string $body) + { + $this->statusCode = $statusCode; + $this->contentType = $contentType; + $this->body = $body; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getContent(): string + { + return $this->body; + } + }; + } + /** * Sets to an existing username so api calls can be * impersonated to this user. @@ -77,7 +127,7 @@ public function stopImpersonateUser(): void */ public function requestGet(string $path): bool { - return $this->request('get', $path); + return $this->runRequest('get', $path); } /** @@ -85,7 +135,7 @@ public function requestGet(string $path): bool */ public function requestPost(string $path, string $body): bool { - return $this->request('post', $path, $body); + return $this->runRequest('post', $path, $body); } /** @@ -93,7 +143,7 @@ public function requestPost(string $path, string $body): bool */ public function requestPut(string $path, string $body): bool { - return $this->request('put', $path, $body); + return $this->runRequest('put', $path, $body); } /** @@ -101,7 +151,7 @@ public function requestPut(string $path, string $body): bool */ public function requestDelete(string $path): bool { - return $this->request('delete', $path); + return $this->runRequest('delete', $path); } /** @@ -211,13 +261,13 @@ private function unsetHttpHeader(string $name): void /** * @throws ClientException If anything goes wrong on curl request */ - private function request(string $method, string $path, string $body = ''): bool + private function runRequest(string $method, string $path, string $body = '', string $contentType = ''): bool { $this->lastResponseStatusCode = 0; $this->lastResponseContentType = ''; $this->lastResponseBody = ''; - $curl = $this->createCurl($method, $path, $body); + $curl = $this->createCurl($method, $path, $body, $contentType); $response = curl_exec($curl); @@ -249,7 +299,7 @@ private function request(string $method, string $path, string $body = ''): bool * * @return \CurlHandle a cURL handle on success, FALSE on errors */ - private function createCurl(string $method, string $path, string $body = '') + private function createCurl(string $method, string $path, string $body = '', string $contentType = '') { // General cURL options $curlOptions = [ @@ -264,7 +314,7 @@ private function createCurl(string $method, string $path, string $body = '') $curlOptions[CURLOPT_URL] = $this->url . $path; // Set the HTTP request headers - $curlOptions[CURLOPT_HTTPHEADER] = $this->createHttpHeader($path); + $curlOptions[CURLOPT_HTTPHEADER] = $this->createHttpHeader($path, $contentType); unset($curlOptions[CURLOPT_CUSTOMREQUEST]); unset($curlOptions[CURLOPT_POST]); @@ -314,7 +364,7 @@ private function createCurl(string $method, string $path, string $body = '') return $curl; } - private function createHttpHeader(string $path): array + private function createHttpHeader(string $path, string $contentType = ''): array { // Additional request headers $httpHeaders = [ @@ -352,14 +402,18 @@ private function createHttpHeader(string $path): array // Now set or reset mandatory headers // Content type headers - $tmp = parse_url($this->url . $path); - - if ($this->isUploadCall($path)) { - $httpHeaders[] = 'Content-Type: application/octet-stream'; - } elseif ('json' === substr($tmp['path'], -4)) { - $httpHeaders[] = 'Content-Type: application/json'; - } elseif ('xml' === substr($tmp['path'], -3)) { - $httpHeaders[] = 'Content-Type: text/xml'; + if ($contentType !== '') { + $httpHeaders[] = 'Content-Type: ' . $contentType; + } else { + $tmp = parse_url($this->url . $path); + + if ($this->isUploadCall($path)) { + $httpHeaders[] = 'Content-Type: application/octet-stream'; + } elseif ('json' === substr($tmp['path'], -4)) { + $httpHeaders[] = 'Content-Type: application/json'; + } elseif ('xml' === substr($tmp['path'], -3)) { + $httpHeaders[] = 'Content-Type: text/xml'; + } } return $httpHeaders; From bbaae132b2fe884a46a076c07fb6b67a2041dcbd Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 14:05:22 +0100 Subject: [PATCH 04/11] Create HttpFactory for creating Response objects --- src/Redmine/Api/AbstractApi.php | 44 +++---------------------- src/Redmine/Client/NativeCurlClient.php | 31 ++--------------- src/Redmine/Client/Psr18Client.php | 31 ++--------------- src/Redmine/Http/HttpFactory.php | 44 +++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 95 deletions(-) create mode 100644 src/Redmine/Http/HttpFactory.php diff --git a/src/Redmine/Api/AbstractApi.php b/src/Redmine/Api/AbstractApi.php index f7001a27..2cff81e0 100644 --- a/src/Redmine/Api/AbstractApi.php +++ b/src/Redmine/Api/AbstractApi.php @@ -9,6 +9,7 @@ use Redmine\Exception; use Redmine\Exception\SerializerException; use Redmine\Http\HttpClient; +use Redmine\Http\HttpFactory; use Redmine\Http\Request; use Redmine\Http\Response; use Redmine\Serializer\JsonSerializer; @@ -73,7 +74,7 @@ final protected function getHttpClient(): HttpClient final protected function getLastResponse(): Response { - return $this->lastResponse !== null ? $this->lastResponse : $this->createResponse(0, '', ''); + return $this->lastResponse !== null ? $this->lastResponse : HttpFactory::makeResponse(0, '', ''); } /** @@ -412,16 +413,12 @@ private function getResponseAsArray(Response $response): array private function handleClient(Client $client): HttpClient { - $responseFactory = Closure::fromCallable([$this, 'createResponse']); - - return new class ($client, $responseFactory) implements HttpClient { + return new class ($client) implements HttpClient { private $client; - private $responseFactory; - public function __construct(Client $client, Closure $responseFactory) + public function __construct(Client $client) { $this->client = $client; - $this->responseFactory = $responseFactory; } public function request(Request $request): Response @@ -436,7 +433,7 @@ public function request(Request $request): Response $this->client->requestGet($request->getPath()); } - return ($this->responseFactory)( + return HttpFactory::makeResponse( $this->client->getLastResponseStatusCode(), $this->client->getLastResponseContentType(), $this->client->getLastResponseBody() @@ -445,37 +442,6 @@ public function request(Request $request): Response }; } - private function createResponse(int $statusCode, string $contentType, string $body): Response - { - return new class ($statusCode, $contentType, $body) implements Response { - private $statusCode; - private $contentType; - private $body; - - public function __construct(int $statusCode, string $contentType, string $body) - { - $this->statusCode = $statusCode; - $this->contentType = $contentType; - $this->body = $body; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - - public function getContentType(): string - { - return $this->contentType; - } - - public function getContent(): string - { - return $this->body; - } - }; - } - private function createRequest(string $method, string $path, string $contentType, string $content = ''): Request { return new class ($method, $path, $contentType, $content) implements Request { diff --git a/src/Redmine/Client/NativeCurlClient.php b/src/Redmine/Client/NativeCurlClient.php index 033871ff..b4f584a1 100644 --- a/src/Redmine/Client/NativeCurlClient.php +++ b/src/Redmine/Client/NativeCurlClient.php @@ -6,6 +6,7 @@ use Redmine\Exception\ClientException; use Redmine\Http\HttpClient; +use Redmine\Http\HttpFactory; use Redmine\Http\Request; use Redmine\Http\Response; @@ -72,37 +73,11 @@ public function request(Request $request): Response $request->getContentType() ); - return new class ( + return HttpFactory::makeResponse( $this->lastResponseStatusCode, $this->lastResponseContentType, $this->lastResponseBody - ) implements Response { - private $statusCode; - private $contentType; - private $body; - - public function __construct(int $statusCode, string $contentType, string $body) - { - $this->statusCode = $statusCode; - $this->contentType = $contentType; - $this->body = $body; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - - public function getContentType(): string - { - return $this->contentType; - } - - public function getContent(): string - { - return $this->body; - } - }; + ); } /** diff --git a/src/Redmine/Client/Psr18Client.php b/src/Redmine/Client/Psr18Client.php index 55a17550..f5f1899b 100644 --- a/src/Redmine/Client/Psr18Client.php +++ b/src/Redmine/Client/Psr18Client.php @@ -14,6 +14,7 @@ use Psr\Http\Message\StreamFactoryInterface; use Redmine\Exception\ClientException; use Redmine\Http\HttpClient; +use Redmine\Http\HttpFactory; use Redmine\Http\Request; use Redmine\Http\Response; @@ -90,37 +91,11 @@ public function request(Request $request): Response $request->getContentType() ); - return new class ( + return HttpFactory::makeResponse( $response->getStatusCode(), $response->getHeaderLine('Content-Type'), strval($response->getBody()) - ) implements Response { - private $statusCode; - private $contentType; - private $body; - - public function __construct(int $statusCode, string $contentType, string $body) - { - $this->statusCode = $statusCode; - $this->contentType = $contentType; - $this->body = $body; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - - public function getContentType(): string - { - return $this->contentType; - } - - public function getContent(): string - { - return $this->body; - } - }; + ); } /** diff --git a/src/Redmine/Http/HttpFactory.php b/src/Redmine/Http/HttpFactory.php new file mode 100644 index 00000000..61b18dc1 --- /dev/null +++ b/src/Redmine/Http/HttpFactory.php @@ -0,0 +1,44 @@ +statusCode = $statusCode; + $this->contentType = $contentType; + $this->body = $body; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getContent(): string + { + return $this->body; + } + }; + } +} From f4760995570bdf65efc70074ae257e17fe42bafd Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 14:15:36 +0100 Subject: [PATCH 05/11] Create test for HttpFactory::makeResponse() --- .../Http/HttpFactory/MakeResponseTest.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/Unit/Http/HttpFactory/MakeResponseTest.php diff --git a/tests/Unit/Http/HttpFactory/MakeResponseTest.php b/tests/Unit/Http/HttpFactory/MakeResponseTest.php new file mode 100644 index 00000000..407b90ec --- /dev/null +++ b/tests/Unit/Http/HttpFactory/MakeResponseTest.php @@ -0,0 +1,25 @@ +assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getContentType()); + $this->assertSame('content', $response->getContent()); + } +} From 44528568de82a7fb45ea91a66f1cf887e89dd54c Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 15:04:45 +0100 Subject: [PATCH 06/11] Add tests for Psr18Client::request() --- tests/Unit/Client/Psr18Client/RequestTest.php | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/Unit/Client/Psr18Client/RequestTest.php diff --git a/tests/Unit/Client/Psr18Client/RequestTest.php b/tests/Unit/Client/Psr18Client/RequestTest.php new file mode 100644 index 00000000..056c72f6 --- /dev/null +++ b/tests/Unit/Client/Psr18Client/RequestTest.php @@ -0,0 +1,136 @@ +createConfiguredMock(ClientInterface::class, [ + 'sendRequest' => $this->createConfiguredMock(ResponseInterface::class, [ + 'getStatusCode' => $statusCode, + 'getHeaderLine' => $contentType, + 'getBody' => $this->createConfiguredMock(StreamInterface::class, [ + '__toString' => $content, + ]), + ]) + ]); + + $requestFactory = $this->createConfiguredMock(RequestFactoryInterface::class, [ + 'createRequest' => (function() { + $request = $this->createMock(RequestInterface::class); + $request->method('withHeader')->willReturn($request); + $request->method('withBody')->willReturn($request); + + return $request; + })(), + ]); + + $client = new Psr18Client( + $httpClient, + $requestFactory, + $this->createMock(StreamFactoryInterface::class), + 'http://test.local', + 'access_token' + ); + + $request = $this->createConfiguredMock(Request::class, [ + 'getMethod' => $method, + 'getPath' => '/path', + 'getContentType' => $contentType, + 'getContent' => $data, + ]); + + $response = $client->request($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($statusCode, $response->getStatusCode()); + $this->assertSame($contentType, $response->getContentType()); + $this->assertSame($content, $response->getContent()); + } + + public static function getRequestReponseData(): array + { + return [ + ['GET', '', 101, 'text/plain', ''], + ['GET', '', 200, 'application/json', '{"foo_bar": 12345}'], + ['GET', '', 301, 'application/json', ''], + ['GET', '', 404, 'application/json', '{"title": "404 Not Found"}'], + ['GET', '', 500, 'text/plain', 'Internal Server Error'], + ['POST', '{"foo":"bar"}', 101, 'text/plain', ''], + ['POST', '{"foo":"bar"}', 200, 'application/json', '{"foo_bar": 12345}'], + ['POST', '{"foo":"bar"}', 301, 'application/json', ''], + ['POST', '{"foo":"bar"}', 404, 'application/json', '{"title": "404 Not Found"}'], + ['POST', '{"foo":"bar"}', 500, 'text/plain', 'Internal Server Error'], + ['PUT', '{"foo":"bar"}', 101, 'text/plain', ''], + ['PUT', '{"foo":"bar"}', 200, 'application/json', '{"foo_bar": 12345}'], + ['PUT', '{"foo":"bar"}', 301, 'application/json', ''], + ['PUT', '{"foo":"bar"}', 404, 'application/json', '{"title": "404 Not Found"}'], + ['PUT', '{"foo":"bar"}', 500, 'text/plain', 'Internal Server Error'], + ['DELETE', '', 101, 'text/plain', ''], + ['DELETE', '', 200, 'application/json', '{"foo_bar": 12345}'], + ['DELETE', '', 301, 'application/json', ''], + ['DELETE', '', 404, 'application/json', '{"title": "404 Not Found"}'], + ['DELETE', '', 500, 'text/plain', 'Internal Server Error'], + ]; + } + + public function testRequestThrowsClientException() + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(1))->method('sendRequest')->willThrowException( + new class('error message') extends Exception implements ClientExceptionInterface {} + ); + + $requestFactory = $this->createConfiguredMock(RequestFactoryInterface::class, [ + 'createRequest' => (function() { + $request = $this->createMock(RequestInterface::class); + $request->method('withHeader')->willReturn($request); + $request->method('withBody')->willReturn($request); + + return $request; + })(), + ]); + + $client = new Psr18Client( + $httpClient, + $requestFactory, + $this->createMock(StreamFactoryInterface::class), + 'http://test.local', + 'access_token' + ); + + $request = $this->createConfiguredMock(Request::class, [ + 'getMethod' => 'GET', + 'getPath' => '/path', + 'getContentType' => 'application/json', + 'getContent' => '', + ]); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('error message'); + + $client->request($request); + } +} From 94044532eec2ecd3c2985d41c2e7c8a3e3540b78 Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 15:34:51 +0100 Subject: [PATCH 07/11] Create test for NativeCurlClient::request() --- .../Client/NativeCurlClient/RequestTest.php | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/Unit/Client/NativeCurlClient/RequestTest.php diff --git a/tests/Unit/Client/NativeCurlClient/RequestTest.php b/tests/Unit/Client/NativeCurlClient/RequestTest.php new file mode 100644 index 00000000..90f83835 --- /dev/null +++ b/tests/Unit/Client/NativeCurlClient/RequestTest.php @@ -0,0 +1,93 @@ +createMock(stdClass::class); + + $curlInit = $this->getFunctionMock($namespace, 'curl_init'); + $curlInit->expects($this->exactly(1))->willReturn($curl); + + $curlExec = $this->getFunctionMock($namespace, 'curl_exec'); + $curlExec->expects($this->exactly(1))->willReturn($content); + + $curlSetoptArray = $this->getFunctionMock($namespace, 'curl_setopt_array'); + + $curlGetinfo = $this->getFunctionMock($namespace, 'curl_getinfo'); + $curlGetinfo->expects($this->exactly(2))->will($this->returnValueMap(([ + [$curl, CURLINFO_HTTP_CODE, $statusCode], + [$curl, CURLINFO_CONTENT_TYPE, $contentType], + ]))); + + $curlErrno = $this->getFunctionMock($namespace, 'curl_errno'); + $curlErrno->expects($this->exactly(1))->willReturn(CURLE_OK); + + $curlClose = $this->getFunctionMock($namespace, 'curl_close'); + + $client = new NativeCurlClient( + 'http://test.local', + 'access_token' + ); + + $request = $this->createConfiguredMock(Request::class, [ + 'getMethod' => $method, + 'getPath' => '/path', + 'getContentType' => $contentType, + 'getContent' => $data, + ]); + + $response = $client->request($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame($statusCode, $response->getStatusCode()); + $this->assertSame($contentType, $response->getContentType()); + $this->assertSame($content, $response->getContent()); + } + + public static function getRequestReponseData(): array + { + return [ + ['GET', '', 101, 'text/plain', ''], + ['GET', '', 200, 'application/json', '{"foo_bar": 12345}'], + ['GET', '', 301, 'application/json', ''], + ['GET', '', 404, 'application/json', '{"title": "404 Not Found"}'], + ['GET', '', 500, 'text/plain', 'Internal Server Error'], + ['POST', '{"foo":"bar"}', 101, 'text/plain', ''], + ['POST', '{"foo":"bar"}', 200, 'application/json', '{"foo_bar": 12345}'], + ['POST', '{"foo":"bar"}', 301, 'application/json', ''], + ['POST', '{"foo":"bar"}', 404, 'application/json', '{"title": "404 Not Found"}'], + ['POST', '{"foo":"bar"}', 500, 'text/plain', 'Internal Server Error'], + ['PUT', '{"foo":"bar"}', 101, 'text/plain', ''], + ['PUT', '{"foo":"bar"}', 200, 'application/json', '{"foo_bar": 12345}'], + ['PUT', '{"foo":"bar"}', 301, 'application/json', ''], + ['PUT', '{"foo":"bar"}', 404, 'application/json', '{"title": "404 Not Found"}'], + ['PUT', '{"foo":"bar"}', 500, 'text/plain', 'Internal Server Error'], + ['DELETE', '', 101, 'text/plain', ''], + ['DELETE', '', 200, 'application/json', '{"foo_bar": 12345}'], + ['DELETE', '', 301, 'application/json', ''], + ['DELETE', '', 404, 'application/json', '{"title": "404 Not Found"}'], + ['DELETE', '', 500, 'text/plain', 'Internal Server Error'], + ]; + } +} From 9fc8de6575b2d38af93a316177d6b9435eaca6f2 Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 15:45:35 +0100 Subject: [PATCH 08/11] Increase code coverage --- .../Client/NativeCurlClient/RequestTest.php | 2 ++ tests/Unit/Client/Psr18Client/RequestTest.php | 4 +++- tests/Unit/Client/Psr18ClientTest.php | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Client/NativeCurlClient/RequestTest.php b/tests/Unit/Client/NativeCurlClient/RequestTest.php index 90f83835..b1cf4fe5 100644 --- a/tests/Unit/Client/NativeCurlClient/RequestTest.php +++ b/tests/Unit/Client/NativeCurlClient/RequestTest.php @@ -12,6 +12,7 @@ /** * @covers \Redmine\Client\NativeCurlClient::request * @covers \Redmine\Client\NativeCurlClient::runRequest + * @covers \Redmine\Client\NativeCurlClient::createHttpHeader */ class RequestTest extends TestCase { @@ -68,6 +69,7 @@ public function testRequestReturnsCorrectResponse($method, $data, $statusCode, $ public static function getRequestReponseData(): array { return [ + ['GET', '', 101, '', ''], ['GET', '', 101, 'text/plain', ''], ['GET', '', 200, 'application/json', '{"foo_bar": 12345}'], ['GET', '', 301, 'application/json', ''], diff --git a/tests/Unit/Client/Psr18Client/RequestTest.php b/tests/Unit/Client/Psr18Client/RequestTest.php index 056c72f6..919ed90b 100644 --- a/tests/Unit/Client/Psr18Client/RequestTest.php +++ b/tests/Unit/Client/Psr18Client/RequestTest.php @@ -19,6 +19,7 @@ /** * @covers \Redmine\Client\Psr18Client::request * @covers \Redmine\Client\Psr18Client::runRequest + * @covers \Redmine\Client\Psr18Client::createRequest */ class RequestTest extends TestCase { @@ -73,9 +74,10 @@ public function testRequestReturnsCorrectResponse($method, $data, $statusCode, $ public static function getRequestReponseData(): array { return [ + ['GET', '', 101, '', ''], ['GET', '', 101, 'text/plain', ''], ['GET', '', 200, 'application/json', '{"foo_bar": 12345}'], - ['GET', '', 301, 'application/json', ''], + ['GET', '', 301, 'application/xml', ''], ['GET', '', 404, 'application/json', '{"title": "404 Not Found"}'], ['GET', '', 500, 'text/plain', 'Internal Server Error'], ['POST', '{"foo":"bar"}', 101, 'text/plain', ''], diff --git a/tests/Unit/Client/Psr18ClientTest.php b/tests/Unit/Client/Psr18ClientTest.php index 05d4b271..4b4d5808 100644 --- a/tests/Unit/Client/Psr18ClientTest.php +++ b/tests/Unit/Client/Psr18ClientTest.php @@ -2,6 +2,7 @@ namespace Redmine\Tests\Unit\Client; +use Exception; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; @@ -14,6 +15,7 @@ use Redmine\Client\Client; use Redmine\Client\Psr18Client; use Redmine\Http\HttpClient; +use stdClass; class Psr18ClientTest extends TestCase { @@ -298,6 +300,23 @@ public static function getApiClassesProvider(): array ]; } + /** + * @covers \Redmine\Client\Psr18Client::__construct + */ + public function testCreateWithoutFactoryThrowsException() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Redmine\Client\Psr18Client::__construct(): Argument #2 ($requestFactory) must be of type Psr\Http\Message\RequestFactoryInterface'); + + $client = new Psr18Client( + $this->createMock(ClientInterface::class), + new stdClass(), + $this->createMock(StreamFactoryInterface::class), + 'http://test.local', + 'access_token' + ); + } + /** * @covers \Redmine\Client\Psr18Client * @test From e1414679e3be425437b02c1ada6da893f17d4779 Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 15:46:29 +0100 Subject: [PATCH 09/11] Fix code style --- tests/Unit/Client/Psr18Client/RequestTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Client/Psr18Client/RequestTest.php b/tests/Unit/Client/Psr18Client/RequestTest.php index 919ed90b..3230c888 100644 --- a/tests/Unit/Client/Psr18Client/RequestTest.php +++ b/tests/Unit/Client/Psr18Client/RequestTest.php @@ -39,7 +39,7 @@ public function testRequestReturnsCorrectResponse($method, $data, $statusCode, $ ]); $requestFactory = $this->createConfiguredMock(RequestFactoryInterface::class, [ - 'createRequest' => (function() { + 'createRequest' => (function () { $request = $this->createMock(RequestInterface::class); $request->method('withHeader')->willReturn($request); $request->method('withBody')->willReturn($request); @@ -102,11 +102,11 @@ public function testRequestThrowsClientException() { $httpClient = $this->createMock(ClientInterface::class); $httpClient->expects($this->exactly(1))->method('sendRequest')->willThrowException( - new class('error message') extends Exception implements ClientExceptionInterface {} + new class ('error message') extends Exception implements ClientExceptionInterface {} ); $requestFactory = $this->createConfiguredMock(RequestFactoryInterface::class, [ - 'createRequest' => (function() { + 'createRequest' => (function () { $request = $this->createMock(RequestInterface::class); $request->method('withHeader')->willReturn($request); $request->method('withBody')->willReturn($request); From dc8fe5a801255145da3e459b19c9fdb9b21b103b Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 16:05:03 +0100 Subject: [PATCH 10/11] Fix bug in NativeCurlClient --- src/Redmine/Client/NativeCurlClient.php | 14 +++++++------- tests/End2End/Group/GroupTest.php | 10 ++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/Redmine/Client/NativeCurlClient.php b/src/Redmine/Client/NativeCurlClient.php index b4f584a1..f2129020 100644 --- a/src/Redmine/Client/NativeCurlClient.php +++ b/src/Redmine/Client/NativeCurlClient.php @@ -102,7 +102,7 @@ public function stopImpersonateUser(): void */ public function requestGet(string $path): bool { - return $this->runRequest('get', $path); + return $this->runRequest('GET', $path); } /** @@ -110,7 +110,7 @@ public function requestGet(string $path): bool */ public function requestPost(string $path, string $body): bool { - return $this->runRequest('post', $path, $body); + return $this->runRequest('POST', $path, $body); } /** @@ -118,7 +118,7 @@ public function requestPost(string $path, string $body): bool */ public function requestPut(string $path, string $body): bool { - return $this->runRequest('put', $path, $body); + return $this->runRequest('PUT', $path, $body); } /** @@ -126,7 +126,7 @@ public function requestPut(string $path, string $body): bool */ public function requestDelete(string $path): bool { - return $this->runRequest('delete', $path); + return $this->runRequest('DELETE', $path); } /** @@ -295,7 +295,7 @@ private function createCurl(string $method, string $path, string $body = '', str unset($curlOptions[CURLOPT_POST]); unset($curlOptions[CURLOPT_POSTFIELDS]); switch ($method) { - case 'post': + case 'POST': $curlOptions[CURLOPT_POST] = 1; if ($this->isUploadCall($path) && $this->isValidFilePath($body)) { @trigger_error('Uploading an attachment by filepath is deprecated, use file_get_contents() to upload the file content instead.', E_USER_DEPRECATED); @@ -311,13 +311,13 @@ private function createCurl(string $method, string $path, string $body = '', str $curlOptions[CURLOPT_POSTFIELDS] = $body; } break; - case 'put': + case 'PUT': $curlOptions[CURLOPT_CUSTOMREQUEST] = 'PUT'; if ($body !== '') { $curlOptions[CURLOPT_POSTFIELDS] = $body; } break; - case 'delete': + case 'DELETE': $curlOptions[CURLOPT_CUSTOMREQUEST] = 'DELETE'; break; default: // GET diff --git a/tests/End2End/Group/GroupTest.php b/tests/End2End/Group/GroupTest.php index b2d3faf2..2f033fc9 100644 --- a/tests/End2End/Group/GroupTest.php +++ b/tests/End2End/Group/GroupTest.php @@ -29,11 +29,13 @@ public function testInteractionWithGroup(RedmineVersion $redmineVersion): void 'name' => $groupName, ]); - $groupData = json_decode(json_encode($xmlData), true); + $jsonData = json_encode($xmlData); - $this->assertIsArray($groupData, json_encode($groupData)); - $this->assertIsString($groupData['id']); - $this->assertSame($groupName, $groupData['name']); + $groupData = json_decode($jsonData, true); + + $this->assertIsArray($groupData, $jsonData); + $this->assertIsString($groupData['id'], $jsonData); + $this->assertSame($groupName, $groupData['name'], $jsonData); $groupId = (int) $groupData['id']; From ef1b7ce5a6732e67e735eb82a45fc744e5d100c0 Mon Sep 17 00:00:00 2001 From: Art4 Date: Wed, 31 Jan 2024 16:21:59 +0100 Subject: [PATCH 11/11] increase code coverage in Psr18Client --- tests/Unit/Client/Psr18ClientTest.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Client/Psr18ClientTest.php b/tests/Unit/Client/Psr18ClientTest.php index 4b4d5808..7a9bf49b 100644 --- a/tests/Unit/Client/Psr18ClientTest.php +++ b/tests/Unit/Client/Psr18ClientTest.php @@ -10,6 +10,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestFactoryInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; use Redmine\Client\Client; @@ -40,13 +41,20 @@ public function shouldPassApiKeyToConstructor() /** * @covers \Redmine\Client\Psr18Client - * @test */ - public function acceptServerRequestFactoryInConstructorForBC() + public function testServerRequestFactoryIsAcceptedInConstructorForBC() { $client = new Psr18Client( $this->createMock(ClientInterface::class), - $this->createMock(ServerRequestFactoryInterface::class), + $this->createConfiguredMock(ServerRequestFactoryInterface::class, [ + 'createServerRequest' => (function () { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('withHeader')->willReturn($request); + $request->method('withBody')->willReturn($request); + + return $request; + })(), + ]), $this->createMock(StreamFactoryInterface::class), 'http://test.local', 'access_token' @@ -54,6 +62,8 @@ public function acceptServerRequestFactoryInConstructorForBC() $this->assertInstanceOf(Psr18Client::class, $client); $this->assertInstanceOf(Client::class, $client); + + $client->requestGet('/path.xml'); } /**