From 8c93b26ec0911b3b68041cba155f675521f074dc Mon Sep 17 00:00:00 2001 From: Imugiii Date: Mon, 15 Jun 2026 14:13:49 +0000 Subject: [PATCH] feat(core): from-scratch PHP MVC skeleton (autoloader/config/PDO/router/front controller) + PHPUnit/PHPStan + composer-less CI --- .forgejo/workflows/ci.yml | 42 +++++--- .gitignore | 5 +- phpstan.neon | 22 ++++ phpunit.xml | 26 +++++ src/Controllers/HealthController.php | 49 +++++++++ src/Controllers/HomeController.php | 28 ++++++ src/Core/Autoloader.php | 43 ++++++++ src/Core/Config.php | 80 +++++++++++++++ src/Core/Controller.php | 68 +++++++++++++ src/Core/Database.php | 94 +++++++++++++++++ src/Core/Request.php | 145 +++++++++++++++++++++++++++ src/Core/Response.php | 96 ++++++++++++++++++ src/Core/Router.php | 104 +++++++++++++++++++ src/Views/home.php | 25 +++++ src/Views/layout.php | 32 ++++++ src/public/admin/index.php | 75 ++++++++------ tests/Unit/ConfigTest.php | 125 +++++++++++++++++++++++ tests/Unit/RouterTest.php | 123 +++++++++++++++++++++++ tests/bootstrap.php | 13 +++ 19 files changed, 1151 insertions(+), 44 deletions(-) create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/Controllers/HealthController.php create mode 100644 src/Controllers/HomeController.php create mode 100644 src/Core/Autoloader.php create mode 100644 src/Core/Config.php create mode 100644 src/Core/Controller.php create mode 100644 src/Core/Database.php create mode 100644 src/Core/Request.php create mode 100644 src/Core/Response.php create mode 100644 src/Core/Router.php create mode 100644 src/Views/home.php create mode 100644 src/Views/layout.php create mode 100644 tests/Unit/ConfigTest.php create mode 100644 tests/Unit/RouterTest.php create mode 100644 tests/bootstrap.php diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index fa2da16..f16db0c 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -58,30 +58,42 @@ jobs: static-tests: runs-on: docker + # COMPOSER-LESS (decision 4 / 5, PROJECT_CONTEXT.md) : PHPStan et PHPUnit + # tournent depuis leur .phar autonome telecharge ici, jamais via Composer. + # Versions epinglees pour des CI reproductibles (pas de "latest"). + env: + PHPUNIT_VERSION: "11.5.2" + PHPSTAN_VERSION: "1.12.27" steps: - uses: actions/checkout@v4 - name: PHPStan (guarded) run: | - if [ -f composer.json ] && [ -f phpstan.neon ]; then - echo "phpstan config detected - running" - apt-get update -qq && apt-get install -y -qq php-cli unzip git >/dev/null - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - composer install --no-interaction --no-progress - vendor/bin/phpstan analyse --no-progress - else - echo "PHPStan skipped: no composer.json/phpstan.neon yet (activates in P2)" + set -eu + if [ ! -f phpstan.neon ]; then + echo "PHPStan skipped: no phpstan.neon yet (activates in P2)" + exit 0 fi + echo "phpstan.neon detected - running PHPStan ${PHPSTAN_VERSION} via .phar" + apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null + # PHPUnit phar present pour que phpstan.neon (scanDirectories phar://phpunit.phar) + # resolve les symboles PHPUnit\Framework\* utilises sous tests/. + curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar + curl -sSL "https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar" -o phpstan.phar + php phpstan.phar --version + # memory_limit=-1 : l'analyse parallele depasse les 128M par defaut du php-cli. + php -d memory_limit=-1 phpstan.phar analyse --no-progress --error-format=raw - name: PHPUnit (guarded) run: | - if [ -d tests ] && [ -f phpunit.xml ]; then - echo "phpunit config detected - running" - apt-get update -qq && apt-get install -y -qq php-cli >/dev/null - if [ -f vendor/bin/phpunit ]; then vendor/bin/phpunit; \ - elif [ -f phpunit.phar ]; then php phpunit.phar; \ - else echo "phpunit binary missing despite config" && exit 1; fi - else + set -eu + if [ ! -d tests ] || [ ! -f phpunit.xml ]; then echo "PHPUnit skipped: no tests/ + phpunit.xml yet (activates in P2)" + exit 0 fi + echo "phpunit.xml + tests/ detected - running PHPUnit ${PHPUNIT_VERSION} via .phar" + apt-get update -qq && apt-get install -y -qq php-cli curl ca-certificates >/dev/null + curl -sSL "https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar" -o phpunit.phar + php phpunit.phar --version + php phpunit.phar -c phpunit.xml auto-merge: # Fusion automatique OPT-IN : poser le label `auto-merge` sur la PR. diff --git a/.gitignore b/.gitignore index dffbf98..b876df3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,11 @@ vendor/ composer.lock composer.phar -# === Tests === +# === Tests / Analyse statique (tooling via .phar autonome, sans Composer) === .phpunit.result.cache +.phpunit.cache/ +/phpunit.phar +/phpstan.phar /tests/_output/ /tests/_support/_generated/ diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..6e3dabb --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,22 @@ +# Analyse statique sans Composer : lancee via le .phar autonome +# (php phpstan.phar analyse --no-progress --error-format=raw). +# Aucune baseline, aucun vendor/ : colle au "from scratch" (PROJECT_CONTEXT.md decision 4). +parameters: + level: 6 + paths: + - src + - tests + treatPhpDocTypesAsCertain: false + # Les classes de PHPUnit (TestCase, ...) vivent dans le .phar autonome, hors + # de src/. On les expose a PHPStan en scannant le phar telecharge par la CI. + # Si phpunit.phar est absent (analyse de src/ seul en local), la ligne est + # sans effet : on neutralise alors le bruit "classe inconnue" cote tests. + scanDirectories: + - phar://phpunit.phar + ignoreErrors: + # Tolere l'absence de phpunit.phar en local : les symboles PHPUnit ne + # sont alors pas resolus. En CI le phar est present, l'analyse est complete. + - + identifier: class.notFound + path: tests/* + reportUnmatched: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a1f3799 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,26 @@ + + + + + + tests/Unit + + + + + src + + + diff --git a/src/Controllers/HealthController.php b/src/Controllers/HealthController.php new file mode 100644 index 0000000..d9e7750 --- /dev/null +++ b/src/Controllers/HealthController.php @@ -0,0 +1,49 @@ + routeur + * -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond. + */ +final class HealthController extends Controller +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + $dbStatus = 'ok'; + $categories = null; + $httpStatus = 200; + + try { + $row = $this->database->fetch('SELECT COUNT(*) AS total FROM category'); + $categories = (int) ($row['total'] ?? 0); + } catch (Throwable) { + // Detail de l'erreur volontairement non expose (information disclosure) ; + // un statut degrade suffit a la sonde, les logs conteneur portent le reste. + $dbStatus = 'error'; + $httpStatus = 503; + } + + return $this->json( + [ + 'status' => $dbStatus === 'ok' ? 'ok' : 'degraded', + 'app_env' => $this->config->appEnv(), + 'php_version' => PHP_VERSION, + 'db' => $dbStatus, + 'categories' => $categories, + ], + $httpStatus, + ); + } +} diff --git a/src/Controllers/HomeController.php b/src/Controllers/HomeController.php new file mode 100644 index 0000000..e29cf2b --- /dev/null +++ b/src/Controllers/HomeController.php @@ -0,0 +1,28 @@ + vue -> layout sans dependre de la BDD. + */ +final class HomeController extends Controller +{ + /** + * @param array $params + */ + public function index(array $params = []): Response + { + return $this->view('home', [ + 'title' => 'Wakdo back-office', + 'appEnv' => $this->config->appEnv(), + ]); + } +} diff --git a/src/Core/Autoloader.php b/src/Core/Autoloader.php new file mode 100644 index 0000000..06f4a10 --- /dev/null +++ b/src/Core/Autoloader.php @@ -0,0 +1,43 @@ + {src}/Core/Router.php + */ +final class Autoloader +{ + private const PREFIX = 'App\\'; + + /** + * Enregistre l'autoloader aupres de la pile SPL. + * + * La racine src/ est calculee depuis l'emplacement de ce fichier + * (src/Core/Autoloader.php) : dirname(__DIR__) remonte de Core/ a src/. + * Aucun chemin code en dur, donc portable host/conteneur. + */ + public static function register(): void + { + $root = dirname(__DIR__); + + spl_autoload_register(static function (string $class) use ($root): void { + if (!str_starts_with($class, self::PREFIX)) { + return; + } + + $relative = substr($class, strlen(self::PREFIX)); + $path = $root . DIRECTORY_SEPARATOR + . str_replace('\\', DIRECTORY_SEPARATOR, $relative) + . '.php'; + + if (is_file($path)) { + require $path; + } + }); + } +} diff --git a/src/Core/Config.php b/src/Core/Config.php new file mode 100644 index 0000000..550a2e9 --- /dev/null +++ b/src/Core/Config.php @@ -0,0 +1,80 @@ +get($key); + + if ($value === null) { + throw new RuntimeException(sprintf('Missing required configuration: %s', $key)); + } + + return $value; + } + + public function int(string $key, int $default = 0): int + { + $value = $this->get($key); + + return $value === null ? $default : (int) $value; + } + + /** + * Interprete les conventions usuelles de booleen textuel d'environnement. + */ + public function bool(string $key, bool $default = false): bool + { + $value = $this->get($key); + + if ($value === null) { + return $default; + } + + return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); + } + + public function appEnv(): string + { + return $this->get('APP_ENV', 'production') ?? 'production'; + } + + public function isDebug(): bool + { + return $this->bool('APP_DEBUG', false); + } + + public function timezone(): string + { + return $this->get('APP_TIMEZONE', 'UTC') ?? 'UTC'; + } +} diff --git a/src/Core/Controller.php b/src/Core/Controller.php new file mode 100644 index 0000000..a481fd6 --- /dev/null +++ b/src/Core/Controller.php @@ -0,0 +1,68 @@ + ProductController, etc., demonstration heritage Cr 4.c.1). + * + * Recoit ses dependances par constructeur : la requete courante, la config et + * l'acces BDD, injectes par le Router. + */ +abstract class Controller +{ + public function __construct( + protected readonly Request $request, + protected readonly Config $config, + protected readonly Database $database, + ) { + } + + /** + * @param array $data + */ + protected function json(array $data, int $status = 200): Response + { + return (new Response())->json($data, $status); + } + + /** + * Rend une vue PHP sous src/Views/.php avec ses donnees extraites. + * + * Le rendu est bufferise puis injecte dans le layout via la variable + * $content, ce qui permet aux vues de rester de simples fragments. + * + * @param array $data + */ + protected function view(string $name, array $data = [], int $status = 200): Response + { + $content = $this->render($name, $data); + $html = $this->render('layout', $data + ['content' => $content]); + + return (new Response())->html($html, $status); + } + + /** + * @param array $data + */ + private function render(string $name, array $data): string + { + $file = dirname(__DIR__) . '/Views/' . $name . '.php'; + + if (!is_file($file)) { + throw new RuntimeException(sprintf('View not found: %s', $name)); + } + + // Les cles deviennent des variables locales a la vue ; le buffering + // capture le HTML produit sans l'emettre directement. + extract($data, EXTR_SKIP); + ob_start(); + require $file; + + return (string) ob_get_clean(); + } +} diff --git a/src/Core/Database.php b/src/Core/Database.php new file mode 100644 index 0000000..d149d5e --- /dev/null +++ b/src/Core/Database.php @@ -0,0 +1,94 @@ +pdo === null) { + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', + $this->config->required('DB_HOST'), + $this->config->int('DB_PORT', 3306), + $this->config->required('DB_NAME'), + ); + + $this->pdo = new PDO( + $dsn, + $this->config->required('DB_USER'), + $this->config->required('DB_PASSWORD'), + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + // Vraies requetes preparees cote serveur (pas d'emulation) : + // le SQL et les valeurs voyagent separement, fermant l'injection. + PDO::ATTR_EMULATE_PREPARES => false, + ], + ); + } + + return $this->pdo; + } + + /** + * Prepare puis execute une requete avec ses parametres lies. + * + * @param array $params + */ + public function query(string $sql, array $params = []): PDOStatement + { + $statement = $this->pdo()->prepare($sql); + $statement->execute($params); + + return $statement; + } + + /** + * @param array $params + * @return array|null + */ + public function fetch(string $sql, array $params = []): ?array + { + $row = $this->query($sql, $params)->fetch(); + + return $row === false ? null : $row; + } + + /** + * @param array $params + * @return array> + */ + public function fetchAll(string $sql, array $params = []): array + { + return $this->query($sql, $params)->fetchAll(); + } + + /** + * Execute une ecriture et renvoie le nombre de lignes affectees. + * + * @param array $params + */ + public function execute(string $sql, array $params = []): int + { + return $this->query($sql, $params)->rowCount(); + } +} diff --git a/src/Core/Request.php b/src/Core/Request.php new file mode 100644 index 0000000..0e124b9 --- /dev/null +++ b/src/Core/Request.php @@ -0,0 +1,145 @@ + $query + * @param array $headers + */ + public function __construct( + private readonly string $method, + private readonly string $path, + private readonly array $query, + private readonly array $headers, + private readonly string $rawBody, + ) { + } + + public static function fromGlobals(): self + { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + + // REQUEST_URI inclut la query string ; on isole le chemin seul. + $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/'); + $path = parse_url($uri, PHP_URL_PATH); + $path = is_string($path) ? $path : '/'; + $path = self::normalizePath($path); + + /** @var array $query */ + $query = $_GET; + + return new self( + $method, + $path, + $query, + self::extractHeaders(), + (string) file_get_contents('php://input'), + ); + } + + /** + * Garde un slash de tete et retire le slash de fin (sauf racine) pour + * que "/api/health/" et "/api/health" matchent la meme route. + */ + private static function normalizePath(string $path): string + { + if ($path === '') { + return '/'; + } + + if ($path[0] !== '/') { + $path = '/' . $path; + } + + if ($path !== '/' && str_ends_with($path, '/')) { + $path = rtrim($path, '/'); + } + + return $path; + } + + /** + * @return array + */ + private static function extractHeaders(): array + { + $headers = []; + + foreach ($_SERVER as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $name = str_replace('_', '-', substr($key, 5)); + $headers[strtolower($name)] = (string) $value; + } + } + + // Content-Type / Content-Length ne sont pas prefixes HTTP_ par PHP. + if (isset($_SERVER['CONTENT_TYPE'])) { + $headers['content-type'] = (string) $_SERVER['CONTENT_TYPE']; + } + if (isset($_SERVER['CONTENT_LENGTH'])) { + $headers['content-length'] = (string) $_SERVER['CONTENT_LENGTH']; + } + + return $headers; + } + + public function method(): string + { + return $this->method; + } + + public function path(): string + { + return $this->path; + } + + public function query(string $key, ?string $default = null): ?string + { + return $this->query[$key] ?? $default; + } + + /** + * @return array + */ + public function allQuery(): array + { + return $this->query; + } + + public function header(string $name, ?string $default = null): ?string + { + return $this->headers[strtolower($name)] ?? $default; + } + + public function rawBody(): string + { + return $this->rawBody; + } + + /** + * Decode le corps JSON ; renvoie un tableau vide si le corps est vide ou + * invalide, pour laisser la validation metier decider (pas de fatale ici). + * + * @return array + */ + public function json(): array + { + if ($this->rawBody === '') { + return []; + } + + $decoded = json_decode($this->rawBody, true); + + return is_array($decoded) ? $decoded : []; + } +} diff --git a/src/Core/Response.php b/src/Core/Response.php new file mode 100644 index 0000000..294e370 --- /dev/null +++ b/src/Core/Response.php @@ -0,0 +1,96 @@ + */ + private array $headers = []; + + private string $body = ''; + + public function __construct(private int $status = 200) + { + } + + public function setStatus(int $status): self + { + $this->status = $status; + + return $this; + } + + public function status(): int + { + return $this->status; + } + + public function setHeader(string $name, string $value): self + { + $this->headers[$name] = $value; + + return $this; + } + + public function setBody(string $body): self + { + $this->body = $body; + + return $this; + } + + /** + * @param array $headers + */ + public static function make(string $body, int $status, array $headers): self + { + $response = new self($status); + $response->body = $body; + + foreach ($headers as $name => $value) { + $response->setHeader($name, $value); + } + + return $response; + } + + /** + * @param array $data + */ + public function json(array $data, int $status = 200): self + { + $this->status = $status; + $this->setHeader('Content-Type', 'application/json; charset=utf-8'); + $this->body = (string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return $this; + } + + public function html(string $body, int $status = 200): self + { + $this->status = $status; + $this->setHeader('Content-Type', 'text/html; charset=utf-8'); + $this->body = $body; + + return $this; + } + + public function send(): void + { + http_response_code($this->status); + + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value); + } + + echo $this->body; + } +} diff --git a/src/Core/Router.php b/src/Core/Router.php new file mode 100644 index 0000000..8daf935 --- /dev/null +++ b/src/Core/Router.php @@ -0,0 +1,104 @@ + + */ + private array $routes = []; + + public function __construct( + private readonly Config $config, + private readonly Database $database, + ) { + } + + /** + * @param array{0: class-string, 1: string} $handler [ControllerClass::class, 'action'] + */ + public function add(string $method, string $pattern, array $handler): self + { + $this->routes[] = [ + 'method' => strtoupper($method), + 'regex' => $this->compile($pattern), + 'handler' => $handler, + ]; + + return $this; + } + + /** + * Traduit "/api/orders/{number}" en une regex ancree avec groupes nommes. + * Les segments litteraux sont echappes pour neutraliser tout metacaractere. + */ + private function compile(string $pattern): string + { + $regex = preg_replace_callback( + '/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', + static fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)', + $pattern, + ); + + // preg_quote n'est pas applicable globalement (il echapperait les groupes + // generes) ; les patterns sont des litteraux de route controles, donc on + // se contente de figer les delimiteurs avec un delimiteur improbable. + return '#^' . $regex . '$#'; + } + + /** + * Resout la requete : instancie le controleur et appelle l'action avec les + * parametres de route extraits, ou renvoie une reponse 404 / 405. + */ + public function dispatch(Request $request): Response + { + $pathMatched = false; + + foreach ($this->routes as $route) { + if (preg_match($route['regex'], $request->path(), $matches) !== 1) { + continue; + } + + $pathMatched = true; + + if ($route['method'] !== $request->method()) { + continue; + } + + $params = array_filter( + $matches, + static fn (int|string $key): bool => is_string($key), + ARRAY_FILTER_USE_KEY, + ); + + [$controllerClass, $action] = $route['handler']; + + /** @var Controller $controller */ + $controller = new $controllerClass($request, $this->config, $this->database); + + return $controller->$action($params); + } + + if ($pathMatched) { + return (new Response())->json( + ['data' => null, 'error' => ['code' => 'METHOD_NOT_ALLOWED', 'message' => 'Method not allowed']], + 405, + ); + } + + return (new Response())->json( + ['data' => null, 'error' => ['code' => 'NOT_FOUND', 'message' => 'Resource not found']], + 404, + ); + } +} diff --git a/src/Views/home.php b/src/Views/home.php new file mode 100644 index 0000000..76e8fab --- /dev/null +++ b/src/Views/home.php @@ -0,0 +1,25 @@ + +
+

