From 8a7cab9fbd0570c0bb2b40cea0265d1365c2c715 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:09:08 +0400 Subject: [PATCH 01/10] [#566] Implement native CurlAdapter execution --- src/HttpClient/Adapters/CurlAdapter.php | 258 ++++++++++++++++-- .../HttpClient/Adapters/CurlAdapterTest.php | 79 +++++- tests/Unit/HttpClient/HttpClientTest.php | 14 - 3 files changed, 305 insertions(+), 46 deletions(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index b21bb2e0..1833f9bf 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -11,6 +11,11 @@ namespace Quantum\HttpClient\Adapters; use Quantum\HttpClient\Contracts\CurlAdapterInterface; +use Curl\CaseInsensitiveArray; +use JsonSerializable; +use SimpleXMLElement; +use RuntimeException; +use CurlHandle; use Curl\Curl; /** @@ -19,16 +24,81 @@ */ class CurlAdapter implements CurlAdapterInterface { - private Curl $client; + private static int $lastId = 0; + + private ?Curl $client; + + private CurlHandle $handle; + + private int $id; + + private ?string $url = null; + + /** + * @var array + */ + private array $headers = []; + + private string $rawResponseHeaders = ''; + + /** + * @var mixed|null + */ + private $response; + + private CaseInsensitiveArray $responseHeaders; + + /** + * @var array + */ + private array $responseCookies = []; + + private bool $error = false; + + private int $errorCode = 0; + + private ?string $errorMessage = null; public function __construct(?Curl $client = null) { - $this->client = $client ?? new Curl(); + $this->client = $client; + $this->id = self::$lastId++; + + $handle = curl_init(); + + if (!$handle instanceof CurlHandle) { + throw new RuntimeException('Unable to initialize curl handle'); + } + + $this->handle = $handle; + $this->responseHeaders = new CaseInsensitiveArray(); + $this->applyOption(CURLOPT_RETURNTRANSFER, true); + $this->applyOption(CURLOPT_HEADER, false); + $this->applyOption(CURLOPT_HEADERFUNCTION, function ($handle, string $header): int { + $this->rawResponseHeaders .= $header; + $this->parseCookieHeader($header); + + return strlen($header); + }); + } + + public function __destruct() + { + curl_close($this->handle); + } + + /** + * @param mixed $value + */ + private function applyOption(int $option, $value): void + { + curl_setopt($this->handle, $option, $value); } public function setUrl(string $url): CurlAdapterInterface { - $this->client->setUrl($url); + $this->url = $url; + $this->applyOption(CURLOPT_URL, $url); return $this; } @@ -38,7 +108,7 @@ public function setUrl(string $url): CurlAdapterInterface */ public function setOpt(int $option, $value): CurlAdapterInterface { - $this->client->setOpt($option, $value); + $this->applyOption($option, $value); return $this; } @@ -48,7 +118,9 @@ public function setOpt(int $option, $value): CurlAdapterInterface */ public function setOpts(array $options): CurlAdapterInterface { - $this->client->setOpts($options); + foreach ($options as $option => $value) { + $this->setOpt($option, $value); + } return $this; } @@ -58,7 +130,8 @@ public function setOpts(array $options): CurlAdapterInterface */ public function setHeader(string $key, $value): CurlAdapterInterface { - $this->client->setHeader($key, $value); + $this->headers[$key] = $value; + $this->applyHeaders(); return $this; } @@ -68,7 +141,11 @@ public function setHeader(string $key, $value): CurlAdapterInterface */ public function setHeaders(array $headers): CurlAdapterInterface { - $this->client->setHeaders($headers); + foreach ($headers as $key => $value) { + $this->headers[trim((string) $key)] = trim((string) $value); + } + + $this->applyHeaders(); return $this; } @@ -79,12 +156,63 @@ public function setHeaders(array $headers): CurlAdapterInterface */ public function buildPostData($data) { - return $this->client->buildPostData($data); + if ( + $this->hasJsonContentType() && + ( + is_array($data) || + $data instanceof JsonSerializable + ) + ) { + return json_encode($data) ?: ''; + } + + return is_array($data) ? http_build_query($data) : $data; + } + + private function applyHeaders(): void + { + $headers = []; + + foreach ($this->headers as $key => $value) { + $headers[] = $key . ': ' . $value; + } + + $this->applyOption(CURLOPT_HTTPHEADER, $headers); + } + + private function hasJsonContentType(): bool + { + foreach ($this->headers as $key => $value) { + if (strtolower((string) $key) === 'content-type') { + return preg_match('/^application\/(?:[a-z.-]+\+)?json\b/i', (string) $value) === 1; + } + } + + return false; } public function start(): void { - $this->client->exec(); + if ($this->client !== null) { + $this->client->exec(); + return; + } + + $this->resetResponseState(); + + $rawResponse = curl_exec($this->handle); + $curlErrorCode = curl_errno($this->handle); + $curlErrorMessage = curl_error($this->handle); + $httpStatusCode = (int) $this->getInfo(CURLINFO_HTTP_CODE); + + $this->responseHeaders = $this->parseResponseHeaders($this->rawResponseHeaders); + $this->response = $this->parseResponse(is_string($rawResponse) ? $rawResponse : ''); + + $this->error = $curlErrorCode !== 0 || in_array((int) floor($httpStatusCode / 100), [4, 5], true); + $this->errorCode = $this->error ? ($curlErrorCode !== 0 ? $curlErrorCode : $httpStatusCode) : 0; + $this->errorMessage = $curlErrorCode !== 0 + ? trim(curl_strerror($curlErrorCode) . ($curlErrorMessage !== '' ? ': ' . $curlErrorMessage : '')) + : ($this->responseHeaders['Status-Line'] ?? ''); } /** @@ -92,22 +220,22 @@ public function start(): void */ public function getId() { - return $this->client->getId(); + return $this->client !== null ? $this->client->getId() : $this->id; } public function isError(): bool { - return $this->client->isError(); + return $this->client !== null ? $this->client->isError() : $this->error; } public function getErrorCode(): int { - return $this->client->getErrorCode(); + return $this->client !== null ? $this->client->getErrorCode() : $this->errorCode; } public function getErrorMessage(): ?string { - return $this->client->getErrorMessage(); + return $this->client !== null ? $this->client->getErrorMessage() : $this->errorMessage; } /** @@ -115,7 +243,7 @@ public function getErrorMessage(): ?string */ public function getResponseHeaders(): iterable { - return $this->client->getResponseHeaders(); + return $this->client !== null ? $this->client->getResponseHeaders() : $this->responseHeaders; } /** @@ -123,7 +251,7 @@ public function getResponseHeaders(): iterable */ public function getResponseCookies() { - return $this->client->getResponseCookies(); + return $this->client !== null ? $this->client->getResponseCookies() : $this->responseCookies; } /** @@ -131,7 +259,7 @@ public function getResponseCookies() */ public function getResponse() { - return $this->client->getResponse(); + return $this->client !== null ? $this->client->getResponse() : $this->response; } /** @@ -139,17 +267,21 @@ public function getResponse() */ public function getInfo(?int $option = null) { - return $option !== null ? $this->client->getInfo($option) : $this->client->getInfo(); + if ($this->client !== null) { + return $option !== null ? $this->client->getInfo($option) : $this->client->getInfo(); + } + + return $option !== null ? curl_getinfo($this->handle, $option) : curl_getinfo($this->handle); } public function getUrl(): ?string { - return $this->client->getUrl(); + return $this->url; } public function supportsMethod(string $method): bool { - return method_exists($this->client, $method); + return $this->client !== null && method_exists($this->client, $method); } /** @@ -158,6 +290,94 @@ public function supportsMethod(string $method): bool */ public function callMethod(string $method, array $arguments) { + if ($this->client === null) { + return null; + } + return $this->client->$method(...$arguments); } + + private function resetResponseState(): void + { + $this->rawResponseHeaders = ''; + $this->response = null; + $this->responseHeaders = new CaseInsensitiveArray(); + $this->responseCookies = []; + $this->error = false; + $this->errorCode = 0; + $this->errorMessage = null; + } + + private function parseCookieHeader(string $header): void + { + if (preg_match('/^Set-Cookie:\s*([^=]+)=([^;]+)/i', $header, $cookie) === 1) { + $this->responseCookies[$cookie[1]] = rawurldecode(trim($cookie[2], " \n\r\t\0\x0B")); + } + } + + private function parseResponseHeaders(string $rawHeaders): CaseInsensitiveArray + { + $headerBlocks = explode("\r\n\r\n", trim($rawHeaders)); + $responseHeader = ''; + + for ($i = count($headerBlocks) - 1; $i >= 0; $i--) { + if (isset($headerBlocks[$i]) && stripos($headerBlocks[$i], 'HTTP/') === 0) { + $responseHeader = $headerBlocks[$i]; + break; + } + } + + $headers = new CaseInsensitiveArray(); + $rawLines = preg_split('/\r\n/', $responseHeader, -1, PREG_SPLIT_NO_EMPTY); + + if ($rawLines === false || $rawLines === []) { + return $headers; + } + + $headers['Status-Line'] = $rawLines[0]; + + for ($i = 1, $count = count($rawLines); $i < $count; $i++) { + if (strpos($rawLines[$i], ':') === false) { + continue; + } + + [$key, $value] = array_pad(explode(':', $rawLines[$i], 2), 2, ''); + $key = trim($key); + $value = trim($value); + + if (isset($headers[$key])) { + $headers[$key] .= ',' . $value; + } else { + $headers[$key] = $value; + } + } + + return $headers; + } + + /** + * @return mixed + */ + private function parseResponse(string $rawResponse) + { + $response = $rawResponse; + $contentType = $this->responseHeaders['Content-Type'] ?? null; + + if (is_string($contentType) && preg_match('/\bjson\b/i', $contentType) === 1) { + $decoded = json_decode($rawResponse); + return json_last_error() === JSON_ERROR_NONE ? $decoded : $response; + } + + if (is_string($contentType) && preg_match('/\bxml\b/i', $contentType) === 1) { + $xml = simplexml_load_string($rawResponse); + return $xml instanceof SimpleXMLElement ? $xml : $response; + } + + if (($this->responseHeaders['Content-Encoding'] ?? null) === 'gzip') { + $decoded = gzdecode($rawResponse); + return $decoded !== false ? $decoded : $response; + } + + return $response; + } } diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 497f1909..d67e5ddd 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -19,16 +19,7 @@ public function tearDown(): void public function testCurlAdapterDelegatesRequestMethods(): void { - $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->once()->with('https://example.com'); - $curl->shouldReceive('setHeader')->once()->with('Accept', 'application/json'); - $curl->shouldReceive('setHeaders')->once()->with(['X-Test' => 'yes']); - $curl->shouldReceive('setOpt')->once()->with(CURLOPT_TIMEOUT, 10); - $curl->shouldReceive('setOpts')->once()->with([CURLOPT_CONNECTTIMEOUT => 5]); - $curl->shouldReceive('buildPostData')->once()->with(['a' => 1])->andReturn('a=1'); - $curl->shouldReceive('exec')->once()->andReturn('ok'); - - $adapter = new CurlAdapter($curl); + $adapter = new CurlAdapter(); $this->assertSame($adapter, $adapter->setUrl('https://example.com')); $this->assertSame($adapter, $adapter->setHeader('Accept', 'application/json')); @@ -36,8 +27,71 @@ public function testCurlAdapterDelegatesRequestMethods(): void $this->assertSame($adapter, $adapter->setOpt(CURLOPT_TIMEOUT, 10)); $this->assertSame($adapter, $adapter->setOpts([CURLOPT_CONNECTTIMEOUT => 5])); $this->assertSame('a=1', $adapter->buildPostData(['a' => 1])); + $this->assertSame('https://example.com', $adapter->getUrl()); + } + + public function testCurlAdapterBuildsJsonPostData(): void + { + $adapter = new CurlAdapter(); + $adapter->setHeader('Content-Type', 'application/json'); + + $this->assertSame('{"a":1}', $adapter->buildPostData(['a' => 1])); + } + + public function testCurlAdapterGeneratesNativeRequestIds(): void + { + $adapter1 = new CurlAdapter(); + $adapter2 = new CurlAdapter(); + + $this->assertNotSame($adapter1->getId(), $adapter2->getId()); + } + + public function testCurlAdapterParsesNativeResponseHeaders(): void + { + $adapter = new CurlAdapter(); + + $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', [ + "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Test: one\r\nX-Test: two\r\n\r\n", + ]); - $adapter->start(); + $this->assertSame('HTTP/1.1 200 OK', $headers['status-line']); + $this->assertSame('application/json', $headers['content-type']); + $this->assertSame('one,two', $headers['x-test']); + } + + public function testCurlAdapterParsesNativeCookieHeader(): void + { + $adapter = new CurlAdapter(); + + $this->invokePrivateMethod($adapter, 'parseCookieHeader', ['Set-Cookie: sid=abc%20123; Path=/; HttpOnly']); + + $this->assertSame(['sid' => 'abc 123'], $adapter->getResponseCookies()); + } + + public function testCurlAdapterParsesJsonResponse(): void + { + $adapter = new CurlAdapter(); + $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', [ + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n", + ]); + $this->setPrivateProperty($adapter, 'responseHeaders', $headers); + + $response = $this->invokePrivateMethod($adapter, 'parseResponse', ['{"ok":true}']); + + $this->assertTrue($response->ok); + } + + public function testCurlAdapterParsesXmlResponse(): void + { + $adapter = new CurlAdapter(); + $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', [ + "HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n\r\n", + ]); + $this->setPrivateProperty($adapter, 'responseHeaders', $headers); + + $response = $this->invokePrivateMethod($adapter, 'parseResponse', ['yes']); + + $this->assertSame('yes', (string) $response->ok); } public function testCurlAdapterDelegatesResponseMethods(): void @@ -54,9 +108,8 @@ public function testCurlAdapterDelegatesResponseMethods(): void $curl->shouldReceive('getResponseCookies')->once()->andReturn(['sid' => 'abc']); $curl->shouldReceive('getResponse')->once()->andReturn($response); $curl->shouldReceive('getInfo')->with(CURLINFO_HTTP_CODE)->once()->andReturn(200); - $curl->shouldReceive('getUrl')->once()->andReturn('https://example.com'); - $adapter = new CurlAdapter($curl); + $adapter->setUrl('https://example.com'); $this->assertSame(7, $adapter->getId()); $this->assertFalse($adapter->isError()); diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index abe3e22c..594e64fb 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -60,8 +60,6 @@ public function testHttpClientIsMultiRequest(): void { $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->once(); - $multi = Mockery::mock(MultiCurl::class); $multi->shouldReceive('complete')->once(); @@ -109,8 +107,6 @@ public function testHttpClientEnsureSingleRequestThrowsOnMulti(): void public function testHttpClientSingleRequestResponseFlow(): void { $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->once(); - $curl->shouldReceive('setOpt')->atLeast()->once(); $curl->shouldReceive('exec')->once(); $curl->shouldReceive('isError')->andReturn(false); $curl->shouldReceive('getId')->andReturn(0); @@ -133,10 +129,6 @@ public function testHttpClientSingleRequestResponseFlow(): void public function testHttpClientPostRequestWithData(): void { $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->once(); - $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'POST')->once(); - $curl->shouldReceive('buildPostData')->once()->andReturn('payload'); - $curl->shouldReceive('setOpt')->with(CURLOPT_POSTFIELDS, 'payload')->once(); $curl->shouldReceive('exec')->once(); $curl->shouldReceive('isError')->andReturn(false); $curl->shouldReceive('getId')->andReturn(0); @@ -156,8 +148,6 @@ public function testHttpClientPostRequestWithData(): void public function testHttpClientSingleRequestError(): void { $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->once(); - $curl->shouldReceive('setOpt')->atLeast()->once(); $curl->shouldReceive('exec')->once(); $curl->shouldReceive('isError')->andReturn(true); $curl->shouldReceive('getId')->andReturn(0); @@ -276,8 +266,6 @@ public function testHttpClientCreateAsyncMultiRequestRegistersCallbacks(): void public function testHttpClientInfoAndUrl(): void { $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->once(); - $curl->shouldReceive('setOpt')->atLeast()->once(); $curl->shouldReceive('exec')->once(); $curl->shouldReceive('isError')->andReturn(false); $curl->shouldReceive('getId')->andReturn(0); @@ -287,7 +275,6 @@ public function testHttpClientInfoAndUrl(): void $curl->shouldReceive('getInfo')->andReturnUsing( fn ($opt = null) => $opt === CURLINFO_HTTP_CODE ? 200 : ['http_code' => 200] ); - $curl->shouldReceive('getUrl')->andReturn('https://example.com'); $this->httpClient ->createRequest('https://example.com', $curl) @@ -301,7 +288,6 @@ public function testHttpClientInfoAndUrl(): void public function testHttpClientPassesZeroInfoOption(): void { $curl = Mockery::mock(Curl::class); - $curl->shouldReceive('setUrl')->once(); $curl->shouldReceive('getInfo')->with(0)->once()->andReturn('zero'); $this->httpClient->createRequest('https://example.com', $curl); From c7e59b8f80f08b0ee1e4ef214dcd626fc89bf360 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:15:21 +0400 Subject: [PATCH 02/10] [#566] Document CurlAdapter vendor bridge --- src/HttpClient/Adapters/CurlAdapter.php | 3 +++ tests/Unit/HttpClient/Adapters/CurlAdapterTest.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index 1833f9bf..dfb29ede 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -59,6 +59,9 @@ class CurlAdapter implements CurlAdapterInterface private ?string $errorMessage = null; + /** + * The injected vendor client is a temporary bridge for MultiCurlAdapter until #567. + */ public function __construct(?Curl $client = null) { $this->client = $client; diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index d67e5ddd..2d7a811d 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -94,7 +94,7 @@ public function testCurlAdapterParsesXmlResponse(): void $this->assertSame('yes', (string) $response->ok); } - public function testCurlAdapterDelegatesResponseMethods(): void + public function testCurlAdapterKeepsInjectedVendorClientAsTransitionBridge(): void { $headers = new CaseInsensitiveArray(['Content-Type' => 'application/json']); $response = (object) ['ok' => true]; From e169fa3e96ef6f4b92b09087b14e8bb640882110 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:18:57 +0400 Subject: [PATCH 03/10] [#566] Cover native CurlAdapter edge cases --- .../HttpClient/Adapters/CurlAdapterTest.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 2d7a811d..12f1c233 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -94,6 +94,30 @@ public function testCurlAdapterParsesXmlResponse(): void $this->assertSame('yes', (string) $response->ok); } + public function testCurlAdapterPassesZeroInfoOptionToNativeCurl(): void + { + $adapter = new CurlAdapter(); + + $this->assertFalse($adapter->getInfo(0)); + } + + public function testCurlAdapterStoresNativeHttpErrorState(): void + { + $adapter = new CurlAdapter(); + $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', [ + "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\n", + ]); + + $this->setPrivateProperty($adapter, 'responseHeaders', $headers); + $this->setPrivateProperty($adapter, 'error', true); + $this->setPrivateProperty($adapter, 'errorCode', 404); + $this->setPrivateProperty($adapter, 'errorMessage', $headers['Status-Line']); + + $this->assertTrue($adapter->isError()); + $this->assertSame(404, $adapter->getErrorCode()); + $this->assertSame('HTTP/1.1 404 Not Found', $adapter->getErrorMessage()); + } + public function testCurlAdapterKeepsInjectedVendorClientAsTransitionBridge(): void { $headers = new CaseInsensitiveArray(['Content-Type' => 'application/json']); From f77f520a39c95de42f4bc31f156be97b30e8538b Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:21:18 +0400 Subject: [PATCH 04/10] [#566] Update changelog for native CurlAdapter --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05211c09..67b8c46a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on Keep a Changelog. ### Changed - Refactored `HttpClient` internals behind explicit `CurlAdapter` and `MultiCurlAdapter` wrappers while preserving the existing facade methods and keeping `php-curl-class` as the underlying transport for this phase (#534) +- Replaced the single-request `HttpClient` `CurlAdapter` execution path with native PHP cURL while keeping the multi-curl adapter and `php-curl-class` dependency in place until the multi-request migration is complete (#566) - Refactored the Lang package to resolve adapter instances through `LangFactory` configuration and load file translations lazily on first use instead of preloading them during web boot (#533) - **BREAKING:** Reshaped Lang configuration so `lang.default` now selects the adapter, locale fallback moved to `lang.default_locale`, and the unused `lang.enabled` toggle was removed (#533) - **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533) From be7732ced49bdbf63d8519224e037a9ff7a74258 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:33:36 +0400 Subject: [PATCH 05/10] [#566] Support native CurlAdapter facade methods --- src/HttpClient/Adapters/CurlAdapter.php | 9 +++++++-- tests/Unit/HttpClient/Adapters/CurlAdapterTest.php | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index dfb29ede..3481c4b3 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -284,7 +284,8 @@ public function getUrl(): ?string public function supportsMethod(string $method): bool { - return $this->client !== null && method_exists($this->client, $method); + return in_array($method, ['setHeader', 'setHeaders', 'setOpt', 'setOpts'], true) + || ($this->client !== null && method_exists($this->client, $method)); } /** @@ -293,6 +294,10 @@ public function supportsMethod(string $method): bool */ public function callMethod(string $method, array $arguments) { + if (in_array($method, ['setHeader', 'setHeaders', 'setOpt', 'setOpts'], true)) { + return $this->$method(...$arguments); + } + if ($this->client === null) { return null; } @@ -340,7 +345,7 @@ private function parseResponseHeaders(string $rawHeaders): CaseInsensitiveArray $headers['Status-Line'] = $rawLines[0]; for ($i = 1, $count = count($rawLines); $i < $count; $i++) { - if (strpos($rawLines[$i], ':') === false) { + if (!str_contains($rawLines[$i], ':')) { continue; } diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 12f1c233..fcbc310b 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -167,4 +167,13 @@ public function testCurlAdapterSupportsAndCallsVendorMethods(): void $this->assertFalse($adapter->supportsMethod('missingMethod')); $this->assertNull($adapter->callMethod('setTimeout', [15])); } + + public function testCurlAdapterSupportsAndCallsNativeFacadeMethods(): void + { + $adapter = new CurlAdapter(); + + $this->assertTrue($adapter->supportsMethod('setHeaders')); + $this->assertFalse($adapter->supportsMethod('setTimeout')); + $this->assertSame($adapter, $adapter->callMethod('setHeaders', [['Accept' => 'application/json']])); + } } From 47cb54414fd47ccbb5a38d64a75a02161031c811 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:51:05 +0400 Subject: [PATCH 06/10] [#566] Cover CurlAdapter response parsing branches --- .../HttpClient/Adapters/CurlAdapterTest.php | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index fcbc310b..00094069 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -51,7 +51,7 @@ public function testCurlAdapterParsesNativeResponseHeaders(): void $adapter = new CurlAdapter(); $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', [ - "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Test: one\r\nX-Test: two\r\n\r\n", + "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Type: application/json\r\nMalformed Header\r\nX-Test: one\r\nX-Test: two\r\n\r\n", ]); $this->assertSame('HTTP/1.1 200 OK', $headers['status-line']); @@ -59,6 +59,15 @@ public function testCurlAdapterParsesNativeResponseHeaders(): void $this->assertSame('one,two', $headers['x-test']); } + public function testCurlAdapterReturnsEmptyHeadersWhenNoHttpHeaderBlockExists(): void + { + $adapter = new CurlAdapter(); + + $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', ['']); + + $this->assertCount(0, $headers); + } + public function testCurlAdapterParsesNativeCookieHeader(): void { $adapter = new CurlAdapter(); @@ -94,6 +103,19 @@ public function testCurlAdapterParsesXmlResponse(): void $this->assertSame('yes', (string) $response->ok); } + public function testCurlAdapterDecodesGzipResponse(): void + { + $adapter = new CurlAdapter(); + $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', [ + "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n\r\n", + ]); + $this->setPrivateProperty($adapter, 'responseHeaders', $headers); + + $response = $this->invokePrivateMethod($adapter, 'parseResponse', [gzencode('compressed response')]); + + $this->assertSame('compressed response', $response); + } + public function testCurlAdapterPassesZeroInfoOptionToNativeCurl(): void { $adapter = new CurlAdapter(); From e031541ae3f2b9aa547dd3824d2e1176aa899c74 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:01:05 +0400 Subject: [PATCH 07/10] [#566] Preserve CurlAdapter compatibility payloads --- src/HttpClient/Adapters/CurlAdapter.php | 49 +++++++++++++++++-- .../HttpClient/Adapters/CurlAdapterTest.php | 47 +++++++++++++++++- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index 3481c4b3..26553eb6 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -13,10 +13,10 @@ use Quantum\HttpClient\Contracts\CurlAdapterInterface; use Curl\CaseInsensitiveArray; use JsonSerializable; -use SimpleXMLElement; use RuntimeException; use CurlHandle; use Curl\Curl; +use CURLFile; /** * Class CurlAdapter @@ -102,6 +102,7 @@ public function setUrl(string $url): CurlAdapterInterface { $this->url = $url; $this->applyOption(CURLOPT_URL, $url); + $this->client?->setUrl($url); return $this; } @@ -112,6 +113,7 @@ public function setUrl(string $url): CurlAdapterInterface public function setOpt(int $option, $value): CurlAdapterInterface { $this->applyOption($option, $value); + $this->client?->setOpt($option, $value); return $this; } @@ -135,6 +137,7 @@ public function setHeader(string $key, $value): CurlAdapterInterface { $this->headers[$key] = $value; $this->applyHeaders(); + $this->client?->setHeader($key, $value); return $this; } @@ -149,6 +152,7 @@ public function setHeaders(array $headers): CurlAdapterInterface } $this->applyHeaders(); + $this->client?->setHeaders($headers); return $this; } @@ -159,6 +163,10 @@ public function setHeaders(array $headers): CurlAdapterInterface */ public function buildPostData($data) { + if ($this->client !== null) { + return $this->client->buildPostData($data); + } + if ( $this->hasJsonContentType() && ( @@ -169,7 +177,13 @@ public function buildPostData($data) return json_encode($data) ?: ''; } - return is_array($data) ? http_build_query($data) : $data; + if (is_array($data)) { + return $this->hasMultipartContentType() || $this->hasCurlFile($data) + ? $data + : http_build_query($data); + } + + return $data; } private function applyHeaders(): void @@ -194,6 +208,35 @@ private function hasJsonContentType(): bool return false; } + private function hasMultipartContentType(): bool + { + foreach ($this->headers as $key => $value) { + if (strtolower((string) $key) === 'content-type') { + return stripos((string) $value, 'multipart/form-data') !== false; + } + } + + return false; + } + + /** + * @param array $data + */ + private function hasCurlFile(array $data): bool + { + foreach ($data as $value) { + if ($value instanceof CURLFile) { + return true; + } + + if (is_array($value) && $this->hasCurlFile($value)) { + return true; + } + } + + return false; + } + public function start(): void { if ($this->client !== null) { @@ -378,7 +421,7 @@ private function parseResponse(string $rawResponse) if (is_string($contentType) && preg_match('/\bxml\b/i', $contentType) === 1) { $xml = simplexml_load_string($rawResponse); - return $xml instanceof SimpleXMLElement ? $xml : $response; + return $xml !== false ? $xml : $response; } if (($this->responseHeaders['Content-Encoding'] ?? null) === 'gzip') { diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 00094069..0bc2a16a 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -38,6 +38,24 @@ public function testCurlAdapterBuildsJsonPostData(): void $this->assertSame('{"a":1}', $adapter->buildPostData(['a' => 1])); } + public function testCurlAdapterPreservesMultipartPostData(): void + { + $adapter = new CurlAdapter(); + $adapter->setHeader('Content-Type', 'multipart/form-data'); + $data = ['name' => 'avatar']; + + $this->assertSame($data, $adapter->buildPostData($data)); + } + + public function testCurlAdapterPreservesCurlFilePostData(): void + { + $adapter = new CurlAdapter(); + $file = new \CURLFile(__FILE__); + $data = ['file' => $file]; + + $this->assertSame($data, $adapter->buildPostData($data)); + } + public function testCurlAdapterGeneratesNativeRequestIds(): void { $adapter1 = new CurlAdapter(); @@ -103,6 +121,19 @@ public function testCurlAdapterParsesXmlResponse(): void $this->assertSame('yes', (string) $response->ok); } + public function testCurlAdapterReturnsRawResponseWhenXmlParsingFails(): void + { + $adapter = new CurlAdapter(); + $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', [ + "HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\n\r\n", + ]); + $this->setPrivateProperty($adapter, 'responseHeaders', $headers); + + $response = $this->invokePrivateMethod($adapter, 'parseResponse', ['']); + + $this->assertSame('', $response); + } + public function testCurlAdapterDecodesGzipResponse(): void { $adapter = new CurlAdapter(); @@ -146,6 +177,12 @@ public function testCurlAdapterKeepsInjectedVendorClientAsTransitionBridge(): vo $response = (object) ['ok' => true]; $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setUrl')->once()->with('https://example.com'); + $curl->shouldReceive('setHeader')->once()->with('Accept', 'application/json'); + $curl->shouldReceive('setHeaders')->once()->with(['X-Test' => 'yes']); + $curl->shouldReceive('setOpt')->once()->with(CURLOPT_TIMEOUT, 10); + $curl->shouldReceive('buildPostData')->once()->with(['a' => 1])->andReturn('payload'); + $curl->shouldReceive('exec')->once(); $curl->shouldReceive('getId')->once()->andReturn(7); $curl->shouldReceive('isError')->once()->andReturn(false); $curl->shouldReceive('getErrorCode')->once()->andReturn(0); @@ -155,8 +192,14 @@ public function testCurlAdapterKeepsInjectedVendorClientAsTransitionBridge(): vo $curl->shouldReceive('getResponse')->once()->andReturn($response); $curl->shouldReceive('getInfo')->with(CURLINFO_HTTP_CODE)->once()->andReturn(200); $adapter = new CurlAdapter($curl); - $adapter->setUrl('https://example.com'); - + $adapter + ->setUrl('https://example.com') + ->setHeader('Accept', 'application/json') + ->setHeaders(['X-Test' => 'yes']) + ->setOpt(CURLOPT_TIMEOUT, 10) + ->start(); + + $this->assertSame('payload', $adapter->buildPostData(['a' => 1])); $this->assertSame(7, $adapter->getId()); $this->assertFalse($adapter->isError()); $this->assertSame(0, $adapter->getErrorCode()); From 805d17b4b236c78b1cb9e8ff73c2dbed3a86e2e3 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:09:20 +0400 Subject: [PATCH 08/10] [#566] Preserve CurlAdapter wrapped client state --- src/HttpClient/Adapters/CurlAdapter.php | 12 ++++++---- .../HttpClient/Adapters/CurlAdapterTest.php | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index 26553eb6..f73ac8d3 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -256,9 +256,13 @@ public function start(): void $this->error = $curlErrorCode !== 0 || in_array((int) floor($httpStatusCode / 100), [4, 5], true); $this->errorCode = $this->error ? ($curlErrorCode !== 0 ? $curlErrorCode : $httpStatusCode) : 0; - $this->errorMessage = $curlErrorCode !== 0 - ? trim(curl_strerror($curlErrorCode) . ($curlErrorMessage !== '' ? ': ' . $curlErrorMessage : '')) - : ($this->responseHeaders['Status-Line'] ?? ''); + $this->errorMessage = $this->error + ? ( + $curlErrorCode !== 0 + ? trim(curl_strerror($curlErrorCode) . ($curlErrorMessage !== '' ? ': ' . $curlErrorMessage : '')) + : ($this->responseHeaders['Status-Line'] ?? '') + ) + : null; } /** @@ -322,7 +326,7 @@ public function getInfo(?int $option = null) public function getUrl(): ?string { - return $this->url; + return $this->url ?? $this->client?->getUrl(); } public function supportsMethod(string $method): bool diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 0bc2a16a..0fdb0fdc 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -171,6 +171,19 @@ public function testCurlAdapterStoresNativeHttpErrorState(): void $this->assertSame('HTTP/1.1 404 Not Found', $adapter->getErrorMessage()); } + public function testCurlAdapterKeepsNativeErrorMessageNullOnSuccess(): void + { + $adapter = new CurlAdapter(); + + $this->setPrivateProperty($adapter, 'error', false); + $this->setPrivateProperty($adapter, 'errorCode', 0); + $this->setPrivateProperty($adapter, 'errorMessage', null); + + $this->assertFalse($adapter->isError()); + $this->assertSame(0, $adapter->getErrorCode()); + $this->assertNull($adapter->getErrorMessage()); + } + public function testCurlAdapterKeepsInjectedVendorClientAsTransitionBridge(): void { $headers = new CaseInsensitiveArray(['Content-Type' => 'application/json']); @@ -211,6 +224,16 @@ public function testCurlAdapterKeepsInjectedVendorClientAsTransitionBridge(): vo $this->assertSame('https://example.com', $adapter->getUrl()); } + public function testCurlAdapterGetsUrlFromWrappedVendorClient(): void + { + $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('getUrl')->once()->andReturn('https://example.com/from-multi'); + + $adapter = new CurlAdapter($curl); + + $this->assertSame('https://example.com/from-multi', $adapter->getUrl()); + } + public function testCurlAdapterPassesZeroInfoOption(): void { $curl = Mockery::mock(Curl::class); From 3b27e78cdba134b73df5981437c0a84eddd5dd8a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:16:56 +0400 Subject: [PATCH 09/10] [#566] Harden CurlAdapter response parsing --- src/HttpClient/Adapters/CurlAdapter.php | 11 ++++++++-- .../HttpClient/Adapters/CurlAdapterTest.php | 21 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index f73ac8d3..d61d6179 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -365,7 +365,7 @@ private function resetResponseState(): void private function parseCookieHeader(string $header): void { - if (preg_match('/^Set-Cookie:\s*([^=]+)=([^;]+)/i', $header, $cookie) === 1) { + if (preg_match('/^Set-Cookie:\s*([^=]+)=([^;]*)/i', $header, $cookie) === 1) { $this->responseCookies[$cookie[1]] = rawurldecode(trim($cookie[2], " \n\r\t\0\x0B")); } } @@ -424,7 +424,14 @@ private function parseResponse(string $rawResponse) } if (is_string($contentType) && preg_match('/\bxml\b/i', $contentType) === 1) { - $xml = simplexml_load_string($rawResponse); + $previousUseInternalErrors = libxml_use_internal_errors(true); + + try { + $xml = simplexml_load_string($rawResponse); + } finally { + libxml_use_internal_errors($previousUseInternalErrors); + } + return $xml !== false ? $xml : $response; } diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 0fdb0fdc..9ae01a27 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -95,6 +95,16 @@ public function testCurlAdapterParsesNativeCookieHeader(): void $this->assertSame(['sid' => 'abc 123'], $adapter->getResponseCookies()); } + public function testCurlAdapterParsesEmptyNativeCookieValue(): void + { + $adapter = new CurlAdapter(); + + $this->invokePrivateMethod($adapter, 'parseCookieHeader', ['Set-Cookie: sid=abc123; Path=/; HttpOnly']); + $this->invokePrivateMethod($adapter, 'parseCookieHeader', ['Set-Cookie: sid=; Max-Age=0; Path=/']); + + $this->assertSame(['sid' => ''], $adapter->getResponseCookies()); + } + public function testCurlAdapterParsesJsonResponse(): void { $adapter = new CurlAdapter(); @@ -129,9 +139,16 @@ public function testCurlAdapterReturnsRawResponseWhenXmlParsingFails(): void ]); $this->setPrivateProperty($adapter, 'responseHeaders', $headers); - $response = $this->invokePrivateMethod($adapter, 'parseResponse', ['']); + $previousUseInternalErrors = libxml_use_internal_errors(false); + + try { + $response = $this->invokePrivateMethod($adapter, 'parseResponse', ['']); - $this->assertSame('', $response); + $this->assertSame('', $response); + $this->assertFalse(libxml_use_internal_errors()); + } finally { + libxml_use_internal_errors($previousUseInternalErrors); + } } public function testCurlAdapterDecodesGzipResponse(): void From bb12b3c399e123f68f3dc11ddd7adb0dfefda512 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:37:03 +0400 Subject: [PATCH 10/10] [#566] Cover CurlAdapter gzip response parsing --- src/HttpClient/Adapters/CurlAdapter.php | 15 ++++++++------- .../Unit/HttpClient/Adapters/CurlAdapterTest.php | 13 +++++++++++++ tests/Unit/HttpClient/HttpClientTest.php | 12 ++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/HttpClient/Adapters/CurlAdapter.php b/src/HttpClient/Adapters/CurlAdapter.php index d61d6179..2e8182ff 100644 --- a/src/HttpClient/Adapters/CurlAdapter.php +++ b/src/HttpClient/Adapters/CurlAdapter.php @@ -417,9 +417,15 @@ private function parseResponse(string $rawResponse) { $response = $rawResponse; $contentType = $this->responseHeaders['Content-Type'] ?? null; + $contentEncoding = $this->responseHeaders['Content-Encoding'] ?? null; + + if (is_string($contentEncoding) && strtolower($contentEncoding) === 'gzip') { + $decoded = gzdecode($rawResponse); + $response = $decoded !== false ? $decoded : $response; + } if (is_string($contentType) && preg_match('/\bjson\b/i', $contentType) === 1) { - $decoded = json_decode($rawResponse); + $decoded = json_decode($response); return json_last_error() === JSON_ERROR_NONE ? $decoded : $response; } @@ -427,7 +433,7 @@ private function parseResponse(string $rawResponse) $previousUseInternalErrors = libxml_use_internal_errors(true); try { - $xml = simplexml_load_string($rawResponse); + $xml = simplexml_load_string($response); } finally { libxml_use_internal_errors($previousUseInternalErrors); } @@ -435,11 +441,6 @@ private function parseResponse(string $rawResponse) return $xml !== false ? $xml : $response; } - if (($this->responseHeaders['Content-Encoding'] ?? null) === 'gzip') { - $decoded = gzdecode($rawResponse); - return $decoded !== false ? $decoded : $response; - } - return $response; } } diff --git a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php index 9ae01a27..fb8478be 100644 --- a/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php +++ b/tests/Unit/HttpClient/Adapters/CurlAdapterTest.php @@ -164,6 +164,19 @@ public function testCurlAdapterDecodesGzipResponse(): void $this->assertSame('compressed response', $response); } + public function testCurlAdapterParsesGzippedJsonResponse(): void + { + $adapter = new CurlAdapter(); + $headers = $this->invokePrivateMethod($adapter, 'parseResponseHeaders', [ + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Encoding: GZip\r\n\r\n", + ]); + $this->setPrivateProperty($adapter, 'responseHeaders', $headers); + + $response = $this->invokePrivateMethod($adapter, 'parseResponse', [gzencode('{"ok":true}')]); + + $this->assertTrue($response->ok); + } + public function testCurlAdapterPassesZeroInfoOptionToNativeCurl(): void { $adapter = new CurlAdapter(); diff --git a/tests/Unit/HttpClient/HttpClientTest.php b/tests/Unit/HttpClient/HttpClientTest.php index 594e64fb..c2121dba 100644 --- a/tests/Unit/HttpClient/HttpClientTest.php +++ b/tests/Unit/HttpClient/HttpClientTest.php @@ -59,6 +59,7 @@ public function testHttpClientGetSetData(): void public function testHttpClientIsMultiRequest(): void { $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setUrl')->with('https://example.com')->once(); $multi = Mockery::mock(MultiCurl::class); @@ -107,6 +108,8 @@ public function testHttpClientEnsureSingleRequestThrowsOnMulti(): void public function testHttpClientSingleRequestResponseFlow(): void { $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setUrl')->with('https://example.com')->once(); + $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'GET')->once(); $curl->shouldReceive('exec')->once(); $curl->shouldReceive('isError')->andReturn(false); $curl->shouldReceive('getId')->andReturn(0); @@ -129,6 +132,10 @@ public function testHttpClientSingleRequestResponseFlow(): void public function testHttpClientPostRequestWithData(): void { $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setUrl')->with('https://example.com')->once(); + $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'POST')->once(); + $curl->shouldReceive('buildPostData')->with(['x' => 1])->once()->andReturn('x=1'); + $curl->shouldReceive('setOpt')->with(CURLOPT_POSTFIELDS, 'x=1')->once(); $curl->shouldReceive('exec')->once(); $curl->shouldReceive('isError')->andReturn(false); $curl->shouldReceive('getId')->andReturn(0); @@ -148,6 +155,8 @@ public function testHttpClientPostRequestWithData(): void public function testHttpClientSingleRequestError(): void { $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setUrl')->with('https://bad.local')->once(); + $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'GET')->once(); $curl->shouldReceive('exec')->once(); $curl->shouldReceive('isError')->andReturn(true); $curl->shouldReceive('getId')->andReturn(0); @@ -266,6 +275,8 @@ public function testHttpClientCreateAsyncMultiRequestRegistersCallbacks(): void public function testHttpClientInfoAndUrl(): void { $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setUrl')->with('https://example.com')->once(); + $curl->shouldReceive('setOpt')->with(CURLOPT_CUSTOMREQUEST, 'GET')->once(); $curl->shouldReceive('exec')->once(); $curl->shouldReceive('isError')->andReturn(false); $curl->shouldReceive('getId')->andReturn(0); @@ -288,6 +299,7 @@ public function testHttpClientInfoAndUrl(): void public function testHttpClientPassesZeroInfoOption(): void { $curl = Mockery::mock(Curl::class); + $curl->shouldReceive('setUrl')->with('https://example.com')->once(); $curl->shouldReceive('getInfo')->with(0)->once()->andReturn('zero'); $this->httpClient->createRequest('https://example.com', $curl);