diff --git a/src/http/ErrorHandler.php b/src/http/ErrorHandler.php index a85f5518..61072954 100644 --- a/src/http/ErrorHandler.php +++ b/src/http/ErrorHandler.php @@ -86,9 +86,7 @@ public function clearOutput(): void while ($currentLevel > $minLevel) { if (@ob_end_clean() === false) { - // @codeCoverageIgnoreStart ob_clean(); - // @codeCoverageIgnoreEnd } $currentLevel = ob_get_level(); diff --git a/tests/http/ErrorHandlerTest.php b/tests/http/ErrorHandlerTest.php index f0aea969..22d86464 100644 --- a/tests/http/ErrorHandlerTest.php +++ b/tests/http/ErrorHandlerTest.php @@ -25,7 +25,7 @@ public function testClearOutputCleansAllBuffersInNonTestEnvironment(): void $initialLevel = ob_get_level(); try { - @runkit_constant_redefine('YII_ENV_TEST', false); + @\runkit_constant_redefine('YII_ENV_TEST', false); $errorHandler = new ErrorHandler(); @@ -56,7 +56,7 @@ public function testClearOutputCleansAllBuffersInNonTestEnvironment(): void ob_start(); } - @runkit_constant_redefine('YII_ENV_TEST', true); + @\runkit_constant_redefine('YII_ENV_TEST', true); } } diff --git a/tests/http/stateless/ApplicationErrorHandlerTest.php b/tests/http/stateless/ApplicationErrorHandlerTest.php index b8a1099e..00b54529 100644 --- a/tests/http/stateless/ApplicationErrorHandlerTest.php +++ b/tests/http/stateless/ApplicationErrorHandlerTest.php @@ -13,9 +13,12 @@ use yii2\extensions\psrbridge\http\Response; use yii2\extensions\psrbridge\tests\provider\StatelessApplicationProvider; use yii2\extensions\psrbridge\tests\support\FactoryHelper; +use yii2\extensions\psrbridge\tests\support\stub\MockerFunctions; use yii2\extensions\psrbridge\tests\TestCase; use function array_filter; +use function ini_get; +use function ini_set; use function is_array; use function ob_get_level; use function ob_start; @@ -346,6 +349,82 @@ static function ($errno, $errstr, $errfile, $errline) use (&$warningsCaptured): } } + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + #[RequiresPhpExtension('runkit7')] + public function testRenderExceptionSetsDisplayErrorsInDebugMode(): void + { + @\runkit_constant_redefine('YII_ENV_TEST', false); + + MockerFunctions::setObEndCleanShouldFail(true); + + $bufferBeforeLevel = ob_get_level(); + + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-exception', + ]; + + ob_start(); + echo 'buffer content that should be cleared'; + ob_start(); + echo 'nested buffer content'; + + $originalDisplayErrors = ini_get('display_errors'); + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => [ + 'discardExistingOutput' => true, + 'errorAction' => null, + ], + ], + ], + ); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 500, + $response->getStatusCode(), + "Expected HTTP '500' for route 'site/trigger-exception'.", + ); + self::assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/trigger-exception'.", + ); + self::assertSame( + '1', + ini_get('display_errors'), + "'display_errors' should be set to '1' in debug mode when rendering exception.", + ); + + $bufferAfterLevel = ob_get_level(); + + self::assertSame( + 0, + $bufferAfterLevel, + "Output buffers should be cleared to level '0' when 'discardExistingOutput' is 'true'.", + ); + + while (ob_get_level() < $bufferBeforeLevel) { + ob_start(); + } + + self::assertSame( + $bufferBeforeLevel, + ob_get_level(), + 'Output buffers should be restored to initial level.', + ); + + ini_set('display_errors', $originalDisplayErrors); + + @\runkit_constant_redefine('YII_ENV_TEST', true); + } + /** * @throws InvalidConfigException if the configuration is invalid or incomplete. * diff --git a/tests/http/stateless/ApplicationTest.php b/tests/http/stateless/ApplicationTest.php index f4b367ce..2d4fe9bc 100644 --- a/tests/http/stateless/ApplicationTest.php +++ b/tests/http/stateless/ApplicationTest.php @@ -4,16 +4,11 @@ namespace yii2\extensions\psrbridge\tests\http\stateless; -use PHPUnit\Framework\Attributes\{Group, RequiresPhpExtension}; +use PHPUnit\Framework\Attributes\Group; use yii\base\InvalidConfigException; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; -use function ini_get; -use function ini_set; -use function ob_get_level; -use function ob_start; - #[Group('http')] final class ApplicationTest extends TestCase { @@ -24,65 +19,6 @@ protected function tearDown(): void parent::tearDown(); } - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - #[RequiresPhpExtension('runkit7')] - public function testRenderExceptionSetsDisplayErrorsInDebugMode(): void - { - @\runkit_constant_redefine('YII_ENV_TEST', false); - - $initialBufferLevel = ob_get_level(); - - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', - ]; - - $originalDisplayErrors = ini_get('display_errors'); - - try { - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => ['errorAction' => null], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Expected HTTP '500' for route 'site/trigger-exception'.", - ); - self::assertSame( - 'text/html; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/trigger-exception'.", - ); - self::assertSame( - '1', - ini_get('display_errors'), - "'display_errors' should be set to '1' when YII_DEBUG mode is enabled and rendering exception view.", - ); - self::assertStringContainsString( - 'yii\base\Exception: Exception error message.', - $response->getBody()->getContents(), - 'Response should contain exception details when YII_DEBUG mode is enabled.', - ); - } finally { - ini_set('display_errors', $originalDisplayErrors); - - while (ob_get_level() < $initialBufferLevel) { - ob_start(); - } - - @\runkit_constant_redefine('YII_ENV_TEST', true); - } - } - /** * @throws InvalidConfigException if the configuration is invalid or incomplete. */ diff --git a/tests/support/MockerExtension.php b/tests/support/MockerExtension.php index 10abdcdf..bb871d09 100644 --- a/tests/support/MockerExtension.php +++ b/tests/support/MockerExtension.php @@ -85,6 +85,11 @@ public static function load(): void 'name' => 'microtime', 'function' => static fn(bool $as_float = false): float|string => MockerFunctions::microtime($as_float), ], + [ + 'namespace' => 'yii2\extensions\psrbridge\http', + 'name' => 'ob_end_clean', + 'function' => static fn(): bool => MockerFunctions::ob_end_clean(), + ], [ 'namespace' => 'yii2\extensions\psrbridge\adapter', 'name' => 'stream_get_contents', diff --git a/tests/support/stub/MockerFunctions.php b/tests/support/stub/MockerFunctions.php index c7d7b7f4..55af651d 100644 --- a/tests/support/stub/MockerFunctions.php +++ b/tests/support/stub/MockerFunctions.php @@ -33,6 +33,7 @@ * - {@see \headers_sent()} (with file/line tracking). * - {@see \http_response_code()} (get/set response code). * - {@see \microtime()} (mockable time for timing tests). + * - {@see \ob_end_clean()} (controllable success/failure). * - {@see \stream_get_contents()} (controllable stream read/failure). * - {@see \time()} (mockable time for timing tests). * - Consistent behavior matching PHP native functions for test reliability. @@ -84,6 +85,16 @@ final class MockerFunctions */ private static int|null $mockedTime = null; + /** + * Tracks the number of times {@see \ob_end_clean()} was called. + */ + private static int $obEndCleanCallCount = 0; + + /** + * Indicates whether {@see \ob_end_clean()} should fail. + */ + private static bool $obEndCleanShouldFail = false; + /** * Tracks the HTTP response code. */ @@ -190,6 +201,18 @@ public static function microtime(bool $as_float = false): float|string return \microtime($as_float); } + public static function ob_end_clean(): bool + { + self::$obEndCleanCallCount++; + + // simulate failure only on the first call after enabling + if (self::$obEndCleanShouldFail && self::$obEndCleanCallCount === 1) { + return false; + } + + return @\ob_end_clean(); + } + public static function reset(): void { self::$flushedTimes = 0; @@ -198,8 +221,11 @@ public static function reset(): void self::$headersSentFile = ''; self::$headersSentLine = 0; self::$mockedTime = null; + self::$obEndCleanCallCount = 0; + self::$obEndCleanShouldFail = false; self::$responseCode = 200; self::$streamGetContentsShouldFail = false; + self::clearMockedMicrotime(); } @@ -225,6 +251,15 @@ public static function setMockedTime(int $time): void self::$mockedTime = $time; } + public static function setObEndCleanShouldFail(bool $shouldFail = true): void + { + self::$obEndCleanShouldFail = $shouldFail; + + if ($shouldFail) { + self::$obEndCleanCallCount = 0; + } + } + public static function stream_get_contents(mixed $resource, int $maxlength = -1, int $offset = -1): string|false { if (self::$streamGetContentsShouldFail) {