first commit.

This commit is contained in:
kj
2026-10-05 15:08:39 -03:00
commit b7799f9fff
93 changed files with 8966 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Tests;
use Libs\Neuron;
use Libs\Router;
use Tests\Support\HttpStubs;
use Tests\Support\Response;
/**
* Base para tests de endpoint: despacha peticiones al router in-process
* (sin servidor HTTP) y expone helpers de aserción.
*
* No incluye helpers de autenticación: Duckbrain no define login por defecto,
* así que simular una sesión es responsabilidad de cada proyecto. Extiende esta
* clase (o crea tu propia subclase) y añade tu helper, p. ej. `loggedInAs()`.
*
* Nota: `#[RunTestsInSeparateProcesses]` NO se hereda en PHPUnit 11, así que
* cada clase concreta de endpoint debe declarar el atributo para aislar el
* estado estático (Synapsis, Router::$params, superglobales, http_response_code).
*/
abstract class EndpointTestCase extends TestCase
{
protected function dispatch(
string $method,
string $path,
array $data = [],
array $headers = []
): Response {
$method = strtoupper($method);
$_SERVER['REQUEST_METHOD'] = $method;
$_SERVER['REQUEST_URI'] = $path;
$_SERVER['HTTP_ACCEPT'] = $headers['Accept'] ?? 'text/html';
$_GET = [];
$_POST = [];
$query = parse_url($path, PHP_URL_QUERY);
if (is_string($query)) {
parse_str($query, $_GET);
}
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
$_POST = $data;
}
foreach ($headers as $name => $value) {
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
$_SERVER[$key] = $value;
}
Router::$params = new Neuron();
HttpStubs::reset();
http_response_code(200);
ob_start();
try {
Router::apply(parse_url($path, PHP_URL_PATH) ?: $path);
} finally {
$body = (string) ob_get_clean();
}
return new Response(
http_response_code(),
$body,
HttpStubs::$headers,
HttpStubs::$cookies
);
}
protected function get(string $path, array $headers = []): Response
{
return $this->dispatch('GET', $path, [], $headers);
}
protected function post(string $path, array $data = [], array $headers = []): Response
{
return $this->dispatch('POST', $path, $data, $headers);
}
protected function patch(string $path, array $data = [], array $headers = []): Response
{
return $this->dispatch('PATCH', $path, $data, $headers);
}
protected function delete(string $path, array $data = [], array $headers = []): Response
{
return $this->dispatch('DELETE', $path, $data, $headers);
}
protected function assertStatus(int $expected, Response $response): void
{
$this->assertSame(
$expected,
$response->status,
"Expected status {$expected}, got {$response->status}. Body: {$response->body}"
);
}
protected function assertRedirect(string $expectedPath, Response $response): void
{
$target = $response->redirectTarget();
$this->assertNotNull($target, 'Expected a redirect header (HX-Redirect or Location).');
$this->assertStringEndsWith($expectedPath, $target);
}
protected function assertHtmxTrigger(string $event, Response $response): void
{
$this->assertSame($event, $response->trigger());
}
}
+55
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;
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Tests\Support;
/**
* Colector estático de headers y cookies emitidos durante los tests.
*
* En el SAPI CLI, `header()` y `setcookie()` son no-op y `headers_list()`
* queda vacío; las funciones de `tests/Support/http_stubs.php` registran
* aquí lo que la aplicación intenta emitir.
*/
class HttpStubs
{
public static array $headers = [];
public static array $cookies = [];
public static function header(string $value, bool $replace = true, int $responseCode = 0): void
{
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $value, $matches)) {
http_response_code((int) $matches[1]);
}
if ($responseCode > 0) {
http_response_code($responseCode);
}
static::$headers[] = $value;
}
public static function setcookie(
string $name,
string $value = '',
int $expires = 0,
string $path = '',
string $domain = '',
bool $secure = false,
bool $httponly = false
): void {
static::$cookies[$name] = $value;
}
public static function reset(): void
{
static::$headers = [];
static::$cookies = [];
}
public static function headerValue(string $prefix): ?string
{
foreach (static::$headers as $header) {
if (stripos($header, $prefix) === 0) {
return substr($header, strlen($prefix));
}
}
return null;
}
public static function hasHeader(string $prefix): bool
{
return static::headerValue($prefix) !== null;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Tests\Support;
/**
* Respuesta capturada de una petición despachada in-process.
*/
class Response
{
public function __construct(
public int $status,
public string $body,
public array $headers = [],
public array $cookies = [],
) {
}
public function header(string $name): ?string
{
foreach ($this->headers as $header) {
if (stripos($header, $name . ':') === 0) {
return trim(substr($header, strlen($name) + 1));
}
}
return null;
}
public function hasHeader(string $name): bool
{
return $this->header($name) !== null;
}
public function redirectTarget(): ?string
{
return $this->header('HX-Redirect') ?? $this->header('Location');
}
public function trigger(): ?string
{
return $this->header('HX-Trigger');
}
}
@@ -0,0 +1,27 @@
<?php
/**
* Stubs por namespace de `header()` y `setcookie()`.
*
* Como el SAPI CLI no emite headers, estas funciones interceptan las llamadas
* no cualificadas de los namespaces de la aplicación y las registran en
* {@see \Tests\Support\HttpStubs}.
*
* La plantilla cubre únicamente el namespace del núcleo (`Libs`), que es el
* único que emite cabeceras en Duckbrain. Si tu proyecto emite headers o
* cookies desde otro namespace (`Models`, `Handlers`, `Controllers\...`, etc.),
* copia el bloque de `Libs` cambiando el nombre del namespace. Las llamadas
* desde namespaces no registrados aquí se ignorarán en los tests.
*/
namespace Libs {
function header(string $header, bool $replace = true, int $response_code = 0): void
{
\Tests\Support\HttpStubs::header($header, $replace, $response_code);
}
function setcookie(string $name, string $value = '', int $expires = 0, string $path = '', string $domain = '', bool $secure = false, bool $httponly = false): void
{
\Tests\Support\HttpStubs::setcookie($name, $value, $expires, $path, $domain, $secure, $httponly);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Tests;
use Commands\Migration;
use Override;
use PHPUnit\Framework\TestCase as PHPunitTestCase;
abstract class TestCase extends PHPunitTestCase
{
private static Migration $db_migration;
#[Override]
public static function setUpBeforeClass(): void
{
// Garantiza que ningún test corra contra la base de datos real.
self::assertSame('sqlite', DB_TYPE, 'Tests must run on SQLite.');
self::assertSame(':memory:', DB_NAME, 'Tests must run on an in-memory database.');
static::$db_migration = new Migration(verbose: false);
static::$db_migration->ensureDatabaseExists();
// fresh() limpia el historial de migraciones y vuelve a aplicarlas: en
// modo aislado, el proceso padre también ejecuta este hook y deja el
// archivo de control poblado, por lo que migrate() se saltaría el
// esquema sobre la base :memory: vacía del hijo.
static::$db_migration->fresh();
}
#[Override]
public static function tearDownAfterClass(): void
{
// Ejecutar el rollback de las migraciones
static::$db_migration->rollback();
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tests\Unit;
use Libs\Database;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* Prueba de humo: verifica que el entorno de pruebas quedó bien instalado.
*/
class BootstrapTest extends TestCase
{
#[Test]
public function coreClassesAreAutoloadable(): void
{
$this->assertTrue(class_exists(Database::class));
$this->assertTrue(class_exists(\Libs\Loader::class));
$this->assertTrue(class_exists(\Libs\Router::class));
}
#[Test]
public function databaseConstantsAreDefined(): void
{
$this->assertNotEmpty(DB_TYPE);
$this->assertNotEmpty(DB_NAME);
}
#[Test]
public function testDatabaseIsReachable(): void
{
$db = Database::getInstance(
type: DB_TYPE,
host: DB_HOST,
name: DB_NAME,
user: DB_USER,
pass: DB_PASS,
);
$this->assertSame(1, (int) $db->query('SELECT 1')->fetchColumn());
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
// Bootstrap para tests - carga el autoload de Composer
use Libs\Loader;
require_once __DIR__ . '/../vendor/autoload.php';
// ============================================
// IMPORTANT: Definir constantes ANTES de cargar config.php
// para sobreescribir la configuración para definir una
// base de datos para sólo los test
// ============================================
define('DB_TYPE', 'sqlite');
define('DB_HOST', 'localhost');
define('DB_NAME', ':memory:');
define('DB_USER', '');
define('DB_PASS', '');
// Incluir config.php con el autoloader
@require_once __DIR__ . '/../autoload.php';
// Router autoload
Loader::load(ROOT_CORE . '/Routers/');
// Other autoloads
Loader::load(ROOT_CORE . '/Autoloads/');
// Stubs de header()/setcookie() por namespace
require_once __DIR__ . '/Support/http_stubs.php';