From 51b160b5636dca100badc7cee5296c468b5bfff3 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 18 Jul 2026 12:33:13 +0800 Subject: [PATCH 1/3] Add support for mocking readable stream bodies in HTTP requests --- src/TestingHttpHandler.php | 8 +- .../Executors/SSERequestExecutor.php | 39 ++++-- .../Executors/StandardRequestExecutor.php | 26 +++- tests/Simulation/StreamBodyMockingTest.php | 117 ++++++++++++++++++ 4 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 tests/Simulation/StreamBodyMockingTest.php diff --git a/src/TestingHttpHandler.php b/src/TestingHttpHandler.php index c5223ca..079fead 100644 --- a/src/TestingHttpHandler.php +++ b/src/TestingHttpHandler.php @@ -524,11 +524,11 @@ public function sse( ): PromiseInterface { $mockedRequests = array_values($this->mockedRequests); - $curlOnlyOptions = array_filter($options, 'is_int', ARRAY_FILTER_USE_KEY); - + // Keep the original $options array intact (retaining both string and integer keys) + // so that the '_hibla_stream' property and other context values are not discarded $innerPromise = $this->requestExecutor->executeSSE( $url, - $curlOnlyOptions, + $options, $mockedRequests, $this->globalSettings, $onEvent, @@ -604,4 +604,4 @@ public function reset(): void $this->cookieManager->cleanup(); $this->requestRecorder->reset(); } -} +} \ No newline at end of file diff --git a/src/Utilities/Executors/SSERequestExecutor.php b/src/Utilities/Executors/SSERequestExecutor.php index 0bebd52..7b67721 100644 --- a/src/Utilities/Executors/SSERequestExecutor.php +++ b/src/Utilities/Executors/SSERequestExecutor.php @@ -10,7 +10,9 @@ use Hibla\HttpClient\Testing\Utilities\RequestMatcher; use Hibla\HttpClient\Testing\Utilities\RequestRecorder; use Hibla\HttpClient\Testing\Utilities\ResponseFactory; +use Hibla\HttpClient\Utils\HiblaStreamAdapter; use Hibla\Promise\Interfaces\PromiseInterface; +use Hibla\Stream\Interfaces\ReadableStreamInterface; class SSERequestExecutor { @@ -48,9 +50,32 @@ public function execute( ?callable $parentSSE = null, $reconnectConfig = null ): PromiseInterface { - $method = 'GET'; - // Check for a matching mock FIRST before evaluating retry settings + $hiblaStream = $curlOptions['_hibla_stream'] ?? null; + if ($hiblaStream instanceof ReadableStreamInterface) { + $adapter = new HiblaStreamAdapter($hiblaStream); + $bufferedBody = $adapter->getContents(); + + $curlOptions[CURLOPT_POSTFIELDS] = $bufferedBody; + unset($curlOptions['_hibla_stream']); + unset($curlOptions[CURLOPT_UPLOAD]); + unset($curlOptions[CURLOPT_READFUNCTION]); + + if (isset($curlOptions[CURLOPT_HTTPHEADER]) && is_array($curlOptions[CURLOPT_HTTPHEADER])) { + $curlOptions[CURLOPT_HTTPHEADER] = array_values(array_filter( + $curlOptions[CURLOPT_HTTPHEADER], + function ($header) { + return ! (is_string($header) && stripos($header, 'Transfer-Encoding: chunked') !== false); + } + )); + } + } + + $method = $curlOptions[CURLOPT_CUSTOMREQUEST] ?? (isset($curlOptions[CURLOPT_POSTFIELDS]) ? 'POST' : 'GET'); + if (! is_string($method)) { + $method = 'GET'; + } + $match = $this->requestMatcher->findMatchingMock($mockedRequests, $method, $url, $curlOptions); if ($match === null) { @@ -80,7 +105,8 @@ public function execute( $onError, $reconnectConfig, $parentSSE, - $match + $match, + $method ); } @@ -158,7 +184,7 @@ private function handleNoMatch( } /** @var PromiseInterface<\Hibla\HttpClient\SSE\SSEResponse> $result */ - $result = $parentSSE($url, [], $onEvent, $onError, $reconnectConfig); + $result = $parentSSE($url, $curlOptions, $onEvent, $onError, $reconnectConfig); return $result; } @@ -180,10 +206,9 @@ private function executeWithRetry( ?callable $onError, \Hibla\HttpClient\SSE\SSEReconnectConfig $reconnectConfig, ?callable $parentSSE, - ?array $initialMatch = null + ?array $initialMatch = null, + string $method = 'GET' ): PromiseInterface { - $method = 'GET'; - $mockProvider = $this->createMockProvider($method, $url, $curlOptions, $mockedRequests, $initialMatch); $onReconnectCallback = $reconnectConfig->onReconnect; diff --git a/src/Utilities/Executors/StandardRequestExecutor.php b/src/Utilities/Executors/StandardRequestExecutor.php index 9c301d6..728bed0 100644 --- a/src/Utilities/Executors/StandardRequestExecutor.php +++ b/src/Utilities/Executors/StandardRequestExecutor.php @@ -14,8 +14,10 @@ use Hibla\HttpClient\Testing\Utilities\RequestRecorder; use Hibla\HttpClient\Testing\Utilities\ResponseFactory; use Hibla\HttpClient\Testing\Utilities\Validators\RequestValidator; +use Hibla\HttpClient\Utils\HiblaStreamAdapter; use Hibla\HttpClient\ValueObjects\RetryConfig; use Hibla\Promise\Interfaces\PromiseInterface; +use Hibla\Stream\Interfaces\ReadableStreamInterface; class StandardRequestExecutor { @@ -68,6 +70,28 @@ public function execute( ?RetryConfig $retryConfig = null, ?callable $parentSendRequest = null ): PromiseInterface { + + $hiblaStream = $curlOptions['_hibla_stream'] ?? null; + if ($hiblaStream instanceof ReadableStreamInterface) { + $adapter = new HiblaStreamAdapter($hiblaStream); + $bufferedBody = $adapter->getContents(); + + $curlOptions[CURLOPT_POSTFIELDS] = $bufferedBody; + unset($curlOptions['_hibla_stream']); + unset($curlOptions[CURLOPT_UPLOAD]); + unset($curlOptions[CURLOPT_READFUNCTION]); + + // Strip out 'Transfer-Encoding: chunked' since we have fully buffered the body + if (isset($curlOptions[CURLOPT_HTTPHEADER]) && is_array($curlOptions[CURLOPT_HTTPHEADER])) { + $curlOptions[CURLOPT_HTTPHEADER] = array_values(array_filter( + $curlOptions[CURLOPT_HTTPHEADER], + function ($header) { + return ! (is_string($header) && stripos($header, 'Transfer-Encoding: chunked') !== false); + } + )); + } + } + /** @var array $curlOnlyOptions */ $curlOnlyOptions = array_filter($curlOptions, 'is_int', ARRAY_FILTER_USE_KEY); @@ -114,7 +138,7 @@ public function execute( */ private function extractMethod(array $curlOptions): string { - $method = $curlOptions[CURLOPT_CUSTOMREQUEST] ?? 'GET'; + $method = $curlOptions[CURLOPT_CUSTOMREQUEST] ?? (isset($curlOptions[CURLOPT_POSTFIELDS]) ? 'POST' : 'GET'); return is_string($method) ? $method : 'GET'; } diff --git a/tests/Simulation/StreamBodyMockingTest.php b/tests/Simulation/StreamBodyMockingTest.php new file mode 100644 index 0000000..882b607 --- /dev/null +++ b/tests/Simulation/StreamBodyMockingTest.php @@ -0,0 +1,117 @@ +expectBody() can match it', function () { + Http::mock('POST') + ->url('https://api.example.com/upload') + ->expectBody('*chunk 2*') + ->respondJson(['success' => true]) + ->register(); + + $stream = new ThroughStream(); + + Loop::addTimer(0.05, fn() => $stream->write('chunk 1, ')); + Loop::addTimer(0.10, function() use ($stream) { + $stream->write('chunk 2'); + $stream->end(); + }); + + $response = await( + Http::client() + ->body($stream) + ->post('https://api.example.com/upload') + ); + + expect($response->status())->toBe(200) + ->and($response->json('success'))->toBeTrue(); + }); + + it('records the streamed body so it can be asserted later', function () { + Http::mock('POST') + ->url('https://api.example.com/upload') + ->respondWithStatus(201) + ->register(); + + $stream = new ThroughStream(); + + Loop::addTimer(0.01, function() use ($stream) { + $stream->write('{"user_id": 99}'); + $stream->end(); + }); + + await( + Http::client() + ->body($stream) + ->post('https://api.example.com/upload') + ); + + Http::assertRequestWithBody('POST', 'https://api.example.com/upload', '{"user_id": 99}'); + Http::assertRequestIsJson('POST', 'https://api.example.com/upload'); + Http::assertRequestJsonContains('POST', 'https://api.example.com/upload', ['user_id' => 99]); + }); + + it('supports POSTing a readable stream body to an SSE mock connection', function () { + // 1. Mock an SSE endpoint that only matches a POST request with specific body content + Http::mock('POST') + ->url('https://api.example.com/sse-stream') + ->expectBody('*stream_active*') + ->respondWithSSE([ + ['event' => 'acknowledged', 'data' => '{"received":true}', 'id' => '1'], + ]) + ->register(); + + $stream = new ThroughStream(); + + // Feed the request body stream asynchronously + Loop::addTimer(0.05, fn() => $stream->write('payload_')); + Loop::addTimer(0.10, function() use ($stream) { + $stream->write('stream_active'); + $stream->end(); + }); + + $events = []; + + // 2. Open an SSE connection, passing the stream as the request body and explicitly setting the method to POST + $promise = Http::client() + ->withMethod('POST') // Instruct the client to POST the stream instead of defaulting to GET + ->body($stream) + ->sse('https://api.example.com/sse-stream') + ->onEvent(function (SSEEvent $event) use (&$events) { + $events[] = $event; + }) + ->connect(); + + await($promise); + + // 3. Verify that the client received the SSE events + expect($events)->toHaveCount(1) + ->and($events[0]->event)->toBe('acknowledged') + ->and($events[0]->data)->toBe('{"received":true}'); + + // 4. Assertions on the intercepted stream body & headers + Http::assertRequestMade('POST', 'https://api.example.com/sse-stream'); + Http::assertRequestWithBody('POST', 'https://api.example.com/sse-stream', 'payload_stream_active'); + Http::assertSSEConnectionMade('https://api.example.com/sse-stream'); + }); + +}); \ No newline at end of file From 0520b701e42a1efb8dac0015532bc2b4b3c700cd Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 18 Jul 2026 21:01:23 +0800 Subject: [PATCH 2/3] Refactor PHPDoc for curlOptions to support string keys and improve stream body mocking tests --- src/TestingHttpHandler.php | 4 +-- .../Executors/SSERequestExecutor.php | 12 +++---- src/Utilities/RequestExecutor.php | 5 +-- src/Utilities/RequestMatcher.php | 2 +- tests/Simulation/StreamBodyMockingTest.php | 32 +++++++++++-------- tests/Simulation/StreamLifeCycleTest.php | 5 +++ tests/Simulation/StreamingAndDownloadTest.php | 6 +++- 7 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/TestingHttpHandler.php b/src/TestingHttpHandler.php index 079fead..d59bcd5 100644 --- a/src/TestingHttpHandler.php +++ b/src/TestingHttpHandler.php @@ -524,8 +524,6 @@ public function sse( ): PromiseInterface { $mockedRequests = array_values($this->mockedRequests); - // Keep the original $options array intact (retaining both string and integer keys) - // so that the '_hibla_stream' property and other context values are not discarded $innerPromise = $this->requestExecutor->executeSSE( $url, $options, @@ -604,4 +602,4 @@ public function reset(): void $this->cookieManager->cleanup(); $this->requestRecorder->reset(); } -} \ No newline at end of file +} diff --git a/src/Utilities/Executors/SSERequestExecutor.php b/src/Utilities/Executors/SSERequestExecutor.php index 7b67721..92dd558 100644 --- a/src/Utilities/Executors/SSERequestExecutor.php +++ b/src/Utilities/Executors/SSERequestExecutor.php @@ -33,7 +33,7 @@ public function __construct( } /** - * @param array $curlOptions + * @param array $curlOptions * @param list $mockedRequests * @param array $globalSettings * @param mixed $reconnectConfig @@ -153,7 +153,7 @@ private function handleMatchedSSE( } /** - * @param array $curlOptions + * @param array $curlOptions // <-- FIXED: Updated PHPDoc * @param list $mockedRequests * @param array $globalSettings * @param mixed $reconnectConfig @@ -190,7 +190,7 @@ private function handleNoMatch( } /** - * @param array $curlOptions + * @param array $curlOptions // <-- FIXED: Updated PHPDoc * @param list $mockedRequests * @param array $globalSettings * @param array{mock: MockedRequest, index: int}|null $initialMatch @@ -223,7 +223,7 @@ private function executeWithRetry( } /** - * @param array $curlOptions + * @param array $curlOptions * @param list $mockedRequests * @param array{mock: MockedRequest, index: int}|null $initialMatch */ @@ -286,9 +286,9 @@ private function createMockProvider( } /** - * @param array $curlOptions + * @param array $curlOptions * - * @return array + * @return array */ private function addLastEventId(array $curlOptions, ?string $lastEventId): array { diff --git a/src/Utilities/RequestExecutor.php b/src/Utilities/RequestExecutor.php index b417e97..247e251 100644 --- a/src/Utilities/RequestExecutor.php +++ b/src/Utilities/RequestExecutor.php @@ -5,6 +5,7 @@ namespace Hibla\HttpClient\Testing\Utilities; use Hibla\HttpClient\Response; +use Hibla\HttpClient\SSE\SSEResponse; use Hibla\HttpClient\StreamingResponse; use Hibla\HttpClient\Testing\MockedRequest; use Hibla\HttpClient\Testing\Utilities\Executors\SSERequestExecutor; @@ -99,12 +100,12 @@ public function executeSendRequest( } /** - * @param array $curlOptions + * @param array $curlOptions * @param list $mockedRequests * @param array $globalSettings * @param mixed $reconnectConfig * - * @return PromiseInterface<\Hibla\HttpClient\SSE\SSEResponse> + * @return PromiseInterface */ public function executeSSE( string $url, diff --git a/src/Utilities/RequestMatcher.php b/src/Utilities/RequestMatcher.php index c8518c5..05a01c8 100644 --- a/src/Utilities/RequestMatcher.php +++ b/src/Utilities/RequestMatcher.php @@ -10,7 +10,7 @@ class RequestMatcher { /** * @param array $mocks - * @param array $options + * @param array $options * * @return array{mock: MockedRequest, index: int}|null */ diff --git a/tests/Simulation/StreamBodyMockingTest.php b/tests/Simulation/StreamBodyMockingTest.php index 882b607..cdc2f7b 100644 --- a/tests/Simulation/StreamBodyMockingTest.php +++ b/tests/Simulation/StreamBodyMockingTest.php @@ -26,12 +26,13 @@ ->url('https://api.example.com/upload') ->expectBody('*chunk 2*') ->respondJson(['success' => true]) - ->register(); + ->register() + ; $stream = new ThroughStream(); - Loop::addTimer(0.05, fn() => $stream->write('chunk 1, ')); - Loop::addTimer(0.10, function() use ($stream) { + Loop::addTimer(0.05, fn () => $stream->write('chunk 1, ')); + Loop::addTimer(0.10, function () use ($stream) { $stream->write('chunk 2'); $stream->end(); }); @@ -43,18 +44,20 @@ ); expect($response->status())->toBe(200) - ->and($response->json('success'))->toBeTrue(); + ->and($response->json('success'))->toBeTrue() + ; }); it('records the streamed body so it can be asserted later', function () { Http::mock('POST') ->url('https://api.example.com/upload') ->respondWithStatus(201) - ->register(); + ->register() + ; $stream = new ThroughStream(); - - Loop::addTimer(0.01, function() use ($stream) { + + Loop::addTimer(0.01, function () use ($stream) { $stream->write('{"user_id": 99}'); $stream->end(); }); @@ -78,13 +81,14 @@ ->respondWithSSE([ ['event' => 'acknowledged', 'data' => '{"received":true}', 'id' => '1'], ]) - ->register(); + ->register() + ; $stream = new ThroughStream(); // Feed the request body stream asynchronously - Loop::addTimer(0.05, fn() => $stream->write('payload_')); - Loop::addTimer(0.10, function() use ($stream) { + Loop::addTimer(0.05, fn () => $stream->write('payload_')); + Loop::addTimer(0.10, function () use ($stream) { $stream->write('stream_active'); $stream->end(); }); @@ -99,14 +103,16 @@ ->onEvent(function (SSEEvent $event) use (&$events) { $events[] = $event; }) - ->connect(); + ->connect() + ; await($promise); // 3. Verify that the client received the SSE events expect($events)->toHaveCount(1) ->and($events[0]->event)->toBe('acknowledged') - ->and($events[0]->data)->toBe('{"received":true}'); + ->and($events[0]->data)->toBe('{"received":true}') + ; // 4. Assertions on the intercepted stream body & headers Http::assertRequestMade('POST', 'https://api.example.com/sse-stream'); @@ -114,4 +120,4 @@ Http::assertSSEConnectionMade('https://api.example.com/sse-stream'); }); -}); \ No newline at end of file +}); diff --git a/tests/Simulation/StreamLifeCycleTest.php b/tests/Simulation/StreamLifeCycleTest.php index e676176..61bc45d 100644 --- a/tests/Simulation/StreamLifeCycleTest.php +++ b/tests/Simulation/StreamLifeCycleTest.php @@ -6,6 +6,9 @@ use Hibla\HttpClient\Http; +use function Hibla\await; +use function Hibla\delay; + beforeEach(function () { Http::startTesting(); }); @@ -22,6 +25,8 @@ $chunk1 = $response->readAsync(5)->wait(); expect($chunk1)->toBe('part1'); + await(delay(0.02)); + $fullBody = $response->body(); expect($fullBody)->toBe('part1part2part3'); expect($response->body())->toBe('part1part2part3'); diff --git a/tests/Simulation/StreamingAndDownloadTest.php b/tests/Simulation/StreamingAndDownloadTest.php index b8f30e3..d26a7ac 100644 --- a/tests/Simulation/StreamingAndDownloadTest.php +++ b/tests/Simulation/StreamingAndDownloadTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +namespace Tests\Simulation; + use Hibla\HttpClient\Http; use Hibla\HttpClient\Testing\TestingHttpHandler; @@ -26,7 +28,9 @@ $receivedChunks[] = $chunk; }; - Http::stream('/stream', $onChunkCallback)->wait(); + $response = Http::stream('/stream', $onChunkCallback)->wait(); + + $response->readAllAsync()->wait(); Http::assertStreamMade('/stream'); expect($receivedChunks)->toBe(['first chunk', ' second chunk', ' last chunk']); From 3624ccef1cbffac7e4ee4402cb19588d69a83cb3 Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 18 Jul 2026 21:11:02 +0800 Subject: [PATCH 3/3] Remove unnecessary delay in StreamLifeCycleTest for improved test reliability --- .gitattributes | 1 + tests/Simulation/StreamLifeCycleTest.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 65f33d4..ec49234 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,4 @@ /pint.json export-ignore /.github export-ignore /docker export-ignore +/README.md export-ignore \ No newline at end of file diff --git a/tests/Simulation/StreamLifeCycleTest.php b/tests/Simulation/StreamLifeCycleTest.php index 61bc45d..04305b2 100644 --- a/tests/Simulation/StreamLifeCycleTest.php +++ b/tests/Simulation/StreamLifeCycleTest.php @@ -25,7 +25,7 @@ $chunk1 = $response->readAsync(5)->wait(); expect($chunk1)->toBe('part1'); - await(delay(0.02)); + // await(delay(0.02)); $fullBody = $response->body(); expect($fullBody)->toBe('part1part2part3');