Wakdo back-office

+

Le squelette back-end (P2) est en ligne.

+

+ + Coeur MVC from scratch : autoloader PSR-4 manuel, routeur, PDO prepared statements. + Environnement : . + +

+

+ Sonde de sante : GET /api/health +

+
diff --git a/src/Views/layout.php b/src/Views/layout.php new file mode 100644 index 0000000..5bf12f5 --- /dev/null +++ b/src/Views/layout.php @@ -0,0 +1,32 @@ + + + + + + + + <?= $pageTitle ?> + + + + + + diff --git a/src/public/admin/index.php b/src/public/admin/index.php index bea045c..707255f 100644 --- a/src/public/admin/index.php +++ b/src/public/admin/index.php @@ -1,34 +1,53 @@ public -> src) pour atteindre la racine src/. +require dirname(__DIR__, 2) . '/Core/Autoloader.php'; +Autoloader::register(); + +// En-tetes de securite poses tot, valables sur toute reponse y compris une 500. +header('X-Content-Type-Options: nosniff'); header('X-Robots-Tag: noindex, nofollow'); -$phpVersion = htmlspecialchars(PHP_VERSION, ENT_QUOTES, 'UTF-8'); -$now = htmlspecialchars(date('Y-m-d H:i:s'), ENT_QUOTES, 'UTF-8'); -?> - - - - - - Wakdo - back-office - - - -

