feat(core): from-scratch PHP MVC skeleton (autoloader/config/PDO/router/front controller) + PHPUnit/PHPStan + composer-less CI

This commit is contained in:
Imugiii 2026-06-15 14:13:49 +00:00
parent 41f9c96d33
commit 8c93b26ec0
19 changed files with 1151 additions and 44 deletions

View file

@ -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.

5
.gitignore vendored
View file

@ -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/

22
phpstan.neon Normal file
View file

@ -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

26
phpunit.xml Normal file
View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Configuration PHPUnit sans Composer : le bootstrap charge l'autoloader manuel
du Core (PSR-4 maison). Lance via le .phar autonome (php phpunit.phar -c phpunit.xml),
conformement a la stack lockee (PROJECT_CONTEXT.md section 6 : tests via .phar).
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
beStrictAboutOutputDuringTests="true"
beStrictAboutTestsThatDoNotTestAnything="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use Throwable;
use App\Core\Controller;
use App\Core\Response;
/**
* Sonde de sante. GET /api/health.
*
* Le comptage des categories prouve la chaine complete (autoloader -> routeur
* -> controleur -> PDO -> BDD seedee), pas seulement que PHP repond.
*/
final class HealthController extends Controller
{
/**
* @param array<string, string> $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,
);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Response;
/**
* Page d'accueil du back-office. GET /.
*
* Volontairement minimale en P2 : prouve que le rendu de vue MVC traverse
* controleur -> vue -> layout sans dependre de la BDD.
*/
final class HomeController extends Controller
{
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
return $this->view('home', [
'title' => 'Wakdo back-office',
'appEnv' => $this->config->appEnv(),
]);
}
}

43
src/Core/Autoloader.php Normal file
View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* PSR-4 autoloader manuel, sans Composer (exigence "from scratch" Cr 4.c.3).
*
* Mappe le prefixe de namespace racine "App\" sur le dossier src/.
* Exemple : App\Core\Router -> {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;
}
});
}
}

80
src/Core/Config.php Normal file
View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Core;
use RuntimeException;
/**
* Acces type a la configuration, lue depuis les variables d'environnement.
*
* Pas de parsing .env : en conteneur le .env n'est pas monte, les valeurs
* sont injectees par docker-compose / l'environnement (getenv).
*/
final class Config
{
public function get(string $key, ?string $default = null): ?string
{
$value = getenv($key);
// getenv renvoie false si absent ; une chaine vide est traitee comme absente
// car les variables d'env vides n'apportent pas d'information exploitable.
if ($value === false || $value === '') {
return $default;
}
return $value;
}
/**
* Lit une valeur obligatoire ; echoue tot si la config est incomplete
* plutot que de laisser une erreur survenir plus loin (fail-fast).
*/
public function required(string $key): string
{
$value = $this->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';
}
}

68
src/Core/Controller.php Normal file
View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Core;
use RuntimeException;
/**
* Controleur de base. Toute la hierarchie de controleurs en herite
* (BaseController -> 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<string|int, mixed> $data
*/
protected function json(array $data, int $status = 200): Response
{
return (new Response())->json($data, $status);
}
/**
* Rend une vue PHP sous src/Views/<name>.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<string, mixed> $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<string, mixed> $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();
}
}

94
src/Core/Database.php Normal file
View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
use PDOStatement;
/**
* Enveloppe PDO MariaDB, requetes preparees exclusivement (anti-SQLi, Cr 4.e.1).
*
* Connexion paresseuse : le PDO n'est ouvert qu'au premier acces afin que les
* routes sans BDD (ex : la home back-office) fonctionnent meme si la base est
* indisponible.
*/
final class Database
{
private ?PDO $pdo = null;
public function __construct(private readonly Config $config)
{
}
private function pdo(): PDO
{
if ($this->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<string|int, mixed> $params
*/
public function query(string $sql, array $params = []): PDOStatement
{
$statement = $this->pdo()->prepare($sql);
$statement->execute($params);
return $statement;
}
/**
* @param array<string|int, mixed> $params
* @return array<string, mixed>|null
*/
public function fetch(string $sql, array $params = []): ?array
{
$row = $this->query($sql, $params)->fetch();
return $row === false ? null : $row;
}
/**
* @param array<string|int, mixed> $params
* @return array<int, array<string, mixed>>
*/
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<string|int, mixed> $params
*/
public function execute(string $sql, array $params = []): int
{
return $this->query($sql, $params)->rowCount();
}
}

145
src/Core/Request.php Normal file
View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Representation immuable de la requete HTTP entrante.
*
* Construite depuis les super-globales par fromGlobals() ; le reste de
* l'application ne touche jamais $_SERVER / $_GET directement.
*/
final class Request
{
/**
* @param array<string, string> $query
* @param array<string, string> $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<string, string> $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<string, string>
*/
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<string, string>
*/
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<string, mixed>
*/
public function json(): array
{
if ($this->rawBody === '') {
return [];
}
$decoded = json_decode($this->rawBody, true);
return is_array($decoded) ? $decoded : [];
}
}

96
src/Core/Response.php Normal file
View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Reponse HTTP accumulee puis emise par send().
*
* Permet de construire entierement la reponse avant tout echo, ce qui rend
* le front controller testable et evite les "headers already sent".
*/
final class Response
{
/** @var array<string, string> */
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<string, string> $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<string|int, mixed> $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;
}
}

104
src/Core/Router.php Normal file
View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Routeur a base d'expressions regulieres compilees.
*
* Les patterns acceptent des segments dynamiques {param} compiles en groupes
* nommes. Le dispatch distingue 404 (aucun chemin ne correspond) de 405
* (le chemin correspond mais pas la methode).
*/
final class Router
{
/**
* @var array<int, array{method: string, regex: string, handler: array{0: class-string, 1: string}}>
*/
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,
);
}
}

