From 4e21c30c8240497e2fedda05fb917cf82a50751e Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 23 Aug 2025 17:16:57 -0400 Subject: [PATCH 1/3] test(http): Extract error handler from `ApplicationTest` to `ApplicationErrorHandlerTest` class. --- .../stateless/ApplicationErrorHandlerTest.php | 807 +++++++++++++++++ tests/http/stateless/ApplicationTest.php | 809 +----------------- 2 files changed, 810 insertions(+), 806 deletions(-) create mode 100644 tests/http/stateless/ApplicationErrorHandlerTest.php diff --git a/tests/http/stateless/ApplicationErrorHandlerTest.php b/tests/http/stateless/ApplicationErrorHandlerTest.php new file mode 100644 index 00000000..a3d5e841 --- /dev/null +++ b/tests/http/stateless/ApplicationErrorHandlerTest.php @@ -0,0 +1,807 @@ + 'not-a-secret-api-key', + 'AUTH_TOKEN' => 'dummy-bearer-token', + 'DB_PASSWORD' => 'not-a-real-password', + 'HTTP_HOST' => 'example.com', + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/nonexistent-action', + 'SAFE_VARIABLE' => 'this-should-appear', + 'SECRET_KEY' => 'not-a-real-secret-key', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => [ + 'errorAction' => 'invalid/nonexistent-action', + ], + ], + ], + ); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 500, + $response->getStatusCode(), + "Expected HTTP '500' for route 'site/nonexistent-action'.", + ); + self::assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/nonexistent-action'.", + ); + + $body = $response->getBody()->getContents(); + + self::assertStringContainsString( + 'An Error occurred while handling another error:', + $body, + 'Response body should contain fallback error message when ErrorHandler action is invalid.', + ); + + if (YII_DEBUG) { + self::assertStringContainsString( + "\n\$_SERVER = [", + $body, + "Response body should contain '\$_SERVER = [' in correct order (label before array) for fallback " . + 'exception debug output.', + ); + self::assertStringNotContainsString( + 'not-a-secret-api-key', + $body, + 'Response body should NOT contain API_KEY value in debug output for fallback exception.', + ); + self::assertStringNotContainsString( + 'dummy-bearer-token', + $body, + 'Response body should NOT contain AUTH_TOKEN value in debug output for fallback exception', + ); + self::assertStringNotContainsString( + 'not-a-real-password', + $body, + 'Response body should NOT contain DB_PASSWORD value in debug output for fallback exception.', + ); + self::assertStringContainsString( + 'example.com', + $body, + 'Response body should contain HTTP_HOST value in debug output for fallback exception.', + ); + self::assertStringNotContainsString( + 'not-a-real-secret-key', + $body, + 'Response body should NOT contain SECRET_KEY value in debug output for fallback exception.', + ); + self::assertStringContainsString( + 'this-should-appear', + $body, + 'Response body should contain SAFE_VARIABLE value in debug output for fallback exception.', + ); + } + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testLogExceptionIsCalledWhenHandlingException(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-exception', + ]; + + $app = $this->statelessApplication( + [ + 'flushLogger' => false, + 'components' => [ + 'errorHandler' => ['errorAction' => null], + 'log' => [ + 'traceLevel' => YII_DEBUG ? 1 : 0, + 'targets' => [ + [ + 'class' => FileTarget::class, + 'levels' => ['error'], + ], + ], + ], + ], + ], + ); + + $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'.", + ); + + $logMessages = $app->getLog()->getLogger()->messages; + + 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::assertNotEmpty( + $logMessages, + 'Logger should contain log messages after handling an exception.', + ); + + $exceptionLogFound = false; + $expectedCategory = Exception::class; + + foreach ($logMessages as $logMessage) { + if ( + is_array($logMessage) && + isset($logMessage[0], $logMessage[1], $logMessage[2]) && + $logMessage[1] === Logger::LEVEL_ERROR && + $logMessage[0] instanceof Exception && + $logMessage[2] === $expectedCategory && + str_contains($logMessage[0]->getMessage(), 'Exception error message.') + ) { + $exceptionLogFound = true; + + break; + } + } + + self::assertTrue( + $exceptionLogFound, + "Logger should contain an error log entry with category '{$expectedCategory}' and message 'Exception error message.' " . + "when 'logException()' is called during exception handling.", + ); + self::assertFalse( + $app->flushLogger, + "Test must keep logger messages in memory to assert on them; 'flushLogger' should be 'false'.", + ); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + #[RequiresPhpExtension('runkit7')] + public function testRenderExceptionPassesExceptionParameterToTemplateView(): void + { + @\runkit_constant_redefine('YII_ENV_TEST', false); + + $initialBufferLevel = ob_get_level(); + + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-exception', + ]; + + $warningsCaptured = []; + + set_error_handler( + static function ($errno, $errstr, $errfile, $errline) use (&$warningsCaptured): bool { + if ($errno === E_WARNING || $errno === E_NOTICE) { + $warningsCaptured[] = [ + 'type' => $errno, + 'message' => $errstr, + 'file' => $errfile, + 'line' => $errline, + ]; + } + + return false; + }, + ); + + 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'.", + ); + + $undefinedExceptionWarnings = array_filter( + $warningsCaptured, + static fn(array $warning): bool => str_contains($warning['message'], 'Undefined variable'), + ); + + self::assertEmpty( + $undefinedExceptionWarnings, + "Should be no 'Undefined variable' warnings, confirming that 'exception' parameter is defined in the " . + 'view context when rendering exception.', + ); + + $body = $response->getBody()->getContents(); + + self::assertStringContainsString( + Exception::class, + $body, + "Response body should contain exception class when 'exception' parameter is passed to 'renderFile()'.", + ); + self::assertStringContainsString( + 'Stack trace:', + $body, + "Response body should contain 'Stack trace:' section, confirming exception object is available to template.", + ); + self::assertStringContainsString( + 'Exception error message.', + $body, + "Response body should contain the exact exception message 'Exception error message.', " . + 'confirming the exception object was properly passed to the view.', + ); + self::assertStringContainsString( + 'SiteController.php', + $body, + "Response body should contain reference to 'SiteController.php' where the exception was thrown, " . + 'confirming full exception details are available in the view.', + ); + } finally { + restore_error_handler(); + + while (ob_get_level() < $initialBufferLevel) { + ob_start(); + } + + @\runkit_constant_redefine('YII_ENV_TEST', true); + } + } + + /** + * @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'); + + $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 is 'true' and rendering exception view.", + ); + self::assertStringContainsString( + 'yii\base\Exception: Exception error message.', + $response->getBody()->getContents(), + "Response should contain exception details when YII_DEBUG is 'true'.", + ); + + 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. + */ + #[RequiresPhpExtension('runkit7')] + public function testRenderExceptionWithErrorActionReturningResponseObject(): void + { + @\runkit_constant_redefine('YII_DEBUG', false); + + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-exception', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => ['errorAction' => 'site/error-with-response'], + ], + ], + ); + + $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( + self::normalizeLineEndings( + << + Custom Response object from error action: Exception error message. + + HTML, + ), + self::normalizeLineEndings($response->getBody()->getContents()), + "Response body should contain content from Response object returned by 'errorAction'.", + ); + + @\runkit_constant_redefine('YII_DEBUG', true); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testRenderExceptionWithRawFormat(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-exception', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'response' => ['format' => Response::FORMAT_RAW], + 'errorHandler' => ['errorAction' => null], + ], + ], + ); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 500, + $response->getStatusCode(), + "Expected HTTP '500' for route 'site/trigger-exception'.", + ); + self::assertEmpty( + $response->getHeaderLine('Content-Type'), + "Expected Content-Type empty string for route 'site/trigger-exception'.", + ); + + $body = $response->getBody()->getContents(); + + self::assertStringContainsString( + Exception::class, + $body, + 'RAW format response should contain exception class name.', + ); + self::assertStringContainsString( + 'Exception error message.', + $body, + 'RAW format response should contain exception message.', + ); + self::assertStringNotContainsString( + '
',
+            $body,
+            "RAW format response should not contain HTML tag '
'.",
+        );
+        self::assertStringNotContainsString(
+            '
', + $body, + "RAW format response should not contain HTML tag '
'.", + ); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testReturnHtmlErrorResponseWhenErrorHandlerActionIsInvalid(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/nonexistent-action', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => ['errorAction' => 'invalid/nonexistent-action'], + ], + ], + ); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 500, + $response->getStatusCode(), + "Expected HTTP '500' for route 'site/nonexistent-action'.", + ); + self::assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/nonexistent-action'.", + ); + self::assertStringContainsString( + self::normalizeLineEndings( + <<An Error occurred while handling another error: + yii\base\InvalidRouteException: Unable to resolve the request "invalid/nonexistent-action". + HTML, + ), + self::normalizeLineEndings($response->getBody()->getContents()), + "Response body should contain error message about 'An Error occurred while handling another error' and " . + 'the InvalidRouteException when errorHandler action is invalid.', + ); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testThrowableOccursDuringRequestHandling(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'nonexistent/invalidaction', + ]; + + $app = $this->statelessApplication(); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 404, + $response->getStatusCode(), + "Expected HTTP '404' for route 'nonexistent/invalidaction'.", + ); + self::assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'text/html; charset=UTF-8' for route 'nonexistent/invalidaction'.", + ); + self::assertStringContainsString( + '
Not Found: Page not found.
', + $response->getBody()->getContents(), + "Response body should contain error message about 'Not Found: Page not found' when 'Throwable' occurs " . + 'during request handling.', + ); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testThrowNotFoundHttpExceptionWhenStrictParsingDisabledAndRouteIsMissing(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/profile/123', + ]; + + $app = $this->statelessApplication(); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 404, + $response->getStatusCode(), + "Expected HTTP '404' for route 'site/profile/123'.", + ); + self::assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/profile/123'.", + ); + self::assertStringContainsString( + '
Not Found: Page not found.
', + $response->getBody()->getContents(), + "Response body should contain the default not found message '
Not Found: Page not found.
' " . + "when a 'NotFoundHttpException' is thrown.", + ); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testThrowNotFoundHttpExceptionWhenStrictParsingEnabledAndRouteIsMissing(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/profile/123', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'urlManager' => ['enableStrictParsing' => true], + ], + ], + ); + + $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + $this->expectException(NotFoundHttpException::class); + $this->expectExceptionMessage(Message::PAGE_NOT_FOUND->getMessage()); + + $app->request->resolve(); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + #[RequiresPhpExtension('runkit7')] + public function testUseErrorViewLogicWithDebugFalseAndException(): void + { + @\runkit_constant_redefine('YII_DEBUG', false); + + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-exception', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => ['errorAction' => 'site/error'], + ], + ], + ); + + $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( + self::normalizeLineEndings( + << + Custom error page from errorAction. + + yii\base\Exception + + + Exception error message. + + + HTML, + ), + self::normalizeLineEndings($response->getBody()->getContents()), + "Response body should contain 'Custom error page from errorAction' when 'Exception' is triggered " . + "and 'debug' mode is disabled with errorAction configured.", + ); + + @\runkit_constant_redefine('YII_DEBUG', true); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + #[RequiresPhpExtension('runkit7')] + public function testUseErrorViewLogicWithDebugFalseAndUserException(): void + { + @\runkit_constant_redefine('YII_DEBUG', false); + + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-user-exception', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => ['errorAction' => 'site/error'], + ], + ], + ); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 500, + $response->getStatusCode(), + "Expected HTTP '500' for route 'site/trigger-user-exception'.", + ); + self::assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/trigger-user-exception''.", + ); + self::assertSame( + self::normalizeLineEndings( + << + Custom error page from errorAction. + + yii\base\UserException + + + User-friendly error message. + + + HTML, + ), + self::normalizeLineEndings($response->getBody()->getContents()), + "Response body should contain 'Custom error page from errorAction' when 'UserException' is triggered " . + "and 'debug' mode is disabled with errorAction configured.", + ); + + @\runkit_constant_redefine('YII_DEBUG', true); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testUseErrorViewLogicWithDebugTrueAndUserException(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-user-exception', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => ['errorAction' => 'site/error'], + ], + ], + ); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 500, + $response->getStatusCode(), + "Expected HTTP '500' for route 'site/trigger-user-exception'.", + ); + self::assertSame( + 'text/html; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/trigger-user-exception''.", + ); + self::assertSame( + self::normalizeLineEndings( + << + Custom error page from errorAction. + + yii\base\UserException + + + User-friendly error message. + + + HTML, + ), + self::normalizeLineEndings($response->getBody()->getContents()), + "Response body should contain 'User-friendly error message.' when 'UserException' is triggered and " . + "'debug' mode is enabled.", + ); + } + + /** + * @throws InvalidConfigException if the configuration is invalid or incomplete. + */ + public function testUseErrorViewLogicWithNonHtmlFormat(): void + { + $_SERVER = [ + 'REQUEST_METHOD' => 'GET', + 'REQUEST_URI' => 'site/trigger-exception', + ]; + + $app = $this->statelessApplication( + [ + 'components' => [ + 'errorHandler' => ['errorAction' => 'site/error'], + 'response' => ['format' => Response::FORMAT_JSON], + ], + ], + ); + + $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); + + self::assertSame( + 500, + $response->getStatusCode(), + "Expected HTTP '500' for route 'site/error'.", + ); + self::assertSame( + 'application/json; charset=UTF-8', + $response->getHeaderLine('Content-Type'), + "Expected Content-Type 'application/json; charset=UTF-8' for route 'site/error''.", + ); + + $body = $response->getBody()->getContents(); + + self::assertStringNotContainsString( + 'Custom error page from errorAction.', + $body, + "Response body should NOT contain 'Custom error page from errorAction' when format is JSON " . + "because 'useErrorView' should be 'false' regardless of YII_DEBUG or exception type.", + ); + + $decodedResponse = Json::decode($body); + + self::assertIsArray( + $decodedResponse, + 'JSON response should be decodable to array', + ); + self::assertArrayHasKey( + 'message', + $decodedResponse, + 'JSON error response should contain message key', + ); + } +} diff --git a/tests/http/stateless/ApplicationTest.php b/tests/http/stateless/ApplicationTest.php index 70d512ea..5fec1f0a 100644 --- a/tests/http/stateless/ApplicationTest.php +++ b/tests/http/stateless/ApplicationTest.php @@ -4,26 +4,14 @@ namespace yii2\extensions\psrbridge\tests\http\stateless; -use PHPUnit\Framework\Attributes\{Group, RequiresPhpExtension, TestWith}; -use yii\base\{Exception, InvalidConfigException}; -use yii\helpers\Json; -use yii\log\{FileTarget, Logger}; -use yii\web\NotFoundHttpException; -use yii2\extensions\psrbridge\exception\Message; -use yii2\extensions\psrbridge\http\{Response, StatelessApplication}; +use PHPUnit\Framework\Attributes\{Group, TestWith}; +use yii\base\{InvalidConfigException}; +use yii2\extensions\psrbridge\http\{StatelessApplication}; use yii2\extensions\psrbridge\tests\support\FactoryHelper; use yii2\extensions\psrbridge\tests\TestCase; -use function array_filter; use function explode; -use function ini_get; -use function ini_set; -use function ob_get_level; -use function ob_start; -use function restore_error_handler; -use function set_error_handler; use function sprintf; -use function str_contains; use function str_starts_with; #[Group('http')] @@ -36,424 +24,6 @@ protected function tearDown(): void parent::tearDown(); } - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testFiltersSensitiveServerVariablesInFallbackExceptionMessage(): void - { - $_SERVER = [ - 'API_KEY' => 'not-a-secret-api-key', - 'AUTH_TOKEN' => 'dummy-bearer-token', - 'DB_PASSWORD' => 'not-a-real-password', - 'HTTP_HOST' => 'example.com', - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/nonexistent-action', - 'SAFE_VARIABLE' => 'this-should-appear', - 'SECRET_KEY' => 'not-a-real-secret-key', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => 'invalid/nonexistent-action', - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when 'ErrorHandler' triggers fallback exception handling in " . - "'StatelessApplication'.", - ); - - $responseBody = $response->getBody()->getContents(); - - self::assertStringContainsString( - 'An Error occurred while handling another error:', - $responseBody, - "Response 'body' should contain fallback error message when 'ErrorHandler' action is invalid in " . - "'StatelessApplication'.", - ); - - if (YII_DEBUG) { - self::assertStringContainsString( - "\n\$_SERVER = [", - $responseBody, - "Response 'body' should contain '\$_SERVER = [' in correct order (label before array) for fallback " . - "exception debug output in 'StatelessApplication'.", - ); - self::assertStringNotContainsString( - 'not-a-secret-api-key', - $responseBody, - "Response 'body' should NOT contain 'API_KEY' value in debug output for fallback exception in " . - "'StatelessApplication'.", - ); - self::assertStringNotContainsString( - 'dummy-bearer-token', - $responseBody, - "Response 'body' should NOT contain 'AUTH_TOKEN' value in debug output for fallback exception in " . - "'StatelessApplication'.", - ); - self::assertStringNotContainsString( - 'not-a-real-password', - $responseBody, - "Response 'body' should NOT contain 'DB_PASSWORD' value in debug output for fallback exception in " . - "'StatelessApplication'.", - ); - self::assertStringContainsString( - 'example.com', - $responseBody, - "Response 'body' should contain 'HTTP_HOST' value in debug output for fallback exception in " . - "'StatelessApplication'.", - ); - self::assertStringNotContainsString( - 'not-a-real-secret-key', - $responseBody, - "Response 'body' should NOT contain 'SECRET_KEY' value in debug output for fallback exception in " . - "'StatelessApplication'.", - ); - self::assertStringContainsString( - 'this-should-appear', - $responseBody, - "Response 'body' should contain 'SAFE_VARIABLE' value in debug output for fallback exception in " . - "'StatelessApplication'.", - ); - } - } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testLogExceptionIsCalledWhenHandlingException(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', - ]; - - $app = $this->statelessApplication( - [ - 'flushLogger' => false, - 'components' => [ - 'errorHandler' => [ - 'errorAction' => null, - ], - 'log' => [ - 'traceLevel' => YII_DEBUG ? 1 : 0, - 'targets' => [ - [ - 'class' => FileTarget::class, - 'levels' => [ - 'error', - ], - ], - ], - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - $logMessages = $app->getLog()->getLogger()->messages; - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when an exception occurs in 'StatelessApplication'.", - ); - self::assertNotEmpty( - $logMessages, - "Logger should contain log messages after handling an exception in 'StatelessApplication'.", - ); - - $exceptionLogFound = false; - $expectedCategory = Exception::class; - - foreach ($logMessages as $logMessage) { - if ( - is_array($logMessage) && - isset($logMessage[0], $logMessage[1], $logMessage[2]) && - $logMessage[1] === Logger::LEVEL_ERROR && - $logMessage[0] instanceof Exception && - $logMessage[2] === $expectedCategory && - str_contains($logMessage[0]->getMessage(), 'Exception error message.') - ) { - $exceptionLogFound = true; - - break; - } - } - - self::assertTrue( - $exceptionLogFound, - "Logger should contain an error log entry with category '{$expectedCategory}' and message 'Exception error message.' " . - "when 'logException()' is called during exception handling in 'StatelessApplication'.", - ); - self::assertFalse( - $app->flushLogger, - "Test must keep logger messages in memory to assert on them; 'flushLogger' should be 'false'.", - ); - } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - #[RequiresPhpExtension('runkit7')] - public function testRenderExceptionPassesExceptionParameterToTemplateView(): void - { - @\runkit_constant_redefine('YII_ENV_TEST', false); - - $initialBufferLevel = ob_get_level(); - - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', - ]; - - $warningsCaptured = []; - - set_error_handler( - static function ($errno, $errstr, $errfile, $errline) use (&$warningsCaptured): bool { - if ($errno === E_WARNING || $errno === E_NOTICE) { - $warningsCaptured[] = [ - 'type' => $errno, - 'message' => $errstr, - 'file' => $errfile, - 'line' => $errline, - ]; - } - - return false; - }, - ); - - try { - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => null, - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - $undefinedExceptionWarnings = array_filter( - $warningsCaptured, - static fn(array $warning): bool => str_contains($warning['message'], 'Undefined variable'), - ); - - self::assertEmpty( - $undefinedExceptionWarnings, - "Should be no 'Undefined variable' warnings, confirming that 'exception' parameter is defined in the " . - "view context when rendering exception in 'StatelessApplication'.", - ); - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when exception occurs and template rendering is used in " . - "'StatelessApplication'.", - ); - - $responseBody = $response->getBody()->getContents(); - - self::assertStringContainsString( - Exception::class, - $responseBody, - "Response 'body' should contain exception class when 'exception' parameter is passed to 'renderFile()'.", - ); - self::assertStringContainsString( - 'Stack trace:', - $responseBody, - "Response 'body' should contain 'Stack trace:' section, confirming exception object is available to template.", - ); - self::assertStringContainsString( - 'Exception error message.', - $responseBody, - "Response 'body' should contain the exact exception message 'Exception error message.', " . - 'confirming the exception object was properly passed to the view.', - ); - self::assertStringContainsString( - 'SiteController.php', - $responseBody, - "Response 'body' should contain reference to 'SiteController.php' where the exception was thrown, " . - 'confirming full exception details are available in the view.', - ); - } finally { - restore_error_handler(); - - while (ob_get_level() < $initialBufferLevel) { - ob_start(); - } - - @\runkit_constant_redefine('YII_ENV_TEST', true); - } - } - - /** - * @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'); - - $app = $this->statelessApplication([ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => null, - ], - ], - ]); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - '1', - ini_get('display_errors'), - "'display_errors' should be set to '1' when 'YII_DEBUG' is 'true' and rendering exception view.", - ); - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' for exception.", - ); - self::assertStringContainsString( - 'yii\base\Exception: Exception error message.', - $response->getBody()->getContents(), - "Response should contain exception details when 'YII_DEBUG' is 'true'.", - ); - - 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. - */ - #[RequiresPhpExtension('runkit7')] - public function testRenderExceptionWithErrorActionReturningResponseObject(): void - { - @\runkit_constant_redefine('YII_DEBUG', false); - - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => 'site/error-with-response', - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when 'errorAction' returns Response object.", - ); - self::assertSame( - 'text/html; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Response 'Content-Type' should be 'text/html; charset=UTF-8' when 'errorAction' returns Response object.", - ); - self::assertSame( - self::normalizeLineEndings( - << - Custom Response object from error action: Exception error message. - - HTML, - ), - self::normalizeLineEndings($response->getBody()->getContents()), - "Response 'body' should contain content from Response object returned by 'errorAction'.", - ); - - @\runkit_constant_redefine('YII_DEBUG', true); - } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testRenderExceptionWithRawFormat(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'response' => [ - 'format' => Response::FORMAT_RAW, - ], - 'errorHandler' => [ - 'errorAction' => null, - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' for exception with RAW format.", - ); - - $body = $response->getBody()->getContents(); - - self::assertStringContainsString( - Exception::class, - $body, - 'RAW format response should contain exception class name.', - ); - self::assertStringContainsString( - 'Exception error message.', - $body, - 'RAW format response should contain exception message.', - ); - self::assertStringNotContainsString( - '
',
-            $body,
-            "RAW format response should not contain HTML tag '
'.",
-        );
-        self::assertStringNotContainsString(
-            '
', - $body, - "RAW format response should not contain HTML tag '
'.", - ); - } - /** * @throws InvalidConfigException if the configuration is invalid or incomplete. */ @@ -527,53 +97,6 @@ public function testReturnCookiesHeadersForSiteCookieRoute(): void } } - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testReturnHtmlErrorResponseWhenErrorHandlerActionIsInvalid(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/nonexistent-action', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => 'invalid/nonexistent-action', - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when 'ErrorHandler' is misconfigured and a nonexistent action is " . - "requested in 'StatelessApplication'.", - ); - self::assertSame( - 'text/html; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Response 'Content-Type' should be 'text/html; charset=UTF-8' for error response when ErrorHandler " . - "action is invalid in 'StatelessApplication'.", - ); - self::assertStringContainsString( - self::normalizeLineEndings( - <<An Error occurred while handling another error: - yii\base\InvalidRouteException: Unable to resolve the request "invalid/nonexistent-action". - HTML, - ), - self::normalizeLineEndings($response->getBody()->getContents()), - "Response 'body' should contain error message about 'An Error occurred while handling another error' and " . - "the InvalidRouteException when errorHandler action is invalid in 'StatelessApplication'.", - ); - } - /** * @throws InvalidConfigException if the configuration is invalid or incomplete. */ @@ -892,102 +415,6 @@ public function testReturnsStatusCode201ForSiteStatusCodeRoute(): void ); } - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testThrowableOccursDuringRequestHandling(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'nonexistent/invalidaction', - ]; - - $app = $this->statelessApplication(); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 404, - $response->getStatusCode(), - "Response 'status code' should be '404' when handling a request to 'non-existent' route in " . - "'StatelessApplication', confirming proper error handling in catch block.", - ); - self::assertSame( - 'text/html; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Response 'Content-Type' should be 'text/html; charset=UTF-8' for error response when 'Throwable' occurs " . - "during request handling in 'StatelessApplication'.", - ); - self::assertStringContainsString( - '
Not Found: Page not found.
', - $response->getBody()->getContents(), - "Response 'body' should contain error message about 'Not Found: Page not found' when 'Throwable' occurs " . - "during request handling in 'StatelessApplication'.", - ); - } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testThrowNotFoundHttpExceptionWhenStrictParsingDisabledAndRouteIsMissing(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/profile/123', - ]; - - $app = $this->statelessApplication(); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 404, - $response->getStatusCode(), - "Response 'status code' should be '404' when accessing a non-existent route in 'StatelessApplication', " . - "indicating a 'Not Found' error.", - ); - self::assertSame( - 'text/html; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Response 'Content-Type' should be 'text/html; charset=UTF-8' for 'NotFoundHttpException' in " . - "'StatelessApplication'.", - ); - self::assertStringContainsString( - '
Not Found: Page not found.
', - $response->getBody()->getContents(), - "Response 'body' should contain the default not found message '
Not Found: Page not found.
' " . - "when a 'NotFoundHttpException' is thrown in 'StatelessApplication'.", - ); - } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testThrowNotFoundHttpExceptionWhenStrictParsingEnabledAndRouteIsMissing(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/profile/123', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'urlManager' => [ - 'enableStrictParsing' => true, - ], - ], - ], - ); - - $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - $this->expectException(NotFoundHttpException::class); - $this->expectExceptionMessage(Message::PAGE_NOT_FOUND->getMessage()); - - $app->request->resolve(); - } - /** * @throws InvalidConfigException if the configuration is invalid or incomplete. */ @@ -1010,234 +437,4 @@ static function () use (&$eventTriggered): void { self::assertTrue($eventTriggered, "Should trigger '{$eventName}' event during handle()"); } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - #[RequiresPhpExtension('runkit7')] - public function testUseErrorViewLogicWithDebugFalseAndException(): void - { - @\runkit_constant_redefine('YII_DEBUG', false); - - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => 'site/error', - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when a 'Exception' occurs and 'debug' mode is disabled in " . - "'StatelessApplication', indicating an 'internal server error'.", - ); - self::assertSame( - 'text/html; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Response 'Content-Type' should be 'text/html; charset=UTF-8' for error response when 'Exception' " . - "occurs and 'debug' mode is disabled in 'StatelessApplication'.", - ); - self::assertSame( - self::normalizeLineEndings( - << - Custom error page from errorAction. - - yii\base\Exception - - - Exception error message. - - - HTML, - ), - self::normalizeLineEndings($response->getBody()->getContents()), - "Response 'body' should contain 'Custom error page from errorAction' when 'Exception' is triggered " . - "and 'debug' mode is disabled with errorAction configured in 'StatelessApplication'.", - ); - - @\runkit_constant_redefine('YII_DEBUG', true); - } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - #[RequiresPhpExtension('runkit7')] - public function testUseErrorViewLogicWithDebugFalseAndUserException(): void - { - @\runkit_constant_redefine('YII_DEBUG', false); - - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-user-exception', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => 'site/error', - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when a 'UserException' occurs and 'debug' mode is disabled in " . - "'StatelessApplication', indicating an 'internal server error'.", - ); - self::assertSame( - 'text/html; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Response 'Content-Type' should be 'text/html; charset=UTF-8' for error response when 'UserException' " . - "occurs and 'debug' mode is disabled in 'StatelessApplication'.", - ); - self::assertSame( - self::normalizeLineEndings( - << - Custom error page from errorAction. - - yii\base\UserException - - - User-friendly error message. - - - HTML, - ), - self::normalizeLineEndings($response->getBody()->getContents()), - "Response 'body' should contain 'Custom error page from errorAction' when 'UserException' is triggered " . - "and 'debug' mode is disabled with errorAction configured in 'StatelessApplication'.", - ); - - @\runkit_constant_redefine('YII_DEBUG', true); - } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testUseErrorViewLogicWithDebugTrueAndUserException(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-user-exception', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => 'site/error', - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when a 'UserException' occurs and 'debug' mode is enabled in " . - "'StatelessApplication', indicating an 'internal server error'.", - ); - self::assertSame( - 'text/html; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Response 'Content-Type' should be 'text/html; charset=UTF-8' for error response when 'UserException'" . - "occurs and 'debug' mode is enabled in 'StatelessApplication'.", - ); - self::assertSame( - self::normalizeLineEndings( - << - Custom error page from errorAction. - - yii\base\UserException - - - User-friendly error message. - - - HTML, - ), - self::normalizeLineEndings($response->getBody()->getContents()), - "Response 'body' should contain 'User-friendly error message.' when 'UserException' is triggered and " . - "'debug' mode is enabled in 'StatelessApplication'.", - ); - } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testUseErrorViewLogicWithNonHtmlFormat(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => [ - 'errorAction' => 'site/error', - ], - 'response' => [ - 'format' => Response::FORMAT_JSON, - ], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - $responseBody = $response->getBody()->getContents(); - - self::assertSame( - 500, - $response->getStatusCode(), - "Response 'status code' should be '500' when a 'Exception' occurs with JSON format in " . - "'StatelessApplication', indicating an 'internal server error'.", - ); - self::assertSame( - 'application/json; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Response 'Content-Type' should be 'application/json; charset=UTF-8' for error response when 'Exception'" . - "occurs with JSON format in 'StatelessApplication'.", - ); - self::assertStringNotContainsString( - 'Custom error page from errorAction.', - $responseBody, - "Response 'body' should NOT contain 'Custom error page from errorAction' when format is JSON " . - "because useErrorView should be false regardless of YII_DEBUG or exception type in 'StatelessApplication'.", - ); - - $decodedResponse = Json::decode($responseBody); - - self::assertIsArray( - $decodedResponse, - 'JSON response should be decodable to array', - ); - self::assertArrayHasKey( - 'message', - $decodedResponse, - 'JSON error response should contain message key', - ); - } } From 064785474546804933f12a8957e5061d74ae0576 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 23 Aug 2025 17:44:59 -0400 Subject: [PATCH 2/3] Apply fixed review coderabbitai nitpick comments. --- tests/http/stateless/ApplicationErrorHandlerTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/http/stateless/ApplicationErrorHandlerTest.php b/tests/http/stateless/ApplicationErrorHandlerTest.php index a3d5e841..d6973d4c 100644 --- a/tests/http/stateless/ApplicationErrorHandlerTest.php +++ b/tests/http/stateless/ApplicationErrorHandlerTest.php @@ -4,7 +4,7 @@ namespace yii2\extensions\psrbridge\tests\http\stateless; -use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\{Group, RequiresPhpExtension}; use yii\base\{Exception, InvalidConfigException}; use yii\helpers\Json; use yii\log\{FileTarget, Logger}; @@ -24,6 +24,7 @@ use function set_error_handler; use function str_contains; +#[Group('http')] final class ApplicationErrorHandlerTest extends TestCase { /** From f1e9e83b6e2da1c53c9dee2287e83daeef585a24 Mon Sep 17 00:00:00 2001 From: Wilmer Arambula Date: Sat, 23 Aug 2025 18:12:29 -0400 Subject: [PATCH 3/3] test(http): Refactor `ApplicationErrorHandlerTest` to use `DataProvider` for exception rendering formats. --- .../stateless/ApplicationErrorHandlerTest.php | 180 ++++++++---------- .../provider/StatelessApplicationProvider.php | 42 ++++ 2 files changed, 117 insertions(+), 105 deletions(-) diff --git a/tests/http/stateless/ApplicationErrorHandlerTest.php b/tests/http/stateless/ApplicationErrorHandlerTest.php index d6973d4c..242de36b 100644 --- a/tests/http/stateless/ApplicationErrorHandlerTest.php +++ b/tests/http/stateless/ApplicationErrorHandlerTest.php @@ -4,13 +4,14 @@ namespace yii2\extensions\psrbridge\tests\http\stateless; -use PHPUnit\Framework\Attributes\{Group, RequiresPhpExtension}; +use PHPUnit\Framework\Attributes\{DataProviderExternal, Group, RequiresPhpExtension}; use yii\base\{Exception, InvalidConfigException}; use yii\helpers\Json; use yii\log\{FileTarget, Logger}; use yii\web\NotFoundHttpException; use yii2\extensions\psrbridge\exception\Message; 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\TestCase; @@ -358,21 +359,27 @@ public function testRenderExceptionSetsDisplayErrorsInDebugMode(): void /** * @throws InvalidConfigException if the configuration is invalid or incomplete. + * + * @phpstan-param string[] $expectedContent */ - #[RequiresPhpExtension('runkit7')] - public function testRenderExceptionWithErrorActionReturningResponseObject(): void - { - @\runkit_constant_redefine('YII_DEBUG', false); - + #[DataProviderExternal(StatelessApplicationProvider::class, 'exceptionRenderingFormats')] + public function testRenderExceptionWithDifferentFormats( + string $format, + string $expectedContentType, + int $expectedStatusCode, + string $route, + array $expectedContent, + ): void { $_SERVER = [ 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', + 'REQUEST_URI' => $route, ]; $app = $this->statelessApplication( [ 'components' => [ - 'errorHandler' => ['errorAction' => 'site/error-with-response'], + 'response' => ['format' => $format], + 'errorHandler' => ['errorAction' => null], ], ], ); @@ -380,35 +387,62 @@ public function testRenderExceptionWithErrorActionReturningResponseObject(): voi $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); self::assertSame( - 500, + $expectedStatusCode, $response->getStatusCode(), - "Expected HTTP '500' for route 'site/trigger-exception'.", + "Expected HTTP '{$expectedStatusCode}' for route '{$route}'.", ); self::assertSame( - 'text/html; charset=UTF-8', + $expectedContentType, $response->getHeaderLine('Content-Type'), - "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/trigger-exception'.", - ); - self::assertSame( - self::normalizeLineEndings( - << - Custom Response object from error action: Exception error message. - - HTML, - ), - self::normalizeLineEndings($response->getBody()->getContents()), - "Response body should contain content from Response object returned by 'errorAction'.", + "Expected Content-Type '{$expectedContentType}' for route '{$route}'.", ); - @\runkit_constant_redefine('YII_DEBUG', true); + $body = $response->getBody()->getContents(); + + foreach ($expectedContent as $content) { + self::assertStringContainsString( + $content, + $body, + "Response body should contain '{$content}' for {$format} format.", + ); + } + + if ($format === Response::FORMAT_RAW) { + self::assertStringNotContainsString( + '
',
+                $body,
+                "RAW format response should not contain HTML tag '
'.",
+            );
+            self::assertStringNotContainsString(
+                '
', + $body, + "RAW format response should not contain HTML tag '
'.", + ); + } + + if ($format === Response::FORMAT_JSON) { + $decodedResponse = Json::decode($body); + + self::assertIsArray( + $decodedResponse, + 'JSON response should be decodable to array', + ); + self::assertArrayHasKey( + 'message', + $decodedResponse, + 'JSON error response should contain message key', + ); + } } /** * @throws InvalidConfigException if the configuration is invalid or incomplete. */ - public function testRenderExceptionWithRawFormat(): void + #[RequiresPhpExtension('runkit7')] + public function testRenderExceptionWithErrorActionReturningResponseObject(): void { + @\runkit_constant_redefine('YII_DEBUG', false); + $_SERVER = [ 'REQUEST_METHOD' => 'GET', 'REQUEST_URI' => 'site/trigger-exception', @@ -417,8 +451,7 @@ public function testRenderExceptionWithRawFormat(): void $app = $this->statelessApplication( [ 'components' => [ - 'response' => ['format' => Response::FORMAT_RAW], - 'errorHandler' => ['errorAction' => null], + 'errorHandler' => ['errorAction' => 'site/error-with-response'], ], ], ); @@ -430,33 +463,24 @@ public function testRenderExceptionWithRawFormat(): void $response->getStatusCode(), "Expected HTTP '500' for route 'site/trigger-exception'.", ); - self::assertEmpty( + self::assertSame( + 'text/html; charset=UTF-8', $response->getHeaderLine('Content-Type'), - "Expected Content-Type empty string for route 'site/trigger-exception'.", - ); - - $body = $response->getBody()->getContents(); - - self::assertStringContainsString( - Exception::class, - $body, - 'RAW format response should contain exception class name.', - ); - self::assertStringContainsString( - 'Exception error message.', - $body, - 'RAW format response should contain exception message.', - ); - self::assertStringNotContainsString( - '
',
-            $body,
-            "RAW format response should not contain HTML tag '
'.",
+            "Expected Content-Type 'text/html; charset=UTF-8' for route 'site/trigger-exception'.",
         );
