diff --git a/tests/Unit/RouterTest.php b/tests/Unit/RouterTest.php new file mode 100644 index 0000000..a5584cd --- /dev/null +++ b/tests/Unit/RouterTest.php @@ -0,0 +1,209 @@ +serverBackup = $_SERVER; + $_SERVER['REQUEST_METHOD'] = 'GET'; + unset($_SERVER['HTTP_ACCEPT']); + http_response_code(200); + $this->resetRouterState(); + } + + protected function tearDown(): void + { + $_SERVER = $this->serverBackup; + 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()); + } +}