serverBackup = $_SERVER; $this->getBackup = $_GET; $this->postBackup = $_POST; $_SERVER['REQUEST_METHOD'] = 'GET'; unset($_SERVER['HTTP_ACCEPT']); http_response_code(200); $this->resetRouterState(); } protected function tearDown(): void { $_SERVER = $this->serverBackup; $_GET = $this->getBackup; $_POST = $this->postBackup; http_response_code(200); $this->resetRouterState(); } private function resetRouterState(): void { foreach (['get', 'post', 'put', 'patch', 'delete'] as $method) { (new ReflectionProperty(Router::class, $method))->setValue(null, []); } Router::$params = new Neuron(); Router::$notFoundCallback = 'Libs\Router::defaultNotFound'; Router::$exceptionCallback = 'Libs\Router::defaultException'; } private function render(\Throwable $exception): string { ob_start(); Router::defaultException($exception); return ob_get_clean(); } #[Test] public function intCodeInHttpStatusRangeBecomesResponseStatus(): void { $output = $this->render(new Exception('The edad must be at least 18.', 422)); $this->assertSame(422, http_response_code()); $this->assertStringContainsString('The edad must be at least 18.', $output); } #[Test] public function stringSqlStateCodeFallsBackToServerError(): void { $this->render(new PDOException('SQLSTATE[23000]: Integrity constraint violation', '23000')); $this->assertSame(500, http_response_code(), 'A string SQLSTATE code must never reach http_response_code'); } #[Test] public function intCodeOutOfRangeFallsBackToServerError(): void { $this->render(new Exception('arbitrary code', 7)); $this->assertSame(500, http_response_code()); } #[Test] public function throwableWithoutMeaningfulCodeAnswersServerError(): void { $this->render(new TypeError('must be of the type int')); $this->assertSame(500, http_response_code()); } #[Test] public function plainTextIsDefaultRepresentation(): void { $output = $this->render(new Exception('boom', 418)); $this->assertStringStartsWith("boom\n", $output); $this->assertStringContainsString('#0', $output, 'Plain text must include the full trace'); } #[Test] public function jsonRepresentationWhenClientAcceptsJson(): void { $_SERVER['HTTP_ACCEPT'] = 'application/json'; $output = $this->render(new Exception('The e must be a valid email address.', 422)); $payload = json_decode($output, true); $this->assertIsArray($payload, 'Accept: application/json must produce a JSON body'); $this->assertSame(422, http_response_code()); $this->assertSame('The e must be a valid email address.', $payload['error']); $this->assertSame('Exception', $payload['exception']); $this->assertArrayHasKey('file', $payload); $this->assertArrayHasKey('line', $payload); $this->assertIsArray($payload['trace']); $this->assertIsString($payload['trace'][0], 'The trace must be serialized as readable lines, not raw frames'); } #[Test] public function middlewareExceptionStopsTheRemainingChain(): void { $log = []; $caught = []; Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void { $caught[] = $exception; }; Router::get('/chain', function () use (&$log): void { $log[] = 'final'; }); Router::middleware(function () use (&$log): void { $log[] = 'mw-late-registered'; }); Router::middleware(function () use (&$log): void { throw new Exception('chain break', 422); }); Router::middleware(function () use (&$log): void { $log[] = 'mw-first-registered'; }); Router::apply('/chain'); // Callbacks run in reverse registration order: the last registered middleware // ran first, the thrower stopped everything after it, and apply() returned // normally (this line being reached proves there was no exit()). $this->assertSame(['mw-first-registered'], $log); $this->assertCount(1, $caught, 'The route chain must deliver exactly one exception to the callback'); $this->assertSame('chain break', $caught[0]->getMessage()); $this->assertSame(422, $caught[0]->getCode()); } #[Test] public function finalCallbackExceptionIsDeliveredToTheExceptionCallback(): void { $caught = []; Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void { $caught[] = get_class($exception) . ': ' . $exception->getMessage(); }; Router::get('/throwing', function (): void { throw new LogicException('controller boom'); }); Router::apply('/throwing'); $this->assertSame(['LogicException: controller boom'], $caught); } #[Test] public function notFoundCallbackExceptionReachesTheExceptionCallback(): void { $caught = []; Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void { $caught[] = $exception; }; Router::$notFoundCallback = function (): void { throw new Exception('not found exploded', 418); }; Router::apply('/no-such-route'); $this->assertCount(1, $caught); $this->assertSame('not found exploded', $caught[0]->getMessage()); $this->assertSame(418, $caught[0]->getCode()); } #[Test] public function normalFlowStillPrintsReturnedData(): void { Router::$exceptionCallback = function (): void { $this->fail('The exception callback must not run on a successful request'); }; Router::get('/ok', function (): array { return ['status' => 'ok']; }); ob_start(); Router::apply('/ok'); $output = ob_get_clean(); $this->assertSame('{"status":"ok"}', $output); $this->assertSame(200, http_response_code()); } #[Test] public function customOnInvalidExceptionReachesTheExceptionCallbackThroughTheBoundary(): void { $_SERVER['REQUEST_METHOD'] = 'POST'; $_SERVER['REQUEST_URI'] = '/signup'; $_SERVER['DOCUMENT_ROOT'] = '/nonexistent-docroot'; $_POST = ['age' => '5']; $caught = []; Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void { $caught[] = $exception; }; $finalRan = false; Router::post('/signup', function (StrictSignup $request) use (&$finalRan): void { $finalRan = true; }); Router::apply('/signup'); // The Request was built by the container for the final callback and its // custom onInvalid() threw during dependency resolution: the custom type // must arrive intact to the callback, and the final callback must not run. $this->assertCount(1, $caught); $this->assertInstanceOf(RequestRejected::class, $caught[0]); $this->assertSame('The age must be at least 18.', $caught[0]->getMessage()); $this->assertSame(400, $caught[0]->getCode(), 'The status channel must survive custom exception types'); $this->assertFalse($finalRan, 'A Request that throws during DI resolution must stop the route'); } } // phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses /** * Fixtures locales de la prueba de simetrĂ­a: un tipo de excepcion propio que * un Request hijo lanza desde su onInvalid() reescrito. */ final class RequestRejected extends Exception { } final class StrictSignup extends Request { public function rules(): array { return ['age' => 'required|min:18']; } public function onInvalid(string $error): never { throw new RequestRejected($error, 400); } }