Compare commits

...

88 Commits

Author SHA1 Message Date
kj
2e96aa886e test(validator): rename enum rule to in 2026-09-14 16:46:07 -03:00
kj
f4b112032e refactor(validator): rename enum rule and method to in 2026-09-14 16:43:09 -03:00
kj
7b1d5e42bd test(Router): cover default callback resolution through Synapsis 2026-09-08 12:05:55 -03:00
kj
26589062f5 refactor(Router): use string callable syntax for default callbacks 2026-09-08 12:05:35 -03:00
kj
5022fdac75 refactor(router): remove exit statement from redirect method 2026-09-07 19:59:36 -03:00
kj
70b1016194 refactor(autoload): remove global exception handler 2026-09-07 17:34:58 -03:00
kj
86ab8b979a test(router): verify custom exceptions reach exception callback 2026-09-07 17:34:14 -03:00
kj
9ddb00e719 test(request): cover exception-based validation failures 2026-09-07 17:21:47 -03:00
kj
a534613d26 refactor(request): propagate validation failures as exceptions 2026-09-07 17:20:19 -03:00
kj
65380f37c6 test(Router): Add unit tests for defaultException and apply flows 2026-09-07 17:04:52 -03:00
kj
5f3fc16735 feat(router): add error boundary with exception callback 2026-09-07 17:04:20 -03:00
kj
146aed0db8 test(synapsis): cover named argument injection in resolve 2026-09-07 16:18:09 -03:00
kj
9dbfd7da8e feat: add named parameter injection to resolve() 2026-09-07 16:17:34 -03:00
kj
3e7c367182 test(integration): cover query failure state cleanup 2026-09-05 16:47:39 -03:00
kj
25db49f4bd fix(model): reset query state when database query fails 2026-09-05 16:45:02 -03:00
kj
9cb41d51d0 fix(model): use RANDOM() for non-MySQL databases in orderBy 2026-09-05 16:37:37 -03:00
kj
d0c4b3c503 feat(test): add multi-engine integration matrix 2026-09-05 16:15:28 -03:00
kj
48d3ed6b3f docs(makefile): document develop/master publish workflow 2026-09-05 14:17:14 -03:00
kj
d9ac4c3f14 build: add Makefile with test and publish targets 2026-09-05 14:09:40 -03:00
kj
d594aa3ec1 test(unit): Add Neuron and Validator regression tests 2026-09-05 13:43:35 -03:00
kj
bf03e10b54 test(bootstrap): add smoke test for autoload and test DB 2026-09-05 13:25:47 -03:00
kj
f1bda46723 test(factories): add abstract Factory base class 2026-09-05 12:37:41 -03:00
kj
dbbde42fe7 test: add base TestCase and bootstrap for in-memory SQLite 2026-09-05 12:33:20 -03:00
kj
7494ec5fda chore: add PHPUnit configuration and unit tests directory 2026-09-05 12:11:08 -03:00
kj
c0e5cf79d0 build: Add composer setup with phpunit and faker 2026-09-05 12:02:34 -03:00
kj
a4fcd21492 chore: add .gitignore for PHP project 2026-09-05 11:50:27 -03:00
kj
a4611e8718 chore(src): rename .keep files to .gitkeep
Use the conventional `.gitkeep` filename so Git tooling recognizes
these placeholder files by default.
2026-09-05 11:35:04 -03:00
kj
9027f36748 refactor(model): use reflection for search field discovery 2026-09-05 00:39:16 -03:00
kj
2ae9ef39f3 feat(request): negotiate error response frmat based on Accept header 2026-09-03 18:23:42 -03:00
kj
ee0db0307e docs(Validator): Expand documentation with complete rules reference 2026-09-03 18:22:52 -03:00
kj
44cbe6bbdc feat(validator): add file upload validation rules 2026-09-03 18:07:54 -03:00
kj
31728af351 docs: Clarify phpdoc 2026-09-03 16:12:54 -03:00
kj
4845d14ef5 chore(validator): suppress PHPCS rule for non-camel-case method 2026-09-03 15:59:48 -03:00
kj
aca4e732ed feat(validation): Add human-readable validation error messages 2026-09-03 15:59:36 -03:00
kj
f475c9125f feat(Validator): Add confirmed, required_with, and required_if rules 2026-09-03 15:03:28 -03:00
kj
4fe7314389 feat(Validator): Add regex and date validation methods 2026-09-03 14:42:15 -03:00
kj
1f366830bc feat(validation): add type and size-based validation methods
Add string, array, min, max, between, and size validators to the
Validator class. A private measure helper centralizes size semantics
for numbers (value), strings (length), and arrays (element count).
2026-09-03 14:35:02 -03:00
kj
15df7c8d96 refactor(Validator): improve rule argument parsing 2026-09-03 14:20:02 -03:00
kj
b8dd1fd0f6 refactor(model): remove default 'id' from dbIgnoreSave list 2026-06-19 16:08:29 -03:00
kj
83c80bd4ab fix(model): prevent overwriting primary key on save 2026-06-19 15:34:47 -03:00
kj
b13c07d3c4 fix(model): Remove the NULL check in the attribute loop 2026-06-19 15:29:51 -03:00
kj
b95747bfe0 feat(model): add type casting for DateTime 2026-06-19 15:14:25 -03:00
kj
01c5eceeb4 refactor(model): Use match expression for PDO type detection 2026-06-19 14:52:25 -03:00
kj
ee9c109ed9 fix(model): improve PDO parameter binding and update logic 2026-06-19 14:47:01 -03:00
kj
7eed69725d refactor(model): extract type casting logic into helper methods 2026-06-19 14:46:30 -03:00
kj
e37035eed8 fix(model): support both backed and unit enums 2026-06-13 16:48:07 -03:00
kj
5312e5060e fix(model): reset dbQueryVariables before update and insert
Clear the static $dbQueryVariables array before performing update
and insert operations to prevent data leakage between queries.
2026-06-13 15:30:45 -03:00
kj
3c9db3ac53 refactor(neuron): remove return type hint from __get method 2026-06-05 13:28:19 -03:00
2b5d19ecd5 fix(synapsis): Skip variadic parameters in argument resolution 2026-04-15 16:35:32 -03:00
53e862d126 feat(exception): Register global exception handler
This handler catches uncaught exceptions and prints their message
and stack trace.
2026-03-16 06:38:26 -03:00
b6baeef32f style(Synapsis): Wrap long exception message for readability 2026-03-16 06:25:09 -03:00
159da01d5c fix(synapsis): Include parameter name in untyped parameter error 2026-03-16 06:24:16 -03:00
3770dd6d9b refactor(model): Track model persistence state 2026-03-11 18:48:32 -03:00
76a69e5ef8 refactor(Model): Standarize property names 2026-03-11 18:22:16 -03:00
kj
c5a4967ae4 refactor: Extract autoloader to dedicated file 2026-02-10 14:09:13 -03:00
kj
c0b9f3851a chore(router): Remove redundant middleware count check 2026-02-02 10:23:48 -03:00
kj
78e199e300 feat(router): Introduce withMiddleware() for scoped middlewares 2026-02-01 08:25:54 -03:00
kj
8cd5e114dd refactor(router): Align type hint for optional parameter in docblock 2026-02-01 07:47:04 -03:00
kj
ce03bdb27d fix(model): Fix postgresql search. 2025-10-27 15:16:54 -03:00
kj
b24598b118 fix(sqlite): Correct base table for RIGHT JOIN conversion 2025-10-27 15:15:45 -03:00
kj
5019b89cc0 docs(readme): Update PostgreSQL compatibility status 2025-10-27 10:27:18 -03:00
kj
d030e8d30e refactor(model)!: Allow groupBy to accept multiple args instead an array 2025-10-27 10:26:12 -03:00
kj
b2672ad92f fix(model): Remove ON clause from cross join 2025-10-27 10:24:25 -03:00
kj
4c57770b43 refactor(model): Remove backticks from column names 2025-10-27 10:23:55 -03:00
kj
8e84450f95 fix(query): Adjust LIMIT OFFSET clause syntax 2025-10-27 10:23:31 -03:00
kj
ece32b9bbe docs(readme): Update supported where clauses 2025-10-27 06:50:13 -03:00
kj
a5cf9d239d refactor(model): Fix typo on WHERE EXISTS/NOT EXISTS methods 2025-10-27 06:46:17 -03:00
kj
b9509c49ed refactor(model): Add string type hint to select columns 2025-10-21 16:26:39 -03:00
kj
6e433b4d06 feat(query-builder): Implement advanced WHERE and new JOIN clauses 2025-10-17 13:13:01 -03:00
kj
892b3614ec refactor(db): Detect DB type using PDO driver name 2025-10-17 13:12:02 -03:00
kj
a0b544eae5 refactor(Model): Rename bindValue to bind and make public 2025-10-17 11:55:37 -03:00
kj
20fd78ab53 docs(Request): Remove param comment 2025-10-16 20:51:52 -03:00
kj
f5411daaa5 Translate comments to English 2025-10-16 20:02:54 -03:00
kj
7e7ec68fd7 refactor(router): Use Neuron object for route parameters 2025-10-16 12:04:05 -03:00
kj
ac9a661bc0 fix(htaccess): Block direct access to vendor and src folders 2025-10-11 12:15:15 -03:00
kj
c8d7b69367 feat(bootstrap): Introduce Libs\Loader for directory loading 2025-10-11 10:44:20 -03:00
kj
7f62e06ff9 docs: Improve documentation and translate comments to English 2025-10-10 21:18:22 -03:00
kj
674c9d5ff4 fix(validator): Improve filter_var robustness in validation methods 2025-10-10 21:17:41 -03:00
kj
2f50532e13 refactor(Model): Type hint setNull variadic arguments 2025-10-10 21:06:11 -03:00
kj
b41514a491 fix(di): Improve parameter resolution and validation 2025-10-10 19:15:07 -03:00
kj
a1a15f492c refactor!: add Synapsis for dependency resolution and injection
- Remove Middleware class and custom callback handling logic.
- Implement Synapsis as a dependency injection container for automatic
resolution.
- Refactor Router to use Synapsis for process route callbacks and not found
handler.
- Update Request to remove middleware-specific properties and use
Router::$currentParams for path parameters.
2025-10-10 17:44:49 -03:00
kj
b19e7d8789 Remove a forgotten debug line. 2025-09-07 18:52:33 -03:00
kj
4dfdb52519 Remove unnecessary and really never-used tableSufix property. 2025-09-07 15:25:48 -03:00
kj
b0891885f9 BREAKING CHANGE: Make select, from, and setNull methods variadic. 2025-09-07 15:20:06 -03:00
kj
b282d5479f Convert object/database naming conventions.
Adhere to PSR-12 by converting object properties to "lowerCamelCase" for objects
and "snake_case" for database interactions.

