Compare commits
16 Commits
d0c4b3c503
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e96aa886e | |||
| f4b112032e | |||
| 7b1d5e42bd | |||
| 26589062f5 | |||
| 5022fdac75 | |||
| 70b1016194 | |||
| 86ab8b979a | |||
| 9ddb00e719 | |||
| a534613d26 | |||
| 65380f37c6 | |||
| 5f3fc16735 | |||
| 146aed0db8 | |||
| 9dbfd7da8e | |||
| 3e7c367182 | |||
| 25db49f4bd | |||
| 9cb41d51d0 |
5
Makefile
5
Makefile
@@ -25,11 +25,6 @@
|
|||||||
# localhost because Database.php's DSN has no port field; stop any local
|
# localhost because Database.php's DSN has no port field; stop any local
|
||||||
# server on those ports first (db-up aborts naming the port).
|
# server on those ports first (db-up aborts naming the port).
|
||||||
#
|
#
|
||||||
# Known red: ModelFilterTest::orderByRandReturnsEveryRow fails on the
|
|
||||||
# sqlite and pgsql legs BY DESIGN until the core RAND translation is fixed
|
|
||||||
# (finding F1 in openspec/changes/*/add-multi-engine-model-tests/findings.md);
|
|
||||||
# the mysql leg passes it and everything else.
|
|
||||||
#
|
|
||||||
# Publishing ritual (human only — AI agents must never run it; it commits):
|
# Publishing ritual (human only — AI agents must never run it; it commits):
|
||||||
# 1. make publish MSG="feat: ..." (MSG optional, defaults to "sync: <date>")
|
# 1. make publish MSG="feat: ..." (MSG optional, defaults to "sync: <date>")
|
||||||
# 2. review the new master commit (git log -1 refs/heads/master); if the
|
# 2. review the new master commit (git log -1 refs/heads/master); if the
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ class Model
|
|||||||
|
|
||||||
$vars = json_encode(static::$dbQueryVariables);
|
$vars = json_encode(static::$dbQueryVariables);
|
||||||
|
|
||||||
|
if ($resetQuery) {
|
||||||
|
static::resetQuery();
|
||||||
|
}
|
||||||
|
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
"\nError at query to database.\n" .
|
"\nError at query to database.\n" .
|
||||||
"Query: $query\n" .
|
"Query: $query\n" .
|
||||||
@@ -1008,7 +1012,9 @@ class Model
|
|||||||
public static function orderBy(string $value, string $order = 'ASC'): static
|
public static function orderBy(string $value, string $order = 'ASC'): static
|
||||||
{
|
{
|
||||||
if ($value == "RAND") {
|
if ($value == "RAND") {
|
||||||
static::$dbQuery['orderBy'] = 'RAND()';
|
$random = static::db()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? 'RAND()' : 'RANDOM()';
|
||||||
|
static::$dbQuery['orderBy'] = $random;
|
||||||
|
|
||||||
return new static();
|
return new static();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace Libs;
|
namespace Libs;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request - DuckBrain
|
* Request - DuckBrain
|
||||||
*
|
*
|
||||||
@@ -59,17 +61,20 @@ class Request extends Neuron
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run configured validations
|
// Run configured validations
|
||||||
if (!$this->validate()) {
|
$this->validate();
|
||||||
exit();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the configured validation.
|
* Starts the configured validation.
|
||||||
*
|
*
|
||||||
* @return bool
|
* On failure the single error message is built with Validator::message()
|
||||||
|
* and handed to onInvalid(), which throws by default: failures travel up
|
||||||
|
* as exceptions instead of answering HTTP here.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws Exception When a configured rule fails.
|
||||||
*/
|
*/
|
||||||
public function validate(): bool
|
public function validate(): void
|
||||||
{
|
{
|
||||||
$actual = match ($_SERVER['REQUEST_METHOD']) {
|
$actual = match ($_SERVER['REQUEST_METHOD']) {
|
||||||
'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])},
|
'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])},
|
||||||
@@ -88,7 +93,7 @@ class Request extends Neuron
|
|||||||
Validator::validateList(static::getRules(), $this->get) &&
|
Validator::validateList(static::getRules(), $this->get) &&
|
||||||
Validator::validateList(static::rules(), $body)
|
Validator::validateList(static::rules(), $body)
|
||||||
) {
|
) {
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$error = Validator::message(
|
$error = Validator::message(
|
||||||
@@ -98,7 +103,6 @@ class Request extends Neuron
|
|||||||
);
|
);
|
||||||
|
|
||||||
static::onInvalid($error);
|
static::onInvalid($error);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,27 +161,20 @@ class Request extends Neuron
|
|||||||
/**
|
/**
|
||||||
* Function to execute when an invalid value has been detected.
|
* Function to execute when an invalid value has been detected.
|
||||||
*
|
*
|
||||||
* Always answers with a single error and HTTP 422. The representation is
|
* The default implementation always throws a generic \Exception carrying
|
||||||
* negotiated from the request's Accept header: JSON when the client asks
|
* the single error message and HTTP 422 as its code; the framework's
|
||||||
* for application/json, plain text otherwise.
|
* exception boundary (Router::apply) renders the response. Override it to
|
||||||
|
* throw a more specific exception type instead. The never return type is
|
||||||
|
* the contract: an override that returned would let the request continue
|
||||||
|
* with invalid data, so PHP rejects such an override at compile time.
|
||||||
*
|
*
|
||||||
* @param string $error
|
* @param string $error
|
||||||
*
|
*
|
||||||
* @return void
|
* @return never
|
||||||
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function onInvalid(string $error): void
|
public function onInvalid(string $error): never
|
||||||
{
|
{
|
||||||
http_response_code(422);
|
throw new Exception($error, 422);
|
||||||
|
|
||||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
|
||||||
|
|
||||||
if (str_contains($accept, 'application/json')) {
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
print(json_encode(['error' => $error]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print($error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,54 @@ class Router
|
|||||||
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Page Not Found</h2>';
|
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Page Not Found</h2>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback function to be executed when the router's boundary
|
||||||
|
* catches an exception thrown anywhere in the matched route chain.
|
||||||
|
* It receives the exception as a named argument: the handler must
|
||||||
|
* declare its parameter as $exception (typeable as \Throwable).
|
||||||
|
*
|
||||||
|
* @var callable $exceptionCallback
|
||||||
|
*/
|
||||||
|
public static $exceptionCallback = 'Libs\Router::defaultException';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default callback function for exception responses.
|
||||||
|
*
|
||||||
|
* The HTTP status comes from the exception's code only when it is an
|
||||||
|
* integer in the 400-599 range; anything else (0, arbitrary codes, the
|
||||||
|
* SQLSTATE string carried by PDOException) answers 500. The body is
|
||||||
|
* negotiated from the request's Accept header: JSON when the client asks
|
||||||
|
* for application/json, plain text otherwise. The trace is serialized
|
||||||
|
* from getTraceAsString() because json_encode() of a Throwable yields an
|
||||||
|
* empty object: its properties are protected.
|
||||||
|
*
|
||||||
|
* @param \Throwable $exception
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function defaultException(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
$code = $exception->getCode();
|
||||||
|
http_response_code(is_int($code) && $code >= 400 && $code <= 599 ? $code : 500);
|
||||||
|
|
||||||
|
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||||
|
|
||||||
|
if (str_contains($accept, 'application/json')) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
print(json_encode([
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
'exception' => get_class($exception),
|
||||||
|
'file' => $exception->getFile(),
|
||||||
|
'line' => $exception->getLine(),
|
||||||
|
'trace' => explode(PHP_EOL, $exception->getTraceAsString()),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print($exception->getMessage() . PHP_EOL . $exception->getTraceAsString());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* __construct
|
* __construct
|
||||||
*/
|
*/
|
||||||
@@ -136,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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -363,12 +410,19 @@ class Router
|
|||||||
/**
|
/**
|
||||||
* Applies the route configuration.
|
* Applies the route configuration.
|
||||||
*
|
*
|
||||||
|
* This method is the framework's error boundary: any \Throwable thrown
|
||||||
|
* while running the matched route's callback chain, while printing the
|
||||||
|
* returned data, or while resolving the not-found callback is caught
|
||||||
|
* here and rendered through $exceptionCallback. A failing handler is
|
||||||
|
* deliberately left uncaught (double failure falls back to PHP itself).
|
||||||
|
*
|
||||||
* @param string|null $path (optional) Path to use. If not defined, it detects the current path.
|
* @param string|null $path (optional) Path to use. If not defined, it detects the current path.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function apply(?string $path = null): void
|
public static function apply(?string $path = null): void
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
$path = $path ?? static::currentPath();
|
$path = $path ?? static::currentPath();
|
||||||
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
|
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
|
||||||
'POST' => static::$post,
|
'POST' => static::$post,
|
||||||
@@ -408,5 +462,8 @@ class Router
|
|||||||
|
|
||||||
// If no router matches, call $notFoundCallBack
|
// If no router matches, call $notFoundCallBack
|
||||||
Synapsis::resolve(static::$notFoundCallback);
|
Synapsis::resolve(static::$notFoundCallback);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,13 @@ class Synapsis
|
|||||||
* Resolves and injects dependencies for a callable and returns its result.
|
* Resolves and injects dependencies for a callable and returns its result.
|
||||||
*
|
*
|
||||||
* @param callable $action
|
* @param callable $action
|
||||||
|
* @param array<string, mixed> $named Associative array of values injected into the callable's
|
||||||
|
* parameters by exact name match. Consumed in resolveParameterValues().
|
||||||
*
|
*
|
||||||
* @return mixed
|
* @return mixed
|
||||||
* @throws Exception If an unhandled callable type is provided.
|
* @throws Exception If an unhandled callable type is provided.
|
||||||
*/
|
*/
|
||||||
public static function resolve(callable $action): mixed
|
public static function resolve(callable $action, array $named = []): mixed
|
||||||
{
|
{
|
||||||
if ($action instanceof Closure) { // If it's an anonymous function
|
if ($action instanceof Closure) { // If it's an anonymous function
|
||||||
$reflectionCallback = new ReflectionFunction($action);
|
$reflectionCallback = new ReflectionFunction($action);
|
||||||
@@ -90,7 +92,7 @@ class Synapsis
|
|||||||
// Get the parameters
|
// Get the parameters
|
||||||
return call_user_func_array(
|
return call_user_func_array(
|
||||||
$action,
|
$action,
|
||||||
static::resolveParameterValues($reflectionCallback->getParameters())
|
static::resolveParameterValues($reflectionCallback->getParameters(), $named)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,11 +134,15 @@ class Synapsis
|
|||||||
* Resolves parameter values by injecting dependencies.
|
* Resolves parameter values by injecting dependencies.
|
||||||
*
|
*
|
||||||
* @param array<ReflectionParameter> $parameters
|
* @param array<ReflectionParameter> $parameters
|
||||||
|
* @param array<string, mixed> $named
|
||||||
|
* Values injected into parameters whose name matches the key,
|
||||||
|
* taking precedence over optional defaults and DI resolution.
|
||||||
|
* Keys matching no parameter are ignored.
|
||||||
*
|
*
|
||||||
* @return array<mixed>
|
* @return array<mixed>
|
||||||
* @throws Exception If a primitive parameter does not have a default value.
|
* @throws Exception If a primitive parameter does not have a default value.
|
||||||
*/
|
*/
|
||||||
public static function resolveParameterValues(array $parameters): array
|
public static function resolveParameterValues(array $parameters, array $named = []): array
|
||||||
{
|
{
|
||||||
$values = [];
|
$values = [];
|
||||||
foreach ($parameters as $parameter) {
|
foreach ($parameters as $parameter) {
|
||||||
@@ -144,6 +150,11 @@ class Synapsis
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_key_exists($parameter->getName(), $named)) { // Named values win over defaults and DI
|
||||||
|
$values[] = $named[$parameter->getName()];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if ($parameter->isOptional()) { // Always use the default value first
|
if ($parameter->isOptional()) { // Always use the default value first
|
||||||
$values[] = $parameter->getDefaultValue();
|
$values[] = $parameter->getDefaultValue();
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
100
tests/Integration/ModelFailureTest.php
Normal file
100
tests/Integration/ModelFailureTest.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Integration;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use PHPUnit\Framework\Attributes\Group;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use ReflectionMethod;
|
||||||
|
use Tests\Models\User;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelFailureTest - DuckBrain integration matrix
|
||||||
|
*
|
||||||
|
* Family: query-failure state handling (findings F2 / spec model).
|
||||||
|
*
|
||||||
|
* All contamination is provoked and observed INSIDE a single test: the
|
||||||
|
* harness-wide resetQuery guard in Tests\TestCase::setUp() protects the
|
||||||
|
* next test, never the current one, so these assertions depend solely on
|
||||||
|
* Model::query()'s own bookkeeping.
|
||||||
|
*/
|
||||||
|
#[Group('integration')]
|
||||||
|
final class ModelFailureTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
private static array $ids = [];
|
||||||
|
|
||||||
|
private const BAD_SQL = 'SELECT * FROM table_that_does_not_exist';
|
||||||
|
|
||||||
|
public static function setUpBeforeClass(): void
|
||||||
|
{
|
||||||
|
self::createTable('users', [
|
||||||
|
'id' => 'pk',
|
||||||
|
'username' => 'string',
|
||||||
|
'email' => 'string',
|
||||||
|
'bio' => 'text',
|
||||||
|
'age' => 'number',
|
||||||
|
'is_active' => 'bool',
|
||||||
|
'last_login' => 'timestamp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (['fer', 'fiona', 'frog'] as $name) {
|
||||||
|
$user = new User();
|
||||||
|
$user->username = $name;
|
||||||
|
$user->email = $name . '@duckbrain.dev';
|
||||||
|
$user->age = 33;
|
||||||
|
$user->save();
|
||||||
|
self::$ids[] = (int) $user->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function runQuery(string $sql, bool $resetQuery = true): void
|
||||||
|
{
|
||||||
|
(new ReflectionMethod(User::class, 'query'))->invokeArgs(null, [$sql, $resetQuery]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function currentSql(): string
|
||||||
|
{
|
||||||
|
return (new ReflectionMethod(User::class, 'buildQuery'))->invoke(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function failingQueryDoesNotContaminateTheNextQuery(): void
|
||||||
|
{
|
||||||
|
User::where('id', self::$ids[0]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
self::runQuery(self::BAD_SQL);
|
||||||
|
$this->fail('a query against a missing table must throw');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->assertStringContainsString('Error at query to database', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the default resetQuery: true, the failed run must have left
|
||||||
|
// the builder as good as new; get() therefore sees all three rows.
|
||||||
|
$this->assertCount(3, User::get(), 'builder state leaked from the failed query');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function failedKeepStateQueryLeavesTheWhereForRetry(): void
|
||||||
|
{
|
||||||
|
User::where('username', 'fiona');
|
||||||
|
|
||||||
|
try {
|
||||||
|
self::runQuery(self::BAD_SQL, false);
|
||||||
|
$this->fail('a query against a missing table must throw');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->assertStringContainsString('Error at query to database', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetQuery: false contracts to keep the where alive for the
|
||||||
|
// caller's deliberate retry (also guards against a fix that resets
|
||||||
|
// unconditionally).
|
||||||
|
$this->assertStringContainsString('WHERE', self::currentSql());
|
||||||
|
|
||||||
|
$retry = User::get();
|
||||||
|
$this->assertCount(1, $retry, 'retry after failure must reuse the kept where');
|
||||||
|
$this->assertSame('fiona', $retry[0]->username);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,10 @@ final class ModelTransactionTest extends TestCase
|
|||||||
User::beginTransaction();
|
User::beginTransaction();
|
||||||
$user = self::seed('wrecked_carol');
|
$user = self::seed('wrecked_carol');
|
||||||
|
|
||||||
|
// Dirty the builder BEFORE failing: only a leftover-free result after
|
||||||
|
// the exception proves query() itself cleaned up (finding F2).
|
||||||
|
User::where('age', '>', '30');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
(new ReflectionMethod(User::class, 'query'))
|
(new ReflectionMethod(User::class, 'query'))
|
||||||
->invokeArgs(null, ['SELECT * FROM table_that_does_not_exist']);
|
->invokeArgs(null, ['SELECT * FROM table_that_does_not_exist']);
|
||||||
@@ -78,6 +82,14 @@ final class ModelTransactionTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->assertFalse(self::db()->inTransaction(), 'the exception path must close the transaction');
|
$this->assertFalse(self::db()->inTransaction(), 'the exception path must close the transaction');
|
||||||
|
|
||||||
|
$sqlAfterFailure = (new ReflectionMethod(User::class, 'buildQuery'))->invoke(null);
|
||||||
|
$this->assertSame(
|
||||||
|
'SELECT * FROM users',
|
||||||
|
$sqlAfterFailure,
|
||||||
|
'a failed query must leave the builder at its default state'
|
||||||
|
);
|
||||||
|
|
||||||
$this->assertNull(User::where('username', 'wrecked_carol')->getFirst(), 'pending insert must be undone');
|
$this->assertNull(User::where('username', 'wrecked_carol')->getFirst(), 'pending insert must be undone');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
138
tests/Unit/RequestTest.php
Normal file
138
tests/Unit/RequestTest.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Libs\Request;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RequestTest - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Cubre el modelo de fallo por excepcion del Request: la validacion lanza
|
||||||
|
* con code 422 y el mensaje unico de Validator::message() (con overrides de
|
||||||
|
* messages()/attributes()), en lugar de responder HTTP y hacer exit() como
|
||||||
|
* hacia antes. Que estas pruebas puedan ejecutar expectException ya demuestra
|
||||||
|
* que el constructor no termina el proceso.
|
||||||
|
*/
|
||||||
|
final class RequestTest 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';
|
||||||
|
$_SERVER['REQUEST_URI'] = '/test';
|
||||||
|
$_SERVER['DOCUMENT_ROOT'] = '/nonexistent-docroot';
|
||||||
|
unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_ACCEPT']);
|
||||||
|
$_GET = [];
|
||||||
|
$_POST = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$_SERVER = $this->serverBackup;
|
||||||
|
$_GET = $this->getBackup;
|
||||||
|
$_POST = $this->postBackup;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function failedValidationThrowsWithHttp422Code(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_POST = ['age' => '5'];
|
||||||
|
|
||||||
|
$this->expectException(Exception::class);
|
||||||
|
$this->expectExceptionMessage('The age must be at least 18.');
|
||||||
|
$this->expectExceptionCode(422);
|
||||||
|
|
||||||
|
new class extends Request {
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return ['age' => 'required|min:18'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function messagesOverrideWinsForFailedRule(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_POST = ['age' => '5'];
|
||||||
|
|
||||||
|
$this->expectException(Exception::class);
|
||||||
|
$this->expectExceptionMessage('Way too short');
|
||||||
|
|
||||||
|
new class extends Request {
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return ['age' => 'required|min:18'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return ['age.min' => 'Way too short'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function attributesRenameTheFieldInDefaultMessage(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_POST = ['age' => '5'];
|
||||||
|
|
||||||
|
$this->expectException(Exception::class);
|
||||||
|
$this->expectExceptionMessage('The applicant age must be at least 18.');
|
||||||
|
|
||||||
|
new class extends Request {
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return ['age' => 'required|min:18'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return ['age' => 'applicant age'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function getRulesFailureThrowsToo(): void
|
||||||
|
{
|
||||||
|
$this->expectException(Exception::class);
|
||||||
|
$this->expectExceptionMessage('The q field is required.');
|
||||||
|
|
||||||
|
new class extends Request {
|
||||||
|
public function getRules(): array
|
||||||
|
{
|
||||||
|
return ['q' => 'required'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function validDataDoesNotThrow(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
$_POST = ['age' => '20'];
|
||||||
|
|
||||||
|
$request = new class extends Request {
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return ['age' => 'required|min:18'];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->assertSame('20', $request->post->age);
|
||||||
|
$this->assertSame('/test', $request->path);
|
||||||
|
}
|
||||||
|
}
|
||||||
317
tests/Unit/RouterTest.php
Normal file
317
tests/Unit/RouterTest.php
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/Unit/SynapsisTest.php
Normal file
85
tests/Unit/SynapsisTest.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Libs\Neuron;
|
||||||
|
use Libs\Synapsis;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SynapsisTest - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Cubre la inyeccion de argumentos por nombre en resolve(): la clave de
|
||||||
|
* $named gana sobre el default de un opcional y sobre la resolucion DI por
|
||||||
|
* tipo, la coincidencia es por nombre y no por posicion, y las claves
|
||||||
|
* sobrantes se ignoran en silencio (riesgo documentado en design.md).
|
||||||
|
*/
|
||||||
|
final class SynapsisTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function namedValueWinsOverOptionalDefault(): void
|
||||||
|
{
|
||||||
|
$result = Synapsis::resolve(
|
||||||
|
fn ($config = 'default') => $config,
|
||||||
|
['config' => 'forced']
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('forced', $result, 'A named argument must take precedence over an optional default');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function namedValueWinsOverTypeBasedResolution(): void
|
||||||
|
{
|
||||||
|
$real = new Exception('the real one', 422);
|
||||||
|
|
||||||
|
$result = Synapsis::resolve(
|
||||||
|
fn (Exception $exception) => $exception,
|
||||||
|
['exception' => $real]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame($real, $result, 'The injected instance must be the named one, not a container-built empty Exception');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function namedParametersMatchByNameNotPosition(): void
|
||||||
|
{
|
||||||
|
$real = new Exception('matched by name');
|
||||||
|
|
||||||
|
[$b, $exception] = Synapsis::resolve(
|
||||||
|
fn (Neuron $b, Exception $exception) => [$b, $exception],
|
||||||
|
['exception' => $real]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Neuron::class, $b, 'Parameters without a named match must keep resolving through DI');
|
||||||
|
$this->assertSame($real, $exception, 'Position of the named parameter must not matter');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function unmatchedNamedKeysAreIgnored(): void
|
||||||
|
{
|
||||||
|
$result = Synapsis::resolve(
|
||||||
|
fn ($x = 'untouched') => $x,
|
||||||
|
['foo' => 1]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame('untouched', $result, 'A key matching no parameter must be silently ignored');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function renamedParameterFallsBackToContainerResolution(): void
|
||||||
|
{
|
||||||
|
$real = new Exception('never arrives');
|
||||||
|
|
||||||
|
// The handler renamed its parameter to $e, so the 'exception' key
|
||||||
|
// matches nothing and $e degrades to DI: a fresh empty Exception.
|
||||||
|
$result = Synapsis::resolve(
|
||||||
|
fn (Exception $e) => $e,
|
||||||
|
['exception' => $real]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertNotSame($real, $result, 'A renamed handler parameter must not receive the named value');
|
||||||
|
$this->assertSame('', $result->getMessage(), 'The degradation outcome is a container-built empty Exception');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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