diff --git a/src/Barstool.php b/src/Barstool.php index b602e5c..8a42b59 100755 --- a/src/Barstool.php +++ b/src/Barstool.php @@ -265,15 +265,22 @@ public static function getResponseBody(Response $response): string return 'REDACTED'; } - $body = $response->body(); + // Non-seekable bodies (e.g. Guzzle's `stream => true`) can only be read once, so reading + // one here would leave it at EOF and hand the application an empty body. Record a + // placeholder instead, matching how streamed request bodies are handled. + if (! $response->getPsrResponse()->getBody()->isSeekable()) { + return ''; + } $contentTypeHeaderKey = $response->headers()->get('Content-Type') ? 'Content-Type' : 'content-type'; - if (Str::startsWith(mb_strtolower((string) $response->headers()->get($contentTypeHeaderKey)), self::supportedContentTypes())) { - return self::checkContentSize($body) ? $body : ''; + if (! Str::startsWith(mb_strtolower((string) $response->headers()->get($contentTypeHeaderKey)), self::supportedContentTypes())) { + return ''; } - return ''; + $body = $response->body(); + + return self::checkContentSize($body) ? $body : ''; } /** diff --git a/tests/BarstoolTest.php b/tests/BarstoolTest.php index ca1c6ee..f4fe21a 100644 --- a/tests/BarstoolTest.php +++ b/tests/BarstoolTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use GuzzleHttp\Psr7\Utils; +use GuzzleHttp\Psr7\NoSeekStream; use Saloon\Http\Faking\MockClient; use Saloon\Barstool\Models\Barstool; use Saloon\Http\Faking\MockResponse; @@ -10,11 +12,16 @@ use Saloon\Barstool\Enums\RecordingType; use Saloon\Http\Connectors\NullConnector; use Saloon\Barstool\Jobs\RecordBarstoolJob; +use Saloon\Http\Response as SaloonResponse; use function Pest\Laravel\assertDatabaseHas; + +use GuzzleHttp\Psr7\Response as Psr7Response; + use function Pest\Laravel\assertDatabaseCount; use function Pest\Laravel\assertDatabaseEmpty; +use Saloon\Barstool\Barstool as BarstoolRecorder; use Saloon\Exceptions\Request\FatalRequestException; use Saloon\Barstool\Tests\Fixtures\Requests\PostRequest; use Saloon\Barstool\Tests\Fixtures\Requests\GetFileRequest; @@ -714,6 +721,36 @@ }); }); +it('does not consume non-seekable streamed response bodies', function () { + config()->set('barstool.enabled', true); + + // Sending with Guzzle's `stream => true` config produces a response backed by a + // non-seekable socket stream, which can only be read once. We recreate that + // here with a NoSeekStream so the test doesn't need a real HTTP call. + + $pendingRequest = (new SoloUserRequest)->createPendingRequest(); + + $psrResponse = new Psr7Response( + status: 200, + headers: ['Content-Type' => 'application/json'], + body: new NoSeekStream(Utils::streamFor('{"data":"yeehaw"}')), + ); + + $response = SaloonResponse::fromPsrResponse($psrResponse, $pendingRequest, $pendingRequest->createPsrRequest()); + + BarstoolRecorder::record($response); + + $barstool = Barstool::where('uuid', $pendingRequest->headers()->get('X-Barstool-UUID'))->sole(); + + expect($barstool) + ->response_status->toBe(200) + ->successful->toBeTrue() + ->response_body->toBe(''); + + // Recording must not have drained the stream - the application still needs to read it + expect($response->stream()->getContents())->toBe('{"data":"yeehaw"}'); +}); + it('does not dispatch jobs when queue is disabled', function () { Queue::fake();