$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 : = $env ?>.
+
+
+
+ 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 ?>
+
+
+
+= $content ?? '' ?>
+
+
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 = $phpVersion ?> repond a = $now ?>.
- 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 @@
+