Compare commits
10 Commits
master
...
48d3ed6b3f
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||||
96
Makefile
Normal file
96
Makefile
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# DuckBrain development harness — Makefile
|
||||||
|
#
|
||||||
|
# This file (along with tests/, phpunit.xml, composer.*, .gitignore) lives
|
||||||
|
# ONLY on the develop branch. master is a publish-only vitrine that contains
|
||||||
|
# nothing but the readable artifact: nobody commits to master directly, and
|
||||||
|
# no file outside the WHITELIST below ever reaches it.
|
||||||
|
#
|
||||||
|
# Daily loop:
|
||||||
|
# make test run the unit suite (sqlite :memory:, no containers, no
|
||||||
|
# network; installs dev dependencies first if missing)
|
||||||
|
# ...then develop on develop and commit there as usual.
|
||||||
|
#
|
||||||
|
# Publishing ritual (human only — AI agents must never run it; it commits):
|
||||||
|
# 1. make publish MSG="feat: ..." (MSG optional, defaults to "sync: <date>")
|
||||||
|
# 2. review the new master commit (git log -1 refs/heads/master); if the
|
||||||
|
# generated message needs polish, rewrite it BEFORE pushing, e.g.
|
||||||
|
# from Emacs
|
||||||
|
# 3. git push origin master (manual on purpose: pushing is a
|
||||||
|
# decision, never a side effect)
|
||||||
|
#
|
||||||
|
# How publish builds master — wipe-and-rebuild mirror, no merges, no
|
||||||
|
# cherry-picks (see design decisions behind establish-dev-publish-workflow):
|
||||||
|
# - refuses to run off develop or with a dirty working tree
|
||||||
|
# - checks master out into a disposable worktree under .publish/
|
||||||
|
# (your current working tree is never touched)
|
||||||
|
# - for each WHITELIST path: removes master's copy and checks out develop's,
|
||||||
|
# so deleted/renamed artifact files never survive as ghosts in master
|
||||||
|
# - BLACKLIST then subtracts noise found INSIDE whitelisted paths
|
||||||
|
# - creates the commit only when the artifact actually changed; its body
|
||||||
|
# lists the develop commits (after the last-sync tag) that touched
|
||||||
|
# WHITELIST paths — pure-noise commits are filtered out automatically
|
||||||
|
# - moves the last-sync tag to develop's tip, removes the worktree, and
|
||||||
|
# prints any root entry left out of WHITELIST (add it if it belongs to
|
||||||
|
# the artifact)
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
.PHONY: test publish
|
||||||
|
|
||||||
|
PHPUNIT := vendor/bin/phpunit
|
||||||
|
|
||||||
|
# --- publish configuration -------------------------------------------------
|
||||||
|
WHITELIST := src config.php index.php autoload.php .htaccess readme.org
|
||||||
|
BLACKLIST :=
|
||||||
|
DEVELOP_BRANCH := develop
|
||||||
|
DEVELOP_REF := refs/heads/$(DEVELOP_BRANCH)
|
||||||
|
MASTER_BRANCH := master
|
||||||
|
MASTER_REF := refs/heads/$(MASTER_BRANCH)
|
||||||
|
LAST_SYNC_TAG := last-sync
|
||||||
|
MSG ?= sync: $(shell date +%Y-%m-%d)
|
||||||
|
|
||||||
|
test: $(PHPUNIT)
|
||||||
|
./$(PHPUNIT)
|
||||||
|
|
||||||
|
$(PHPUNIT): composer.json composer.lock
|
||||||
|
composer install --no-interaction
|
||||||
|
|
||||||
|
publish:
|
||||||
|
@if [ "$$(git branch --show-current)" != "$(DEVELOP_BRANCH)" ]; then \
|
||||||
|
echo "ABORT: publish must be invoked from $(DEVELOP_BRANCH) (current: '$$(git branch --show-current)')"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@if [ -n "$$(git status --porcelain)" ]; then \
|
||||||
|
echo "ABORT: working tree is dirty; commit your changes on $(DEVELOP_BRANCH) before publishing:"; \
|
||||||
|
git status --short; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
git worktree remove --force .publish/master 2>/dev/null || true
|
||||||
|
git worktree add --quiet .publish/master $(MASTER_BRANCH)
|
||||||
|
@test "$$(git -C .publish/master symbolic-ref HEAD)" = "$(MASTER_REF)" || { \
|
||||||
|
echo "ABORT: publish worktree HEAD is not attached to $(MASTER_REF)"; \
|
||||||
|
git worktree remove --force .publish/master; exit 1; }
|
||||||
|
@for w in $(WHITELIST); do git -C .publish/master rm -rf --ignore-unmatch --quiet -- $$w; done
|
||||||
|
git -C .publish/master checkout $(DEVELOP_REF) -- $(WHITELIST)
|
||||||
|
@for b in $(BLACKLIST); do git -C .publish/master rm -rf --ignore-unmatch --quiet -- $$b; done
|
||||||
|
git -C .publish/master diff --quiet $(DEVELOP_REF) -- $(WHITELIST)
|
||||||
|
@if git -C .publish/master diff --cached --quiet; then \
|
||||||
|
echo "nothing to publish: master already mirrors $(DEVELOP_BRANCH)"; \
|
||||||
|
else \
|
||||||
|
set -e; \
|
||||||
|
if git rev-parse -q --verify tags/$(LAST_SYNC_TAG) >/dev/null; then \
|
||||||
|
RANGE=$(LAST_SYNC_TAG)..$(DEVELOP_REF); \
|
||||||
|
else \
|
||||||
|
echo "note: no $(LAST_SYNC_TAG) tag yet; summarizing full $(DEVELOP_BRANCH) history"; \
|
||||||
|
RANGE=$(DEVELOP_REF); \
|
||||||
|
fi; \
|
||||||
|
BODY=$$(git log $$RANGE --oneline -- $(WHITELIST)); \
|
||||||
|
{ printf '%s\n' "$(MSG)"; printf '\n'; printf '%s\n' "$$BODY"; } > .publish/msg.txt; \
|
||||||
|
git -C .publish/master commit --quiet -F "$$(pwd)/.publish/msg.txt"; \
|
||||||
|
echo "publish commit created; review/edit it (before pushing) with your git tool of choice"; \
|
||||||
|
fi
|
||||||
|
git tag -f $(LAST_SYNC_TAG) $(DEVELOP_REF) >/dev/null
|
||||||
|
git worktree remove .publish/master
|
||||||
|
@echo "root entries left behind in $(DEVELOP_BRANCH) (add to WHITELIST if they belong to the artifact):"; \
|
||||||
|
comm -23 <(git ls-tree --name-only $(DEVELOP_REF) | sort) \
|
||||||
|
<(for x in $(WHITELIST) $(BLACKLIST); do echo $$x; done | sort) | sed 's/^/ - /'
|
||||||
@@ -12,3 +12,8 @@ spl_autoload_register(function ($className) {
|
|||||||
require_once $file;
|
require_once $file;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
set_exception_handler(function ($exception) {
|
||||||
|
echo "Uncaught exception: " , $exception->getMessage(), "\n";
|
||||||
|
echo $exception->getTraceAsString();
|
||||||
|
});
|
||||||
|
|||||||
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
22
phpunit.xml
Normal file
22
phpunit.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="tests/bootstrap.php"
|
||||||
|
colors="true"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
executionOrder="depends,defects"
|
||||||
|
requireCoverageMetadata="false"
|
||||||
|
beStrictAboutOutputDuringTests="true"
|
||||||
|
failOnRisky="true"
|
||||||
|
failOnWarning="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory suffix=".php">src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
@@ -176,10 +176,6 @@ class Model
|
|||||||
|
|
||||||
$vars = json_encode(static::$dbQueryVariables);
|
$vars = json_encode(static::$dbQueryVariables);
|
||||||
|
|
||||||
if ($resetQuery) {
|
|
||||||
static::resetQuery();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
"\nError at query to database.\n" .
|
"\nError at query to database.\n" .
|
||||||
"Query: $query\n" .
|
"Query: $query\n" .
|
||||||
@@ -1012,9 +1008,7 @@ class Model
|
|||||||
public static function orderBy(string $value, string $order = 'ASC'): static
|
public static function orderBy(string $value, string $order = 'ASC'): static
|
||||||
{
|
{
|
||||||
if ($value == "RAND") {
|
if ($value == "RAND") {
|
||||||
$random = static::db()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'mysql' ? 'RAND()' : 'RANDOM()';
|
static::$dbQuery['orderBy'] = 'RAND()';
|
||||||
static::$dbQuery['orderBy'] = $random;
|
|
||||||
|
|
||||||
return new static();
|
return new static();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace Libs;
|
namespace Libs;
|
||||||
|
|
||||||
use Exception;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request - DuckBrain
|
* Request - DuckBrain
|
||||||
*
|
*
|
||||||
@@ -61,20 +59,17 @@ class Request extends Neuron
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run configured validations
|
// Run configured validations
|
||||||
$this->validate();
|
if (!$this->validate()) {
|
||||||
|
exit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the configured validation.
|
* Starts the configured validation.
|
||||||
*
|
*
|
||||||
* On failure the single error message is built with Validator::message()
|
* @return bool
|
||||||
* and handed to onInvalid(), which throws by default: failures travel up
|
|
||||||
* as exceptions instead of answering HTTP here.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
* @throws Exception When a configured rule fails.
|
|
||||||
*/
|
*/
|
||||||
public function validate(): void
|
public function validate(): bool
|
||||||
{
|
{
|
||||||
$actual = match ($_SERVER['REQUEST_METHOD']) {
|
$actual = match ($_SERVER['REQUEST_METHOD']) {
|
||||||
'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])},
|
'POST', 'PUT', 'PATCH', 'DELETE' => $this->{strtolower($_SERVER['REQUEST_METHOD'])},
|
||||||
@@ -93,7 +88,7 @@ class Request extends Neuron
|
|||||||
Validator::validateList(static::getRules(), $this->get) &&
|
Validator::validateList(static::getRules(), $this->get) &&
|
||||||
Validator::validateList(static::rules(), $body)
|
Validator::validateList(static::rules(), $body)
|
||||||
) {
|
) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$error = Validator::message(
|
$error = Validator::message(
|
||||||
@@ -103,6 +98,7 @@ class Request extends Neuron
|
|||||||
);
|
);
|
||||||
|
|
||||||
static::onInvalid($error);
|
static::onInvalid($error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,20 +157,27 @@ class Request extends Neuron
|
|||||||
/**
|
/**
|
||||||
* Function to execute when an invalid value has been detected.
|
* Function to execute when an invalid value has been detected.
|
||||||
*
|
*
|
||||||
* The default implementation always throws a generic \Exception carrying
|
* Always answers with a single error and HTTP 422. The representation is
|
||||||
* the single error message and HTTP 422 as its code; the framework's
|
* negotiated from the request's Accept header: JSON when the client asks
|
||||||
* exception boundary (Router::apply) renders the response. Override it to
|
* for application/json, plain text otherwise.
|
||||||
* throw a more specific exception type instead. The never return type is
|
|
||||||
* the contract: an override that returned would let the request continue
|
|
||||||
* with invalid data, so PHP rejects such an override at compile time.
|
|
||||||
*
|
*
|
||||||
* @param string $error
|
* @param string $error
|
||||||
*
|
*
|
||||||
* @return never
|
* @return void
|
||||||
* @throws Exception
|
|
||||||
*/
|
*/
|
||||||
public function onInvalid(string $error): never
|
public function onInvalid(string $error): void
|
||||||
{
|
{
|
||||||
throw new Exception($error, 422);
|
http_response_code(422);
|
||||||
|
|
||||||
|
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||||
|
|
||||||
|
if (str_contains($accept, 'application/json')) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
print(json_encode(['error' => $error]));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
print($error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,54 +64,6 @@ class Router
|
|||||||
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Page Not Found</h2>';
|
echo '<h2 style="text-align: center;margin: 25px 0px;">Error 404 - Page Not Found</h2>';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The callback function to be executed when the router's boundary
|
|
||||||
* catches an exception thrown anywhere in the matched route chain.
|
|
||||||
* It receives the exception as a named argument: the handler must
|
|
||||||
* declare its parameter as $exception (typeable as \Throwable).
|
|
||||||
*
|
|
||||||
* @var callable $exceptionCallback
|
|
||||||
*/
|
|
||||||
public static $exceptionCallback = 'Libs\Router::defaultException';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default callback function for exception responses.
|
|
||||||
*
|
|
||||||
* The HTTP status comes from the exception's code only when it is an
|
|
||||||
* integer in the 400-599 range; anything else (0, arbitrary codes, the
|
|
||||||
* SQLSTATE string carried by PDOException) answers 500. The body is
|
|
||||||
* negotiated from the request's Accept header: JSON when the client asks
|
|
||||||
* for application/json, plain text otherwise. The trace is serialized
|
|
||||||
* from getTraceAsString() because json_encode() of a Throwable yields an
|
|
||||||
* empty object: its properties are protected.
|
|
||||||
*
|
|
||||||
* @param \Throwable $exception
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public static function defaultException(\Throwable $exception): void
|
|
||||||
{
|
|
||||||
$code = $exception->getCode();
|
|
||||||
http_response_code(is_int($code) && $code >= 400 && $code <= 599 ? $code : 500);
|
|
||||||
|
|
||||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
|
||||||
|
|
||||||
if (str_contains($accept, 'application/json')) {
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
print(json_encode([
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
'exception' => get_class($exception),
|
|
||||||
'file' => $exception->getFile(),
|
|
||||||
'line' => $exception->getLine(),
|
|
||||||
'trace' => explode(PHP_EOL, $exception->getTraceAsString()),
|
|
||||||
]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print($exception->getMessage() . PHP_EOL . $exception->getTraceAsString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* __construct
|
* __construct
|
||||||
*/
|
*/
|
||||||
@@ -184,6 +136,7 @@ class Router
|
|||||||
public static function redirect(string $path): void
|
public static function redirect(string $path): void
|
||||||
{
|
{
|
||||||
header('Location: ' . static::basePath() . ltrim($path, '/'));
|
header('Location: ' . static::basePath() . ltrim($path, '/'));
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -410,19 +363,12 @@ class Router
|
|||||||
/**
|
/**
|
||||||
* Applies the route configuration.
|
* Applies the route configuration.
|
||||||
*
|
*
|
||||||
* This method is the framework's error boundary: any \Throwable thrown
|
|
||||||
* while running the matched route's callback chain, while printing the
|
|
||||||
* returned data, or while resolving the not-found callback is caught
|
|
||||||
* here and rendered through $exceptionCallback. A failing handler is
|
|
||||||
* deliberately left uncaught (double failure falls back to PHP itself).
|
|
||||||
*
|
|
||||||
* @param string|null $path (optional) Path to use. If not defined, it detects the current path.
|
* @param string|null $path (optional) Path to use. If not defined, it detects the current path.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function apply(?string $path = null): void
|
public static function apply(?string $path = null): void
|
||||||
{
|
{
|
||||||
try {
|
|
||||||
$path = $path ?? static::currentPath();
|
$path = $path ?? static::currentPath();
|
||||||
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
|
$routers = match ($_SERVER['REQUEST_METHOD']) { // Selects an array of routers based on the method
|
||||||
'POST' => static::$post,
|
'POST' => static::$post,
|
||||||
@@ -462,8 +408,5 @@ class Router
|
|||||||
|
|
||||||
// If no router matches, call $notFoundCallBack
|
// If no router matches, call $notFoundCallBack
|
||||||
Synapsis::resolve(static::$notFoundCallback);
|
Synapsis::resolve(static::$notFoundCallback);
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
Synapsis::resolve(static::$exceptionCallback, ['exception' => $exception]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,13 +66,11 @@ class Synapsis
|
|||||||
* Resolves and injects dependencies for a callable and returns its result.
|
* Resolves and injects dependencies for a callable and returns its result.
|
||||||
*
|
*
|
||||||
* @param callable $action
|
* @param callable $action
|
||||||
* @param array<string, mixed> $named Associative array of values injected into the callable's
|
|
||||||
* parameters by exact name match. Consumed in resolveParameterValues().
|
|
||||||
*
|
*
|
||||||
* @return mixed
|
* @return mixed
|
||||||
* @throws Exception If an unhandled callable type is provided.
|
* @throws Exception If an unhandled callable type is provided.
|
||||||
*/
|
*/
|
||||||
public static function resolve(callable $action, array $named = []): mixed
|
public static function resolve(callable $action): mixed
|
||||||
{
|
{
|
||||||
if ($action instanceof Closure) { // If it's an anonymous function
|
if ($action instanceof Closure) { // If it's an anonymous function
|
||||||
$reflectionCallback = new ReflectionFunction($action);
|
$reflectionCallback = new ReflectionFunction($action);
|
||||||
@@ -92,7 +90,7 @@ class Synapsis
|
|||||||
// Get the parameters
|
// Get the parameters
|
||||||
return call_user_func_array(
|
return call_user_func_array(
|
||||||
$action,
|
$action,
|
||||||
static::resolveParameterValues($reflectionCallback->getParameters(), $named)
|
static::resolveParameterValues($reflectionCallback->getParameters())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,15 +132,11 @@ class Synapsis
|
|||||||
* Resolves parameter values by injecting dependencies.
|
* Resolves parameter values by injecting dependencies.
|
||||||
*
|
*
|
||||||
* @param array<ReflectionParameter> $parameters
|
* @param array<ReflectionParameter> $parameters
|
||||||
* @param array<string, mixed> $named
|
|
||||||
* Values injected into parameters whose name matches the key,
|
|
||||||
* taking precedence over optional defaults and DI resolution.
|
|
||||||
* Keys matching no parameter are ignored.
|
|
||||||
*
|
*
|
||||||
* @return array<mixed>
|
* @return array<mixed>
|
||||||
* @throws Exception If a primitive parameter does not have a default value.
|
* @throws Exception If a primitive parameter does not have a default value.
|
||||||
*/
|
*/
|
||||||
public static function resolveParameterValues(array $parameters, array $named = []): array
|
public static function resolveParameterValues(array $parameters): array
|
||||||
{
|
{
|
||||||
$values = [];
|
$values = [];
|
||||||
foreach ($parameters as $parameter) {
|
foreach ($parameters as $parameter) {
|
||||||
@@ -150,11 +144,6 @@ class Synapsis
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (array_key_exists($parameter->getName(), $named)) { // Named values win over defaults and DI
|
|
||||||
$values[] = $named[$parameter->getName()];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($parameter->isOptional()) { // Always use the default value first
|
if ($parameter->isOptional()) { // Always use the default value first
|
||||||
$values[] = $parameter->getDefaultValue();
|
$values[] = $parameter->getDefaultValue();
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use Exception;
|
|||||||
* | url | | Must be a valid URL |
|
* | url | | Must be a valid URL |
|
||||||
* | date | | Must be a parseable date |
|
* | date | | Must be a parseable date |
|
||||||
* | regex | :pattern | Must match a PCRE pattern (with delimiters) |
|
* | regex | :pattern | Must match a PCRE pattern (with delimiters) |
|
||||||
* | in | :a,b,c | Must be one of the listed values |
|
* | enum | :a,b,c | Must be one of the listed values |
|
||||||
* | min | :n | Length/number/count min (files: KB) |
|
* | min | :n | Length/number/count min (files: KB) |
|
||||||
* | max | :n | Length/number/count max (files: KB) |
|
* | max | :n | Length/number/count max (files: KB) |
|
||||||
* | between | :min,:max | Value within range (files: KB per upload) |
|
* | between | :min,:max | Value within range (files: KB per upload) |
|
||||||
@@ -105,7 +105,7 @@ class Validator
|
|||||||
* | :size | the size rule value |
|
* | :size | the size rule value |
|
||||||
* | :value | the required_if expected value |
|
* | :value | the required_if expected value |
|
||||||
* | :other | the required_if companion field / confirmation |
|
* | :other | the required_if companion field / confirmation |
|
||||||
* | :values | a comma list (in, mimes, required_with) |
|
* | :values | a comma list (enum, mimes, required_with) |
|
||||||
* |----------+----------------------------------------------------|
|
* |----------+----------------------------------------------------|
|
||||||
*
|
*
|
||||||
* @var array<string,string>
|
* @var array<string,string>
|
||||||
@@ -122,7 +122,7 @@ class Validator
|
|||||||
'array' => 'The :attribute must be an array.',
|
'array' => 'The :attribute must be an array.',
|
||||||
'email' => 'The :attribute must be a valid email address.',
|
'email' => 'The :attribute must be a valid email address.',
|
||||||
'url' => 'The :attribute must be a valid URL.',
|
'url' => 'The :attribute must be a valid URL.',
|
||||||
'in' => 'The selected :attribute is invalid. Allowed: :values.',
|
'enum' => 'The selected :attribute is invalid. Allowed: :values.',
|
||||||
'min' => 'The :attribute must be at least :min.',
|
'min' => 'The :attribute must be at least :min.',
|
||||||
'max' => 'The :attribute must not be greater than :max.',
|
'max' => 'The :attribute must not be greater than :max.',
|
||||||
'between' => 'The :attribute must be between :min and :max.',
|
'between' => 'The :attribute must be between :min and :max.',
|
||||||
@@ -243,7 +243,7 @@ class Validator
|
|||||||
case 'size':
|
case 'size':
|
||||||
$replace[':size'] = $args[0] ?? '';
|
$replace[':size'] = $args[0] ?? '';
|
||||||
break;
|
break;
|
||||||
case 'in':
|
case 'enum':
|
||||||
case 'mimes':
|
case 'mimes':
|
||||||
case 'required_with':
|
case 'required_with':
|
||||||
$replace[':values'] = implode(', ', $args);
|
$replace[':values'] = implode(', ', $args);
|
||||||
@@ -305,7 +305,7 @@ class Validator
|
|||||||
* arguments legitimately contain ':' or ',' - such as regex or mimes -
|
* arguments legitimately contain ':' or ',' - such as regex or mimes -
|
||||||
* are preserved intact. The caller decides how to split the arguments.
|
* are preserved intact. The caller decides how to split the arguments.
|
||||||
*
|
*
|
||||||
* @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "in:a,b".
|
* @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "enum:a,b".
|
||||||
*
|
*
|
||||||
* @return array A two element array: [name, rawArguments]. When the rule
|
* @return array A two element array: [name, rawArguments]. When the rule
|
||||||
* has no parameters, rawArguments is an empty string.
|
* has no parameters, rawArguments is an empty string.
|
||||||
@@ -419,7 +419,7 @@ class Validator
|
|||||||
* Splits a rule's raw argument string into the arguments to pass to the
|
* Splits a rule's raw argument string into the arguments to pass to the
|
||||||
* rule's method (the subject is not included).
|
* rule's method (the subject is not included).
|
||||||
*
|
*
|
||||||
* Most rules take a comma-separated list of values (e.g. "in:a,b").
|
* Most rules take a comma-separated list of values (e.g. "enum:a,b").
|
||||||
* A few rules receive an argument that must be kept intact because it can
|
* A few rules receive an argument that must be kept intact because it can
|
||||||
* legitimately contain commas or colons: "regex" (a pattern) and "not"
|
* legitimately contain commas or colons: "regex" (a pattern) and "not"
|
||||||
* (a sub-rule that is itself parsed recursively). An empty argument string
|
* (a sub-rule that is itself parsed recursively). An empty argument string
|
||||||
@@ -810,7 +810,7 @@ class Validator
|
|||||||
*
|
*
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function in(mixed $subject, ...$values): bool
|
public static function enum(mixed $subject, ...$values): bool
|
||||||
{
|
{
|
||||||
return in_array($subject, $values);
|
return in_array($subject, $values);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
61
tests/TestCase.php
Normal file
61
tests/TestCase.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Libs\Database;
|
||||||
|
use PDO;
|
||||||
|
use PHPUnit\Framework\TestCase as FrameworkTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TestCase - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Base class for the framework's own tests. Deliberately lightweight: this
|
||||||
|
* repo has no migrations/ nor duckbrain-commands, so each test defines its
|
||||||
|
* schema with raw DDL through createTable(), and tables registered there are
|
||||||
|
* dropped automatically after the class finishes.
|
||||||
|
*/
|
||||||
|
abstract class TestCase extends FrameworkTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string> Tables created through createTable() for this class.
|
||||||
|
*/
|
||||||
|
private static array $tables = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the PDO connection to the in-memory test database.
|
||||||
|
*
|
||||||
|
* @return PDO
|
||||||
|
*/
|
||||||
|
protected static function db(): PDO
|
||||||
|
{
|
||||||
|
return Database::getInstance(DB_TYPE, DB_HOST, DB_NAME, DB_USER, DB_PASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a table in the test database and registers it for cleanup.
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* Table name.
|
||||||
|
*
|
||||||
|
* @param string $columns
|
||||||
|
* Raw column definition, as accepted by the test
|
||||||
|
* engine (sqlite): "id INTEGER PRIMARY KEY, x TEXT".
|
||||||
|
*/
|
||||||
|
protected static function createTable(string $name, string $columns): void
|
||||||
|
{
|
||||||
|
static::db()->exec("CREATE TABLE IF NOT EXISTS {$name} ({$columns})");
|
||||||
|
static::$tables[] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops every table registered through createTable() during this class.
|
||||||
|
*/
|
||||||
|
public static function tearDownAfterClass(): void
|
||||||
|
{
|
||||||
|
foreach (array_unique(static::$tables) as $table) {
|
||||||
|
static::db()->exec("DROP TABLE IF EXISTS {$table}");
|
||||||
|
}
|
||||||
|
|
||||||
|
static::$tables = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
0
tests/Unit/.gitkeep
Normal file
0
tests/Unit/.gitkeep
Normal file
51
tests/Unit/BootstrapTest.php
Normal file
51
tests/Unit/BootstrapTest.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Libs\Neuron;
|
||||||
|
use PDO;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BootstrapTest - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Prueba de humo de la infraestructura: autoload de composer + framework,
|
||||||
|
* constantes de la DB de prueba y conexión PDO realmente utilizable. Si
|
||||||
|
* algo del entorno está mal, esta prueba falla primero con el diagnóstico.
|
||||||
|
*/
|
||||||
|
final class BootstrapTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testFrameworkClassesAreAutoloaded(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(
|
||||||
|
class_exists(Neuron::class),
|
||||||
|
'MISSING AUTOLOAD: autoload.php did not resolve Libs\Neuron; check ROOT_CORE and run from the project root'
|
||||||
|
);
|
||||||
|
|
||||||
|
$neuron = new Neuron(['key' => 'value']);
|
||||||
|
$this->assertSame('value', $neuron->key);
|
||||||
|
$this->assertNull($neuron->doesNotExist);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDatabaseConstantsPointToInMemorySqlite(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
'sqlite',
|
||||||
|
DB_TYPE,
|
||||||
|
'MISSING OVERRIDE: DB_TYPE comes from config.php; the bootstrap must define the DB_* constants BEFORE requiring autoload.php'
|
||||||
|
);
|
||||||
|
$this->assertSame(':memory:', DB_NAME, 'DB_NAME should be :memory: so tests never touch disks or servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTestDatabaseIsUsable(): void
|
||||||
|
{
|
||||||
|
$db = self::db();
|
||||||
|
$this->assertInstanceOf(PDO::class, $db, 'Could not obtain the test PDO connection');
|
||||||
|
$this->assertSame($db, self::db(), 'Database::getInstance should return the same singleton');
|
||||||
|
|
||||||
|
$db->exec('CREATE TABLE smoke (id INTEGER PRIMARY KEY, v TEXT)');
|
||||||
|
$db->prepare('INSERT INTO smoke (v) VALUES (?)')->execute(['quack']);
|
||||||
|
$this->assertSame('quack', $db->query('SELECT v FROM smoke')->fetchColumn());
|
||||||
|
$db->exec('DROP TABLE smoke');
|
||||||
|
}
|
||||||
|
}
|
||||||
59
tests/Unit/NeuronTest.php
Normal file
59
tests/Unit/NeuronTest.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Libs\Neuron;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NeuronTest - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Regression net para el contenedor de valores del core: construcción
|
||||||
|
* desde array/objeto, propiedades dinámicas y null en inexistentes.
|
||||||
|
*/
|
||||||
|
final class NeuronTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testConstructFromAssociativeArray(): void
|
||||||
|
{
|
||||||
|
$n = new Neuron(['username' => 'kj', 'level' => 3]);
|
||||||
|
|
||||||
|
$this->assertSame('kj', $n->username);
|
||||||
|
$this->assertSame(3, $n->level);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstructFromObjectCopiesPublicProperties(): void
|
||||||
|
{
|
||||||
|
$source = new \stdClass();
|
||||||
|
$source->id = 7;
|
||||||
|
$source->email = 'kj@example.com';
|
||||||
|
|
||||||
|
$n = new Neuron($source);
|
||||||
|
|
||||||
|
$this->assertSame(7, $n->id);
|
||||||
|
$this->assertSame('kj@example.com', $n->email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUndefinedPropertyIsNullWithoutNotice(): void
|
||||||
|
{
|
||||||
|
$n = new Neuron();
|
||||||
|
|
||||||
|
$this->assertNull($n->thisDoesNotExist);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDynamicPropertiesCanBeAssignedAndRead(): void
|
||||||
|
{
|
||||||
|
$n = new Neuron();
|
||||||
|
$n->fresh = ['a', 'b'];
|
||||||
|
|
||||||
|
$this->assertSame(['a', 'b'], $n->fresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNestedValuesArePreservedVerbatim(): void
|
||||||
|
{
|
||||||
|
$payload = ['meta' => ['tags' => ['x', 'y'], 'n' => null]];
|
||||||
|
$n = new Neuron($payload);
|
||||||
|
|
||||||
|
$this->assertSame($payload['meta'], $n->meta);
|
||||||
|
$this->assertNull($n->meta['n']);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
tests/Unit/ValidatorTest.php
Normal file
166
tests/Unit/ValidatorTest.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use Libs\Neuron;
|
||||||
|
use Libs\Validator;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ValidatorTest - DuckBrain test harness
|
||||||
|
*
|
||||||
|
* Regression net de las reglas escalares, el batch validateList()
|
||||||
|
* (paro en el primer fallo + $lastFailed) y message(). Las reglas de
|
||||||
|
* archivo (file/image/mimes) requieren fixtures de $_FILES y se dejan
|
||||||
|
* para la suite multi-motor de integración.
|
||||||
|
*/
|
||||||
|
final class ValidatorTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
Validator::$lastFailed = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function scalarRuleProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'valid email' => ['email', 'kj@duckbrain.dev', true],
|
||||||
|
'invalid email' => ['email', 'not-an-email', false],
|
||||||
|
'valid url' => ['url', 'https://kj2.me', true],
|
||||||
|
'invalid url' => ['url', 'kj2.me', false],
|
||||||
|
'int from string' => ['int', '42', true],
|
||||||
|
'int with decimals' => ['int', '42.5', false],
|
||||||
|
'float' => ['float', '3.14', true],
|
||||||
|
'number e-notation' => ['number', '1e3', true],
|
||||||
|
'bool yes' => ['bool', 'yes', true],
|
||||||
|
'bool garbage' => ['bool', 'quizas', false],
|
||||||
|
'strict string' => ['string', '42', true],
|
||||||
|
'string rejects int' => ['string', 42, false],
|
||||||
|
'array' => ['array', [1], true],
|
||||||
|
'array rejects string' => ['array', 'x', false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('scalarRuleProvider')]
|
||||||
|
public function testScalarRules(string $rule, mixed $value, bool $expected): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expected, Validator::checkRule($value, $rule), "rule {$rule} with " . var_export($value, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExistsAndRequiredDisagreeOnEmptyString(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('', 'exists'));
|
||||||
|
$this->assertFalse(Validator::checkRule('', 'required'));
|
||||||
|
$this->assertFalse(Validator::checkRule(null, 'exists'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSizeRulesMeasureStringNumberAndArray(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('abc', 'min:3'));
|
||||||
|
$this->assertFalse(Validator::checkRule('abc', 'min:4'));
|
||||||
|
$this->assertTrue(Validator::checkRule(10, 'max:10'));
|
||||||
|
$this->assertTrue(Validator::checkRule([1, 2, 3], 'between:2,3'));
|
||||||
|
$this->assertTrue(Validator::checkRule('hey', 'size:3'));
|
||||||
|
$this->assertFalse(Validator::checkRule('hey', 'size:4'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegexKeepsColonsAndCommasInsidePattern(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('a,b', 'regex:/^a,b$/'));
|
||||||
|
$this->assertFalse(Validator::checkRule('a;b', 'regex:/^a,b$/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnumIsLooseComparison(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('1', 'enum:1,2,3'));
|
||||||
|
$this->assertFalse(Validator::checkRule('9', 'enum:1,2,3'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNotNegatesNextRule(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(Validator::checkRule('3.5', 'not:int'));
|
||||||
|
$this->assertFalse(Validator::checkRule('42', 'not:int'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParseRule(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(['required', ''], Validator::parseRule('required'));
|
||||||
|
$this->assertSame(['enum', 'a,b'], Validator::parseRule('enum:a,b'));
|
||||||
|
$this->assertSame(['regex', '/^a,b$/'], Validator::parseRule('regex:/^a,b$/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateListPassesFullyValidBatch(): void
|
||||||
|
{
|
||||||
|
$data = new Neuron(['username' => 'kj', 'email' => 'kj@duckbrain.dev']);
|
||||||
|
|
||||||
|
$this->assertTrue(Validator::validateList(
|
||||||
|
['username' => 'required|string|min:2', 'email' => 'required|email'],
|
||||||
|
$data
|
||||||
|
));
|
||||||
|
$this->assertSame('', Validator::$lastFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateListStopsAtFirstFailure(): void
|
||||||
|
{
|
||||||
|
$data = new Neuron(['email' => 'not-an-email', 'name' => null]);
|
||||||
|
|
||||||
|
$this->assertFalse(Validator::validateList(
|
||||||
|
['email' => 'required|email', 'name' => 'required'],
|
||||||
|
$data
|
||||||
|
));
|
||||||
|
$this->assertSame('email.email', Validator::$lastFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateListNullableSkipsRestWhenEmpty(): void
|
||||||
|
{
|
||||||
|
$rules = ['bio' => 'nullable|min:10'];
|
||||||
|
|
||||||
|
$this->assertTrue(Validator::validateList($rules, new Neuron(['bio' => null])));
|
||||||
|
$this->assertFalse(Validator::validateList($rules, new Neuron(['bio' => 'too-short'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateListConfirmedUsesSiblingField(): void
|
||||||
|
{
|
||||||
|
$rules = ['password' => 'required|confirmed'];
|
||||||
|
|
||||||
|
$ok = new Neuron(['password' => 'secret', 'password_confirmation' => 'secret']);
|
||||||
|
$bad = new Neuron(['password' => 'secret', 'password_confirmation' => 'different']);
|
||||||
|
|
||||||
|
$this->assertTrue(Validator::validateList($rules, $ok));
|
||||||
|
$this->assertFalse(Validator::validateList($rules, $bad));
|
||||||
|
$this->assertSame('password.confirmed', Validator::$lastFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMessageBuildsHumanTextFromLastFailed(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
'The email must be a valid email address.',
|
||||||
|
Validator::message('email.email')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'The age must be between 5 and 10.',
|
||||||
|
Validator::message('age.between:5,10')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'The selected status is invalid. Allowed: a, b, c.',
|
||||||
|
Validator::message('status.enum:a,b,c')
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'The reason field is required when mode is other.',
|
||||||
|
Validator::message('reason.required_if:mode,other')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMessageRespectsAttributesAndOverrides(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(
|
||||||
|
'The user name field is required.',
|
||||||
|
Validator::message('username.required', [], ['username' => 'user name'])
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'custom',
|
||||||
|
Validator::message('username.min:3', ['username.min:3' => 'custom'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tests/bootstrap.php
Normal file
16
tests/bootstrap.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Test bootstrap for the DuckBrain development harness.
|
||||||
|
// Run PHPUnit from the project root (autoload.php resolves config.php via cwd).
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Define DB constants BEFORE config.php so the test database wins over the
|
||||||
|
// real configuration (config.php's define() warnings are suppressed by @).
|
||||||
|
define('DB_TYPE', 'sqlite');
|
||||||
|
define('DB_HOST', 'localhost');
|
||||||
|
define('DB_NAME', ':memory:');
|
||||||
|
define('DB_USER', '');
|
||||||
|
define('DB_PASS', '');
|
||||||
|
|
||||||
|
@require_once __DIR__ . '/../autoload.php';
|
||||||
Reference in New Issue
Block a user