Backward compatibility is maintained: object properties already using
"snake_case" will continue to work without issue.
2025-09-07 15:01:45 -03:00
kj
c9f467345b BREAKING CHANGE: Adhere to PSR-12 coding standards.
- Model: where_in method was renamed as whereIn.
2025-09-07 11:07:07 -03:00
kj
0f46848d15 Remove the predefined 'id' property.
This property is not necessary and gives less flexibility.
2025-08-15 14:51:16 -03:00
kj
b2cb8d6883 Allow statically call beginTransaction, rollback and commit methods. 2025-07-15 18:12:41 -03:00
44 changed files with 6979 additions and 908 deletions

4
.gitignore vendored Normal file
View File

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

View File

@@ -1,6 +1,9 @@
<IfModule mod_rewrite.c>
RewriteEngine On
# Bloquear acceso a las carpetas vendor/ y src/
RewriteRule ^(vendor|src)/.*$ - [F,L]
# Handle Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f

197
Makefile Normal file
View File

@@ -0,0 +1,197 @@
# 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.
#
# Multi-engine regression net (run it whenever Model/Database change):
# make integration full matrix: brings the test services up,
# runs the Integration suite on sqlite, mysql
# and pgsql, then takes the services down even
# if a leg fails or you interrupt it
# make integration-engine ENGINE=mysql
# single leg (assumes services are already up);
# the fastest inner loop while iterating
# make db-up / make db-down manage the rootless podman test containers
# (mariadb:11, postgres:16-alpine); idempotent
#
# Hard port constraint: the services must bind default ports 3306/5432 on
# localhost because Database.php's DSN has no port field; stop any local
# server on those ports first (db-up aborts naming the port).
#
# 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 db-up db-up-mariadb db-up-postgres db-down integration integration-engine
PHPUNIT := vendor/bin/phpunit
# Engine for single-leg integration runs (services must already be up).
ENGINE ?= sqlite
# --- 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)
# --- integration database services (podman rootless) -----------------------
# Default ports are non-negotiable: Database.php's DSN carries no port field,
# so the engines must answer on 3306/5432. If a local server owns those
# ports, stop it before `make db-up`.
MARIADB_NAME := duckbrain-test-mariadb
POSTGRES_NAME := duckbrain-test-postgres
MARIADB_IMAGE := docker.io/library/mariadb:11
POSTGRES_IMAGE := docker.io/library/postgres:16-alpine
TEST_DB_NAME := duckbrain_test
TEST_DB_USER := duckbrain
TEST_DB_PASS := duckbrain
DB_READY_SECS := 240
test:
@test -x $(PHPUNIT) || composer install --no-interaction
./$(PHPUNIT) --testsuite Unit
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/^/ - /'
db-up: db-up-mariadb db-up-postgres
db-up-mariadb:
@if podman ps -a --format '{{.Names}}' | grep -qx $(MARIADB_NAME); then \
echo "reusing $(MARIADB_NAME)"; podman start $(MARIADB_NAME) >/dev/null; \
else \
(echo > /dev/tcp/127.0.0.1/3306) 2>/dev/null && { \
echo "ABORT: port 3306 already in use; stop the local MySQL/MariaDB server or free the port before db-up"; exit 1; } || true; \
podman run -d --name $(MARIADB_NAME) \
-e MARIADB_ROOT_PASSWORD=$(TEST_DB_PASS) \
-e MARIADB_DATABASE=$(TEST_DB_NAME) \
-e MARIADB_USER=$(TEST_DB_USER) \
-e MARIADB_PASSWORD=$(TEST_DB_PASS) \
-p 127.0.0.1:3306:3306 $(MARIADB_IMAGE) >/dev/null; \
fi
@echo "waiting for $(MARIADB_NAME) to accept connections..."; \
for i in $$(seq 1 $(DB_READY_SECS)); do \
podman exec $(MARIADB_NAME) mariadb-admin ping -h 127.0.0.1 -u $(TEST_DB_USER) -p$(TEST_DB_PASS) --silent >/dev/null 2>&1 \
&& echo "$(MARIADB_NAME) ready" && exit 0; \
sleep 2; \
done; \
echo "TIMEOUT: $(MARIADB_NAME) never became ready; inspect: podman logs $(MARIADB_NAME)"; exit 1
db-up-postgres:
@if podman ps -a --format '{{.Names}}' | grep -qx $(POSTGRES_NAME); then \
echo "reusing $(POSTGRES_NAME)"; podman start $(POSTGRES_NAME) >/dev/null; \
else \
(echo > /dev/tcp/127.0.0.1/5432) 2>/dev/null && { \
echo "ABORT: port 5432 already in use; stop the local PostgreSQL server or free the port before db-up"; exit 1; } || true; \
podman run -d --name $(POSTGRES_NAME) \
-e POSTGRES_DB=$(TEST_DB_NAME) \
-e POSTGRES_USER=$(TEST_DB_USER) \
-e POSTGRES_PASSWORD=$(TEST_DB_PASS) \
-p 127.0.0.1:5432:5432 $(POSTGRES_IMAGE) >/dev/null; \
fi
@echo "waiting for $(POSTGRES_NAME) to accept connections..."; \
for i in $$(seq 1 $(DB_READY_SECS)); do \
podman exec $(POSTGRES_NAME) pg_isready -U $(TEST_DB_USER) -d $(TEST_DB_NAME) >/dev/null 2>&1 \
&& echo "$(POSTGRES_NAME) ready" && exit 0; \
sleep 2; \
done; \
echo "TIMEOUT: $(POSTGRES_NAME) never became ready; inspect: podman logs $(POSTGRES_NAME)"; exit 1
db-down:
@for c in $(MARIADB_NAME) $(POSTGRES_NAME); do \
if podman ps -a --format '{{.Names}}' | grep -qx $$c; then \
podman stop $$c >/dev/null 2>&1; podman rm $$c >/dev/null 2>&1; echo "removed $$c"; \
else \
echo "$$c not present"; \
fi; \
done
integration:
@bash -c ' \
trap "$(MAKE) db-down" EXIT; \
trap "exit 130" INT; \
trap "exit 143" TERM; \
$(MAKE) --no-print-directory db-up || exit 1; \
rc=0; \
for e in sqlite mysql pgsql; do \
echo "============ leg: $$e ============"; \
$(MAKE) --no-print-directory integration-engine ENGINE=$$e || rc=1; \
done; \
echo "=================================="; \
if [ $$rc -eq 0 ]; then echo "integration: all legs green"; else echo "integration: at least one leg FAILED (rc=1)"; fi; \
exit $$rc \
'
integration-engine:
@test -x $(PHPUNIT) || composer install --no-interaction
DUCKBRAIN_TEST_DB=$(ENGINE) ./$(PHPUNIT) --testsuite Integration --cache-directory .phpunit.cache/$(ENGINE)

14
autoload.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
require_once('config.php');
// Autoloader for the core classes
spl_autoload_register(function ($className) {
$fp = str_replace('\\', '/', $className);
$name = basename($fp);
$dir = dirname($fp);
$file = ROOT_CORE . '/' . $dir . '/' . $name . '.php';
if (file_exists($file)) {
require_once $file;
}
});

15
composer.json Normal file
View File

