Compare commits

..

6 Commits

Author SHA1 Message Date
kj
3b6da45b01 refactor(validator): rename enum rule and method to in 2026-09-14 16:48:02 -03:00
kj
b7260b5199 refactor(Router): use string callable syntax for default callbacks 2026-09-08 12:08:32 -03:00
kj
1f29622914 refactor(router): remove exit statement from redirect method 2026-09-07 20:00:15 -03:00
kj
eb882db0c8 sync: 2026-09-07
70b1016 refactor(autoload): remove global exception handler
a534613 refactor(request): propagate validation failures as exceptions
5f3fc16 feat(router): add error boundary with exception callback
9dbfd7d feat: add named parameter injection to resolve()
2026-09-07 18:00:18 -03:00
kj
9ba43e3f25 sync: 2026-09-05
25db49f fix(model): reset query state when database query fails
9cb41d5 fix(model): use RANDOM() for non-MySQL databases in orderBy
2026-09-05 16:49:10 -03:00
kj
54e96ba4bb sync: first sync with the new develop branch
a4611e8 chore(src): rename .keep files to .gitkeep
2026-09-05 14:21:52 -03:00
18 changed files with 140 additions and 2589 deletions

4
.gitignore vendored
View File

@@ -1,4 +0,0 @@
/vendor/
/.phpunit.cache/
*.sqlite
/.publish/

View File

@@ -1,96 +0,0 @@
# DuckBrain development harness — Makefile
#
# This file (along with tests/, phpunit.xml, composer.*, .gitignore) lives
# ONLY on the develop branch. master is a publish-only vitrine that contains
# nothing but the readable artifact: nobody commits to master directly, and
# no file outside the WHITELIST below ever reaches it.
#
# Daily loop:
# make test run the unit suite (sqlite :memory:, no containers, no
# network; installs dev dependencies first if missing)
# ...then develop on develop and commit there as usual.
#
# Publishing ritual (human only — AI agents must never run it; it commits):
# 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
# generated message needs polish, rewrite it BEFORE pushing, e.g.
# from Emacs
# 3. git push origin master (manual on purpose: pushing is a
# decision, never a side effect)
#
# How publish builds master — wipe-and-rebuild mirror, no merges, no
# cherry-picks (see design decisions behind establish-dev-publish-workflow):
# - refuses to run off develop or with a dirty working tree
# - checks master out into a disposable worktree under .publish/
# (your current working tree is never touched)
# - for each WHITELIST path: removes master's copy and checks out develop's,
# so deleted/renamed artifact files never survive as ghosts in master
# - BLACKLIST then subtracts noise found INSIDE whitelisted paths
# - creates the commit only when the artifact actually changed; its body
# lists the develop commits (after the last-sync tag) that touched
# WHITELIST paths — pure-noise commits are filtered out automatically
# - moves the last-sync tag to develop's tip, removes the worktree, and
# prints any root entry left out of WHITELIST (add it if it belongs to
# the artifact)
SHELL := /bin/bash
.PHONY: test publish
PHPUNIT := vendor/bin/phpunit
# --- publish configuration -------------------------------------------------
WHITELIST := src config.php index.php autoload.php .htaccess readme.org
BLACKLIST :=
DEVELOP_BRANCH := develop
DEVELOP_REF := refs/heads/$(DEVELOP_BRANCH)
MASTER_BRANCH := master
MASTER_REF := refs/heads/$(MASTER_BRANCH)
LAST_SYNC_TAG := last-sync
MSG ?= sync: $(shell date +%Y-%m-%d)
test: $(PHPUNIT)
./$(PHPUNIT)
$(PHPUNIT): composer.json composer.lock
composer install --no-interaction
publish:
@if [ "$$(git branch --show-current)" != "$(DEVELOP_BRANCH)" ]; then \
echo "ABORT: publish must be invoked from $(DEVELOP_BRANCH) (current: '$$(git branch --show-current)')"; \
exit 1; \
fi
@if [ -n "$$(git status --porcelain)" ]; then \
echo "ABORT: working tree is dirty; commit your changes on $(DEVELOP_BRANCH) before publishing:"; \
git status --short; \
exit 1; \
fi
git worktree remove --force .publish/master 2>/dev/null || true
git worktree add --quiet .publish/master $(MASTER_BRANCH)
@test "$$(git -C .publish/master symbolic-ref HEAD)" = "$(MASTER_REF)" || { \
echo "ABORT: publish worktree HEAD is not attached to $(MASTER_REF)"; \
git worktree remove --force .publish/master; exit 1; }
@for w in $(WHITELIST); do git -C .publish/master rm -rf --ignore-unmatch --quiet -- $$w; done
git -C .publish/master checkout $(DEVELOP_REF) -- $(WHITELIST)
@for b in $(BLACKLIST); do git -C .publish/master rm -rf --ignore-unmatch --quiet -- $$b; done
git -C .publish/master diff --quiet $(DEVELOP_REF) -- $(WHITELIST)
@if git -C .publish/master diff --cached --quiet; then \
echo "nothing to publish: master already mirrors $(DEVELOP_BRANCH)"; \
else \
set -e; \
if git rev-parse -q --verify tags/$(LAST_SYNC_TAG) >/dev/null; then \
RANGE=$(LAST_SYNC_TAG)..$(DEVELOP_REF); \
else \
echo "note: no $(LAST_SYNC_TAG) tag yet; summarizing full $(DEVELOP_BRANCH) history"; \
RANGE=$(DEVELOP_REF); \
fi; \
BODY=$$(git log $$RANGE --oneline -- $(WHITELIST)); \
{ printf '%s\n' "$(MSG)"; printf '\n'; printf '%s\n' "$$BODY"; } > .publish/msg.txt; \
git -C .publish/master commit --quiet -F "$$(pwd)/.publish/msg.txt"; \
echo "publish commit created; review/edit it (before pushing) with your git tool of choice"; \
fi
git tag -f $(LAST_SYNC_TAG) $(DEVELOP_REF) >/dev/null
git worktree remove .publish/master
@echo "root entries left behind in $(DEVELOP_BRANCH) (add to WHITELIST if they belong to the artifact):"; \
comm -23 <(git ls-tree --name-only $(DEVELOP_REF) | sort) \
<(for x in $(WHITELIST) $(BLACKLIST); do echo $$x; done | sort) | sed 's/^/ - /'

