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/src/TestingHttpHandler.php b/src/TestingHttpHandler.php index c5223ca..d59bcd5 100644 --- a/src/TestingHttpHandler.php +++ b/src/TestingHttpHandler.php @@ -524,11 +524,9 @@ public function sse( ): PromiseInterface { $mockedRequests = array_values($this->mockedRequests); - $curlOnlyOptions = array_filter($options, 'is_int', ARRAY_FILTER_USE_KEY); - $innerPromise = $this->requestExecutor->executeSSE( $url, - $curlOnlyOptions, + $options, $mockedRequests, $this->globalSettings, $onEvent, diff --git a/src/Utilities/Executors/SSERequestExecutor.php b/src/Utilities/Executors/SSERequestExecutor.php index 0bebd52..92dd558 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 { @@ -31,7 +33,7 @@ public function __construct( } /** - * @param array $curlOptions + * @param array $curlOptions * @param list $mockedRequests * @param array $globalSettings * @param mixed $reconnectConfig @@ -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 ); } @@ -127,7 +153,7 @@ private function handleMatchedSSE( } /** - * @param array $curlOptions + * @param array $curlOptions // <-- FIXED: Updated PHPDoc * @param list $mockedRequests * @param array $globalSettings * @param mixed $reconnectConfig @@ -158,13 +184,13 @@ 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; } /** - * @param array $curlOptions + * @param array $curlOptions // <-- FIXED: Updated PHPDoc * @param list $mockedRequests * @param array $globalSettings * @param array{mock: MockedRequest, index: int}|null $initialMatch @@ -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; @@ -198,7 +223,7 @@ private function executeWithRetry( } /** - * @param array $curlOptions + * @param array $curlOptions * @param list $mockedRequests * @param array{mock: MockedRequest, index: int}|null $initialMatch */ @@ -261,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/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/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 new file mode 100644 index 0000000..cdc2f7b --- /dev/null +++ b/tests/Simulation/StreamBodyMockingTest.php @@ -0,0 +1,123 @@ +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'); + }); + +}); diff --git a/tests/Simulation/StreamLifeCycleTest.php b/tests/Simulation/StreamLifeCycleTest.php index e676176..04305b2 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']);