318 lines
11 KiB
PHP
318 lines
11 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\RunInSeparateProcess;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use ReflectionClass;
|
|
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. Tambien que los
|
|
* valores por defecto declarados en las propiedades $notFoundCallback y
|
|
* $exceptionCallback (callables string) se resuelvan de punta a punta a
|
|
* traves de Synapsis::resolve() dentro de apply().
|
|
*/
|
|
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); // Ver tearDown: inofensivo mientras no haya un header('HTTP/...') activo.
|
|
$this->resetRouterState();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$_SERVER = $this->serverBackup;
|
|
$_GET = $this->getBackup;
|
|
$_POST = $this->postBackup;
|
|
// El @ cubre el warning de PHP 8.5 ("...has no effect") que dispara
|
|
// cualquier http_response_code() posterior a un header('HTTP/...'),
|
|
// inevitable en el proceso aislado del test de notFound (el valor
|
|
// se aplica igualmente, como confirma el getter).
|
|
@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');
|
|
}
|
|
|
|
#[Test]
|
|
public function declaredDefaultExceptionCallbackRendersThroughTheBoundary(): void
|
|
{
|
|
// Se fuerza el valor declarado en la clase, no el que reasigna
|
|
// resetRouterState(): es justo el camino property default ->
|
|
// Synapsis::resolve(string callable) que debe seguir funcionando.
|
|
Router::$exceptionCallback = (new ReflectionClass(Router::class))
|
|
->getDefaultProperties()['exceptionCallback'];
|
|
|
|
Router::get('/default-exception', function (): void {
|
|
throw new Exception('rendered by the default handler', 422);
|
|
});
|
|
|
|
ob_start();
|
|
Router::apply('/default-exception');
|
|
$output = ob_get_clean();
|
|
|
|
$this->assertSame(422, http_response_code(), 'The declared default handler must derive the status from the code');
|
|
$this->assertStringContainsString('rendered by the default handler', $output);
|
|
$this->assertStringContainsString('#0', $output, 'The declared default handler must render the plain text trace');
|
|
}
|
|
|
|
#[Test]
|
|
#[RunInSeparateProcess]
|
|
public function declaredDefaultNotFoundCallbackRendersThe404Body(): void
|
|
{
|
|
Router::$notFoundCallback = (new ReflectionClass(Router::class))
|
|
->getDefaultProperties()['notFoundCallback'];
|
|
|
|
ob_start();
|
|
Router::apply('/no-such-route');
|
|
$output = ob_get_clean();
|
|
|
|
// Proceso aislado: defaultNotFound() emite header('HTTP/1.0 404 ...') y en
|
|
// PHP 8.5 eso hace que TODO http_response_code() posterior avise, sin forma
|
|
// de limpiar el estado en el mismo proceso. En CLI el header no toca
|
|
// http_response_code(), asi que la asercion util es el cuerpo renderizado.
|
|
$this->assertStringContainsString('Error 404 - Page Not Found', $output);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|