Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
/pint.json export-ignore
/.github export-ignore
/docker export-ignore
/README.md export-ignore
4 changes: 1 addition & 3 deletions src/TestingHttpHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 38 additions & 13 deletions src/Utilities/Executors/SSERequestExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -31,7 +33,7 @@ public function __construct(
}

/**
* @param array<int, mixed> $curlOptions
* @param array<int|string, mixed> $curlOptions
* @param list<MockedRequest> $mockedRequests
* @param array<string, mixed> $globalSettings
* @param mixed $reconnectConfig
Expand All @@ -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) {
Expand Down Expand Up @@ -80,7 +105,8 @@ public function execute(
$onError,
$reconnectConfig,
$parentSSE,
$match
$match,
$method
);
}

Expand Down Expand Up @@ -127,7 +153,7 @@ private function handleMatchedSSE(
}

/**
* @param array<int, mixed> $curlOptions
* @param array<int|string, mixed> $curlOptions // <-- FIXED: Updated PHPDoc
* @param list<MockedRequest> $mockedRequests
* @param array<string, mixed> $globalSettings
* @param mixed $reconnectConfig
Expand Down Expand Up @@ -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<int, mixed> $curlOptions
* @param array<int|string, mixed> $curlOptions // <-- FIXED: Updated PHPDoc
* @param list<MockedRequest> $mockedRequests
* @param array<string, mixed> $globalSettings
* @param array{mock: MockedRequest, index: int}|null $initialMatch
Expand All @@ -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;
Expand All @@ -198,7 +223,7 @@ private function executeWithRetry(
}

/**
* @param array<int, mixed> $curlOptions
* @param array<int|string, mixed> $curlOptions
* @param list<MockedRequest> $mockedRequests
* @param array{mock: MockedRequest, index: int}|null $initialMatch
*/
Expand Down Expand Up @@ -261,9 +286,9 @@ private function createMockProvider(
}

/**
* @param array<int, mixed> $curlOptions
* @param array<int|string, mixed> $curlOptions
*
* @return array<int, mixed>
* @return array<int|string, mixed>
*/
private function addLastEventId(array $curlOptions, ?string $lastEventId): array
{
Expand Down
26 changes: 25 additions & 1 deletion src/Utilities/Executors/StandardRequestExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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<int, mixed> $curlOnlyOptions */
$curlOnlyOptions = array_filter($curlOptions, 'is_int', ARRAY_FILTER_USE_KEY);

Expand Down Expand Up @@ -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';
}
Expand Down
5 changes: 3 additions & 2 deletions src/Utilities/RequestExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,12 +100,12 @@ public function executeSendRequest(
}

/**
* @param array<int, mixed> $curlOptions
* @param array<int|string, mixed> $curlOptions
* @param list<MockedRequest> $mockedRequests
* @param array<string, mixed> $globalSettings
* @param mixed $reconnectConfig
*
* @return PromiseInterface<\Hibla\HttpClient\SSE\SSEResponse>
* @return PromiseInterface<SSEResponse>
*/
public function executeSSE(
string $url,
Expand Down
2 changes: 1 addition & 1 deletion src/Utilities/RequestMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class RequestMatcher
{
/**
* @param array<int, MockedRequest> $mocks
* @param array<int, mixed> $options
* @param array<int|string, mixed> $options
*
* @return array{mock: MockedRequest, index: int}|null
*/
Expand Down
123 changes: 123 additions & 0 deletions tests/Simulation/StreamBodyMockingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

declare(strict_types=1);

namespace Tests\Simulation;

use Hibla\EventLoop\Loop;
use Hibla\HttpClient\Http;
use Hibla\HttpClient\SSE\SSEEvent;
use Hibla\Stream\ThroughStream;

use function Hibla\await;

beforeEach(function () {
Http::startTesting();
});

afterEach(function () {
Http::stopTesting();
});

describe('Async Stream Body Mocking', function () {

it('buffers an asynchronous body stream so that ->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');
});

});
5 changes: 5 additions & 0 deletions tests/Simulation/StreamLifeCycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

use Hibla\HttpClient\Http;

use function Hibla\await;
use function Hibla\delay;

beforeEach(function () {
Http::startTesting();
});
Expand All @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion tests/Simulation/StreamingAndDownloadTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

declare(strict_types=1);

namespace Tests\Simulation;

use Hibla\HttpClient\Http;
use Hibla\HttpClient\Testing\TestingHttpHandler;

Expand All @@ -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']);
Expand Down
Loading