Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b1d5e42bd | |||
| 26589062f5 | |||
| 5022fdac75 | |||
| 70b1016194 | |||
| 86ab8b979a | |||
| 9ddb00e719 | |||
| a534613d26 | |||
| 65380f37c6 | |||
| 5f3fc16735 | |||
| 146aed0db8 | |||
| 9dbfd7da8e | |||
| 3e7c367182 | |||
| 25db49f4bd | |||
| 9cb41d51d0 | |||
| d0c4b3c503 | |||
| 48d3ed6b3f | |||
| d9ac4c3f14 | |||
| d594aa3ec1 | |||
| bf03e10b54 | |||
| f1bda46723 | |||
| dbbde42fe7 | |||
| 7494ec5fda | |||
| c0e5cf79d0 | |||
| a4fcd21492 | |||
| a4611e8718 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/vendor/
|
||||
/.phpunit.cache/
|
||||
*.sqlite
|
||||
/.publish/
|
||||
197
Makefile
Normal file
197
Makefile
Normal 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)
|
||||
15
composer.json
Normal file
15
composer.json
Normal 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
1970
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
phpunit.xml
Normal file
25
phpunit.xml
Normal 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>
|
||||
55
tests/Factories/Factory.php
Normal file
55
tests/Factories/Factory.php
Normal 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;
|
||||
}
|
||||
234
tests/Integration/ModelCrudTest.php
Normal file
234
tests/Integration/ModelCrudTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
100
tests/Integration/ModelFailureTest.php
Normal file
100
tests/Integration/ModelFailureTest.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Exception;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use ReflectionMethod;
|
||||
use Tests\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ModelFailureTest - DuckBrain integration matrix
|
||||
*
|
||||
* Family: query-failure state handling (findings F2 / spec model).
|
||||
*
|
||||
* All contamination is provoked and observed INSIDE a single test: the
|
||||
* harness-wide resetQuery guard in Tests\TestCase::setUp() protects the
|
||||
* next test, never the current one, so these assertions depend solely on
|
||||
* Model::query()'s own bookkeeping.
|
||||
*/
|
||||
#[Group('integration')]
|
||||
final class ModelFailureTest extends TestCase
|
||||
{
|
||||
/** @var list<int> */
|
||||
private static array $ids = [];
|
||||
|
||||
private const BAD_SQL = 'SELECT * FROM table_that_does_not_exist';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
self::createTable('users', [
|
||||
'id' => 'pk',
|
||||
'username' => 'string',
|
||||
'email' => 'string',
|
||||
'bio' => 'text',
|
||||
'age' => 'number',
|
||||
'is_active' => 'bool',
|
||||
'last_login' => 'timestamp',
|
||||
]);
|
||||
|
||||
foreach (['fer', 'fiona', 'frog'] as $name) {
|
||||
$user = new User();
|
||||
$user->username = $name;
|
||||
$user->email = $name . '@duckbrain.dev';
|
||||
$user->age = 33;
|
||||
$user->save();
|
||||
self::$ids[] = (int) $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
private static function runQuery(string $sql, bool $resetQuery = true): void
|
||||
{
|
||||
(new ReflectionMethod(User::class, 'query'))->invokeArgs(null, [$sql, $resetQuery]);
|
||||
}
|
||||
|
||||
private static function currentSql(): string
|
||||
{
|
||||
return (new ReflectionMethod(User::class, 'buildQuery'))->invoke(null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function failingQueryDoesNotContaminateTheNextQuery(): void
|
||||
{
|
||||
User::where('id', self::$ids[0]);
|
||||
|
||||
try {
|
||||
self::runQuery(self::BAD_SQL);
|
||||
$this->fail('a query against a missing table must throw');
|
||||
} catch (Exception $e) {
|
||||
$this->assertStringContainsString('Error at query to database', $e->getMessage());
|
||||
}
|
||||
|
||||
// With the default resetQuery: true, the failed run must have left
|
||||
// the builder as good as new; get() therefore sees all three rows.
|
||||
$this->assertCount(3, User::get(), 'builder state leaked from the failed query');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function failedKeepStateQueryLeavesTheWhereForRetry(): void
|
||||
{
|
||||
User::where('username', 'fiona');
|
||||
|
||||
try {
|
||||
self::runQuery(self::BAD_SQL, false);
|
||||
$this->fail('a query against a missing table must throw');
|
||||
} catch (Exception $e) {
|
||||
$this->assertStringContainsString('Error at query to database', $e->getMessage());
|
||||
}
|
||||
|
||||
// resetQuery: false contracts to keep the where alive for the
|
||||
// caller's deliberate retry (also guards against a fix that resets
|
||||
// unconditionally).
|
||||
$this->assertStringContainsString('WHERE', self::currentSql());
|
||||
|
||||
$retry = User::get();
|
||||
$this->assertCount(1, $retry, 'retry after failure must reuse the kept where');
|
||||
$this->assertSame('fiona', $retry[0]->username);
|
||||
}
|
||||
}
|
||||
245
tests/Integration/ModelFilterTest.php
Normal file
245
tests/Integration/ModelFilterTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
145
tests/Integration/ModelJoinTest.php
Normal file
145
tests/Integration/ModelJoinTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
130
tests/Integration/ModelSearchTest.php
Normal file
130
tests/Integration/ModelSearchTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
95
tests/Integration/ModelTransactionTest.php
Normal file
95
tests/Integration/ModelTransactionTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
91
tests/Integration/SchemaSmokeTest.php
Normal file
91
tests/Integration/SchemaSmokeTest.php
Normal 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
20
tests/Models/Post.php
Normal 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
26
tests/Models/User.php
Normal 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
136
tests/TestCase.php
Normal 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
0
tests/Unit/.gitkeep
Normal file
57
tests/Unit/BootstrapTest.php
Normal file
57
tests/Unit/BootstrapTest.php
Normal 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
69
tests/Unit/NeuronTest.php
Normal 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
138
tests/Unit/RequestTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Exception;
|
||||
use Libs\Request;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* RequestTest - DuckBrain test harness
|
||||
*
|
||||
* Cubre el modelo de fallo por excepcion del Request: la validacion lanza
|
||||
* con code 422 y el mensaje unico de Validator::message() (con overrides de
|
||||
* messages()/attributes()), en lugar de responder HTTP y hacer exit() como
|
||||
* hacia antes. Que estas pruebas puedan ejecutar expectException ya demuestra
|
||||
* que el constructor no termina el proceso.
|
||||
*/
|
||||
final class RequestTest extends TestCase
|
||||
{
|
||||
private array $serverBackup = [];
|
||||
private array $getBackup = [];
|
||||
private array $postBackup = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->serverBackup = $_SERVER;
|
||||
$this->getBackup = $_GET;
|
||||
$this->postBackup = $_POST;
|
||||
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
$_SERVER['REQUEST_URI'] = '/test';
|
||||
$_SERVER['DOCUMENT_ROOT'] = '/nonexistent-docroot';
|
||||
unset($_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_ACCEPT']);
|
||||
$_GET = [];
|
||||
$_POST = [];
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SERVER = $this->serverBackup;
|
||||
$_GET = $this->getBackup;
|
||||
$_POST = $this->postBackup;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function failedValidationThrowsWithHttp422Code(): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_POST = ['age' => '5'];
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('The age must be at least 18.');
|
||||
$this->expectExceptionCode(422);
|
||||
|
||||
new class extends Request {
|
||||
public function rules(): array
|
||||
{
|
||||
return ['age' => 'required|min:18'];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function messagesOverrideWinsForFailedRule(): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_POST = ['age' => '5'];
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('Way too short');
|
||||
|
||||
new class extends Request {
|
||||
public function rules(): array
|
||||
{
|
||||
return ['age' => 'required|min:18'];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return ['age.min' => 'Way too short'];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function attributesRenameTheFieldInDefaultMessage(): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_POST = ['age' => '5'];
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('The applicant age must be at least 18.');
|
||||
|
||||
new class extends Request {
|
||||
public function rules(): array
|
||||
{
|
||||
return ['age' => 'required|min:18'];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return ['age' => 'applicant age'];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getRulesFailureThrowsToo(): void
|
||||
{
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('The q field is required.');
|
||||
|
||||
new class extends Request {
|
||||
public function getRules(): array
|
||||
{
|
||||
return ['q' => 'required'];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function validDataDoesNotThrow(): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_POST = ['age' => '20'];
|
||||
|
||||
$request = new class extends Request {
|
||||
public function rules(): array
|
||||
{
|
||||
return ['age' => 'required|min:18'];
|
||||
}
|
||||
};
|
||||
|
||||
$this->assertSame('20', $request->post->age);
|
||||
$this->assertSame('/test', $request->path);
|
||||
}
|
||||
}
|
||||
317
tests/Unit/RouterTest.php
Normal file
317
tests/Unit/RouterTest.php
Normal file
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Exception;
|
||||
use Libs\Neuron;
|
||||
use Libs\Request;
|
||||
use Libs\Router;
|
||||
use LogicException;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
use Tests\TestCase;
|
||||
use TypeError;
|
||||
|
||||
/**
|
||||
* RouterTest - DuckBrain test harness
|
||||
*
|
||||
* Cubre la respuesta de error que produce Router::defaultException(): el
|
||||
* status derivado del code de la excepcion con guard de rango 400-599 y la
|
||||
* negociacion por Accept entre representacion JSON y texto plano. Tambien la
|
||||
* frontera de Router::apply(): que toda excepcion de la cadena (middlewares,
|
||||
* callback final, notFound) llegue al $exceptionCallback deteniendo la
|
||||
* ejecucion sin exit() y dejando intacto el flujo normal. Tambien que los
|
||||
* valores por defecto declarados en las propiedades $notFoundCallback y
|
||||
* $exceptionCallback (callables string) se resuelvan de punta a punta a
|
||||
* traves de Synapsis::resolve() dentro de apply().
|
||||
*/
|
||||
final class RouterTest extends TestCase
|
||||
{
|
||||
private array $serverBackup = [];
|
||||
private array $getBackup = [];
|
||||
private array $postBackup = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->serverBackup = $_SERVER;
|
||||
$this->getBackup = $_GET;
|
||||
$this->postBackup = $_POST;
|
||||
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||
unset($_SERVER['HTTP_ACCEPT']);
|
||||
@http_response_code(200); // Ver tearDown: inofensivo mientras no haya un header('HTTP/...') activo.
|
||||
$this->resetRouterState();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$_SERVER = $this->serverBackup;
|
||||
$_GET = $this->getBackup;
|
||||
$_POST = $this->postBackup;
|
||||
// El @ cubre el warning de PHP 8.5 ("...has no effect") que dispara
|
||||
// cualquier http_response_code() posterior a un header('HTTP/...'),
|
||||
// inevitable en el proceso aislado del test de notFound (el valor
|
||||
// se aplica igualmente, como confirma el getter).
|
||||
@http_response_code(200);
|
||||
$this->resetRouterState();
|
||||
}
|
||||
|
||||
private function resetRouterState(): void
|
||||
{
|
||||
foreach (['get', 'post', 'put', 'patch', 'delete'] as $method) {
|
||||
(new ReflectionProperty(Router::class, $method))->setValue(null, []);
|
||||
}
|
||||
Router::$params = new Neuron();
|
||||
Router::$notFoundCallback = 'Libs\Router::defaultNotFound';
|
||||
Router::$exceptionCallback = 'Libs\Router::defaultException';
|
||||
}
|
||||
|
||||
private function render(\Throwable $exception): string
|
||||
{
|
||||
ob_start();
|
||||
Router::defaultException($exception);
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function intCodeInHttpStatusRangeBecomesResponseStatus(): void
|
||||
{
|
||||
$output = $this->render(new Exception('The edad must be at least 18.', 422));
|
||||
|
||||
$this->assertSame(422, http_response_code());
|
||||
$this->assertStringContainsString('The edad must be at least 18.', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function stringSqlStateCodeFallsBackToServerError(): void
|
||||
{
|
||||
$this->render(new PDOException('SQLSTATE[23000]: Integrity constraint violation', '23000'));
|
||||
|
||||
$this->assertSame(500, http_response_code(), 'A string SQLSTATE code must never reach http_response_code');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function intCodeOutOfRangeFallsBackToServerError(): void
|
||||
{
|
||||
$this->render(new Exception('arbitrary code', 7));
|
||||
|
||||
$this->assertSame(500, http_response_code());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function throwableWithoutMeaningfulCodeAnswersServerError(): void
|
||||
{
|
||||
$this->render(new TypeError('must be of the type int'));
|
||||
|
||||
$this->assertSame(500, http_response_code());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function plainTextIsDefaultRepresentation(): void
|
||||
{
|
||||
$output = $this->render(new Exception('boom', 418));
|
||||
|
||||
$this->assertStringStartsWith("boom\n", $output);
|
||||
$this->assertStringContainsString('#0', $output, 'Plain text must include the full trace');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function jsonRepresentationWhenClientAcceptsJson(): void
|
||||
{
|
||||
$_SERVER['HTTP_ACCEPT'] = 'application/json';
|
||||
|
||||
$output = $this->render(new Exception('The e must be a valid email address.', 422));
|
||||
|
||||
$payload = json_decode($output, true);
|
||||
$this->assertIsArray($payload, 'Accept: application/json must produce a JSON body');
|
||||
$this->assertSame(422, http_response_code());
|
||||
$this->assertSame('The e must be a valid email address.', $payload['error']);
|
||||
$this->assertSame('Exception', $payload['exception']);
|
||||
$this->assertArrayHasKey('file', $payload);
|
||||
$this->assertArrayHasKey('line', $payload);
|
||||
$this->assertIsArray($payload['trace']);
|
||||
$this->assertIsString($payload['trace'][0], 'The trace must be serialized as readable lines, not raw frames');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function middlewareExceptionStopsTheRemainingChain(): void
|
||||
{
|
||||
$log = [];
|
||||
$caught = [];
|
||||
Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void {
|
||||
$caught[] = $exception;
|
||||
};
|
||||
|
||||
Router::get('/chain', function () use (&$log): void {
|
||||
$log[] = 'final';
|
||||
});
|
||||
Router::middleware(function () use (&$log): void {
|
||||
$log[] = 'mw-late-registered';
|
||||
});
|
||||
Router::middleware(function () use (&$log): void {
|
||||
throw new Exception('chain break', 422);
|
||||
});
|
||||
Router::middleware(function () use (&$log): void {
|
||||
$log[] = 'mw-first-registered';
|
||||
});
|
||||
|
||||
Router::apply('/chain');
|
||||
|
||||
// Callbacks run in reverse registration order: the last registered middleware
|
||||
// ran first, the thrower stopped everything after it, and apply() returned
|
||||
// normally (this line being reached proves there was no exit()).
|
||||
$this->assertSame(['mw-first-registered'], $log);
|
||||
$this->assertCount(1, $caught, 'The route chain must deliver exactly one exception to the callback');
|
||||
$this->assertSame('chain break', $caught[0]->getMessage());
|
||||
$this->assertSame(422, $caught[0]->getCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function finalCallbackExceptionIsDeliveredToTheExceptionCallback(): void
|
||||
{
|
||||
$caught = [];
|
||||
Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void {
|
||||
$caught[] = get_class($exception) . ': ' . $exception->getMessage();
|
||||
};
|
||||
|
||||
Router::get('/throwing', function (): void {
|
||||
throw new LogicException('controller boom');
|
||||
});
|
||||
|
||||
Router::apply('/throwing');
|
||||
|
||||
$this->assertSame(['LogicException: controller boom'], $caught);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function notFoundCallbackExceptionReachesTheExceptionCallback(): void
|
||||
{
|
||||
$caught = [];
|
||||
Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void {
|
||||
$caught[] = $exception;
|
||||
};
|
||||
Router::$notFoundCallback = function (): void {
|
||||
throw new Exception('not found exploded', 418);
|
||||
};
|
||||
|
||||
Router::apply('/no-such-route');
|
||||
|
||||
$this->assertCount(1, $caught);
|
||||
$this->assertSame('not found exploded', $caught[0]->getMessage());
|
||||
$this->assertSame(418, $caught[0]->getCode());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normalFlowStillPrintsReturnedData(): void
|
||||
{
|
||||
Router::$exceptionCallback = function (): void {
|
||||
$this->fail('The exception callback must not run on a successful request');
|
||||
};
|
||||
|
||||
Router::get('/ok', function (): array {
|
||||
return ['status' => 'ok'];
|
||||
});
|
||||
|
||||
ob_start();
|
||||
Router::apply('/ok');
|
||||
$output = ob_get_clean();
|
||||
|
||||
$this->assertSame('{"status":"ok"}', $output);
|
||||
$this->assertSame(200, http_response_code());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function customOnInvalidExceptionReachesTheExceptionCallbackThroughTheBoundary(): void
|
||||
{
|
||||
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||
$_SERVER['REQUEST_URI'] = '/signup';
|
||||
$_SERVER['DOCUMENT_ROOT'] = '/nonexistent-docroot';
|
||||
$_POST = ['age' => '5'];
|
||||
|
||||
$caught = [];
|
||||
Router::$exceptionCallback = function (\Throwable $exception) use (&$caught): void {
|
||||
$caught[] = $exception;
|
||||
};
|
||||
|
||||
$finalRan = false;
|
||||
Router::post('/signup', function (StrictSignup $request) use (&$finalRan): void {
|
||||
$finalRan = true;
|
||||
});
|
||||
|
||||
Router::apply('/signup');
|
||||
|
||||
// The Request was built by the container for the final callback and its
|
||||
// custom onInvalid() threw during dependency resolution: the custom type
|
||||
// must arrive intact to the callback, and the final callback must not run.
|
||||
$this->assertCount(1, $caught);
|
||||
$this->assertInstanceOf(RequestRejected::class, $caught[0]);
|
||||
$this->assertSame('The age must be at least 18.', $caught[0]->getMessage());
|
||||
$this->assertSame(400, $caught[0]->getCode(), 'The status channel must survive custom exception types');
|
||||
$this->assertFalse($finalRan, 'A Request that throws during DI resolution must stop the route');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function declaredDefaultExceptionCallbackRendersThroughTheBoundary(): void
|
||||
{
|
||||
// Se fuerza el valor declarado en la clase, no el que reasigna
|
||||
// resetRouterState(): es justo el camino property default ->
|
||||
// Synapsis::resolve(string callable) que debe seguir funcionando.
|
||||
Router::$exceptionCallback = (new ReflectionClass(Router::class))
|
||||
->getDefaultProperties()['exceptionCallback'];
|
||||
|
||||
Router::get('/default-exception', function (): void {
|
||||
throw new Exception('rendered by the default handler', 422);
|
||||
});
|
||||
|
||||
ob_start();
|
||||
Router::apply('/default-exception');
|
||||
$output = ob_get_clean();
|
||||
|
||||
$this->assertSame(422, http_response_code(), 'The declared default handler must derive the status from the code');
|
||||
$this->assertStringContainsString('rendered by the default handler', $output);
|
||||
$this->assertStringContainsString('#0', $output, 'The declared default handler must render the plain text trace');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
public function declaredDefaultNotFoundCallbackRendersThe404Body(): void
|
||||
{
|
||||
Router::$notFoundCallback = (new ReflectionClass(Router::class))
|
||||
->getDefaultProperties()['notFoundCallback'];
|
||||
|
||||
ob_start();
|
||||
Router::apply('/no-such-route');
|
||||
$output = ob_get_clean();
|
||||
|
||||
// Proceso aislado: defaultNotFound() emite header('HTTP/1.0 404 ...') y en
|
||||
// PHP 8.5 eso hace que TODO http_response_code() posterior avise, sin forma
|
||||
// de limpiar el estado en el mismo proceso. En CLI el header no toca
|
||||
// http_response_code(), asi que la asercion util es el cuerpo renderizado.
|
||||
$this->assertStringContainsString('Error 404 - Page Not Found', $output);
|
||||
}
|
||||
}
|
||||
|
||||
// phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses
|
||||
/**
|
||||
* Fixtures locales de la prueba de simetría: un tipo de excepcion propio que
|
||||
* un Request hijo lanza desde su onInvalid() reescrito.
|
||||
*/
|
||||
final class RequestRejected extends Exception
|
||||
{
|
||||
}
|
||||
|
||||
final class StrictSignup extends Request
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return ['age' => 'required|min:18'];
|
||||
}
|
||||
|
||||
public function onInvalid(string $error): never
|
||||
{
|
||||
throw new RequestRejected($error, 400);
|
||||
}
|
||||
}
|
||||
85
tests/Unit/SynapsisTest.php
Normal file
85
tests/Unit/SynapsisTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Exception;
|
||||
use Libs\Neuron;
|
||||
use Libs\Synapsis;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SynapsisTest - DuckBrain test harness
|
||||
*
|
||||
* Cubre la inyeccion de argumentos por nombre en resolve(): la clave de
|
||||
* $named gana sobre el default de un opcional y sobre la resolucion DI por
|
||||
* tipo, la coincidencia es por nombre y no por posicion, y las claves
|
||||
* sobrantes se ignoran en silencio (riesgo documentado en design.md).
|
||||
*/
|
||||
final class SynapsisTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function namedValueWinsOverOptionalDefault(): void
|
||||
{
|
||||
$result = Synapsis::resolve(
|
||||
fn ($config = 'default') => $config,
|
||||
['config' => 'forced']
|
||||
);
|
||||
|
||||
$this->assertSame('forced', $result, 'A named argument must take precedence over an optional default');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function namedValueWinsOverTypeBasedResolution(): void
|
||||
{
|
||||
$real = new Exception('the real one', 422);
|
||||
|
||||
$result = Synapsis::resolve(
|
||||
fn (Exception $exception) => $exception,
|
||||
['exception' => $real]
|
||||
);
|
||||
|
||||
$this->assertSame($real, $result, 'The injected instance must be the named one, not a container-built empty Exception');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function namedParametersMatchByNameNotPosition(): void
|
||||
{
|
||||
$real = new Exception('matched by name');
|
||||
|
||||
[$b, $exception] = Synapsis::resolve(
|
||||
fn (Neuron $b, Exception $exception) => [$b, $exception],
|
||||
['exception' => $real]
|
||||
);
|
||||
|
||||
$this->assertInstanceOf(Neuron::class, $b, 'Parameters without a named match must keep resolving through DI');
|
||||
$this->assertSame($real, $exception, 'Position of the named parameter must not matter');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unmatchedNamedKeysAreIgnored(): void
|
||||
{
|
||||
$result = Synapsis::resolve(
|
||||
fn ($x = 'untouched') => $x,
|
||||
['foo' => 1]
|
||||
);
|
||||
|
||||
$this->assertSame('untouched', $result, 'A key matching no parameter must be silently ignored');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function renamedParameterFallsBackToContainerResolution(): void
|
||||
{
|
||||
$real = new Exception('never arrives');
|
||||
|
||||
// The handler renamed its parameter to $e, so the 'exception' key
|
||||
// matches nothing and $e degrades to DI: a fresh empty Exception.
|
||||
$result = Synapsis::resolve(
|
||||
fn (Exception $e) => $e,
|
||||
['exception' => $real]
|
||||
);
|
||||
|
||||
$this->assertNotSame($real, $result, 'A renamed handler parameter must not receive the named value');
|
||||
$this->assertSame('', $result->getMessage(), 'The degradation outcome is a container-built empty Exception');
|
||||
}
|
||||
}
|
||||
192
tests/Unit/ValidatorTest.php
Normal file
192
tests/Unit/ValidatorTest.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?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 enumIsLooseComparison(): void
|
||||
{
|
||||
$this->assertTrue(Validator::checkRule('1', 'enum:1,2,3'));
|
||||
$this->assertFalse(Validator::checkRule('9', '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(['enum', 'a,b'], Validator::parseRule('enum: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.enum: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
56
tests/bootstrap.php
Normal 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';
|
||||
Reference in New Issue
Block a user