@@ -0,0 +1,15 @@
{
"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 Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,15 @@
<?php
// Configuración de la base de datos
// Database configuration
define('DB_TYPE', 'mysql');
define('DB_HOST', 'localhost');
define('DB_NAME', '');
define('DB_USER', '');
define('DB_PASS', '');
// Configuración del sitio
// Site configuration
define('SITE_URL', '');
// Configuración avanzada
// Advanced configuration
define('ROOT_DIR', __DIR__);
define('ROOT_CORE', ROOT_DIR . '/src');

View File

@@ -1,20 +1,15 @@
<?php
require_once('config.php');
// Incluir clases
spl_autoload_register(function ($className) {
$fp = str_replace('\\','/',$className);
$name = basename($fp);
$dir = dirname($fp);
$file = ROOT_CORE.'/'.$dir.'/'.$name.'.php';
if (file_exists($file))
require_once $file;
});
use Libs\Loader;
use Libs\Router;
// Incluir routers
$routers = glob(ROOT_CORE.'/Routers/*.php');
require_once('autoload.php');
foreach($routers as $file)
require_once($file);
// Router autoload
Loader::load(ROOT_CORE . '/Routers/');
\Libs\Router::apply();
// Other autoloads
Loader::load(ROOT_CORE . '/Autoloads/');
// Run the routers
Router::apply();

25
phpunit.xml Normal file
View File

@@ -0,0 +1,25 @@
<?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>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

View File

@@ -28,79 +28,91 @@ En la siguiente tabla se encuentra la lista de estados de los gestores de bases
+ *En blanco* como que no ha sido probado aún.
+ *error* como que fue probado, no funciona y no ha sido aún arreglado.
+ *not supported* como no soportado por el gestor de bases de datos.
+ *fixed* para aquello que no existe en el gestor de DB, pero la librería lo traduce a un equivalente.
+ *fixed* para aquello que no existe en el gestor de BD, pero la librería lo traduce a un equivalente.
|------------------+---------------+---------------+------------|
|------------------+---------------+---------+------------|
| method | MySQL/MariaDB | sqlite3 | postgreSQL |
|------------------+---------------+---------------+------------|
| db | ok | ok | |
|------------------+---------------+---------------+------------|
| query | ok | ok | |
|------------------+---------------+---------------+------------|
| resetQuery | ok | ok | |
|------------------+---------------+---------------+------------|
| buildQuery | ok | ok | |
|------------------+---------------+---------------+------------|
| getInstance | ok | ok | |
|------------------+---------------+---------------+------------|
| getVars | ok | ok | |
|------------------+---------------+---------------+------------|
| className | ok | ok | |
|------------------+---------------+---------------+------------|
| table | ok | ok | |
|------------------+---------------+---------------+------------|
| update | ok | ok | |
|------------------+---------------+---------------+------------|
| beginTransaction | ok | ok | |
|------------------+---------------+---------------+------------|
| rollBack | ok | ok | |
|------------------+---------------+---------------+------------|
| commit | ok | ok | |
|------------------+---------------+---------------+------------|
| add | ok | ok | |
|------------------+---------------+---------------+------------|
| save | ok | ok | |
|------------------+---------------+---------------+------------|
| delete | ok | ok | |
|------------------+---------------+---------------+------------|
| select | ok | ok | |
|------------------+---------------+---------------+------------|
| from | ok | ok | |
|------------------+---------------+---------------+------------|
| where | ok | ok | |
|------------------+---------------+---------------+------------|
| where_in | ok | ok | |
|------------------+---------------+---------------+------------|
| leftJoin | ok | ok | |
|------------------+---------------+---------------+------------|
| rightJoin | ok | not supported | |
|------------------+---------------+---------------+------------|
| innerJoin | ok | ok | |
|------------------+---------------+---------------+------------|
| and | ok | ok | |
|------------------+---------------+---------------+------------|
| or | ok | ok | |
|------------------+---------------+---------------+------------|
| groupBy | ok | ok | |
|------------------+---------------+---------------+------------|
| limit | ok | ok | |
|------------------+---------------+---------------+------------|
| orderBy | ok | ok | |
|------------------+---------------+---------------+------------|
| count | ok | ok | |
|------------------+---------------+---------------+------------|
| getById | ok | ok | |
|------------------+---------------+---------------+------------|
| search | ok | ok | |
|------------------+---------------+---------------+------------|
| get | ok | ok | |
|------------------+---------------+---------------+------------|
| getFirst | ok | ok | |
|------------------+---------------+---------------+------------|
| all | ok | ok | |
|------------------+---------------+---------------+------------|
| setNull | ok | ok | |
|------------------+---------------+---------------+------------|
|------------------+---------------+---------+------------|
| db | ok | ok | ok |
|------------------+---------------+---------+------------|
| query | ok | ok | ok |
|------------------+---------------+---------+------------|
| resetQuery | ok | ok | ok |
|------------------+---------------+---------+------------|
| buildQuery | ok | ok | ok |
|------------------+---------------+---------+------------|
| getInstance | ok | ok | ok |
|------------------+---------------+---------+------------|
| getVars | ok | ok | ok |
|------------------+---------------+---------+------------|
| className | ok | ok | pk |
|------------------+---------------+---------+------------|
| table | ok | ok | ok |
|------------------+---------------+---------+------------|
| update | ok | ok | ok |
|------------------+---------------+---------+------------|
| beginTransaction | ok | ok | ok |
|------------------+---------------+---------+------------|
| rollBack | ok | ok | ok |
|------------------+---------------+---------+------------|
| commit | ok | ok | ok |
|------------------+---------------+---------+------------|
| add | ok | ok | ok |
|------------------+---------------+---------+------------|
| save | ok | ok | ok |
|------------------+---------------+---------+------------|
| delete | ok | ok | ok |
|------------------+---------------+---------+------------|
| select | ok | ok | ok |
|------------------+---------------+---------+------------|
| from | ok | ok | ok |
|------------------+---------------+---------+------------|
| where | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereIn | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereNotIn | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereNull | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereNotNull | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereExists | ok | ok | ok |
|------------------+---------------+---------+------------|
| whereNotExists | ok | ok | ok |
|------------------+---------------+---------+------------|
| leftJoin | ok | ok | ok |
|------------------+---------------+---------+------------|
| rightJoin | ok | fixed | ok |
|------------------+---------------+---------+------------|
| innerJoin | ok | ok | ok |
|------------------+---------------+---------+------------|
| crossJoin | ok | ok | ok |
|------------------+---------------+---------+------------|
| and | ok | ok | ok |
|------------------+---------------+---------+------------|
| or | ok | ok | ok |
|------------------+---------------+---------+------------|
| groupBy | ok | ok | ok |
|------------------+---------------+---------+------------|
| limit | ok | ok | ok |
|------------------+---------------+---------+------------|
| orderBy | ok | ok | ok |
|------------------+---------------+---------+------------|
| count | ok | ok | ok |
|------------------+---------------+---------+------------|
| getById | ok | ok | ok |
|------------------+---------------+---------+------------|
| search | ok | ok | ok |
|------------------+---------------+---------+------------|
| get | ok | ok | ok |
|------------------+---------------+---------+------------|
| getFirst | ok | ok | ok |
|------------------+---------------+---------+------------|
| all | ok | ok | ok |
|------------------+---------------+---------+------------|
| setNull | ok | ok | ok |
|------------------+---------------+---------+------------|
* Contacto

View File

@@ -1,45 +1,51 @@
<?php
/**
* Database - DuckBrain
*
* Clase diseñada para crear y devolver una única instancia PDO (database).
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
use Exception;
use PDO;
use PDOException;
use Exception;
class Database extends PDO {
static private array $databases = [];
private function __construct() {}
/**
* Devuelve una instancia homogénea (singlenton) de la base de datos (PDO).
* Database - DuckBrain
*
* Class designed to create and return a single PDO instance (database).
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Database extends PDO
{
private static array $databases = [];
/**
* Private constructor to prevent direct instantiation.
*/
private function __construct()
{
}
/**
* Returns a homogeneous (singleton) instance of the database (PDO).
*
* @return PDO
* @throws Exception If there is an error connecting to the database.
*/
static public function getInstance(
public static function getInstance(
string $type = 'mysql',
string $host = 'localhost',
string $name = '',
string $user = '',
string $pass = '',
): PDO
{
): PDO {
$key = $type . '/' . $host . '/' . $name . '/' . $user;
if (empty(static::$databases[$key])) {
if ($type == 'sqlite') {
$dsn = $type . ':' . $name;
} else
} else {
$dsn = $type . ':dbname=' . $name . ';host=' . $host;
}
try {
static::$databases[$key] = new PDO($dsn, $user, $pass);
@@ -55,4 +61,3 @@ class Database extends PDO {
return static::$databases[$key];
}
}
?>

31
src/Libs/Loader.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace Libs;
/**
* Loader - DuckBrain
*
* Simple library to bulk load multiple php files inside a folder.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Loader
{
/**
* Loads all PHP files from a specified directory.
* If the directory does not exist or is not a directory, no files will be loaded.
*
* @param string $directoryPath The path to the directory containing the PHP files to load.
* @return void
*/
public static function load(string $directoryPath): void
{
if (is_dir($directoryPath)) {
foreach (glob(rtrim($directoryPath, '/') . '/*.php') as $file) {
require_once($file);
}
}
}
}

View File

@@ -1,28 +0,0 @@
<?php
/**
* Middleware - DuckBrain
*
* Librería base para middlewares.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Middleware {
/**
* Llama al siguiente callback.
*
* @param Neuron $req
*
* @return mixed
*/
public static function next(Neuron $req): mixed
{
$next = array_pop($req->next);
return call_user_func_array($next, [$req]);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +1,59 @@
<?php
namespace Libs;
use AllowDynamicProperties;
/**
* Neuron - DuckBrain
*
* Neuron, sirve para crear un objeto que alojará valores, pero
* además tiene la característica especial de que al intentar
* acceder a un atributo que no está definido devolerá nulo en
* lugar de generar un error php notice que indica que se está
* intentando acceder a un valor no definido.
* Neuron, serves to create an object that will hold values.
* In addition, it has the special characteristic that when trying
* to access an undefined property, it will return null instead
* of generating a notice (PHP notice) for an undefined variable or property.
*
* El constructor recibe un objeto o arreglo con los valores que
* sí estarán definidos.
* The constructor accepts an object or an array containing the
* values that will be defined.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
* @license MIT
*/
namespace Libs;
use AllowDynamicProperties;
#[AllowDynamicProperties]
class Neuron {
class Neuron
{
/**
* __construct
* Constructor
*
* @param array $data
* @param array $data Data to initialize the Neuron with. Can be an array or an object.
*/
public function __construct(...$data)
{
if (count($data) === 1 &&
if (
count($data) === 1 &&
isset($data[0]) &&
(is_array($data[0]) ||
is_object($data[0])))
is_object($data[0]))
) {
$data = $data[0];
}
foreach($data as $key => $value)
foreach ($data as $key => $value) {
$this->{$key} = $value;
}
}
/**
* __get
* Magic method __get
*
* @param string $index
* @return null
* This method is called when an undefined property is accessed.
* It returns null instead of triggering an E_NOTICE.
*
* @param string $index The name of the property being accessed.
* @return null Always returns null for undefined properties.
*/
public function __get(string $index): null
public function __get(string $index)
{
return null;
}
}
?>

View File

@@ -1,18 +1,21 @@
<?php
/**
* Request - DuckBrain
*
* Libería complementaria de la libería Router.
* Contiene el cuerpo básico de la petición http (POST, GET, JSON, etc).
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Request extends Neuron {
use Exception;
/**
* Request - DuckBrain
*
* Complementary library to the Router library.
* Contains the basic body of the http request (POST, GET, JSON, etc).
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Request extends Neuron
{
public Neuron $get;
public Neuron $post;
public Neuron $put;
@@ -23,12 +26,9 @@ class Request extends Neuron {
public string $path;
public string $error;
public string $body;
public array $next;
/**
* __construct
*
* @param string $path Ruta actual tomando como raíz la instalación de DuckBrain.
*/
public function __construct()
{
@@ -38,112 +38,143 @@ class Request extends Neuron {
$this->put = new Neuron();
$this->patch = new Neuron();
$this->delete = new Neuron();
$this->params = Router::$params ?? new Neuron();
$this->body = file_get_contents("php://input");
$contentType = isset($_SERVER["CONTENT_TYPE"]) ? trim($_SERVER["CONTENT_TYPE"]) : '';
if ($contentType === "application/json")
if ($contentType === "application/json") {
$this->json = new Neuron(
(object) json_decode(trim($this->body), false)
);
else {
} else {
$this->json = new Neuron();
if (in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'PATCH', 'DELETE']) &&
preg_match('/^[^;?\/:@&=+$,]{1,255}[=]/', $this->body, $matches)) { // Con la expresión regular verificamos que sea un http query string válido y evitamos errores de memoria en caso de que el body tenga algo más grande que eso.
if (
in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'PATCH', 'DELETE']) &&
preg_match('/^[^;?\/:@&=+$,]{1,255}[=]/', $this->body, $matches)
) {
// With the regular expression, we verify that it is a valid
// http query string and avoid memory errors in case
// the body contains something larger than that.
parse_str($this->body, $input_vars);
$this->{strtolower($_SERVER['REQUEST_METHOD'])} = new Neuron($input_vars);
}
}
$this->params = new Neuron();
// Run configured validations
$this->validate();
}
/**
* Corre las validaciones e intenta continuar con la pila de callbacks.
* Starts the configured validation.
*
* @return mixed
*/
public function handle(): mixed
{
if ($this->validate())
return Middleware::next($this);
return null;
}
/**
* Inicia la validación que se haya configurado.
* 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 bool
* @return void
* @throws Exception When a configured rule fails.
*/
public function validate(): bool
public function validate(): void
{
$actual = match ($_SERVER['REQUEST_METHOD']) {
'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])},
default => $this->get
};
if (Validator::validateList(static::paramRules(), $this->params) &&
// Merge uploaded files ($_FILES) into the body data set so the file rules
// (file/image/mimes and the routed min/max/required...) can validate
// uploads through a Request. $this->post is left untouched.
$body = empty($_FILES)
? $actual
: new Neuron(array_merge(get_object_vars($actual), $_FILES));
if (
Validator::validateList(static::paramRules(), $this->params) &&
Validator::validateList(static::getRules(), $this->get) &&
Validator::validateList(static::rules(), $actual))
return true;
if (isset(static::messages()[Validator::$lastFailed]))
$error = static::messages()[Validator::$lastFailed];
else {
$error = 'Error: validation failed of '.preg_replace('/\./', ' as ', Validator::$lastFailed, 1);
Validator::validateList(static::rules(), $body)
) {
return;
}
$error = Validator::message(
Validator::$lastFailed,
static::messages(),
static::attributes()
);
static::onInvalid($error);
return false;
}
/**
* Reglas para el método actual.
* Rules for the current method.
*
* @return array
*/
public function rules(): array {
public function rules(): array
{
return [];
}
/**
* Reglas para los parámetros por URL.
* Rules for URL parameters.
*
* @return array
*/
public function paramRules(): array {
public function paramRules(): array
{
return [];
}
/**
* Reglas para los parámetros GET.
* Rules for GET parameters.
*
* @return array
*/
public function getRules(): array {
public function getRules(): array
{
return [];
}
/**
* Mensajes de error en caso de fallar una validación.
* Error messages in case a validation fails.
*
* @return array
*/
public function messages(): array {
public function messages(): array
{
return [];
}
/**
* Función a ejecutar cuando se ha detectado un valor no válido.
* Human-readable names for the fields.
*
* Forwarded to Validator::message() so the :attribute marker in the error
* text can be replaced with a friendlier name (e.g. "edad" => "la edad").
*
* @return array
*/
public function attributes(): array
{
return [];
}
/**
* Function to execute when an invalid value has been detected.
*
* The default implementation always throws a generic \Exception carrying
* the single error message and HTTP 422 as its code; the framework's
* 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
*
* @return void
* @return never
* @throws Exception
*/
public function onInvalid(string $error): void
public function onInvalid(string $error): never
{
http_response_code(422);
print($error);
throw new Exception($error, 422);
}
}

View File

@@ -1,57 +1,138 @@
<?php
/**
* Router - DuckBrain
*
* Librería de Enrrutador.
* Depende de manera forzada de que la constante ROOT_DIR esté definida
* y de manera optativa de que la constante SITE_URL lo esté también.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class Router {
private static $get = [];
private static $post = [];
private static $put = [];
private static $patch = [];
private static $delete = [];
private static $last;
/**
* Router - DuckBrain
*
* Router library.
* It strictly depends on the ROOT_DIR constant being defined
* and optionally on the SITE_URL constant being defined.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Router
{
private static array $get = [];
private static array $post = [];
private static array $put = [];
private static array $patch = [];
private static array $delete = [];
/**
* Stores a temporary stack of middlewares to be applied to routes defined
* within a specific scope, typically configured by `withMiddleware()`.
* These middlewares are active only during the execution of the scope's callback.
*
* @var array $withMiddlewares<callable>
*/
private static array $withMiddlewares = [];
/**
* Stores the method and index of the last configured route, e.g., ['get', 0].
* Used for chaining methods like middleware() or reconfigure().
*
* @var array $last
*/
private static array $last;
/**
* Stores the parameters extracted from the current matching route.
*
* @var Neuron $params
*/
public static Neuron $params;
/**
* The callback function to be executed when no route matches.
*
* @var callable $notFoundCallback
*/
public static $notFoundCallback = 'Libs\Router::defaultNotFound';
/**
* Función callback por defectio para cuando
* no se encuentra configurada la ruta.
* Default callback function for when
* the route is not found.
*
* @return void
*/
public static function defaultNotFound(): void
{
header("HTTP/1.0 404 Not Found");
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Página no encontrada</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
*/
private function __construct() {}
private function __construct()
{
}
/**
* Parsea para deectar las pseudovariables (ej: {variable})
* Parses to detect pseudovariables (e.g., {variable})
*
* @param string $path
* Ruta con pseudovariables.
* Route with pseudovariables.
*
* @param callable $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
* Callback that will be called when the route configured in $path matches.
*
* @return array
* Arreglo con 2 índices:
* path - Contiene la ruta con las pseudovariables reeplazadas por expresiones regulares.
* callback - Contiene el callback en formato Namespace\Clase::Método.
* Array with 3 indices:
* path - Contains the route with pseudovariables replaced by regular expressions.
* callback - Contains the callback in Namespace\Class::Method format.
* paramNames - An array of parameter names found in the path.
*/
private static function parse(string $path, callable $callback): array
{
@@ -62,72 +143,75 @@ class Router {
$path = preg_replace(
['/\\\{\w+\\\}/s'],
['([^\/]+)'],
$path);
$path
);
return [
'path' => $path,
'callback' => [$callback],
'paramNames' => $paramNames
'paramNames' => $paramNames,
];
}
/**
* Devuelve el ruta base o raiz del proyecto sobre la que trabajará el router.
* Returns the base or root path of the project on which the router will work.
*
* Ej: Si la url del sistema está en "https://ejemplo.com/duckbrain"
* entonces la ruta base sería "/duckbrain"
* Ex: If the system URL is "https://example.com/duckbrain"
* then the base path would be "/duckbrain"
*
* @return string
*/
public static function basePath(): string
{
if (defined('SITE_URL') && !empty(SITE_URL))
if (defined('SITE_URL') && !empty(SITE_URL)) {
return rtrim(parse_url(SITE_URL, PHP_URL_PATH), '/') . '/';
}
return str_replace($_SERVER['DOCUMENT_ROOT'], '/', ROOT_DIR);
}
/**
* Redirije a una ruta relativa interna.
* Redirects to an internal relative path.
*
* @param string $path
* La ruta relativa a la ruta base.
* The path relative to the base path.
*
* Ej: Si nuesto sistema está en "https://ejemplo.com/duckbrain"
* llamamos a Router::redirect('/docs'), entonces seremos
* redirigidos a "https://ejemplo.com/duckbrain/docs".
* Ex: If our system is at "https://example.com/duckbrain"
* and we call Router::redirect('/docs'), we will be
* redirected to "https://example.com/duckbrain/docs".
* @return void
*/
public static function redirect(string $path): void
{
header('Location: ' . static::basePath() . ltrim($path, '/'));
exit;
}
/**
* Añade un middleware a la última ruta usada.
* Solo se puede usar un middleware a la vez.
* Adds a middleware to the last used route.
* Only one middleware can be added at a time.
*
* @param callable $callback
* @param int $prioriry
* @param int|null $priority Optional priority for the middleware execution order.
*
* @return static
* Devuelve la instancia actual.
* Returns the current instance.
*/
public static function middleware(callable $callback, ?int $priority = null): static
{
if (!isset(static::$last))
if (!isset(static::$last)) {
return new static();
}
$method = static::$last[0];
$index = static::$last[1];
if (isset($priority) && $priority <= 0)
if (isset($priority) && $priority <= 0) {
$priority = 1;
}
if (is_null($priority) || $priority >= count(static::$$method[$index]['callback']))
if (is_null($priority) || $priority >= count(static::$$method[$index]['callback'])) {
static::$$method[$index]['callback'][] = $callback;
else {
} else {
static::$$method[$index]['callback'] = array_merge(
array_slice(static::$$method[$index]['callback'], 0, $priority),
[$callback],
@@ -139,7 +223,29 @@ class Router {
}
/**
* Reconfigura el callback final de la última ruta.
* Temporarily applies a set of middlewares to routes defined within a given callback.
* The middlewares are only active for the duration of the callback execution
* and are reset afterwards.
*
* @param callable $middleware
* @param callable $callback
*
* @return static
*/
public static function withMiddleware(callable $middleware, callable $callback): static
{
$currentMiddlewares = static::$withMiddlewares;
array_unshift(static::$withMiddlewares, $middleware);
$callback();
static::$withMiddlewares = $currentMiddlewares; // Restore withMiddleware
return new static();
}
/**
* Reconfigures the final callback of the last route.
*
* @param callable $callback
*
@@ -147,8 +253,9 @@ class Router {
*/
public static function reconfigure(callable $callback): static
{
if (empty(static::$last))
if (empty(static::$last)) {
return new static();
}
$method = static::$last[0];
$index = static::$last[1];
@@ -159,20 +266,20 @@ class Router {
}
/**
* Configura calquier método para todas las rutas.
* Configures any method for all routes.
*
* En caso de no recibir un callback, busca la ruta actual
* solo configura la ruta como la última configurada
* siempre y cuando la misma haya sido configurada previamente.
* If no callback is received, it searches for the current route
* and only sets the route as the last configured one
* provided it has been configured previously.
*
* @param string $method
* Método http.
* HTTP method.
* @param string $path
* Ruta con pseudovariables.
* Route with pseudovariables.
* @param callable|null $callback
*
* @return
* Devuelve la instancia actual.
* @return static
* Returns the current instance.
*/
public static function configure(string $method, string $path, ?callable $callback = null): static
{
@@ -181,32 +288,39 @@ class Router {
$path = preg_replace(
['/\\\{\w+\\\}/s'],
['([^\/]+)'],
$path);
$path
);
foreach(static::$$method as $index => $router)
foreach (static::$$method as $index => $router) {
if ($router['path'] == $path) {
static::$last = [$method, $index];
break;
}
}
return new static();
}
static::$$method[] = static::parse($path, $callback);
static::$last = [$method, count(static::$$method) - 1];
foreach (static::$withMiddlewares as $middleware) {
static::middleware($middleware);
}
return new static();
}
/**
* Define los routers para el método GET.
* Defines routers for the GET method.
*
* @param string $path
* Ruta con pseudovariables.
* Route with pseudovariables.
* @param callable|null $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Devuelve la instancia actual.
* Returns the current instance.
*/
public static function get(string $path, ?callable $callback = null): static
{
@@ -214,15 +328,15 @@ class Router {
}
/**
* Define los routers para el método POST.
* Defines routers for the POST method.
*
* @param string $path
* Ruta con pseudovariables.
* Route with pseudovariables.
* @param callable|null $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Devuelve la instancia actual.
* Returns the current instance.
*/
public static function post(string $path, ?callable $callback = null): static
{
@@ -230,15 +344,15 @@ class Router {
}
/**
* Define los routers para el método PUT.
* Defines routers for the PUT method.
*
* @param string $path
* Ruta con pseudovariables.
* Route with pseudovariables.
* @param callable|null $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Devuelve la instancia actual
* Returns the current instance
*/
public static function put(string $path, ?callable $callback = null): static
@@ -247,15 +361,15 @@ class Router {
}
/**
* Define los routers para el método PATCH.
* Defines routers for the PATCH method.
*
* @param string $path
* Ruta con pseudovariables.
* Route with pseudovariables.
* @param callable|null $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Devuelve la instancia actual
* Returns the current instance
*/
public static function patch(string $path, ?callable $callback = null): static
{
@@ -263,15 +377,15 @@ class Router {
}
/**
* Define los routers para el método DELETE.
* Defines routers for the DELETE method.
*
* @param string $path
* Ruta con pseudovariables
* Route with pseudovariables
* @param callable|null $callback
* Callback que será llamado cuando la ruta configurada en $path coincida.
* Callback that will be called when the route configured in $path matches.
*
* @return static
* Devuelve la instancia actual
* Returns the current instance
*/
public static function delete(string $path, ?callable $callback = null): static
{
@@ -279,27 +393,38 @@ class Router {
}
/**
* Devuelve la ruta actual tomando como raíz la ruta de instalación de DuckBrain.
* Returns the current path, taking the DuckBrain installation path as the root.
*
* @return string
*/
public static function currentPath(): string
{
return preg_replace('/'.preg_quote(static::basePath(), '/').'/',
'/', strtok($_SERVER['REQUEST_URI'], '?'), 1);
return preg_replace(
'/' . preg_quote(static::basePath(), '/') . '/',
'/',
strtok($_SERVER['REQUEST_URI'], '?'),
1
);
}
/**
* Aplica la configuración de rutas.
* Applies the route configuration.
*
* @param string|null $path (opcional) Ruta a usar. Si no se define, detecta la ruta actual.
* 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.
*
* @return void
*/
public static function apply(?string $path = null): void
{
try {
$path = $path ?? static::currentPath();
$routers = match($_SERVER['REQUEST_METHOD']) { // Según el método selecciona un arreglo de routers
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
'POST' => static::$post,
'PUT' => static::$put,
'PATCH' => static::$patch,
@@ -307,58 +432,25 @@ class Router {
default => static::$get
};
foreach ($routers as $router) { // revisa todos los routers para ver si coinciden con la ruta actual
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)) {
unset($matches[0]);
// Objtener un reflection del callback
$lastCallback = $router['callback'][0];
if ($lastCallback instanceof \Closure) { // si es función anónima
$reflectionCallback = new \ReflectionFunction($lastCallback);
} else {
if (is_string($lastCallback))
$lastCallback = preg_split('/::/', $lastCallback);
// Revisamos su es un método o solo una función
if (count($lastCallback) == 2)
$reflectionCallback = new \ReflectionMethod($lastCallback[0], $lastCallback[1]);
else
$reflectionCallback = new \ReflectionFunction($lastCallback[0]);
}
// Obtener los parámetros
$arguments = $reflectionCallback->getParameters();
if (isset($arguments[0])) {
// Obtenemos la clase del primer parámetro
$argumentClass = strval($arguments[0]->getType());
// Verificamos si la clase está o no tipada
if (empty($argumentClass)) {
$request = new Request;
} else {
$request = new $argumentClass;
// Verificamos que sea instancia de Request (requerimiento)
if (!($request instanceof Request))
throw new \Exception('Bad argument type on router callback.');
}
} else {
$request = new Request;
}
// Comprobando y guardando los parámetros variables de la ruta
// Checking and storing the variable parameters of the route
if (isset($matches[1])) {
static::$params = new Neuron();
foreach ($matches as $index => $match) {
$paramName = $router['paramNames'][$index - 1];
$request->params->$paramName = urldecode($match[0]);
static::$params->{$paramName} = urldecode($match[0]);
}
}
// Llama a la validación y luego procesa la cola de callbacks
$request->next = $router['callback'];
$data = $request->handle();
// Processes the callback queue
foreach (array_reverse($router['callback']) as $callback) {
$data = Synapsis::resolve($callback);
}
// Por defecto imprime como JSON si se retorna algo
// By default, prints as JSON if something is returned
if (isset($data)) {
header('Content-Type: application/json');
print(json_encode($data));
@@ -368,7 +460,10 @@ class Router {
}
}
// Si no hay router que coincida llamamos a $notFoundCallBack
call_user_func_array(static::$notFoundCallback, [new Request]);
// If no router matches, call $notFoundCallBack
Synapsis::resolve(static::$notFoundCallback);
} catch (\Throwable $exception) {
Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]);
}
}
}

179
src/Libs/Synapsis.php Normal file
View File

@@ -0,0 +1,179 @@
<?php
namespace Libs;
use Closure;
use Exception;
use ReflectionClass;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionParameter;
/**
* Synapsis - DuckBrain
*
* Class responsible for dependency resolution and injection.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class Synapsis
{
/**
* @var array<class-string, class-string> $bindings
*/
private static array $bindings = [];
/**
* @var array<class-string, mixed> $instances
*/
private static array $instances = [];
/**
* Binds an interface to a class to instantiate it instead.
*
* @param class-string $interfaceName
* @param class-string $className
*
* @return void
* @throws Exception If the interface or class does not exist.
*/
public static function bind(string $interfaceName, string $className): void
{
if (interface_exists($interfaceName) && class_exists($className)) {
static::$bindings[$interfaceName] = $className;
return;
}
throw new Exception('Error at binding non existant Interface or ClassName.');
}
/**
* Binds multiple interfaces to multiple classes in bulk.
*
* @param array<class-string, class-string> $bindings
*
* @return void
*/
public static function bulkBind(array $bindings): void
{
foreach ($bindings as $interfaceName => $className) {
static::bind($interfaceName, $className);
}
}
/**
* Resolves and injects dependencies for a callable and returns its result.
*
* @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
* @throws Exception If an unhandled callable type is provided.
*/
public static function resolve(callable $action, array $named = []): mixed
{
if ($action instanceof Closure) { // If it's an anonymous function
$reflectionCallback = new ReflectionFunction($action);
} else { // Array or string with the method
if (is_string($action)) {
$action = preg_split('/::/', $action);
}
// Check if it's a method or just a function
if (count($action) == 2) {
$reflectionCallback = new ReflectionMethod($action[0], $action[1]);
} else {
$reflectionCallback = new ReflectionFunction($action[0]);
}
}
// Get the parameters
return call_user_func_array(
$action,
static::resolveParameterValues($reflectionCallback->getParameters(), $named)
);
}
/**
* Resolves and injects dependencies for a class and returns its result.
*
* @param class-string $className
*
* @return mixed
* @throws Exception If a binding is missing for an interface or the class does not exist.
*/
public static function &resolveInstance(string $className): mixed
{
if (interface_exists($className)) {
if (isset(static::$bindings[$className])) {
$className = static::$bindings[$className];
} else {
throw new Exception("Missing binding for interface: {$className}");
}
}
if (!class_exists($className)) {
throw new Exception("Cannot resolve: {$className}");
}
if (!isset(static::$instances[$className])) {
// If the instance has not been resolved before, resolve it
$reflectionClass = new ReflectionClass($className);
$constructor = $reflectionClass->getConstructor();
static::$instances[$className] = new $className(
...static::resolveParameterValues($constructor?->getParameters() ?? [])
);
}
return static::$instances[$className];
}
/**
* Resolves parameter values by injecting dependencies.
*
* @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>
* @throws Exception If a primitive parameter does not have a default value.
*/
public static function resolveParameterValues(array $parameters, array $named = []): array
{
$values = [];
foreach ($parameters as $parameter) {
if ($parameter->isVariadic()) { // Skip variatic parameters
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
$values[] = $parameter->getDefaultValue();
continue;
}
$paramType = $parameter->getType();
if ($paramType === null) {
// If no type is declared, and it's not optional, we cannot resolve it.
throw new Exception(
"Untyped parameter '{$parameter}' expect at least a default value or must be optional."
);
}
if ($paramType->isBuiltin()) {
throw new Exception("Primitive parameter '{$parameter->getName()}' expects at least a default value.");
}
$values[] = static::resolveInstance($paramType->getName());
}
return $values;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,24 @@
<?php
/**
* View - DuckBrain
*
* Manejador de vistas simplificado.
*
* @author KJ
* @website https://kj2.me
* @licence MIT
*/
namespace Libs;
class View extends Neuron {
/**
* Incluye el archivo.
* View - DuckBrain
*
* @param string $viewName Ruta relativa y el nommbre sin extensión del archivo.
* @param string|null $viewPath (opcional) Ruta donde se encuentra la vista.
* @param string $extension (opcional) Extensión del archivo.
* Simplified view handler.
*
* @author KJ
* @website https://kj2.me
* @license MIT
*/
class View extends Neuron
{
/**
* Includes the file.
*
* @param string $viewName Relative path and filename without extension.
* @param string|null $viewPath (Optional) Path where the view file is located.
* @param string $extension (Optional) File extension.
*
* @return void
*/
@@ -26,12 +26,11 @@ class View extends Neuron {
string $viewName,
?string $viewPath = null,
string $extension = 'php'
): void
{
$view = $this;
if (isset($viewPath) &&
file_exists("$viewPath$viewName.$extension")) {
): void {
if (
isset($viewPath) &&
file_exists("$viewPath$viewName.$extension")
) {
include("$viewPath$viewName.$extension");
return;
}
@@ -40,12 +39,14 @@ class View extends Neuron {
}
/**
* Función que "renderiza" las vistas
* Function that "renders" views.
*
* @param string $viewName Ruta relativa y el nommbre sin extensión del archivo.
* @param array|Neuron $params (opcional) Arreglo que podrá ser usado en la vista mediante $view ($param['index'] se usaría así: $view->index)
* @param string|null $viewPath (opcional) Ruta donde se encuentra la vista. En caso de que la vista no se encuentre en esa ruta, se usará la ruta por defecto "src/Views/".
* @param string $extension (opcional) Extensión del archivo.
* @param string $viewName Relative path and filename without extension.
* @param array|Neuron $params (Optional) Array that can be used in the view
* via $view ($param['index'] would be used as: $view->index).
* @param string|null $viewPath (Optional) Path where the view is located. If the
* view is not found in this path, the default path "src/Views/" will be used.
* @param string $extension (Optional) File extension.
*
* @return void
*/
@@ -54,18 +55,18 @@ class View extends Neuron {
array|Neuron $params = [],
?string $viewPath = null,
string $extension = 'php'
): void
{
): void {
$instance = new View($params);
$instance->html($viewName, $viewPath, $extension);
}
/**
* Renderiza las vistas HTML
* Renders HTML views.
*
* @param string $viewName Ruta relativa y el nommbre sin extensión del archivo ubicado en src/Views
* @param string|null $viewPath (opcional) Ruta donde se encuentra la vista. En caso de que la vista no se encuentre en esa ruta, se usará la ruta por defecto "src/Views/".
* @param string $extension (opcional) Extensión del archivo.
* @param string $viewName Relative path and filename without extension located in src/Views.
* @param string|null $viewPath (Optional) Path where the view is located. If the view is not
* found in this path, the default path "src/Views/" will be used.
* @param string $extension (Optional) File extension.
*
* @return void
*/
@@ -73,8 +74,7 @@ class View extends Neuron {
string $viewName,
?string $viewPath = null,
string $extension = 'php'
): void
{
): void {
$this->include(
$viewName,
$viewPath,
@@ -83,11 +83,12 @@ class View extends Neuron {
}
/**
* Renderiza código CSS.
* Renders CSS code.
*
* @param string $viewName Ruta relativa y el nommbre sin extensión del archivo ubicado en src/Views
* @param string|null $viewPath (opcional) Ruta donde se encuentra la vista. En caso de que la vista no se encuentre en esa ruta, se usará la ruta por defecto "src/Views/".
* @param string $extension (opcional) Extensión del archivo.
* @param string $viewName Relative path and filename without extension located in src/Views.
* @param string|null $viewPath (Optional) Path where the view is located. If the view is not
* found in this path, the default path "src/Views/" will be used.
* @param string $extension (Optional) File extension.
*
* @return void
*/
@@ -95,18 +96,18 @@ class View extends Neuron {
string $viewName,
?string $viewPath = null,
string $extension = 'css'
): void
{
): void {
header("Content-type: text/css");
$this->include($viewName, $viewPath, $extension);
}
/**
* Renderiza código Javascript.
* Renders Javascript code.
*
* @param string $viewName Ruta relativa y el nommbre sin extensión del archivo ubicado en src/Views
* @param string|null $viewPath (opcional) Ruta donde se encuentra la vista. En caso de que la vista no se encuentre en esa ruta, se usará la ruta por defecto "src/Views/".
* @param string $extension (opcional) Extensión del archivo.
* @param string $viewName Relative path and filename without extension located in src/Views.
* @param string|null $viewPath (Optional) Path where the view is located. If the view is not
* found in this path, the default path "src/Views/" will be used.
* @param string $extension (Optional) File extension.
*
* @return void
*/
@@ -114,16 +115,15 @@ class View extends Neuron {
string $viewName,
?string $viewPath = null,
string $extension = 'js'
): void
{
): void {
header("Content-type: application/javascript");
$this->include($viewName, $viewPath, $extension);
}
/**
* Imprime los datos en Json.
* Prints data as JSON.
*
* @param object|array $data Objeto o array que se imprimirá a JSON.
* @param object|array $data Object or array to be printed as JSON.
*
* @return void
*/
@@ -134,9 +134,9 @@ class View extends Neuron {
}
/**
* Imprime los datos en texto plano
* Prints data as plain text.
*
* @param string $txt Contenido de texto.
* @param string $txt Text content.
*
* @return void
*/
@@ -147,7 +147,7 @@ class View extends Neuron {
}
/**
* Intenta devolver la url absoluta a partir de una ruta relativa.
* Attempts to return the absolute URL from a relative path.
*
* @param string $path
*
@@ -155,10 +155,10 @@ class View extends Neuron {
*/
public static function route(string $path = '/'): string
{
if (defined('SITE_URL') && !empty(SITE_URL))
if (defined('SITE_URL') && !empty(SITE_URL)) {
return rtrim(SITE_URL, '/') . '/' . ltrim($path, '/');
}
return $path;
}
}
?>

View File

@@ -0,0 +1,55 @@
<?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

@@ -0,0 +1,234 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use DateTime;
use Libs\Database;
use PDO;
use ReflectionMethod;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelCrudTest - DuckBrain integration matrix
*
* Family: db, getInstance, getVars, className, table, add, save (new vs
* existing), update, delete, getById, getFirst, all, markAsSaved.
*
* Engine caveats respected here: all()/getFirst() carry no ORDER BY, so
* every assertion targets a specific id or a tally of rows created by this
* class. Rows accumulate across tests on persistent engines; nothing may
* assume an empty table.
*/
#[Group('integration')]
final class ModelCrudTest extends TestCase
{
/** @var list<int> Ids created by this test class (tally for counts). */
private static array $saved = [];
private static function schema(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
}
private static function freshUser(): User
{
$n = count(self::$saved) + 1;
$user = new User();
$user->username = 'user' . $n;
$user->email = 'user' . $n . '@duckbrain.dev';
$user->bio = 'bio ' . $n;
$user->age = 20 + $n;
$user->isActive = true;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
self::$saved[] = $user->id;
return $user;
}
private static function call(mixed $scope, string $method, array $args = []): mixed
{
return (new ReflectionMethod($scope, $method))->invokeArgs(is_object($scope) ? $scope : null, $args);
}
/** @return list<int> */
private static function idsOf(array $instances): array
{
$ids = array_map(static fn (User $u): int => (int) $u->id, $instances);
sort($ids);
return $ids;
}
#[Test]
public function classNameAndTableAreDerivedFromTheClass(): void
{
self::schema();
$this->assertSame('User', User::className());
$this->assertSame('users', self::call(User::class, 'table'));
}
#[Test]
public function dbIsTheSharedSingletonConnection(): void
{
self::schema();
$pdo = self::call(User::class, 'db');
$this->assertInstanceOf(PDO::class, $pdo);
$this->assertSame($pdo, self::call(User::class, 'db'));
}
#[Test]
public function saveInsertsThenUpdatesWithoutDuplicating(): void
{
self::schema();
$user = self::freshUser();
$this->assertGreaterThan(0, $user->id);
$this->assertCount(1, User::where('id', $user->id)->get());
$user->email = 'changed@duckbrain.dev';
$user->age = 40;
$user->save();
$found = User::getById($user->id);
$this->assertSame('changed@duckbrain.dev', $found->email);
$this->assertSame(40, $found->age);
$this->assertCount(1, User::where('id', $user->id)->get(), 'second save() must UPDATE, not INSERT');
}
#[Test]
public function typedPropertiesSurviveTheRoundTrip(): void
{
self::schema();
$user = self::freshUser();
$user->email = null;
$user->save();
$found = User::getById($user->id);
$this->assertIsInt($found->id);
$this->assertNull($found->email, 'nullable typed property must hydrate back as null');
$this->assertIsInt($found->age);
$this->assertInstanceOf(DateTime::class, $found->lastLogin);
$this->assertTrue($found->isActive);
}
#[Test]
public function getByIdReturnsNullForMissingRows(): void
{
self::schema();
$this->assertNull(User::getById(99999));
}
#[Test]
public function getFirstRespectsTheWhereCondition(): void
{
self::schema();
$user = self::freshUser();
$found = User::where('id', $user->id)->getFirst();
$this->assertInstanceOf(User::class, $found);
$this->assertSame($user->id, $found->id);
$this->assertNull(User::where('id', 99999)->getFirst());
}
#[Test]
public function allReturnsEveryInstanceOfThisClass(): void
{
self::schema();
$a = self::freshUser();
$b = self::freshUser();
$all = self::idsOf(User::all());
$this->assertContains($a->id, $all);
$this->assertContains($b->id, $all);
// Defect-tolerant tally: rows deleted by other tests must not count.
$mine = [];
foreach (self::$saved as $id) {
if (User::getById($id) !== null) {
$mine[] = $id;
}
}
sort($mine);
$this->assertSame($mine, $all, 'all() must match exactly the live rows created by this class');
}
#[Test]
public function deleteRemovesTheRow(): void
{
self::schema();
$user = self::freshUser();
$user->delete();
$this->assertNull(User::getById($user->id));
$this->assertNotContains($user->id, self::idsOf(User::all()));
}
#[Test]
public function markAsSavedTurnsTheNextSaveIntoAnUpdate(): void
{
self::schema();
self::freshUser();
$before = count(User::all());
$ghost = new User();
$ghost->id = 900;
$ghost->username = 'ghost';
$ghost->markAsSaved();
$ghost->save();
$this->assertNull(User::getById(900), 'markAsSaved() must prevent INSERT even with a manual id');
$this->assertCount($before, User::all());
}
#[Test]
public function getVarsExposesSnakeCasedCastColumns(): void
{
self::schema();
$user = self::freshUser();
$vars = self::call($user, 'getVars');
$this->assertSame(
['age', 'bio', 'email', 'id', 'is_active', 'last_login', 'username'],
(static function (array $keys): array {
sort($keys);
return $keys;
})(array_keys($vars))
);
$this->assertSame('2026-09-05 10:00:00', $vars['last_login']);
$this->assertIsBool($vars['is_active']);
}
}

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

View File

@@ -0,0 +1,245 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Libs\Model;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelFilterTest - DuckBrain integration matrix
*
* Family: select, from, where, and, or, whereIn, whereNotIn, whereNull,
* whereNotNull, whereExists, whereNotExists, limit, orderBy, count, plus
* resetQuery/buildQuery observed through behavior (state must not leak
* between executed queries).
*/
#[Group('integration')]
final class ModelFilterTest extends TestCase
{
/** @var list<int> ids of alice, bob, carol, dave */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
self::createTable('posts', [
'id' => 'pk',
'user_id' => 'number',
'title' => 'string',
'content' => 'text',
'rating' => 'number',
]);
$rows = [
['alice', 'alice@duckbrain.dev', 'first', 20, true],
['bob', 'bob@duckbrain.dev', 'second', 30, true],
['carol', null, 'third', 40, false],
['dave', 'dave@duckbrain.dev', null, 50, true],
];
foreach ($rows as [$username, $email, $bio, $age, $active]) {
$user = new User();
$user->username = $username;
$user->email = $email;
$user->bio = $bio;
$user->age = $age;
$user->isActive = $active;
$user->save();
self::$ids[] = (int) $user->id;
}
// A post owned by alice only, for the EXISTS tests.
$post = new \Tests\Models\Post();
$post->userId = self::$ids[0];
$post->title = 'hello world';
$post->content = 'body';
$post->rating = 4.5;
$post->save();
}
/** @return list<int> */
private static function sortedIds(array $instances): array
{
$ids = array_map(static fn (Model $m): int => (int) $m->id, $instances);
sort($ids);
return $ids;
}
#[Test]
public function whereEqualityAndOperatorForms(): void
{
$this->assertSame(
[self::$ids[1]],
self::sortedIds(User::where('username', 'bob')->get())
);
$this->assertSame(
[self::$ids[2], self::$ids[3]],
self::sortedIds(User::where('age', '>', '35')->get())
);
}
#[Test]
public function andNestsConditions(): void
{
$this->assertSame(
[self::$ids[3]],
self::sortedIds(User::where('age', '>', '25')->and('username', 'dave')->get())
);
}
#[Test]
public function orCombinesAlternativeConditions(): void
{
$this->assertSame(
[self::$ids[0], self::$ids[3]],
self::sortedIds(User::where('username', 'alice')->or('username', 'dave')->get())
);
}
#[Test]
public function whereInAndWhereNotIn(): void
{
$in = self::sortedIds(User::whereIn('username', ['alice', 'carol'])->get());
$this->assertSame([self::$ids[0], self::$ids[2]], $in);
$notIn = self::sortedIds(User::whereNotIn('username', ['alice', 'carol'])->get());
$this->assertSame([self::$ids[1], self::$ids[3]], $notIn);
}
#[Test]
public function whereNullAndWhereNotNull(): void
{
$this->assertSame(
[self::$ids[3]],
self::sortedIds(User::whereNull('bio')->get())
);
$this->assertSame(
[self::$ids[2]],
self::sortedIds(User::whereNull('email')->get())
);
$this->assertCount(3, User::whereNotNull('email')->get());
}
#[Test]
public function whereExistsAndWhereNotExists(): void
{
$correlated = 'SELECT 1 FROM posts WHERE posts.user_id = users.id';
$this->assertSame(
[self::$ids[0]],
self::sortedIds(User::whereExists($correlated)->get())
);
$this->assertSame(
[self::$ids[1], self::$ids[2], self::$ids[3]],
self::sortedIds(User::whereNotExists($correlated)->get())
);
}
#[Test]
public function selectAndFromRestrictTheProjection(): void
{
$rows = User::select('username')->from('users')->where('username', 'bob')->get();
$this->assertCount(1, $rows);
$this->assertSame('bob', $rows[0]->username);
}
#[Test]
public function limitSingleAndOffsetForms(): void
{
$firstTwo = User::orderBy('id', 'ASC')->limit(2)->get();
$this->assertSame(
[self::$ids[0], self::$ids[1]],
array_map(static fn (Model $m): int => (int) $m->id, $firstTwo)
);
// limit(offset, quantity) -> skip the first two.
$lastTwo = User::orderBy('id', 'ASC')->limit(2, 2)->get();
$this->assertSame(
[self::$ids[2], self::$ids[3]],
array_map(static fn (Model $m): int => (int) $m->id, $lastTwo)
);
}
#[Test]
public function orderByColumnAscendingAndDescending(): void
{
$asc = array_map(static fn (Model $m): string => (string) $m->age, User::orderBy('age', 'ASC')->get());
$this->assertSame(['20', '30', '40', '50'], array_map('strval', array_map('intval', $asc)));
$descFirst = User::orderBy('age', 'DESC')->getFirst();
$this->assertSame('dave', $descFirst->username);
}
#[Test]
public function countWithAndWithoutConditions(): void
{
$this->assertSame(4, User::count());
$this->assertSame(3, User::where('age', '>', '25')->count());
}
#[Test]
public function countWithUseLimit(): void
{
$this->assertSame(2, User::orderBy('id', 'ASC')->limit(2)->count(true, true));
}
#[Test]
public function countWithoutResetKeepsTheOriginalQuery(): void
{
User::where('age', '>', '25');
$count = User::count(false);
$this->assertSame(3, $count);
$this->assertSame(
[self::$ids[1], self::$ids[2], self::$ids[3]],
self::sortedIds(User::get()),
'count(false) must leave the where active; get() consumes it'
);
}
#[Test]
public function queryStateResetsAfterExecution(): void
{
$one = User::where('id', self::$ids[0])->getFirst();
$this->assertSame('alice', $one->username);
$this->assertCount(4, User::all(), 'where must not leak into the next query');
$this->assertCount(4, User::get());
}
#[Test]
public function orderByRandReturnsEveryRow(): void
{
// The readme matrix promises orderBy ok on the three engines.
// Model emits literal RAND(); sqlite and PostgreSQL know RANDOM().
// This test documents the promise; failures are findings, not noise.
$this->assertCount(4, User::orderBy('RAND')->get());
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Tests\Models\Post;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelJoinTest - DuckBrain integration matrix
*
* Family: innerJoin, leftJoin, rightJoin, crossJoin, groupBy.
*
* Data has orphans on both sides (carol/dave have no posts; no post lacks an
* owner) so join cardinality actually discriminates. Projections are always
* qualified and limited to driver-model columns: a bare SELECT * on
* rightJoin differs between engines BY DESIGN (the sqlite branch rewrites
* into a swapped LEFT JOIN, see readme "fixed"); asserting on ambiguous
* duplicate columns would test that rewrite detail, not the join promise.
*/
#[Group('integration')]
final class ModelJoinTest extends TestCase
{
/** @var array<string,int> username => id */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
self::createTable('posts', [
'id' => 'pk',
'user_id' => 'number',
'title' => 'string',
'content' => 'text',
'rating' => 'number',
]);
foreach ([['alice', true], ['bob', true], ['carol', false], ['dave', true]] as $row) {
$user = new User();
$user->username = $row[0];
$user->email = $row[0] . '@duckbrain.dev';
$user->age = 30;
$user->isActive = $row[1];
$user->save();
self::$ids[$row[0]] = (int) $user->id;
}
foreach ([['p1', 'alice'], ['p2', 'alice'], ['p3', 'bob']] as [$title, $owner]) {
$post = new Post();
$post->title = $title;
$post->userId = self::$ids[$owner];
$post->save();
}
}
/** @return list<string> sorted usernames of the result rows */
private static function names(array $rows): array
{
$names = array_map(static fn (User $u): string => (string) $u->username, $rows);
sort($names);
return $names;
}
#[Test]
public function leftJoinKeepsUsersWithoutPosts(): void
{
$rows = User::select('users.id', 'users.username')
->leftJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$this->assertSame(
['alice', 'alice', 'bob', 'carol', 'dave'],
self::names($rows),
'LEFT JOIN must keep orphan users exactly once each'
);
}
#[Test]
public function innerJoinDropsBothOrphans(): void
{
$rows = User::select('users.id', 'users.username')
->innerJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$this->assertSame(['alice', 'alice', 'bob'], self::names($rows));
}
#[Test]
public function rightJoinKeepsEveryRowOfTheJoinedTable(): void
{
// sqlite takes the swapped-LEFT-JOIN rewrite; mysql/pgsql take the
// native RIGHT JOIN branch. Both must produce the same observable set.
$rows = User::select('users.id', 'users.username')
->rightJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$names = self::names($rows);
$this->assertCount(3, $rows, 'posts are the preserved side: 3 rows');
$this->assertSame(['alice', 'alice', 'bob'], $names);
$this->assertNotContains('carol', $names, 'users without posts vanish from a RIGHT JOIN over posts');
}
#[Test]
public function crossJoinProducesTheCartesianProduct(): void
{
$rows = User::select('users.id', 'posts.id')
->crossJoin('posts')
->get();
$this->assertCount(12, $rows, '4 users x 3 posts');
// Qualified both-id columns: the last one wins on every engine
// (users.* comes first), so the surviving id set is 1..3.
$surviving = array_unique(array_map(static fn (User $u): int => (int) $u->id, $rows));
sort($surviving);
$this->assertSame([1, 2, 3], $surviving);
}
#[Test]
public function groupByCollapsesRowsPerGroup(): void
{
$statuses = User::select('users.is_active')->groupBy('users.is_active')->get();
$this->assertCount(2, $statuses, 'active and inactive groups');
$owners = User::select('users.id', 'users.username')
->innerJoin('posts', 'users.id', '=', 'posts.user_id')
->groupBy('users.id')
->get();
$names = self::names($owners);
sort($names);
$this->assertSame(['alice', 'bob'], $names, 'one row per user that owns posts');
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Integration;
use DateTime;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelSearchTest - DuckBrain integration matrix
*
* Family: search (mysql CONCAT branch vs sqlite/pgsql CAST||LIKE branch),
* plus filters that bind every supported column type and must agree on
* matches across engines. Search terms are chosen case-insensitively stable
* (MySQL LIKE is case-insensitive by collation, PostgreSQL's is not).
*/
#[Group('integration')]
final class ModelSearchTest extends TestCase
{
/** @var array<string,int> */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
$seed = [
['alice', 'alice@duckbrain.dev', 'alpha keeper', 20, true],
['bob', 'bob@duckbrain.dev', 'beta reader', 30, true],
['carol', 'carol@duckbrain.dev', 'gamma dreamer', 40, false],
['dave', 'dave@duckbrain.dev', 'delta builder', 50, true],
];
foreach ($seed as [$username, $email, $bio, $age, $active]) {
$user = new User();
$user->username = $username;
$user->email = $email;
$user->bio = $bio;
$user->age = $age;
$user->isActive = $active;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
self::$ids[$username] = (int) $user->id;
}
}
/** @return list<int> */
private static function idsOf(array $rows): array
{
$ids = array_map(static fn (User $u): int => (int) $u->id, $rows);
sort($ids);
return $ids;
}
#[Test]
public function searchMatchesAcrossAllDefaultColumns(): void
{
// 'ali' occurs only in alice's username/bio space.
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::search('ali')->get())
);
}
#[Test]
public function searchFindsNumericValueThroughTextCasting(): void
{
// The number column must be matched by its textual form: sqlite
// casts REAL to '30.0' while mysql/pgsql yield '30'; both contain 30.
$this->assertSame(
[self::$ids['bob']],
self::idsOf(User::search('30')->get())
);
}
#[Test]
public function searchHonorsExplicitColumnList(): void
{
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::search('lpha', ['bio'])->get())
);
$this->assertSame(
[self::$ids['carol']],
self::idsOf(User::search('arol', ['username', 'email'])->get())
);
}
#[Test]
public function searchCombinesWithWhere(): void
{
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::where('is_active', '1')->search('li')->get())
);
}
#[Test]
public function everyColumnTypeFiltersConsistently(): void
{
$this->assertSame([self::$ids['alice']], self::idsOf(User::where('id', self::$ids['alice'])->get()));
$this->assertSame([self::$ids['bob']], self::idsOf(User::where('email', 'bob@duckbrain.dev')->get()));
$this->assertSame([self::$ids['carol']], self::idsOf(User::where('is_active', '0')->get()));
$this->assertCount(4, User::where('last_login', '>', '2026-09-05 09:00:00')->get());
$this->assertSame([self::$ids['dave']], self::idsOf(User::where('age', '>=', '50')->get()));
}
#[Test]
public function booleanFalseSurvivesTheRoundTrip(): void
{
// Promise of the matrix: typed properties hydrate faithfully on the
// three engines. PostgreSQL reports boolean false as 'f' and the
// core casts nothing -> this is the R2 probe.
$carol = User::getById(self::$ids['carol']);
$this->assertNotNull($carol);
$this->assertFalse($carol->isActive, 'inactive user must hydrate as bool false');
}
}

View File

@@ -0,0 +1,95 @@
<?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;
/**
* ModelTransactionTest - DuckBrain integration matrix
*
* Family: beginTransaction, commit, rollBack, plus the implicit rollBack
* that Model.php performs when a query fails mid-transaction (and the
* Exception it rethrows wrapping the PDO error).
*/
#[Group('integration')]
final class ModelTransactionTest extends TestCase
{
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
}
private static function seed(string $username): User
{
$user = new User();
$user->username = $username;
$user->email = $username . '@duckbrain.dev';
$user->age = 25;
$user->save();
return $user;
}
#[Test]
public function commitMakesPendingWritesVisible(): void
{
User::beginTransaction();
$user = self::seed('committed_alice');
$this->assertTrue(User::commit());
$this->assertNotNull(User::getById($user->id));
}
#[Test]
public function rollBackDiscardsPendingWrites(): void
{
User::beginTransaction();
$user = self::seed('rolled_bob');
$this->assertTrue(User::rollBack());
$this->assertNull(User::getById($user->id));
}
#[Test]
public function aFailingQueryRollsBackTheOpenTransaction(): void
{
User::beginTransaction();
$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 {
(new ReflectionMethod(User::class, 'query'))
->invokeArgs(null, ['SELECT * FROM table_that_does_not_exist']);
$this->fail('a failing query inside a transaction must throw');
} catch (Exception $e) {
$this->assertStringContainsString('Error at query to database', $e->getMessage());
}
$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');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use DateTime;
use InvalidArgumentException;
use Tests\Models\User;
use Tests\TestCase;
/**
* SchemaSmokeTest - DuckBrain test harness
*
* Integration smoke test: the six logical column types must translate into
* valid DDL for the current engine and round-trip a row. Runs on all three
* legs (sqlite, mysql, pgsql) unchanged.
*/
#[Group('integration')]
final class SchemaSmokeTest extends TestCase
{
#[Test]
public function allLogicalTypesCreateAndRoundTrip(): void
{
self::createTable('smoke_types', [
'id' => 'pk',
'label' => 'string',
'body' => 'text',
'amount' => 'number',
'active' => 'bool',
'created_at' => 'timestamp',
]);
$insert = self::db()->prepare(
'INSERT INTO smoke_types (label, body, amount, active, created_at) VALUES (?, ?, ?, ?, ?)'
);
$insert->execute(['widget', 'a longer free-form body', 12.5, true, '2026-09-05 12:34:56']);
$row = self::db()->query('SELECT * FROM smoke_types')->fetch();
$this->assertSame(1, (int) $row['id']);
$this->assertSame('widget', $row['label']);
$this->assertSame('a longer free-form body', $row['body']);
$this->assertEquals(12.5, (float) $row['amount']);
$this->assertTrue(filter_var($row['active'], FILTER_VALIDATE_BOOLEAN));
$this->assertStringStartsWith('2026-09-05 12:34:56', (string) $row['created_at']);
}
#[Test]
public function saveAssignsPrimaryKeyAndGetByIdRoundTrips(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
$user = new User();
$user->username = 'keyjay';
$user->email = 'kj@duckbrain.dev';
$user->bio = 'framework keeper';
$user->age = 29;
$user->isActive = true;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
$this->assertSame(1, $user->id, 'save() should assign the generated primary key');
$found = User::getById(1);
$this->assertNotNull($found);
$this->assertSame('keyjay', $found->username);
$this->assertSame(29, $found->age);
$this->assertTrue($found->isActive);
$this->assertInstanceOf(DateTime::class, $found->lastLogin);
$this->assertSame('2026-09-05 10:00:00', $found->lastLogin->format('Y-m-d H:i:s'));
}
#[Test]
public function unknownLogicalTypeFails(): void
{
$this->expectException(InvalidArgumentException::class);
self::createTable('smoke_bad', ['x' => 'money']);
}
}

20
tests/Models/Post.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace Tests\Models;
use Libs\Model;
/**
* Post - DuckBrain test fixture
*
* Second table of the integration matrix, joined to Users through user_id.
* Column names avoid SQL reserved words on every engine.
*/
final class Post extends Model
{
public ?int $id;
public ?int $userId;
public string $title;
public ?string $content;
public ?float $rating;
}

26
tests/Models/User.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace Tests\Models;
use DateTime;
use Libs\Model;
/**
* User - DuckBrain test fixture
*
* Public model used by the integration matrix. $id is declared WITHOUT a
* default so it stays uninitialized until the database assigns it:
* Model::getVars() skips uninitialized typed properties, which lets
* AUTOINCREMENT / AUTO_INCREMENT / SERIAL generate the primary key and
* Model::add() assign it back to the instance.
*/
final class User extends Model
{
public ?int $id;
public string $username;
public ?string $email;
public ?string $bio;
public ?int $age;
public bool $isActive = false;
public ?DateTime $lastLogin;
}

136
tests/TestCase.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
namespace Tests;
use Libs\Database;
use Libs\Model;
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 = [];
/**
* Clears Model's static query-builder state before every test.
*
* A query that dies mid-flight (unsupported function, constraint
* violation, ...) never reaches Model's internal resetQuery(), so
* leftovers would leak into the next test otherwise. This keeps the
* suite order-independent regardless of PHPUnit's executionOrder.
*/
protected function setUp(): void
{
parent::setUp();
(new \ReflectionMethod(Model::class, 'resetQuery'))->invoke(null);
}
/**
* 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);
}
/**
* DDL fragments per engine for each logical column type.
*
* @var array<string,array<string,string>>
*/
private const TYPE_DDL = [
'sqlite' => [
'pk' => 'INTEGER PRIMARY KEY AUTOINCREMENT',
'string' => 'VARCHAR(255)',
'text' => 'TEXT',
'number' => 'REAL',
'bool' => 'INTEGER',
'timestamp' => 'TEXT',
],
'mysql' => [
'pk' => 'INT AUTO_INCREMENT PRIMARY KEY',
'string' => 'VARCHAR(255)',
'text' => 'TEXT',
'number' => 'DOUBLE',
'bool' => 'TINYINT(1)',
'timestamp' => 'DATETIME',
],
'pgsql' => [
'pk' => 'SERIAL PRIMARY KEY',
'string' => 'VARCHAR(255)',
'text' => 'TEXT',
'number' => 'DOUBLE PRECISION',
'bool' => 'BOOLEAN',
'timestamp' => 'TIMESTAMP',
],
];
/**
* Creates a table in the test database from logical column types and
* registers it for cleanup. The DDL is translated per engine, so the
* same declaration works on sqlite, MySQL/MariaDB and PostgreSQL.
*
* @param string $name
* Table name (avoid SQL reserved words).
*
* @param array<string,string> $columns
* Map of column name to logical type:
* pk|string|text|number|bool|timestamp.
*/
protected static function createTable(string $name, array $columns): void
{
$types = self::TYPE_DDL[DB_TYPE] ?? throw new \InvalidArgumentException(
'No type translation for DB_TYPE ' . DB_TYPE
);
$ddl = [];
foreach ($columns as $column => $type) {
$fragment = $types[$type] ?? throw new \InvalidArgumentException(
"Unknown logical type '{$type}' for column '{$column}'; "
. 'expected: ' . implode(', ', array_keys($types))
);
$ddl[] = $column . ' ' . $fragment;
}
static::db()->exec("CREATE TABLE IF NOT EXISTS {$name} (" . implode(', ', $ddl) . ')');
static::$tables[] = $name;
}
/**
* Never let an aborted test leak an open transaction into the next one.
*/
protected function tearDown(): void
{
if (static::db()->inTransaction()) {
static::db()->rollBack();
}
parent::tearDown();
}
/**
* 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 = [];
}
}

0
tests/Unit/.gitkeep Normal file
View File

View File

@@ -0,0 +1,57 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
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
{
#[Test]
public function frameworkClassesAreAutoloaded(): 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);
}
#[Test]
public function databaseConstantsPointToInMemorySqlite(): 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');
}
#[Test]
public function testDatabaseIsUsable(): 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');
}
}

69
tests/Unit/NeuronTest.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
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
{
#[Test]
public function constructFromAssociativeArray(): void
{
$n = new Neuron(['username' => 'kj', 'level' => 3]);
$this->assertSame('kj', $n->username);
$this->assertSame(3, $n->level);
}
#[Test]
public function constructFromObjectCopiesPublicProperties(): 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);
}
#[Test]
public function undefinedPropertyIsNullWithoutNotice(): void
{
$n = new Neuron();
$this->assertNull($n->thisDoesNotExist);
}
#[Test]
public function dynamicPropertiesCanBeAssignedAndRead(): void
{
$n = new Neuron();
$n->fresh = ['a', 'b'];
$this->assertSame(['a', 'b'], $n->fresh);
}
#[Test]
public function nestedValuesArePreservedVerbatim(): void
{
$payload = ['meta' => ['tags' => ['x', 'y'], 'n' => null]];
$n = new Neuron($payload);
$this->assertSame($payload['meta'], $n->meta);
$this->assertNull($n->meta['n']);
}
}

138
tests/Unit/RequestTest.php Normal file
View 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
View 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);
}
}

View 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');
}
}

View File

@@ -0,0 +1,200 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
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')]
#[Test]
public function scalarRules(string $rule, mixed $value, bool $expected): void
{
$this->assertSame($expected, Validator::checkRule($value, $rule), "rule {$rule} with " . var_export($value, true));
}
#[Test]
public function existsAndRequiredDisagreeOnEmptyString(): void
{
$this->assertTrue(Validator::checkRule('', 'exists'));
$this->assertFalse(Validator::checkRule('', 'required'));
$this->assertFalse(Validator::checkRule(null, 'exists'));
}
#[Test]
public function sizeRulesMeasureStringNumberAndArray(): 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'));
}
#[Test]
public function regexKeepsColonsAndCommasInsidePattern(): void
{
$this->assertTrue(Validator::checkRule('a,b', 'regex:/^a,b$/'));
$this->assertFalse(Validator::checkRule('a;b', 'regex:/^a,b$/'));
}
#[Test]
public function inIsLooseComparison(): void
{
$this->assertTrue(Validator::checkRule('1', 'in: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]
public function notNegatesNextRule(): void
{
$this->assertTrue(Validator::checkRule('3.5', 'not:int'));
$this->assertFalse(Validator::checkRule('42', 'not:int'));
}
#[Test]
public function parseRule(): void
{
$this->assertSame(['required', ''], Validator::parseRule('required'));
$this->assertSame(['in', 'a,b'], Validator::parseRule('in:a,b'));
$this->assertSame(['regex', '/^a,b$/'], Validator::parseRule('regex:/^a,b$/'));
}
#[Test]
public function validateListPassesFullyValidBatch(): 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);
}
#[Test]
public function validateListStopsAtFirstFailure(): 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);
}
#[Test]
public function validateListNullableSkipsRestWhenEmpty(): 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'])));
}
#[Test]
public function validateListConfirmedUsesSiblingField(): 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);
}
#[Test]
public function messageBuildsHumanTextFromLastFailed(): 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.in:a,b,c')
);
$this->assertSame(
'The reason field is required when mode is other.',
Validator::message('reason.required_if:mode,other')
);
}
#[Test]
public function messageRespectsAttributesAndOverrides(): 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'])
);
}
}

56
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,56 @@
<?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';
// Engine selection: DUCKBRAIN_TEST_DB=sqlite|mysql|pgsql (default: sqlite).
// sqlite uses an in-memory database, so the unit suite never needs services.
$engine = strtolower(getenv('DUCKBRAIN_TEST_DB') ?: 'sqlite');
$profiles = [
'sqlite' => [
'type' => 'sqlite',
'host' => 'localhost',
'name' => ':memory:',
'user' => '',
'pass' => '',
],
'mysql' => [
'type' => 'mysql',
'host' => '127.0.0.1',
'name' => 'duckbrain_test',
'user' => 'duckbrain',
'pass' => 'duckbrain',
],
'pgsql' => [
'type' => 'pgsql',
'host' => '127.0.0.1',
'name' => 'duckbrain_test',
'user' => 'duckbrain',
'pass' => 'duckbrain',
],
];
if (!isset($profiles[$engine])) {
fwrite(
STDERR,
"Invalid DUCKBRAIN_TEST_DB '{$engine}'. Expected one of: "
. implode(', ', array_keys($profiles)) . "\n"
);
exit(1);
}
// Define DB constants BEFORE config.php so the test database wins over the
// real configuration (config.php's define() warnings are suppressed by @).
// Each field can be overridden without losing the engine's other defaults.
$profile = $profiles[$engine];
define('DB_TYPE', $profile['type']);
define('DB_HOST', getenv('DUCKBRAIN_TEST_HOST') ?: $profile['host']);
define('DB_NAME', getenv('DUCKBRAIN_TEST_NAME') ?: $profile['name']);
define('DB_USER', getenv('DUCKBRAIN_TEST_USER') ?: $profile['user']);
define('DB_PASS', getenv('DUCKBRAIN_TEST_PASS') ?: $profile['pass']);
@require_once __DIR__ . '/../autoload.php';