first commit.
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user