From c2e06bb15cc3f26c5fc9c359289b117c8b1e3191 Mon Sep 17 00:00:00 2001 From: Dustin Wilson Date: Sun, 23 Apr 2023 13:12:21 -0500 Subject: [PATCH] Handler tests --- lib/Catcher/Handler.php | 50 +++---- lib/Catcher/RangeException.php | 11 ++ tests/bootstrap.php | 2 +- tests/cases/TestCatcher.php | 16 ++- tests/cases/TestHandler.php | 194 ++++++++++++++++++++++++++++ tests/lib/Error.php | 11 ++ tests/lib/ErrorHandlingTestCase.php | 31 +++++ tests/lib/TestingHandler.php | 12 +- 8 files changed, 299 insertions(+), 28 deletions(-) create mode 100644 lib/Catcher/RangeException.php create mode 100644 tests/cases/TestHandler.php create mode 100644 tests/lib/Error.php create mode 100644 tests/lib/ErrorHandlingTestCase.php diff --git a/lib/Catcher/Handler.php b/lib/Catcher/Handler.php index 53a1de9..68a4286 100644 --- a/lib/Catcher/Handler.php +++ b/lib/Catcher/Handler.php @@ -36,7 +36,7 @@ abstract class Handler { * an error occurred */ protected string $_charset = 'UTF-8'; - /** If true the handler will force an exit */ + /** If true the handler will force an exit after all handlers have run */ protected bool $_forceExit = false; /** If true the handler will output as soon as possible, unless silenced */ protected bool $_forceOutputNow = false; @@ -70,12 +70,7 @@ abstract class Handler { public function __construct(array $options = []) { foreach ($options as $key => $value) { - $key = "_$key"; - if ($key === '_httpCode' && is_int($value) && $value !== 200 && max(400, min($value, 600)) !== $value) { - throw new \RangeException('Option "httpCode" can only be an integer of 200 or 400-599'); - } - - $this->$key = $value; + $this->setOption($key, $value); } if ($this->_varExporter === null) { @@ -93,8 +88,7 @@ abstract class Handler { // Send the headers if possible and necessary if (isset($_SERVER['REQUEST_URI'])) { - // Can't figure out a way to test coverage here, but the logic is tested thoroughly - // when running tests in HTTP + // Can't figure out a way to test coverage here // @codeCoverageIgnoreStart if (!headers_sent()) { header_remove('location'); @@ -159,6 +153,22 @@ abstract class Handler { return; } + if ( + $name === 'httpCode' && + is_int($value) && + $value !== 200 && + max(400, min($value, 418)) !== $value && + max(421, min($value, 429)) !== $value && + $value !== 431 && + $value !== 451 && + max(500, min($value, 511)) !== $value && + // Cloudflare extensions + max(520, min($value, 527)) !== $value && + $value !== 530 + ) { + throw new RangeException('Option "httpCode" can only be a valid HTTP 200, 4XX, or 5XX code'); + } + $name = "_$name"; $this->$name = $value; } @@ -192,8 +202,7 @@ abstract class Handler { protected function cleanOutputThrowable(array $outputThrowable): array { unset($outputThrowable['controller']); - unset($outputThrowable['controlCode']); - unset($outputThrowable['outputCode']); + unset($outputThrowable['code']); if (isset($outputThrowable['previous'])) { $outputThrowable['previous'] = $this->cleanOutputThrowable($outputThrowable['previous']); @@ -205,10 +214,8 @@ abstract class Handler { return $outputThrowable; } - protected function handleCallback(array $output): array { - return $output; - } + abstract protected function handleCallback(array $output): array; abstract protected function invokeCallback(): void; protected function log(\Throwable $throwable, string $message): void { @@ -227,17 +234,14 @@ abstract class Handler { case \E_USER_DEPRECATED: $this->_logger->warning($message, $context); break; - case \E_RECOVERABLE_ERROR: - $this->_logger->error($message, $context); - break; case \E_PARSE: case \E_CORE_ERROR: case \E_COMPILE_ERROR: - $this->_logger->alert($message, $context); + $this->_logger->critical($message, $context); break; - default: $this->_logger->critical($message, $context); + default: $this->_logger->error($message, $context); } - } elseif ($throwable instanceof \Exception && ($throwable instanceof \PharException || $throwable instanceof \RuntimeException)) { + } elseif ($throwable instanceof \PharException || $throwable instanceof \RuntimeException) { $this->_logger->alert($message, $context); } else { $this->_logger->critical($message, $context); @@ -245,12 +249,12 @@ abstract class Handler { } protected function print(string $string): void { - $string = "$string\n"; if (strtolower(\PHP_SAPI) === 'cli' && $this->_outputToStderr) { // Can't test this in code coverage without printing errors to STDERR fwrite(\STDERR, $string); // @codeCoverageIgnore - } else { - echo $string; + return; // @codeCoverageIgnore } + + echo $string; } } \ No newline at end of file diff --git a/lib/Catcher/RangeException.php b/lib/Catcher/RangeException.php new file mode 100644 index 0000000..b28bf98 --- /dev/null +++ b/lib/Catcher/RangeException.php @@ -0,0 +1,11 @@ +assertInstanceOf(PlainTextHandler::class, $h[0]); } - /** @dataProvider provideErrorHandlingTests */ + /** + * @dataProvider provideErrorHandlingTests + * @covers \MensBeam\Catcher\Error + */ public function testErrorHandling(int $code): void { $t = null; try { @@ -62,19 +65,21 @@ class TestCatcher extends \PHPUnit\Framework\TestCase { } } + /** @covers \MensBeam\Catcher\Error */ public function testExit(): void { $this->catcher->unregister(); $h = Phake::partialMock(TestingHandler::class); $this->catcher = $m = Phake::partialMock(Catcher::class, $h); $m->errorHandlingMethod = Catcher::THROW_NO_ERRORS; Phake::when($m)->exit->thenReturn(null); - Phake::when($m)->handleShutdown()->thenReturn(null); + Phake::when($m)->handleShutdown->thenReturn(null); trigger_error('Ook!', \E_USER_ERROR); Phake::verify($h, Phake::times(1))->invokeCallback(); } + /** @covers \MensBeam\Catcher\Error */ public function testHandlerBubbling(): void { $this->catcher->unregister(); @@ -91,6 +96,7 @@ class TestCatcher extends \PHPUnit\Framework\TestCase { Phake::verify($h2, Phake::never())->invokeCallback(); } + /** @covers \MensBeam\Catcher\Error */ public function testHandlerForceExiting(): void { $this->catcher->setHandlers(new TestingHandler([ 'forceExit' => true ])); $this->catcher->errorHandlingMethod = Catcher::THROW_NO_ERRORS; @@ -108,7 +114,10 @@ class TestCatcher extends \PHPUnit\Framework\TestCase { $this->assertFalse($this->catcher->isRegistered()); } - /** @dataProvider provideShutdownTests */ + /** + * @dataProvider provideShutdownTests + * @covers \MensBeam\Catcher\Error + */ public function testShutdownHandling(\Closure $closure): void { $this->catcher->unregister(); @@ -148,6 +157,7 @@ class TestCatcher extends \PHPUnit\Framework\TestCase { $this->assertEquals(1, count($c->getHandlers())); } + /** @covers \MensBeam\Catcher\Error */ public function testWeirdErrorReporting(): void { error_reporting(\E_ERROR); $t = null; diff --git a/tests/cases/TestHandler.php b/tests/cases/TestHandler.php new file mode 100644 index 0000000..848e9e9 --- /dev/null +++ b/tests/cases/TestHandler.php @@ -0,0 +1,194 @@ +handler = new TestingHandler([ + 'outputBacktrace' => true + ]); + } + + /** @dataProvider provideHandlingTests */ + public function testHandling(\Throwable $throwable, int $expectedCode, array $options = []): void { + foreach ($options as $k => $v) { + $this->handler->setOption($k, $v); + } + + $o = $this->handler->handle(new ThrowableController($throwable)); + $this->assertSame($throwable::class, $o['controller']->getThrowable()::class); + $this->assertEquals($expectedCode, $o['code']); + } + + public function testInvocation(): void { + $this->handler->handle(new ThrowableController(new \Exception('Ook!'))); + $r = new \ReflectionProperty($this->handler::class, 'outputBuffer'); + $r->setAccessible(true); + $this->assertEquals(1, count($r->getValue($this->handler))); + + $h = $this->handler; + $h(); + $this->assertEquals(0, count($r->getValue($this->handler))); + $h(); + $this->assertEquals(0, count($r->getValue($this->handler))); + } + + /** @dataProvider provideLogTests */ + public function testLog(\Throwable $throwable, string $methodName): void { + $l = Phake::mock(LoggerInterface::class); + $this->handler->setOption('logger', $l); + $this->handler->handle(new ThrowableController($throwable)); + $h = $this->handler; + $h(); + Phake::verify($l, Phake::times(1))->$methodName; + } + + /** @dataProvider provideOptionsTests */ + public function testOptions(string $option, mixed $value): void { + $this->handler->setOption($option, $value); + $this->assertSame($value, $this->handler->getOption($option)); + } + + public function testPrinting(): void { + $this->handler->setOption('print', true); + $this->handler->setOption('outputToStderr', false); + $this->handler->handle(new ThrowableController(new \Exception('Ook!'))); + $h = $this->handler; + ob_start(); + $h(); + $o = ob_get_clean(); + $this->assertNotNull($o); + $o = json_decode($o, true); + $this->assertSame(\Exception::class, $o['class']); + $this->assertSame(__FILE__, $o['file']); + $this->assertSame(__LINE__ - 9, $o['line']); + $this->assertSame('Ook!', $o['message']); + } + + + public function testFatalError(): void { + $this->expectException(RangeException::class); + $this->handler->setOption('httpCode', 42); + } + + /** @dataProvider provideNonFatalErrorTests */ + public function testNonFatalErrors(int $code, string $message, \Closure $closure): void { + $closure($this->handler); + $this->assertEquals($code, $this->lastError?->getCode()); + $this->assertSame($message, $this->lastError?->getMessage()); + } + + + public static function provideHandlingTests(): iterable { + $options = [ + [ new \Exception('Ook!'), Handler::BUBBLES | Handler::OUTPUT | Handler::EXIT, [ 'forceExit' => true ] ], + [ new \Error('Ook!'), Handler::BUBBLES | Handler::OUTPUT ], + [ new \Exception('Ook!'), Handler::BUBBLES, [ 'silent' => true ] ], + [ new Error('Ook!', \E_ERROR, '/dev/null', 42, new \Error('Eek!')), Handler::BUBBLES | Handler::OUTPUT | Handler::NOW, [ 'forceOutputNow' => true ] ], + [ new \Exception('Ook!'), Handler::BUBBLES, [ 'silent' => true, 'logger' => Phake::mock(LoggerInterface::class), 'logWhenSilent' => false ] ], + [ new \Error('Ook!'), Handler::BUBBLES | Handler::OUTPUT | Handler::LOG, [ 'forceOutputNow' => true, 'logger' => Phake::mock(LoggerInterface::class) ] ] + ]; + + foreach ($options as $o) { + // TestingHandler adds Handler::NOW to the output code to make + // testing with it in TestCatcher less of a pain, so it needs to be + // added here + $o[1] |= Handler::NOW; + yield $o; + } + } + + public static function provideLogTests(): iterable { + $options = [ + [ new Error('Ook!', \E_NOTICE, '/dev/null', 0, new \Error('Eek!')), 'notice' ], + [ new Error('Ook!', \E_USER_NOTICE, '/dev/null', 0), 'notice' ], + [ new Error('Ook!', \E_STRICT, '/dev/null', 0, new \Error('Eek!')), 'notice' ], + [ new Error('Ook!', \E_WARNING, '/dev/null', 0), 'warning' ], + [ new Error('Ook!', \E_COMPILE_WARNING, '/dev/null', 0, new \Error('Eek!')), 'warning' ], + [ new Error('Ook!', \E_USER_WARNING, '/dev/null', 0), 'warning' ], + [ new Error('Ook!', \E_DEPRECATED, '/dev/null', 0, new \Error('Eek!')), 'warning' ], + [ new Error('Ook!', \E_USER_DEPRECATED, '/dev/null', 0), 'warning' ], + [ new Error('Ook!', \E_PARSE, '/dev/null', 0, new \Error('Eek!')), 'critical' ], + [ new Error('Ook!', \E_CORE_ERROR, '/dev/null', 0), 'critical' ], + [ new Error('Ook!', \E_COMPILE_ERROR, '/dev/null', 0, new \Error('Eek!')), 'critical' ], + [ new Error('Ook!', \E_ERROR, '/dev/null', 0), 'error' ], + [ new Error('Ook!', \E_USER_ERROR, '/dev/null', 0, new \Error('Eek!')), 'error' ], + [ new \PharException('Ook!'), 'alert' ], + [ new \Exception('Ook!'), 'critical' ], + ]; + + foreach ($options as $o) { + yield $o; + } + } + + public static function provideNonFatalErrorTests(): iterable { + $iterable = [ + [ + \E_USER_WARNING, + 'Undefined option in ' . TestingHandler::class . ': ook', + function (Handler $h): void { + $h->getOption('ook'); + } + ], + [ + \E_USER_WARNING, + 'Undefined option in ' . TestingHandler::class . ': ook', + function (Handler $h): void { + $h->setOption('ook', 'eek'); + } + ] + ]; + + foreach ($iterable as $i) { + yield $i; + } + } + + public static function provideOptionsTests(): iterable { + $options = [ + [ 'backtraceArgFrameLimit', 42 ], + [ 'bubbles', false ], + [ 'charset', 'UTF-16' ], + [ 'forceExit', true ], + [ 'forceOutputNow', true ], + [ 'httpCode', 200 ], + [ 'httpCode', 400 ], + [ 'httpCode', 502 ], + [ 'logger', Phake::mock(LoggerInterface::class) ], + [ 'logWhenSilent', false ], + [ 'outputBacktrace', true ], + [ 'outputPrevious', false ], + [ 'outputTime', false ], + [ 'outputToStderr', false ], + [ 'silent', true ], + [ 'timeFormat', 'Y-m-d' ], + [ 'varExporter', fn(mixed $value): string|bool => var_export($value, true) ] + ]; + + foreach ($options as $o) { + yield $o; + } + } +} \ No newline at end of file diff --git a/tests/lib/Error.php b/tests/lib/Error.php new file mode 100644 index 0000000..01344ea --- /dev/null +++ b/tests/lib/Error.php @@ -0,0 +1,11 @@ +lastError = $e; + if (in_array($code, [ \E_ERROR, \E_PARSE, \E_CORE_ERROR, \E_COMPILE_ERROR, \E_USER_ERROR, \E_RECOVERABLE_ERROR ])) { + throw $e; + } + } +} \ No newline at end of file diff --git a/tests/lib/TestingHandler.php b/tests/lib/TestingHandler.php index da13e52..512aaaa 100644 --- a/tests/lib/TestingHandler.php +++ b/tests/lib/TestingHandler.php @@ -11,7 +11,11 @@ use MensBeam\Catcher\Handler; class TestingHandler extends Handler { + public array $output = []; + protected ?string $_name = null; + // Could just use silent option instead, but we need to test Handler::SILENT + protected bool $_print = false; protected function handleCallback(array $output): array { @@ -35,7 +39,13 @@ class TestingHandler extends Handler { ])); } - //$this->print($this->serializeOutputThrowable($o)); + $o = $this->cleanOutputThrowable($o); + + if ($this->_print) { + $this->print(json_encode($o, \JSON_THROW_ON_ERROR)); + } + + $this->output[] = $o; } } } \ No newline at end of file