Compare commits
7 Commits
9ddb00e719
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e96aa886e | |||
| f4b112032e | |||
| 7b1d5e42bd | |||
| 26589062f5 | |||
| 5022fdac75 | |||
| 70b1016194 | |||
| 86ab8b979a |
@@ -12,8 +12,3 @@ spl_autoload_register(function ($className) {
|
|||||||
require_once $file;
|
require_once $file;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
set_exception_handler(function ($exception) {
|
|
||||||
echo "Uncaught exception: " , $exception->getMessage(), "\n";
|
|
||||||
echo $exception->getTraceAsString();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class Router
|
|||||||
*
|
*
|
||||||
* @var callable $notFoundCallback
|
* @var callable $notFoundCallback
|
||||||
*/
|
*/
|
||||||
public static $notFoundCallback = Router::defaultNotFound(...);
|
public static $notFoundCallback = 'Libs\Router::defaultNotFound';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default callback function for when
|
* Default callback function for when
|
||||||
@@ -72,7 +72,7 @@ class Router
|
|||||||
*
|
*
|
||||||
* @var callable $exceptionCallback
|
* @var callable $exceptionCallback
|
||||||
*/
|
*/
|
||||||
public static $exceptionCallback = Router::defaultException(...);
|
public static $exceptionCallback = 'Libs\Router::defaultException';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default callback function for exception responses.
|
* Default callback function for exception responses.
|
||||||
@@ -184,7 +184,6 @@ class Router
|
|||||||
public static function redirect(string $path): void
|
public static function redirect(string $path): void
|
||||||
{
|
{
|
||||||
header('Location: ' . static::basePath() . ltrim($path, '/'));
|
header('Location: ' . static::basePath() . ltrim($path, '/'));
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use Exception;
|
|||||||
* | url | | Must be a valid URL |
|
* | url | | Must be a valid URL |
|
||||||
* | date | | Must be a parseable date |
|
* | date | | Must be a parseable date |
|
||||||
* | regex | :pattern | Must match a PCRE pattern (with delimiters) |
|
* | regex | :pattern | Must match a PCRE pattern (with delimiters) |
|
||||||
* | enum | :a,b,c | Must be one of the listed values |
|
* | in | :a,b,c | Must be one of the listed values |
|
||||||
* | min | :n | Length/number/count min (files: KB) |
|
* | min | :n | Length/number/count min (files: KB) |
|
||||||
* | max | :n | Length/number/count max (files: KB) |
|
* | max | :n | Length/number/count max (files: KB) |
|
||||||
* | between | :min,:max | Value within range (files: KB per upload) |
|
* | between | :min,:max | Value within range (files: KB per upload) |
|
||||||
@@ -105,7 +105,7 @@ class Validator
|
|||||||
* | :size | the size rule value |
|
* | :size | the size rule value |
|
||||||
* | :value | the required_if expected value |
|
* | :value | the required_if expected value |
|
||||||
* | :other | the required_if companion field / confirmation |
|
* | :other | the required_if companion field / confirmation |
|
||||||
* | :values | a comma list (enum, mimes, required_with) |
|
* | :values | a comma list (in, mimes, required_with) |
|
||||||
* |----------+----------------------------------------------------|
|
* |----------+----------------------------------------------------|
|
||||||
*
|
*
|
||||||
* @var array<string,string>
|
* @var array<string,string>
|
||||||
@@ -122,7 +122,7 @@ class Validator
|
|||||||
'array' => 'The :attribute must be an array.',
|
'array' => 'The :attribute must be an array.',
|
||||||
'email' => 'The :attribute must be a valid email address.',
|
'email' => 'The :attribute must be a valid email address.',
|
||||||
'url' => 'The :attribute must be a valid URL.',
|
'url' => 'The :attribute must be a valid URL.',
|
||||||
'enum' => 'The selected :attribute is invalid. Allowed: :values.',
|
'in' => 'The selected :attribute is invalid. Allowed: :values.',
|
||||||
'min' => 'The :attribute must be at least :min.',
|
'min' => 'The :attribute must be at least :min.',
|
||||||
'max' => 'The :attribute must not be greater than :max.',
|
'max' => 'The :attribute must not be greater than :max.',
|
||||||
'between' => 'The :attribute must be between :min and :max.',
|
'between' => 'The :attribute must be between :min and :max.',
|
||||||
@@ -243,7 +243,7 @@ class Validator
|
|||||||
case 'size':
|
case 'size':
|
||||||
$replace[':size'] = $args[0] ?? '';
|
$replace[':size'] = $args[0] ?? '';
|
||||||
break;
|
break;
|
||||||
case 'enum':
|
case 'in':
|
||||||
case 'mimes':
|
case 'mimes':
|
||||||
case 'required_with':
|
case 'required_with':
|
||||||
$replace[':values'] = implode(', ', $args);
|
$replace[':values'] = implode(', ', $args);
|
||||||
@@ -305,7 +305,7 @@ class Validator
|
|||||||
* arguments legitimately contain ':' or ',' - such as regex or mimes -
|
* arguments legitimately contain ':' or ',' - such as regex or mimes -
|
||||||
* are preserved intact. The caller decides how to split the arguments.
|
* are preserved intact. The caller decides how to split the arguments.
|
||||||
*
|
*
|
||||||
* @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "enum:a,b".
|
* @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "in:a,b".
|
||||||
*
|
*
|
||||||
* @return array A two element array: [name, rawArguments]. When the rule
|
* @return array A two element array: [name, rawArguments]. When the rule
|
||||||
* has no parameters, rawArguments is an empty string.
|
* has no parameters, rawArguments is an empty string.
|
||||||
@@ -419,7 +419,7 @@ class Validator
|
|||||||
* Splits a rule's raw argument string into the arguments to pass to the
|
* Splits a rule's raw argument string into the arguments to pass to the
|
||||||
* rule's method (the subject is not included).
|
* rule's method (the subject is not included).
|
||||||
*
|
*
|
||||||
* Most rules take a comma-separated list of values (e.g. "enum:a,b").
|
* Most rules take a comma-separated list of values (e.g. "in:a,b").
|
||||||
* A few rules receive an argument that must be kept intact because it can
|
* A few rules receive an argument that must be kept intact because it can
|
||||||
* legitimately contain commas or colons: "regex" (a pattern) and "not"
|
* legitimately contain commas or colons: "regex" (a pattern) and "not"
|
||||||
* (a sub-rule that is itself parsed recursively). An empty argument string
|
* (a sub-rule that is itself parsed recursively). An empty argument string
|
||||||
@@ -810,7 +810,7 @@ class Validator
|
|||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function enum(mixed $subject, ...$values): bool
|
public static function in(mixed $subject, ...$values): bool
|
||||||
{
|
{
|
||||||
return in_array($subject, $values);
|
return in_array($subject, $values);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ namespace Tests\Unit;
|
|||||||
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Libs\Neuron;
|
use Libs\Neuron;
|
||||||
|
use Libs\Request;
|
||||||
use Libs\Router;
|
use Libs\Router;
|
||||||
use LogicException;
|
use LogicException;
|
||||||
use PDOException;
|
use PDOException;
|
||||||
|
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use ReflectionClass;
|
||||||
use ReflectionProperty;
|
use ReflectionProperty;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
use TypeError;
|
use TypeError;
|
||||||
@@ -20,25 +23,38 @@ use TypeError;
|
|||||||
* negociacion por Accept entre representacion JSON y texto plano. Tambien la
|
* negociacion por Accept entre representacion JSON y texto plano. Tambien la
|
||||||
* frontera de Router::apply(): que toda excepcion de la cadena (middlewares,
|
* frontera de Router::apply(): que toda excepcion de la cadena (middlewares,
|
||||||
* callback final, notFound) llegue al $exceptionCallback deteniendo la
|
* callback final, notFound) llegue al $exceptionCallback deteniendo la
|
||||||
* ejecucion sin exit() y dejando intacto el flujo normal.
|
* 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
|
final class RouterTest extends TestCase
|
||||||
{
|
{
|
||||||
private array $serverBackup = [];
|
private array $serverBackup = [];
|
||||||
|
private array $getBackup = [];
|
||||||
|
private array $postBackup = [];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$this->serverBackup = $_SERVER;
|
$this->serverBackup = $_SERVER;
|
||||||
|
$this->getBackup = $_GET;
|
||||||
|
$this->postBackup = $_POST;
|
||||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
unset($_SERVER['HTTP_ACCEPT']);
|
unset($_SERVER['HTTP_ACCEPT']);
|
||||||
http_response_code(200);
|
@http_response_code(200); // Ver tearDown: inofensivo mientras no haya un header('HTTP/...') activo.
|
||||||
$this->resetRouterState();
|
$this->resetRouterState();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
$_SERVER = $this->serverBackup;
|
$_SERVER = $this->serverBackup;
|
||||||
http_response_code(200);
|
$_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();
|
$this->resetRouterState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,4 +222,96 @@ final class RouterTest extends TestCase
|
|||||||
$this->assertSame('{"status":"ok"}', $output);
|
$this->assertSame('{"status":"ok"}', $output);
|
||||||
$this->assertSame(200, http_response_code());
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,18 @@ final class ValidatorTest extends TestCase
|
|||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|
||||||
public function enumIsLooseComparison(): void
|
public function inIsLooseComparison(): void
|
||||||
{
|
{
|
||||||
$this->assertTrue(Validator::checkRule('1', 'enum:1,2,3'));
|
$this->assertTrue(Validator::checkRule('1', 'in:1,2,3'));
|
||||||
$this->assertFalse(Validator::checkRule('9', 'enum:1,2,3'));
|
$this->assertFalse(Validator::checkRule('9', 'in:1,2,3'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
|
||||||
|
public function enumRuleIsNoLongerRecognized(): void
|
||||||
|
{
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
Validator::checkRule('1', 'enum:1,2,3');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -100,7 +108,7 @@ final class ValidatorTest extends TestCase
|
|||||||
public function parseRule(): void
|
public function parseRule(): void
|
||||||
{
|
{
|
||||||
$this->assertSame(['required', ''], Validator::parseRule('required'));
|
$this->assertSame(['required', ''], Validator::parseRule('required'));
|
||||||
$this->assertSame(['enum', 'a,b'], Validator::parseRule('enum:a,b'));
|
$this->assertSame(['in', 'a,b'], Validator::parseRule('in:a,b'));
|
||||||
$this->assertSame(['regex', '/^a,b$/'], Validator::parseRule('regex:/^a,b$/'));
|
$this->assertSame(['regex', '/^a,b$/'], Validator::parseRule('regex:/^a,b$/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +176,7 @@ final class ValidatorTest extends TestCase
|
|||||||
);
|
);
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'The selected status is invalid. Allowed: a, b, c.',
|
'The selected status is invalid. Allowed: a, b, c.',
|
||||||
Validator::message('status.enum:a,b,c')
|
Validator::message('status.in:a,b,c')
|
||||||
);
|
);
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'The reason field is required when mode is other.',
|
'The reason field is required when mode is other.',
|
||||||
|
|||||||
Reference in New Issue
Block a user