test(Router): Add unit tests for defaultException and apply flows

This commit is contained in:
kj
2026-09-07 17:04:52 -03:00
parent 5f3fc16735
commit 65380f37c6

209
tests/Unit/RouterTest.php Normal file
View File

@@ -0,0 +1,209 @@
<?php
namespace Tests\Unit;
use Exception;
use Libs\Neuron;
use Libs\Router;
use LogicException;
use PDOException;
use PHPUnit\Framework\Attributes\Test;
use ReflectionProperty;
use Tests\TestCase;
use TypeError;
/**
* RouterTest - DuckBrain test harness
*
* Cubre la respuesta de error que produce Router::defaultException(): el
* status derivado del code de la excepcion con guard de rango 400-599 y la
* negociacion por Accept entre representacion JSON y texto plano. Tambien la
* frontera de Router::apply(): que toda excepcion de la cadena (middlewares,
* callback final, notFound) llegue al $exceptionCallback deteniendo la
* ejecucion sin exit() y dejando intacto el flujo normal.
*/
final class RouterTest extends TestCase
{
private array $serverBackup = [];
protected function setUp(): void
{
$this->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());
}
}