test(Router): Add unit tests for defaultException and apply flows
This commit is contained in:
209
tests/Unit/RouterTest.php
Normal file
209
tests/Unit/RouterTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user