-        self::assertStringNotContainsString(
-            '
', - $body, - "RAW format response should not contain HTML tag '
'.", + self::assertSame( + self::normalizeLineEndings( + << + Custom Response object from error action: Exception error message. + + HTML, + ), + self::normalizeLineEndings($response->getBody()->getContents()), + "Response body should contain content from Response object returned by 'errorAction'.", ); + + @\runkit_constant_redefine('YII_DEBUG', true); } /** @@ -751,58 +775,4 @@ public function testUseErrorViewLogicWithDebugTrueAndUserException(): void "'debug' mode is enabled.", ); } - - /** - * @throws InvalidConfigException if the configuration is invalid or incomplete. - */ - public function testUseErrorViewLogicWithNonHtmlFormat(): void - { - $_SERVER = [ - 'REQUEST_METHOD' => 'GET', - 'REQUEST_URI' => 'site/trigger-exception', - ]; - - $app = $this->statelessApplication( - [ - 'components' => [ - 'errorHandler' => ['errorAction' => 'site/error'], - 'response' => ['format' => Response::FORMAT_JSON], - ], - ], - ); - - $response = $app->handle(FactoryHelper::createServerRequestCreator()->createFromGlobals()); - - self::assertSame( - 500, - $response->getStatusCode(), - "Expected HTTP '500' for route 'site/error'.", - ); - self::assertSame( - 'application/json; charset=UTF-8', - $response->getHeaderLine('Content-Type'), - "Expected Content-Type 'application/json; charset=UTF-8' for route 'site/error''.", - ); - - $body = $response->getBody()->getContents(); - - self::assertStringNotContainsString( - 'Custom error page from errorAction.', - $body, - "Response body should NOT contain 'Custom error page from errorAction' when format is JSON " . - "because 'useErrorView' should be 'false' regardless of YII_DEBUG or exception type.", - ); - - $decodedResponse = Json::decode($body); - - self::assertIsArray( - $decodedResponse, - 'JSON response should be decodable to array', - ); - self::assertArrayHasKey( - 'message', - $decodedResponse, - 'JSON error response should contain message key', - ); - } } diff --git a/tests/provider/StatelessApplicationProvider.php b/tests/provider/StatelessApplicationProvider.php index 424ee6fc..996625d3 100644 --- a/tests/provider/StatelessApplicationProvider.php +++ b/tests/provider/StatelessApplicationProvider.php @@ -5,9 +5,13 @@ namespace yii2\extensions\psrbridge\tests\provider; use stdClass; +use yii\base\Exception; +use yii2\extensions\psrbridge\http\Response; use function base64_encode; +use const PHP_INT_SIZE; + final class StatelessApplicationProvider { /** @@ -108,6 +112,7 @@ public static function authCredentials(): array public static function cookies(): array { $cookieWithObject = new stdClass(); + $cookieWithObject->property = 'object_value'; return [ @@ -185,6 +190,43 @@ public static function cookies(): array ]; } + /** + * @phpstan-return array}> + */ + public static function exceptionRenderingFormats(): array + { + return [ + 'HTML format with exception' => [ + Response::FORMAT_HTML, + 'text/html; charset=UTF-8', + 500, + 'site/trigger-exception', + [ + Exception::class, + 'Exception error message.', + 'Stack trace:', + ], + ], + 'JSON format with exception' => [ + Response::FORMAT_JSON, + 'application/json; charset=UTF-8', + 500, + 'site/trigger-exception', + ['"message"'], + ], + 'RAW format with exception' => [ + Response::FORMAT_RAW, + '', + 500, + 'site/trigger-exception', + [ + Exception::class, + 'Exception error message.', + ], + ], + ]; + } + /** * @phpstan-return array */