View File

@@ -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();
});

View File

@@ -1,15 +0,0 @@
{
"name": "kj/duckbrain",
"description": "DuckBrain microframework - development harness (tests and tooling; never published to master).",
"type": "project",
"license": "MIT",
"require-dev": {
"fakerphp/faker": "^1.24",
"phpunit/phpunit": "^11.0"
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}

1970
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

View File

@@ -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();
} }

View File

@@ -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);
} }
} }

View File

@@ -50,7 +50,7 @@ class Router
* *
* @var callable $notFoundCallback * @var callable $notFoundCallback
*/ */
public static $notFoundCallback = 'Libs\Router::defaultNotFound'; public static $notFoundCallback = 'Libs\Router::defaultNotFound';
/** /**
* Default callback function for when * Default callback function for when
@@ -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,50 +410,60 @@ 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
{ {
$path = $path ?? static::currentPath(); try {
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method $path = $path ?? static::currentPath();
'POST' => static::$post, $routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
'PUT' => static::$put, 'POST' => static::$post,
'PATCH' => static::$patch, 'PUT' => static::$put,
'DELETE' => static::$delete, 'PATCH' => static::$patch,
default => static::$get 'DELETE' => static::$delete,
}; default => static::$get
};
foreach ($routers as $router) { // Checks all routers to see if they match the current path foreach ($routers as $router) { // Checks all routers to see if they match the current path
if (preg_match_all('/^' . $router['path'] . '\/?$/si', $path, $matches, PREG_PATTERN_ORDER)) { if (preg_match_all('/^' . $router['path'] . '\/?$/si', $path, $matches, PREG_PATTERN_ORDER)) {
unset($matches[0]); unset($matches[0]);
// Checking and storing the variable parameters of the route // Checking and storing the variable parameters of the route
if (isset($matches[1])) { if (isset($matches[1])) {
static::$params = new Neuron(); static::$params = new Neuron();
foreach ($matches as $index => $match) { foreach ($matches as $index => $match) {
$paramName = $router['paramNames'][$index - 1]; $paramName = $router['paramNames'][$index - 1];
static::$params->{$paramName} = urldecode($match[0]); static::$params->{$paramName} = urldecode($match[0]);
}
} }
}
// Processes the callback queue // Processes the callback queue
foreach (array_reverse($router['callback']) as $callback) { foreach (array_reverse($router['callback']) as $callback) {
$data = Synapsis::resolve($callback); $data = Synapsis::resolve($callback);
} }
// By default, prints as JSON if something is returned // By default, prints as JSON if something is returned
if (isset($data)) { if (isset($data)) {
header('Content-Type: application/json'); header('Content-Type: application/json');
print(json_encode($data)); print(json_encode($data));
} }
return; return;
}
} }
}
// 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]);
}
} }
} }

View File

@@ -65,12 +65,14 @@ 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;

View File

@@ -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);
} }

View File

@@ -1,55 +0,0 @@
<?php
namespace Tests\Factories;
use Faker\Factory as FakerFactory;
use Faker\Generator;
/**
* Factory - DuckBrain testing
*
* Clase base para crear fábricas de datos de prueba. Cada subclase
* define los atributos por defecto de un modelo y construye su
* instancia.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
abstract class Factory
{
protected static ?Generator $fake = null;
/**
* Devuelve una instancia de Faker (singleton por fábrica).
*/
protected static function faker(): Generator
{
return static::$fake ??= FakerFactory::create();
}
/**
* Fusiona los atributos por defecto con los indicados.
*
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
protected static function attributes(array $attributes = []): array
{
return array_merge(static::definition(), $attributes);
}
/**
* Atributos por defecto de la fábrica.
*
* @return array<string, mixed>
*/
abstract protected static function definition(): array;
/**
* Crea una instancia del modelo con los atributos dados.
*
* @param array<string, mixed> $attributes
*/
abstract public static function create(array $attributes = []): object;
}

