269 lines
8.8 KiB
PHP
269 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit;
|
|
|
|
use Exception;
|
|
use Libs\Neuron;
|
|
use Libs\Request;
|
|
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 = [];
|
|
private array $getBackup = [];
|
|
private array $postBackup = [];
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->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);
|
|
}
|
|
}
|