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