View File

@@ -1,61 +0,0 @@
<?php
namespace Tests;
use Libs\Database;
use PDO;
use PHPUnit\Framework\TestCase as FrameworkTestCase;
/**
* TestCase - DuckBrain test harness
*
* Base class for the framework's own tests. Deliberately lightweight: this
* repo has no migrations/ nor duckbrain-commands, so each test defines its
* schema with raw DDL through createTable(), and tables registered there are
* dropped automatically after the class finishes.
*/
abstract class TestCase extends FrameworkTestCase
{
/**
* @var list<string> Tables created through createTable() for this class.
*/
private static array $tables = [];
/**
* Returns the PDO connection to the in-memory test database.
*
* @return PDO
*/
protected static function db(): PDO
{
return Database::getInstance(DB_TYPE, DB_HOST, DB_NAME, DB_USER, DB_PASS);
}
/**
* Creates a table in the test database and registers it for cleanup.
*
* @param string $name
* Table name.
*
* @param string $columns
* Raw column definition, as accepted by the test
* engine (sqlite): "id INTEGER PRIMARY KEY, x TEXT".
*/
protected static function createTable(string $name, string $columns): void
{
static::db()->exec("CREATE TABLE IF NOT EXISTS {$name} ({$columns})");
static::$tables[] = $name;
}
/**
* Drops every table registered through createTable() during this class.
*/
public static function tearDownAfterClass(): void
{
foreach (array_unique(static::$tables) as $table) {
static::db()->exec("DROP TABLE IF EXISTS {$table}");
}
static::$tables = [];
}
}

View File

View File

