diff --git a/.gitignore b/.gitignore index 31e3ac6..f8e12d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ vendor/ .idea +.phpunit.result.cache +coverage/ diff --git a/README.md b/README.md index 449a090..6841d76 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ $apiClient = new ApiClient(); $response = $apiClient->get("https://httpbin.org/get"); ``` +## Testing + +Run the test suite: + +```bash +./vendor/bin/phpunit +``` + +See the [tests/README.md](tests/README.md) for more information. + ## License MIT \ No newline at end of file diff --git a/composer.json b/composer.json index bcdf951..ed39c36 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "type": "library", "require": { "guzzlehttp/guzzle": "^7.0", - "php": "^7.3", + "php": "^7.3|^8.0", "ext-json": "*", "ext-simplexml": "*", "liopoos/http-code": "^1.0" @@ -23,5 +23,10 @@ "psr-4": { "Liopoos\\Booze\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Liopoos\\Booze\\Tests\\": "tests/" + } } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..14212a9 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests/Unit + + + + + ./src + + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..6710ff9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,43 @@ +# Booze Tests + +This directory contains unit tests for the Booze HTTP library. + +## Running Tests + +To run all tests: + +```bash +./vendor/bin/phpunit +``` + +To run tests with detailed output: + +```bash +./vendor/bin/phpunit --testdox +``` + +To run tests with code coverage (requires Xdebug or PCOV): + +```bash +./vendor/bin/phpunit --coverage-html coverage +``` + +## Test Structure + +- `Unit/` - Unit tests for individual components + - `ClientTest.php` - Tests for the Client class + - `Http/HttpClientTest.php` - Tests for the HttpClient class + - `Http/Middleware/RequestHandlerMiddlewareTest.php` - Tests for request middleware + - `Http/Middleware/ResponseHandlerMiddlewareTest.php` - Tests for response middleware + - `Utils/ResponseStreamTest.php` - Tests for the ResponseStream utility + - `Exception/ExceptionTest.php` - Tests for exception classes + +## Test Coverage + +The test suite covers: +- All HTTP methods (GET, POST, PUT, PATCH, DELETE) +- JSON and multipart requests +- Request and response middleware +- Response stream parsing (JSON, XML, plain text) +- Exception handling +- Header management diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php new file mode 100644 index 0000000..ee94a38 --- /dev/null +++ b/tests/Unit/ClientTest.php @@ -0,0 +1,129 @@ +mockHandler = new MockHandler(); + $this->handlerStack = HandlerStack::create($this->mockHandler); + } + + public function testConstructorWithDefaultHandler() + { + $client = new TestClient(); + + $this->assertInstanceOf(Client::class, $client); + } + + public function testConstructorWithCustomHandler() + { + $client = new TestClient(['handler' => $this->handlerStack]); + + $this->assertInstanceOf(Client::class, $client); + } + + public function testWithHeadersReturnsClientInstance() + { + $client = new TestClient(['handler' => $this->handlerStack]); + + $result = $client->withHeaders(['Authorization' => 'Bearer token']); + + $this->assertInstanceOf(Client::class, $result); + } + + public function testWithHeadersWithMultipleHeaders() + { + $client = new TestClient(['handler' => $this->handlerStack]); + + $headers = [ + 'Authorization' => 'Bearer token123', + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ]; + + $result = $client->withHeaders($headers); + + $this->assertInstanceOf(Client::class, $result); + } + + public function testGetHandlerReturnsHandlerStack() + { + $client = new TestClient(['handler' => $this->handlerStack]); + + // Use reflection to access protected method + $reflection = new \ReflectionClass($client); + $method = $reflection->getMethod('getHandler'); + $method->setAccessible(true); + + $handler = $method->invoke($client); + + $this->assertInstanceOf(HandlerStack::class, $handler); + } + + public function testClientWithGuzzleOptions() + { + $options = [ + 'handler' => $this->handlerStack, + 'verify' => false, + 'timeout' => 30 + ]; + + $client = new TestClient($options); + + $this->assertInstanceOf(Client::class, $client); + } + + public function testClientCanMakeRequest() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Success') + ); + + $client = new TestClient(['handler' => $this->handlerStack]); + $result = $client->get('https://httpbin.org/get'); + + $this->assertEquals('Success', $result); + } + + public function testClientWithHeadersCanMakeRequest() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Success with headers') + ); + + $client = new TestClient(['handler' => $this->handlerStack]); + $client->withHeaders(['Authorization' => 'Bearer token']); + + $result = $client->get('https://httpbin.org/get'); + + $this->assertEquals('Success with headers', $result); + } + + public function testClientChainedWithHeaders() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Chained success') + ); + + $client = new TestClient(['handler' => $this->handlerStack]); + $result = $client->withHeaders(['X-Custom' => 'Value'])->get('https://httpbin.org/get'); + + $this->assertEquals('Chained success', $result); + } +} diff --git a/tests/Unit/Exception/ExceptionTest.php b/tests/Unit/Exception/ExceptionTest.php new file mode 100644 index 0000000..8bee636 --- /dev/null +++ b/tests/Unit/Exception/ExceptionTest.php @@ -0,0 +1,80 @@ +assertInstanceOf(TransferException::class, $exception); + $this->assertEquals('Test error', $exception->getMessage()); + } + + public function testNotFoundHttpExceptionExtendsApiException() + { + $exception = new NotFoundHttpException('Not found'); + + $this->assertInstanceOf(ApiException::class, $exception); + $this->assertInstanceOf(TransferException::class, $exception); + $this->assertEquals('Not found', $exception->getMessage()); + } + + public function testUnauthorizedHttpExceptionExtendsApiException() + { + $exception = new UnauthorizedHttpException('Unauthorized'); + + $this->assertInstanceOf(ApiException::class, $exception); + $this->assertInstanceOf(TransferException::class, $exception); + $this->assertEquals('Unauthorized', $exception->getMessage()); + } + + public function testAccessDeniedHttpExceptionExtendsApiException() + { + $exception = new AccessDeniedHttpException('Access denied'); + + $this->assertInstanceOf(ApiException::class, $exception); + $this->assertInstanceOf(TransferException::class, $exception); + $this->assertEquals('Access denied', $exception->getMessage()); + } + + public function testApiExceptionWithCode() + { + $exception = new ApiException('Error', 500); + + $this->assertEquals('Error', $exception->getMessage()); + $this->assertEquals(500, $exception->getCode()); + } + + public function testNotFoundHttpExceptionWithCode() + { + $exception = new NotFoundHttpException('Resource not found', 404); + + $this->assertEquals('Resource not found', $exception->getMessage()); + $this->assertEquals(404, $exception->getCode()); + } + + public function testUnauthorizedHttpExceptionWithCode() + { + $exception = new UnauthorizedHttpException('Invalid credentials', 401); + + $this->assertEquals('Invalid credentials', $exception->getMessage()); + $this->assertEquals(401, $exception->getCode()); + } + + public function testAccessDeniedHttpExceptionWithCode() + { + $exception = new AccessDeniedHttpException('Forbidden resource', 403); + + $this->assertEquals('Forbidden resource', $exception->getMessage()); + $this->assertEquals(403, $exception->getCode()); + } +} diff --git a/tests/Unit/Http/HttpClientTest.php b/tests/Unit/Http/HttpClientTest.php new file mode 100644 index 0000000..fd6e7d0 --- /dev/null +++ b/tests/Unit/Http/HttpClientTest.php @@ -0,0 +1,213 @@ +mockHandler = new MockHandler(); + $this->handlerStack = HandlerStack::create($this->mockHandler); + // Add the ResponseHandlerMiddleware to wrap responses properly + $this->handlerStack->push(Middleware::mapResponse(new ResponseHandlerMiddleware())); + } + + private function createHttpClient() + { + return new TestHttpClient(['handler' => $this->handlerStack]); + } + + public function testGetHttpClient() + { + $httpClient = $this->createHttpClient(); + + $result = $httpClient->getHttpClient(); + + $this->assertInstanceOf(GuzzleClient::class, $result); + } + + public function testGetRequest() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Success') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->get('https://httpbin.org/get'); + + $this->assertEquals('Success', $result); + } + + public function testGetRequestWithQuery() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Query result') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->get('https://httpbin.org/get', ['key' => 'value']); + + $this->assertEquals('Query result', $result); + } + + public function testGetRequestWithHeaders() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Header result') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->get('https://httpbin.org/get', [], ['Authorization' => 'Bearer token']); + + $this->assertEquals('Header result', $result); + } + + public function testPostRequest() + { + $this->mockHandler->append( + new Response(201, ['Content-Type' => 'text/plain'], 'Created') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->post('https://httpbin.org/post', ['name' => 'test']); + + $this->assertEquals('Created', $result); + } + + public function testPostJsonRequest() + { + $this->mockHandler->append( + new Response(201, ['Content-Type' => 'application/json'], '{"status":"success"}') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->postJson('https://httpbin.org/post', ['name' => 'test']); + + $this->assertIsArray($result); + $this->assertEquals(['status' => 'success'], $result); + } + + public function testPostMultiPartRequest() + { + $this->mockHandler->append( + new Response(201, ['Content-Type' => 'text/plain'], 'Uploaded') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->postMultiPart('https://httpbin.org/post', [ + ['name' => 'field1', 'contents' => 'value1'] + ]); + + $this->assertEquals('Uploaded', $result); + } + + public function testDeleteRequest() + { + $this->mockHandler->append( + new Response(204, ['Content-Type' => 'text/plain'], '') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->delete('https://httpbin.org/delete'); + + $this->assertEquals('', $result); + } + + public function testDeleteJsonRequest() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], '{"deleted":true}') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->deleteJson('https://httpbin.org/delete', ['id' => 1]); + + $this->assertIsArray($result); + $this->assertEquals(['deleted' => true], $result); + } + + public function testPatchRequest() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], '{"patched":true}') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->patch('https://httpbin.org/patch', ['field' => 'updated']); + + $this->assertIsArray($result); + $this->assertEquals(['patched' => true], $result); + } + + public function testPutRequest() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Updated') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->put('https://httpbin.org/put', ['name' => 'updated']); + + $this->assertEquals('Updated', $result); + } + + public function testPutJsonRequest() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'application/json'], '{"updated":true}') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->putJson('https://httpbin.org/put', ['name' => 'updated']); + + $this->assertIsArray($result); + $this->assertEquals(['updated' => true], $result); + } + + public function testPutMultiPartRequest() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Updated') + ); + + $httpClient = $this->createHttpClient(); + $result = $httpClient->putMultiPart('https://httpbin.org/put', [ + ['name' => 'field1', 'contents' => 'value1'] + ]); + + $this->assertEquals('Updated', $result); + } + + public function testGetHttpResponse() + { + $this->mockHandler->append( + new Response(200, ['Content-Type' => 'text/plain'], 'Response') + ); + + $httpClient = $this->createHttpClient(); + $httpClient->get('https://httpbin.org/get'); + + $response = $httpClient->getHttpResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + } +} diff --git a/tests/Unit/Http/Middleware/RequestHandlerMiddlewareTest.php b/tests/Unit/Http/Middleware/RequestHandlerMiddlewareTest.php new file mode 100644 index 0000000..af1a7da --- /dev/null +++ b/tests/Unit/Http/Middleware/RequestHandlerMiddlewareTest.php @@ -0,0 +1,87 @@ +middleware = new RequestHandlerMiddleware(); + } + + public function testSetHeaders() + { + $headers = [ + 'Authorization' => 'Bearer token123', + 'Content-Type' => 'application/json' + ]; + + $result = $this->middleware->setHeaders($headers); + + $this->assertInstanceOf(RequestHandlerMiddleware::class, $result); + } + + public function testInvokeAddsHeaderToRequest() + { + $headers = ['Authorization' => 'Bearer token123']; + $this->middleware->setHeaders($headers); + + $request = new Request('GET', 'https://example.com'); + $result = ($this->middleware)($request); + + $this->assertInstanceOf(Request::class, $result); + $this->assertTrue($result->hasHeader('Authorization')); + $this->assertEquals('Bearer token123', $result->getHeaderLine('Authorization')); + } + + public function testInvokeWithMultipleHeaders() + { + $headers = [ + 'Authorization' => 'Bearer token123', + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ]; + $this->middleware->setHeaders($headers); + + $request = new Request('POST', 'https://example.com'); + $result = ($this->middleware)($request); + + $this->assertInstanceOf(Request::class, $result); + $this->assertTrue($result->hasHeader('Authorization')); + $this->assertTrue($result->hasHeader('Content-Type')); + $this->assertTrue($result->hasHeader('Accept')); + $this->assertEquals('Bearer token123', $result->getHeaderLine('Authorization')); + $this->assertEquals('application/json', $result->getHeaderLine('Content-Type')); + $this->assertEquals('application/json', $result->getHeaderLine('Accept')); + } + + public function testInvokeWithNoHeaders() + { + $request = new Request('GET', 'https://example.com'); + $result = ($this->middleware)($request); + + $this->assertInstanceOf(Request::class, $result); + $this->assertEquals($request->getUri(), $result->getUri()); + $this->assertEquals($request->getMethod(), $result->getMethod()); + } + + public function testInvokePreservesExistingHeaders() + { + $headers = ['X-Custom-Header' => 'custom-value']; + $this->middleware->setHeaders($headers); + + $request = new Request('GET', 'https://example.com', ['User-Agent' => 'TestAgent']); + $result = ($this->middleware)($request); + + $this->assertTrue($result->hasHeader('User-Agent')); + $this->assertTrue($result->hasHeader('X-Custom-Header')); + $this->assertEquals('TestAgent', $result->getHeaderLine('User-Agent')); + $this->assertEquals('custom-value', $result->getHeaderLine('X-Custom-Header')); + } +} diff --git a/tests/Unit/Http/Middleware/ResponseHandlerMiddlewareTest.php b/tests/Unit/Http/Middleware/ResponseHandlerMiddlewareTest.php new file mode 100644 index 0000000..6e1400b --- /dev/null +++ b/tests/Unit/Http/Middleware/ResponseHandlerMiddlewareTest.php @@ -0,0 +1,125 @@ +middleware = new ResponseHandlerMiddleware(); + } + + public function testIsSuccessfulWithSuccessfulStatusCode() + { + $response = new Response(200); + + $this->assertTrue($this->middleware->isSuccessful($response)); + } + + public function testIsSuccessfulWithCreatedStatusCode() + { + $response = new Response(201); + + $this->assertTrue($this->middleware->isSuccessful($response)); + } + + public function testIsSuccessfulWithClientErrorStatusCode() + { + $response = new Response(400); + + $this->assertFalse($this->middleware->isSuccessful($response)); + } + + public function testIsSuccessfulWithServerErrorStatusCode() + { + $response = new Response(500); + + $this->assertFalse($this->middleware->isSuccessful($response)); + } + + public function testInvokeWithSuccessfulResponse() + { + $jsonData = ['message' => 'success']; + $jsonString = json_encode($jsonData); + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $jsonString); + rewind($resource); + + $stream = new Stream($resource); + $response = new Response(200, ['Content-Type' => 'application/json'], $stream); + + $result = ($this->middleware)($response); + + $this->assertNotNull($result); + $this->assertEquals(200, $result->getStatusCode()); + } + + public function testHandleErrorResponseWithUnauthorized() + { + $this->expectException(UnauthorizedHttpException::class); + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, 'Unauthorized'); + rewind($resource); + + $stream = new Stream($resource); + $response = new Response(Http::HTTP_UNAUTHORIZED, ['Content-Type' => 'text/plain'], $stream); + + ($this->middleware)($response); + } + + public function testHandleErrorResponseWithNotFound() + { + $this->expectException(NotFoundHttpException::class); + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, 'Not Found'); + rewind($resource); + + $stream = new Stream($resource); + $response = new Response(Http::HTTP_NOT_FOUND, ['Content-Type' => 'text/plain'], $stream); + + ($this->middleware)($response); + } + + public function testHandleErrorResponseWithForbidden() + { + $this->expectException(AccessDeniedHttpException::class); + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, 'Forbidden'); + rewind($resource); + + $stream = new Stream($resource); + $response = new Response(Http::HTTP_FORBIDDEN, ['Content-Type' => 'text/plain'], $stream); + + ($this->middleware)($response); + } + + public function testHandleErrorResponseWithGenericError() + { + $this->expectException(ApiException::class); + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, 'Internal Server Error'); + rewind($resource); + + $stream = new Stream($resource); + $response = new Response(500, ['Content-Type' => 'text/plain'], $stream); + + ($this->middleware)($response); + } +} diff --git a/tests/Unit/Utils/ResponseStreamTest.php b/tests/Unit/Utils/ResponseStreamTest.php new file mode 100644 index 0000000..10e71c4 --- /dev/null +++ b/tests/Unit/Utils/ResponseStreamTest.php @@ -0,0 +1,95 @@ + 'success', 'code' => 200]; + $jsonString = json_encode($jsonData); + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $jsonString); + rewind($resource); + + $stream = new Stream($resource); + $responseStream = new ResponseStream($stream, 'application/json'); + + $result = $responseStream->getStreamContents(); + + $this->assertEquals($jsonData, $result); + } + + public function testGetStreamContentsWithJavascriptContentType() + { + $jsonData = ['data' => 'test', 'status' => 'ok']; + $jsonString = json_encode($jsonData); + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $jsonString); + rewind($resource); + + $stream = new Stream($resource); + $responseStream = new ResponseStream($stream, 'application/javascript'); + + $result = $responseStream->getStreamContents(); + + $this->assertEquals($jsonData, $result); + } + + public function testGetStreamContentsWithXmlContentType() + { + $xmlString = 'success200'; + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $xmlString); + rewind($resource); + + $stream = new Stream($resource); + $responseStream = new ResponseStream($stream, 'application/xml'); + + $result = $responseStream->getStreamContents(); + + $this->assertIsArray($result); + $this->assertEquals('success', $result['message']); + $this->assertEquals('200', $result['code']); + } + + public function testGetStreamContentsWithPlainTextContentType() + { + $textData = 'plain text response'; + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $textData); + rewind($resource); + + $stream = new Stream($resource); + $responseStream = new ResponseStream($stream, 'text/plain'); + + $result = $responseStream->getStreamContents(); + + $this->assertEquals($textData, $result); + } + + public function testGetStreamContentsWithInvalidJson() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Error decode response content to json'); + + $invalidJson = '{invalid json}'; + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $invalidJson); + rewind($resource); + + $stream = new Stream($resource); + $responseStream = new ResponseStream($stream, 'application/json'); + + $responseStream->getStreamContents(); + } +}