25
src/Views/home.php Normal file
View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* Fragment de la page d'accueil back-office, injecte dans layout.php.
*
* @var string $appEnv
*/
$env = htmlspecialchars($appEnv ?? 'unknown', ENT_QUOTES, 'UTF-8');
?>
<main>
<h1>Wakdo back-office</h1>
<p>Le squelette back-end (P2) est en ligne.</p>
<p>
<small>
Coeur MVC from scratch : autoloader PSR-4 manuel, routeur, PDO prepared statements.
Environnement : <code><?= $env ?></code>.
</small>
</p>
<p>
<small>Sonde de sante : <code>GET /api/health</code></small>
</p>
</main>

32
src/Views/layout.php Normal file
View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
/**
* Gabarit HTML5 du back-office. Recoit $title et $content depuis le controleur.
* Les variables sont fournies par Controller::view() via extract().
*
* @var string $title
* @var string $content
*/
$pageTitle = htmlspecialchars($title ?? 'Wakdo', ENT_QUOTES, 'UTF-8');
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Back-office prive : jamais indexe par les moteurs de recherche. -->
<meta name="robots" content="noindex, nofollow">
<title><?= $pageTitle ?></title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a1a1a; line-height: 1.5; }
h1 { font-size: 1.5rem; }
small { color: #666; }
code { background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
</style>
</head>
<body>
<?= $content ?? '' ?>
</body>
</html>

View file

@ -1,34 +1,53 @@
<?php
declare(strict_types=1);
// Stub pour debloquer le routage Apache + valider la chaine FastCGI vers PHP-FPM.
// Sera remplace par le front controller MVC en phase P2 (src/Core/Router.php a venir).
/**
* Front controller du vhost admin (back-office + API sous /api).
*
* Apache reecrit toute requete non-fichier vers ce fichier (RewriteRule ^ index.php).
* Le REQUEST_URI arrive intact (pas de prefixe strippe), donc le routeur voit
* "/", "/api/health", etc.
*/
header('Content-Type: text/html; charset=utf-8');
use App\Controllers\HealthController;
use App\Controllers\HomeController;
use App\Core\Autoloader;
use App\Core\Config;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Core\Router;
// src/public/admin/index.php : __DIR__ = src/public/admin ; remonter de deux
// niveaux (admin -> 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');
?><!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Wakdo - back-office</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; color: #222; }
img { max-height: 80px; }
small { color: #666; }
code { background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
</style>
</head>
<body>
<h1>Wakdo - back-office</h1>
<p>En construction.</p>
<p><small>Phase P1 - conception Merise en cours. Le back-office sera implemente en phases P2 a P4.</small></p>
<hr>
<p><small>Diagnostic FastCGI : PHP <code><?= $phpVersion ?></code> repond a <code><?= $now ?></code>.</small></p>
<p><small>TODO P2 : assets partages (logo, images produits) via Apache Alias entre les 2 vhosts.</small></p>
</body>
</html>
$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();
}

125
tests/Unit/ConfigTest.php Normal file
View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use App\Core\Config;
/**
* La config lit getenv (pas de .env en conteneur). Les tests pilotent
* l'environnement via putenv et nettoient apres eux pour ne pas polluer les
* autres cas.
*/
final class ConfigTest extends TestCase
{
private Config $config;
/** @var list<string> */
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<array{0: string, 1: bool}>
*/
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');
}
}

123
tests/Unit/RouterTest.php Normal file
View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Core\Config;
use App\Core\Controller;
use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Core\Router;
/**
* Controleur sonde : capture les parametres de route recus pour prouver
* l'extraction du segment {id} par le Router. Etend le vrai Controller du Core
* pour traverser le meme chemin d'instanciation que la production.
*
* @param array<string, string> $params
*/
final class RouteProbeController extends Controller
{
/** @var array<string, string> */
public static array $capturedParams = [];
/**
* @param array<string, string> $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());
}
}

13
tests/bootstrap.php Normal file
View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* Amorce PHPUnit sans Composer : on charge l'autoloader manuel du Core puis on
* l'enregistre, exactement comme le fait le front controller en production
* (src/public/admin/index.php). Les tests resolvent ainsi App\... via PSR-4.
*/
require __DIR__ . '/../src/Core/Autoloader.php';
App\Core\Autoloader::register();