diff --git a/plugins/exception-render/src/MixerApiExceptionRenderer.php b/plugins/exception-render/src/MixerApiExceptionRenderer.php index 9b5f632..0c50de3 100644 --- a/plugins/exception-render/src/MixerApiExceptionRenderer.php +++ b/plugins/exception-render/src/MixerApiExceptionRenderer.php @@ -73,11 +73,14 @@ public function render(): ResponseInterface } $response = $response->withStatus($code); - $exceptions = [$exception]; - $previous = $exception->getPrevious(); - while ($previous != null) { - $exceptions[] = $previous; - $previous = $previous->getPrevious(); + $maxExceptions = 10; + $depth = 0; + $exceptions = []; + $current = $exception; + while ($current !== null && $depth < $maxExceptions) { + $depth++; + $exceptions[] = new SerializableException($current); + $current = $current->getPrevious(); } $viewVars = [ diff --git a/plugins/exception-render/src/SerializableException.php b/plugins/exception-render/src/SerializableException.php new file mode 100644 index 0000000..1a369a7 --- /dev/null +++ b/plugins/exception-render/src/SerializableException.php @@ -0,0 +1,44 @@ +wrapped = $exception; + parent::__construct($exception->getMessage(), (int)$exception->getCode()); + $this->file = $exception->getFile(); + $this->line = $exception->getLine(); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'class' => (new ReflectionClass($this->wrapped))->getShortName(), + 'message' => $this->getMessage(), + 'code' => $this->getCode(), + ]; + + if (Configure::read('debug')) { + $data['file'] = $this->getFile(); + $data['line'] = $this->getLine(); + } + + return $data; + } +} diff --git a/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php b/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php index 4a75252..c896abf 100644 --- a/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php +++ b/plugins/exception-render/tests/TestCase/MixerApiExceptionRenderTest.php @@ -2,7 +2,7 @@ namespace MixerApi\ExceptionRender\Test\TestCase; -use Cake\Core\Exception\CakeException; +use Cake\Event\EventManager; use Cake\Http\Exception\HttpException; use Cake\Http\ServerRequest; use Cake\TestSuite\TestCase; @@ -11,6 +11,80 @@ class MixerApiExceptionRenderTest extends TestCase { + public function test_chained_exceptions_serialize_as_structured_arrays(): void + { + $previous = new \RuntimeException('Connection refused', 111); + $exception = new HttpException('Service unavailable', 503, $previous); + + $request = new ServerRequest(); + $request = $request->withHeader('Accept', 'application/json'); + $request = $request->withHeader('Content-Type', 'application/json'); + + $response = (new MixerApiExceptionRenderer($exception, $request))->render(); + + $body = json_decode((string)$response->getBody(), true); + $exceptions = $body['exceptions']; + + $this->assertCount(2, $exceptions); + + $this->assertEquals('HttpException', $exceptions[0]['class']); + $this->assertEquals('Service unavailable', $exceptions[0]['message']); + $this->assertEquals(503, $exceptions[0]['code']); + $this->assertArrayHasKey('file', $exceptions[0]); + $this->assertArrayHasKey('line', $exceptions[0]); + + $this->assertEquals('RuntimeException', $exceptions[1]['class']); + $this->assertEquals('Connection refused', $exceptions[1]['message']); + $this->assertEquals(111, $exceptions[1]['code']); + $this->assertArrayHasKey('file', $exceptions[1]); + $this->assertArrayHasKey('line', $exceptions[1]); + } + + public function test_exception_chain_is_capped_at_max_depth(): void + { + $exception = new \RuntimeException('root'); + for ($i = 0; $i < 11; $i++) { + $exception = new \RuntimeException("level $i", 0, $exception); + } + + $request = new ServerRequest(); + $request = $request->withHeader('Accept', 'application/json'); + $request = $request->withHeader('Content-Type', 'application/json'); + + $response = (new MixerApiExceptionRenderer($exception, $request))->render(); + + $body = json_decode((string)$response->getBody(), true); + $this->assertCount(10, $body['exceptions']); + } + + public function test_chained_exceptions_are_throwable_for_event_listeners(): void + { + $previous = new \RuntimeException('Connection refused', 111); + $exception = new HttpException('Service unavailable', 503, $previous); + + $request = new ServerRequest(); + $request = $request->withHeader('Accept', 'application/json'); + $request = $request->withHeader('Content-Type', 'application/json'); + + $captured = null; + EventManager::instance()->on( + 'MixerApi.ExceptionRender.beforeRender', + function ($event) use (&$captured) { + $captured = $event->getSubject()->getViewVars()['exceptions']; + } + ); + + (new MixerApiExceptionRenderer($exception, $request))->render(); + + $this->assertCount(2, $captured); + $this->assertInstanceOf(\Throwable::class, $captured[0]); + $this->assertInstanceOf(\Throwable::class, $captured[1]); + $this->assertEquals('Service unavailable', $captured[0]->getMessage()); + $this->assertEquals('Connection refused', $captured[1]->getMessage()); + + EventManager::instance()->off('MixerApi.ExceptionRender.beforeRender'); + } + public function test_get_error(): void { $this->assertInstanceOf( diff --git a/plugins/exception-render/tests/TestCase/SerializableExceptionTest.php b/plugins/exception-render/tests/TestCase/SerializableExceptionTest.php new file mode 100644 index 0000000..785470b --- /dev/null +++ b/plugins/exception-render/tests/TestCase/SerializableExceptionTest.php @@ -0,0 +1,92 @@ +assertInstanceOf(\Throwable::class, $exception); + } + + public function test_is_json_serializable(): void + { + $wrapped = new \RuntimeException('test'); + $exception = new SerializableException($wrapped); + + $this->assertInstanceOf(\JsonSerializable::class, $exception); + } + + public function test_proxies_message_and_code(): void + { + $wrapped = new \RuntimeException('Connection refused', 111); + $exception = new SerializableException($wrapped); + + $this->assertEquals('Connection refused', $exception->getMessage()); + $this->assertEquals(111, $exception->getCode()); + } + + public function test_proxies_file_and_line(): void + { + $wrapped = new \RuntimeException('test'); + $exception = new SerializableException($wrapped); + + $this->assertEquals($wrapped->getFile(), $exception->getFile()); + $this->assertEquals($wrapped->getLine(), $exception->getLine()); + } + + public function test_json_serialize_returns_structured_data(): void + { + $wrapped = new \RuntimeException('Connection refused', 111); + $exception = new SerializableException($wrapped); + + Configure::write('debug', true); + $data = $exception->jsonSerialize(); + + $this->assertEquals('RuntimeException', $data['class']); + $this->assertEquals('Connection refused', $data['message']); + $this->assertEquals(111, $data['code']); + $this->assertArrayHasKey('file', $data); + $this->assertArrayHasKey('line', $data); + } + + public function test_json_serialize_excludes_file_and_line_without_debug(): void + { + $wrapped = new \RuntimeException('Connection refused', 111); + $exception = new SerializableException($wrapped); + + Configure::write('debug', false); + $data = $exception->jsonSerialize(); + + $this->assertEquals('RuntimeException', $data['class']); + $this->assertEquals('Connection refused', $data['message']); + $this->assertEquals(111, $data['code']); + $this->assertArrayNotHasKey('file', $data); + $this->assertArrayNotHasKey('line', $data); + + Configure::write('debug', true); + } + + public function test_json_encode_produces_correct_output(): void + { + $wrapped = new \RuntimeException('test', 42); + $exception = new SerializableException($wrapped); + + Configure::write('debug', false); + $json = json_encode($exception); + $decoded = json_decode($json, true); + + $this->assertEquals('RuntimeException', $decoded['class']); + $this->assertEquals('test', $decoded['message']); + $this->assertEquals(42, $decoded['code']); + + Configure::write('debug', true); + } +}