Wakdo - back-office

-

En construction.

-

Phase P1 - conception Merise en cours. Le back-office sera implemente en phases P2 a P4.

-
-

Diagnostic FastCGI : PHP repond a .

-

TODO P2 : assets partages (logo, images produits) via Apache Alias entre les 2 vhosts.

- - +$config = new Config(); +date_default_timezone_set($config->timezone()); + +try { + // Acces BDD paresseux : la connexion n'est ouverte qu'au premier query(), + // donc la home back-office reste servie meme base indisponible. + $database = new Database($config); + + $router = new Router($config, $database); + $router->add('GET', '/', [HomeController::class, 'index']); + $router->add('GET', '/api/health', [HealthController::class, 'index']); + + $response = $router->dispatch(Request::fromGlobals()); + $response->send(); +} catch (Throwable $exception) { + // En debug on remonte le message pour iterer ; en prod, reponse generique + // pour ne rien divulguer de la pile interne (information disclosure). + $payload = $config->isDebug() + ? ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => $exception->getMessage()]] + : ['data' => null, 'error' => ['code' => 'INTERNAL_ERROR', 'message' => 'Internal server error']]; + + (new Response())->json($payload, 500)->send(); +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php new file mode 100644 index 0000000..2b3725c --- /dev/null +++ b/tests/Unit/ConfigTest.php @@ -0,0 +1,125 @@ + */ + private array $touchedKeys = []; + + protected function setUp(): void + { + $this->config = new Config(); + } + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + + $this->touchedKeys = []; + } + + private function setEnv(string $key, string $value): void + { + $this->touchedKeys[] = $key; + putenv($key . '=' . $value); + } + + public function testGetReturnsValueWhenPresent(): void + { + $this->setEnv('WAKDO_TEST_NAME', 'borne'); + + self::assertSame('borne', $this->config->get('WAKDO_TEST_NAME')); + } + + public function testGetReturnsDefaultWhenAbsent(): void + { + self::assertSame('fallback', $this->config->get('WAKDO_TEST_MISSING', 'fallback')); + self::assertNull($this->config->get('WAKDO_TEST_MISSING')); + } + + public function testGetTreatsEmptyStringAsAbsent(): void + { + // Une variable d'env vide n'apporte pas d'information : Config la traite + // comme absente et renvoie le defaut (contrat documente dans Config::get). + $this->setEnv('WAKDO_TEST_EMPTY', ''); + + self::assertSame('def', $this->config->get('WAKDO_TEST_EMPTY', 'def')); + } + + public function testIntCastsValue(): void + { + $this->setEnv('WAKDO_TEST_PORT', '3307'); + + self::assertSame(3307, $this->config->int('WAKDO_TEST_PORT')); + } + + public function testIntReturnsDefaultWhenAbsent(): void + { + self::assertSame(3306, $this->config->int('WAKDO_TEST_PORT_MISSING', 3306)); + } + + /** + * @return list + */ + public static function truthyValuesProvider(): array + { + return [ + ['1', true], + ['true', true], + ['TRUE', true], + ['yes', true], + ['on', true], + ['0', false], + ['false', false], + ['no', false], + ['off', false], + ['anything-else', false], + ]; + } + + #[DataProvider('truthyValuesProvider')] + public function testBoolInterpretsCommonConventions(string $raw, bool $expected): void + { + $this->setEnv('WAKDO_TEST_FLAG', $raw); + + self::assertSame($expected, $this->config->bool('WAKDO_TEST_FLAG')); + } + + public function testBoolReturnsDefaultWhenAbsent(): void + { + self::assertTrue($this->config->bool('WAKDO_TEST_FLAG_MISSING', true)); + self::assertFalse($this->config->bool('WAKDO_TEST_FLAG_MISSING', false)); + } + + public function testRequiredReturnsValueWhenPresent(): void + { + $this->setEnv('WAKDO_TEST_DB', 'wakdo'); + + self::assertSame('wakdo', $this->config->required('WAKDO_TEST_DB')); + } + + public function testRequiredThrowsWhenMissing(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Missing required configuration: WAKDO_TEST_REQUIRED_MISSING'); + + $this->config->required('WAKDO_TEST_REQUIRED_MISSING'); + } +} diff --git a/tests/Unit/RouterTest.php b/tests/Unit/RouterTest.php new file mode 100644 index 0000000..3e9d9e9 --- /dev/null +++ b/tests/Unit/RouterTest.php @@ -0,0 +1,123 @@ + $params + */ +final class RouteProbeController extends Controller +{ + /** @var array */ + public static array $capturedParams = []; + + /** + * @param array $params + */ + public function show(array $params = []): Response + { + self::$capturedParams = $params; + + return (new Response())->json(['data' => $params], 200); + } +} + +final class RouterTest extends TestCase +{ + private Config $config; + private Database $database; + + protected function setUp(): void + { + $this->config = new Config(); + // Le PDO est paresseux (ouvert au premier acces), donc construire la + // Database ne tente aucune connexion : aucune BDD requise pour ces tests. + $this->database = new Database($this->config); + RouteProbeController::$capturedParams = []; + } + + /** + * Fabrique une Request sans toucher aux super-globales : le constructeur + * de Request est public, on injecte directement methode et chemin. + */ + private function request(string $method, string $path): Request + { + return new Request($method, $path, [], [], ''); + } + + private function router(): Router + { + return new Router($this->config, $this->database); + } + + public function testMatchedRouteExtractsNamedParam(): void + { + $router = $this->router(); + $router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('GET', '/api/orders/42')); + + self::assertSame(200, $response->status()); + self::assertSame(['id' => '42'], RouteProbeController::$capturedParams); + } + + public function testMultipleParamsAreAllExtracted(): void + { + $router = $this->router(); + $router->add('GET', '/api/menus/{menu}/options/{option}', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('GET', '/api/menus/7/options/maxi')); + + self::assertSame(200, $response->status()); + self::assertSame(['menu' => '7', 'option' => 'maxi'], RouteProbeController::$capturedParams); + } + + public function testUnknownPathReturns404(): void + { + $router = $this->router(); + $router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('GET', '/api/nope')); + + self::assertSame(404, $response->status()); + self::assertSame([], RouteProbeController::$capturedParams); + } + + public function testKnownPathWrongMethodReturns405(): void + { + $router = $this->router(); + // Seul GET est enregistre sur ce chemin ; un POST matche le chemin mais + // pas la methode, ce qui doit produire 405 et non 404. + $router->add('GET', '/api/orders/{id}', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('POST', '/api/orders/42')); + + self::assertSame(405, $response->status()); + self::assertSame([], RouteProbeController::$capturedParams); + } + + public function testMethodMatchingIsCaseInsensitiveOnRegistration(): void + { + $router = $this->router(); + // add() normalise la methode en majuscules ; une route "get" doit donc + // repondre a une requete GET. + $router->add('get', '/api/health', [RouteProbeController::class, 'show']); + + $response = $router->dispatch($this->request('GET', '/api/health')); + + self::assertSame(200, $response->status()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..4aa2d11 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +