diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php index 53d443e..f456e2f 100644 --- a/src/Http/ResponseEmitter.php +++ b/src/Http/ResponseEmitter.php @@ -4,9 +4,15 @@ use Psr\Http\Message\ResponseInterface; use Laminas\HttpHandlerRunner\Emitter\SapiEmitter; +use Laminas\HttpHandlerRunner\Emitter\EmitterInterface; class ResponseEmitter { + public function __construct(protected ?EmitterInterface $emitter = null) + { + $this->emitter = $emitter ?? new SapiEmitter(); + } + /** * Emit a response. * @@ -23,6 +29,36 @@ public function emit(ResponseInterface $response) return; } - (new SapiEmitter())->emit($response); + // If the output buffer has content, SapiEmitter will throw an exception. + // We circumvent this by stacking a new buffer, emitting into it, + // and then echoing the result. + if ($this->isOutputBufferDirty()) { + $this->emitWithStackedBuffer($response); + return; + } + + $this->emitter->emit($response); + } + + /** + * Check if the output buffer has existing content. + */ + protected function isOutputBufferDirty(): bool + { + return ob_get_level() > 0 && ob_get_length() > 0; + } + + /** + * Emit the response into a clean buffer to bypass SapiEmitter's strict checks. + */ + protected function emitWithStackedBuffer(ResponseInterface $response): void + { + ob_start(); + + try { + $this->emitter->emit($response); + } finally { + echo ob_get_clean(); + } } } diff --git a/tests/Unit/Http/ResponseEmitterTest.php b/tests/Unit/Http/ResponseEmitterTest.php index 1d2cc25..4bcbcdb 100644 --- a/tests/Unit/Http/ResponseEmitterTest.php +++ b/tests/Unit/Http/ResponseEmitterTest.php @@ -6,14 +6,14 @@ use PHPUnit\Framework\Attributes\Test; use Rareloop\Lumberjack\Http\ResponseEmitter; use Laminas\Diactoros\Response\TextResponse; -use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; -use PHPUnit\Framework\Attributes\PreserveGlobalState; +use Laminas\HttpHandlerRunner\Emitter\EmitterInterface; +use Mockery; use phpmock\MockBuilder; -#[RunTestsInSeparateProcesses] -#[PreserveGlobalState(false)] class ResponseEmitterTest extends TestCase { + use \Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; + #[Test] public function emit_should_echo_body_when_headers_sent(): void { @@ -39,7 +39,7 @@ public function emit_should_echo_body_when_headers_sent(): void } #[Test] - public function emit_should_use_sapi_emitter_when_headers_not_sent(): void + public function emit_should_use_provided_emitter_when_headers_not_sent(): void { $builder = new MockBuilder(); $builder->setNamespace('Rareloop\Lumberjack\Http') @@ -50,38 +50,53 @@ public function emit_should_use_sapi_emitter_when_headers_not_sent(): void $mock = $builder->build(); $mock->enable(); - // SapiEmitter uses the global header() function. - // We mock it in the SapiEmitter's namespace to prevent it from actually sending headers - $headerBuilder = new MockBuilder(); - $headerBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter') - ->setName('header') - ->setFunction(function () { - }); - $headerMock = $headerBuilder->build(); - $headerMock->enable(); + $response = new TextResponse('Hello World'); + $innerEmitter = Mockery::mock(EmitterInterface::class); + + $innerEmitter->shouldReceive('emit')->once()->with($response)->andReturn(true); - // SapiEmitterTrait checks for namespaced headers_sent - $emitterHeadersSentBuilder = new MockBuilder(); - $emitterHeadersSentBuilder->setNamespace('Laminas\HttpHandlerRunner\Emitter') + $emitter = new ResponseEmitter($innerEmitter); + $emitter->emit($response); + + $mock->disable(); + } + + #[Test] + public function emit_should_use_stacked_buffer_when_output_buffer_has_content_but_headers_not_sent(): void + { + $builder = new MockBuilder(); + $builder->setNamespace('Rareloop\Lumberjack\Http') ->setName('headers_sent') ->setFunction(function () { return false; }); - $emitterHeadersSentMock = $emitterHeadersSentBuilder->build(); - $emitterHeadersSentMock->enable(); + $mock = $builder->build(); + $mock->enable(); $response = new TextResponse('Hello World'); - $emitter = new ResponseEmitter(); + $innerEmitter = Mockery::mock(EmitterInterface::class); + // We expect the inner emitter to be called. + // We'll make it echo something to verify it's captured by the stacked buffer + $innerEmitter->shouldReceive('emit')->once()->with($response)->andReturnUsing(function () { + echo 'Hello World'; + return true; + }); + + $emitter = new ResponseEmitter($innerEmitter); + + // Start output buffering and put some content in it ob_start(); + echo 'Existing output'; + + // We expect no exception now $emitter->emit($response); + + // Final output should contain both existing and new content $output = ob_get_clean(); - // SapiEmitter echoes the body, so we should see 'Hello World' - $this->assertSame('Hello World', $output); + $this->assertSame('Existing outputHello World', $output); $mock->disable(); - $headerMock->disable(); - $emitterHeadersSentMock->disable(); } }