@@ -1,51 +0,0 @@
<?php
namespace Tests\Unit;
use Libs\Neuron;
use PDO;
use Tests\TestCase;
/**
* BootstrapTest - DuckBrain test harness
*
* Prueba de humo de la infraestructura: autoload de composer + framework,
* constantes de la DB de prueba y conexión PDO realmente utilizable. Si
* algo del entorno está mal, esta prueba falla primero con el diagnóstico.
*/
final class BootstrapTest extends TestCase
{
public function testFrameworkClassesAreAutoloaded(): void
{
$this->assertTrue(
class_exists(Neuron::class),
'MISSING AUTOLOAD: autoload.php did not resolve Libs\Neuron; check ROOT_CORE and run from the project root'
);
$neuron = new Neuron(['key' => 'value']);
$this->assertSame('value', $neuron->key);
$this->assertNull($neuron->doesNotExist);
}
public function testDatabaseConstantsPointToInMemorySqlite(): void
{
$this->assertSame(
'sqlite',
DB_TYPE,
'MISSING OVERRIDE: DB_TYPE comes from config.php; the bootstrap must define the DB_* constants BEFORE requiring autoload.php'
);
$this->assertSame(':memory:', DB_NAME, 'DB_NAME should be :memory: so tests never touch disks or servers');
}
public function testTestDatabaseIsUsable(): void
{
$db = self::db();
$this->assertInstanceOf(PDO::class, $db, 'Could not obtain the test PDO connection');
$this->assertSame($db, self::db(), 'Database::getInstance should return the same singleton');
$db->exec('CREATE TABLE smoke (id INTEGER PRIMARY KEY, v TEXT)');
$db->prepare('INSERT INTO smoke (v) VALUES (?)')->execute(['quack']);
$this->assertSame('quack', $db->query('SELECT v FROM smoke')->fetchColumn());
$db->exec('DROP TABLE smoke');
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace Tests\Unit;
use Libs\Neuron;
use Tests\TestCase;
/**
* NeuronTest - DuckBrain test harness
*
* Regression net para el contenedor de valores del core: construcción
* desde array/objeto, propiedades dinámicas y null en inexistentes.
*/
final class NeuronTest extends TestCase
{
public function testConstructFromAssociativeArray(): void
{
$n = new Neuron(['username' => 'kj', 'level' => 3]);
$this->assertSame('kj', $n->username);
$this->assertSame(3, $n->level);
}
public function testConstructFromObjectCopiesPublicProperties(): void
{
$source = new \stdClass();
$source->id = 7;
$source->email = 'kj@example.com';
$n = new Neuron($source);
$this->assertSame(7, $n->id);
$this->assertSame('kj@example.com', $n->email);
}
public function testUndefinedPropertyIsNullWithoutNotice(): void
{
$n = new Neuron();
$this->assertNull($n->thisDoesNotExist);
}
public function testDynamicPropertiesCanBeAssignedAndRead(): void
{
$n = new Neuron();
$n->fresh = ['a', 'b'];
$this->assertSame(['a', 'b'], $n->fresh);
}
public function testNestedValuesArePreservedVerbatim(): void
{
$payload = ['meta' => ['tags' => ['x', 'y'], 'n' => null]];
$n = new Neuron($payload);
$this->assertSame($payload['meta'], $n->meta);
$this->assertNull($n->meta['n']);
}
}

View File

@@ -1,166 +0,0 @@
<?php
namespace Tests\Unit;
use Libs\Neuron;
use Libs\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* ValidatorTest - DuckBrain test harness
*
* Regression net de las reglas escalares, el batch validateList()
* (paro en el primer fallo + $lastFailed) y message(). Las reglas de
* archivo (file/image/mimes) requieren fixtures de $_FILES y se dejan
* para la suite multi-motor de integración.
*/
final class ValidatorTest extends TestCase
{
protected function setUp(): void
{
Validator::$lastFailed = '';
}
public static function scalarRuleProvider(): array
{
return [
'valid email' => ['email', 'kj@duckbrain.dev', true],
'invalid email' => ['email', 'not-an-email', false],
'valid url' => ['url', 'https://kj2.me', true],
'invalid url' => ['url', 'kj2.me', false],
'int from string' => ['int', '42', true],
'int with decimals' => ['int', '42.5', false],
'float' => ['float', '3.14', true],
'number e-notation' => ['number', '1e3', true],
'bool yes' => ['bool', 'yes', true],
'bool garbage' => ['bool', 'quizas', false],
'strict string' => ['string', '42', true],
'string rejects int' => ['string', 42, false],
'array' => ['array', [1], true],
'array rejects string' => ['array', 'x', false],
];
}
#[DataProvider('scalarRuleProvider')]
public function testScalarRules(string $rule, mixed $value, bool $expected): void
{
$this->assertSame($expected, Validator::checkRule($value, $rule), "rule {$rule} with " . var_export($value, true));
}
public function testExistsAndRequiredDisagreeOnEmptyString(): void
{
$this->assertTrue(Validator::checkRule('', 'exists'));
$this->assertFalse(Validator::checkRule('', 'required'));
$this->assertFalse(Validator::checkRule(null, 'exists'));
}
public function testSizeRulesMeasureStringNumberAndArray(): void
{
$this->assertTrue(Validator::checkRule('abc', 'min:3'));
$this->assertFalse(Validator::checkRule('abc', 'min:4'));
$this->assertTrue(Validator::checkRule(10, 'max:10'));
$this->assertTrue(Validator::checkRule([1, 2, 3], 'between:2,3'));
$this->assertTrue(Validator::checkRule('hey', 'size:3'));
$this->assertFalse(Validator::checkRule('hey', 'size:4'));
}
public function testRegexKeepsColonsAndCommasInsidePattern(): void
{
$this->assertTrue(Validator::checkRule('a,b', 'regex:/^a,b$/'));
$this->assertFalse(Validator::checkRule('a;b', 'regex:/^a,b$/'));
}
public function testEnumIsLooseComparison(): void
{
$this->assertTrue(Validator::checkRule('1', 'enum:1,2,3'));
$this->assertFalse(Validator::checkRule('9', 'enum:1,2,3'));
}
public function testNotNegatesNextRule(): void
{
$this->assertTrue(Validator::checkRule('3.5', 'not:int'));
$this->assertFalse(Validator::checkRule('42', 'not:int'));
}
public function testParseRule(): void
{
$this->assertSame(['required', ''], Validator::parseRule('required'));
$this->assertSame(['enum', 'a,b'], Validator::parseRule('enum:a,b'));
$this->assertSame(['regex', '/^a,b$/'], Validator::parseRule('regex:/^a,b$/'));
}
public function testValidateListPassesFullyValidBatch(): void
{
$data = new Neuron(['username' => 'kj', 'email' => 'kj@duckbrain.dev']);
$this->assertTrue(Validator::validateList(
['username' => 'required|string|min:2', 'email' => 'required|email'],
$data
));
$this->assertSame('', Validator::$lastFailed);
}
public function testValidateListStopsAtFirstFailure(): void
{
$data = new Neuron(['email' => 'not-an-email', 'name' => null]);
$this->assertFalse(Validator::validateList(
['email' => 'required|email', 'name' => 'required'],
$data
));
$this->assertSame('email.email', Validator::$lastFailed);
}
public function testValidateListNullableSkipsRestWhenEmpty(): void
{
$rules = ['bio' => 'nullable|min:10'];
$this->assertTrue(Validator::validateList($rules, new Neuron(['bio' => null])));
$this->assertFalse(Validator::validateList($rules, new Neuron(['bio' => 'too-short'])));
}
public function testValidateListConfirmedUsesSiblingField(): void
{
$rules = ['password' => 'required|confirmed'];
$ok = new Neuron(['password' => 'secret', 'password_confirmation' => 'secret']);
$bad = new Neuron(['password' => 'secret', 'password_confirmation' => 'different']);
$this->assertTrue(Validator::validateList($rules, $ok));
$this->assertFalse(Validator::validateList($rules, $bad));
$this->assertSame('password.confirmed', Validator::$lastFailed);
}
public function testMessageBuildsHumanTextFromLastFailed(): void
{
$this->assertSame(
'The email must be a valid email address.',
Validator::message('email.email')
);
$this->assertSame(
'The age must be between 5 and 10.',
Validator::message('age.between:5,10')
);
$this->assertSame(
'The selected status is invalid. Allowed: a, b, c.',
Validator::message('status.enum:a,b,c')
);
$this->assertSame(
'The reason field is required when mode is other.',
Validator::message('reason.required_if:mode,other')
);
}
public function testMessageRespectsAttributesAndOverrides(): void
{
$this->assertSame(
'The user name field is required.',
Validator::message('username.required', [], ['username' => 'user name'])
);
$this->assertSame(
'custom',
Validator::message('username.min:3', ['username.min:3' => 'custom'])
);
}
}

View File

@@ -1,16 +0,0 @@
<?php
// Test bootstrap for the DuckBrain development harness.
// Run PHPUnit from the project root (autoload.php resolves config.php via cwd).
require_once __DIR__ . '/../vendor/autoload.php';
// Define DB constants BEFORE config.php so the test database wins over the
// real configuration (config.php's define() warnings are suppressed by @).
define('DB_TYPE', 'sqlite');
define('DB_HOST', 'localhost');
define('DB_NAME', ':memory:');
define('DB_USER', '');
define('DB_PASS', '');
@require_once __DIR__ . '/../autoload.php';